diff --git a/app/src/main/kotlin/androidx/compose/foundation/DragAndDrop.kt b/app/src/main/kotlin/androidx/compose/foundation/DragAndDrop.kt new file mode 100644 index 000000000..b11587443 --- /dev/null +++ b/app/src/main/kotlin/androidx/compose/foundation/DragAndDrop.kt @@ -0,0 +1,213 @@ +/* + * Copyright 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.compose.foundation + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +@Composable +fun rememberDragDropState( + lazyListState: LazyListState, + onMove: (Int, Int) -> Unit, +): DragDropState { + val scope = rememberCoroutineScope() + val state = remember(lazyListState) { + DragDropState( + state = lazyListState, + onMove = onMove, + scope = scope, + ) + } + LaunchedEffect(state) { + while (true) { + val diff = state.scrollChannel.receive() + lazyListState.scrollBy(diff) + } + } + return state +} + +class DragDropState internal constructor( + private val state: LazyListState, + private val scope: CoroutineScope, + private val onMove: (Int, Int) -> Unit, +) { + var draggingItemIndex by mutableStateOf(null) + private set + + internal val scrollChannel = Channel() + + private var draggingItemDraggedDelta by mutableFloatStateOf(0f) + private var draggingItemInitialOffset by mutableIntStateOf(0) + internal val draggingItemOffset: Float + get() = draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemDraggedDelta - item.offset + } ?: 0f + + private val draggingItemLayoutInfo: LazyListItemInfo? + get() = state.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == draggingItemIndex } + + internal var previousIndexOfDraggedItem by mutableStateOf(null) + private set + internal var previousItemOffset = Animatable(0f) + private set + + internal fun onDragStart(offset: Offset) { + state.layoutInfo.visibleItemsInfo.firstOrNull { item -> + offset.y.toInt() in item.offset..(item.offset + item.size) + }?.also { + draggingItemIndex = it.index + draggingItemInitialOffset = it.offset + } + } + + internal fun onDragInterrupted() { + if (draggingItemIndex != null) { + previousIndexOfDraggedItem = draggingItemIndex + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + 0f, + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = 1f, + ), + ) + previousIndexOfDraggedItem = null + } + } + draggingItemDraggedDelta = 0f + draggingItemIndex = null + draggingItemInitialOffset = 0 + } + + internal fun onDrag(offset: Offset) { + draggingItemDraggedDelta += offset.y + + val draggingItem = draggingItemLayoutInfo ?: return + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = state.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.toInt() in item.offset..item.offsetEnd && + draggingItem.index != item.index + } + if (targetItem != null) { + val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) { + draggingItem.index + } else if (draggingItem.index == state.firstVisibleItemIndex) { + targetItem.index + } else { + null + } + if (scrollToIndex != null) { + scope.launch { + // this is needed to neutralize automatic keeping the first item first. + state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset) + onMove.invoke(draggingItem.index, targetItem.index) + } + } else { + onMove.invoke(draggingItem.index, targetItem.index) + } + draggingItemIndex = targetItem.index + } else { + val overscroll = when { + draggingItemDraggedDelta > 0 -> + (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + draggingItemDraggedDelta < 0 -> + (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f) + else -> 0f + } + if (overscroll != 0f) { + scrollChannel.trySend(overscroll) + } + } + } + + private val LazyListItemInfo.offsetEnd: Int + get() = this.offset + this.size +} + +fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { + return this.pointerInput(dragDropState) { + detectDragGesturesAfterLongPress( + onDrag = { change, offset -> + change.consume() + dragDropState.onDrag(offset = offset) + }, + onDragStart = { offset -> dragDropState.onDragStart(offset) }, + onDragEnd = { dragDropState.onDragInterrupted() }, + onDragCancel = { dragDropState.onDragInterrupted() }, + ) + } +} + +@ExperimentalFoundationApi +@Composable +fun LazyItemScope.DraggableItem( + dragDropState: DragDropState, + index: Int, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.(isDragging: Boolean) -> Unit, +) { + val dragging = index == dragDropState.draggingItemIndex + val draggingModifier = if (dragging) { + Modifier + .zIndex(1f) + .graphicsLayer { + translationY = dragDropState.draggingItemOffset + } + } else if (index == dragDropState.previousIndexOfDraggedItem) { + Modifier.zIndex(1f) + .graphicsLayer { + translationY = dragDropState.previousItemOffset.value + } + } else { + Modifier.animateItemPlacement() + } + Column(modifier = modifier.then(draggingModifier)) { + content(dragging) + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/EhEngine.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/EhEngine.kt index e85e96b6f..b85aae375 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/EhEngine.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/EhEngine.kt @@ -129,6 +129,7 @@ suspend fun fetchCompat( reqBody: RequestBody? = null, parser: suspend (ByteBuffer) -> T, ): T { + Log.d(TAG, url) fun commonChecks(body: ByteBuffer) { // Check sad panda(without panda) if (!body.hasRemaining()) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/ListUrlBuilder.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/ListUrlBuilder.kt index c42c8bc0f..b31936cc3 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/ListUrlBuilder.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/ListUrlBuilder.kt @@ -48,23 +48,6 @@ data class ListUrlBuilder( var isOnlySearchCovers: Boolean = false, ) : Parcelable { - fun reset() { - mode = MODE_NORMAL - mPrev = null - mNext = null - mJumpTo = null - this.category = EhUtils.NONE - mKeyword = null - advanceSearch = -1 - minRating = -1 - pageFrom = -1 - pageTo = -1 - imagePath = null - isUseSimilarityScan = false - isOnlySearchCovers = false - hash = null - } - fun setIndex(index: String?, isNext: Boolean = true) { mNext = index?.takeIf { isNext } mPrev = index?.takeUnless { isNext } @@ -80,8 +63,7 @@ data class ListUrlBuilder( mKeyword = keyword } - fun set(q: QuickSearch) { - reset() + constructor(q: QuickSearch) : this() { mode = q.mode this.category = q.category mKeyword = q.keyword @@ -134,9 +116,8 @@ data class ListUrlBuilder( /** * @param query xxx=yyy&mmm=nnn */ - fun setQuery(query: String?) { + constructor(query: String?) : this() { // TODO page - reset() if (query.isNullOrEmpty()) { return } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryListUrlParser.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryListUrlParser.kt index 0fc8ad6a5..4496abec7 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryListUrlParser.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/parser/GalleryListUrlParser.kt @@ -42,9 +42,7 @@ object GalleryListUrlParser { } val path = url.path ?: return null return if (PATH_NORMAL == path || path.isEmpty()) { - val builder = ListUrlBuilder() - builder.setQuery(url.query) - builder + ListUrlBuilder(url.query) } else if (path.startsWith(PATH_UPLOADER)) { parseUploader(path) } else if (path.startsWith(PATH_TAG)) { @@ -57,10 +55,7 @@ object GalleryListUrlParser { } catch (e: NumberFormatException) { return null } - val builder = ListUrlBuilder() - builder.setQuery(url.query) - builder.category = category - builder + ListUrlBuilder(url.query).also { it.category = category } } else { null } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt index 2095c879a..c524b09db 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt @@ -43,6 +43,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.verticalScroll @@ -69,6 +70,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.currentRecomposeScope import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -105,7 +107,7 @@ import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder import com.hippo.ehviewer.ui.legacy.EditTextDialogBuilder import com.hippo.ehviewer.ui.scene.BaseScene import com.hippo.ehviewer.ui.scene.GalleryDetailScene -import com.hippo.ehviewer.ui.scene.GalleryListScene.Companion.toStartArgs +import com.hippo.ehviewer.ui.scene.GalleryListFragment.Companion.toStartArgs import com.hippo.ehviewer.ui.scene.ProgressFragment import com.hippo.ehviewer.ui.scene.TokenArgs import com.hippo.ehviewer.ui.scene.navAnimated @@ -151,21 +153,13 @@ class MainActivity : EhActivity() { private lateinit var navController: NavController private val isInitializedFlow = MutableStateFlow(false) - private var sideSheet by mutableStateOf<(@Composable ColumnScope.(DrawerState2) -> Unit)?>(null) + private var sideSheet = mutableStateListOf<@Composable ColumnScope.(DrawerState2) -> Unit>() @Composable fun ProvideSideSheetContent(content: @Composable ColumnScope.(DrawerState2) -> Unit) { DisposableEffect(content) { - require(sideSheet == null) { - "Provide SideSheet content when previous content not released!!!" - } - sideSheet = content - onDispose { - require(sideSheet == content) { - "Try to release SideSheetContent not belong to us" - } - sideSheet = null - } + sideSheet.add(0, content) + onDispose { sideSheet.remove(content) } } } @@ -354,7 +348,7 @@ class MainActivity : EhActivity() { drawerState = drawerState, gesturesEnabled = !drawerLocked || drawerState.isOpen, ) { - val sheet = sideSheet + val sheet = sideSheet.firstOrNull() val sideDrawerState = rememberDrawerState(DrawerValue.Closed) LaunchedEffect(Unit) { openSideSheetFlow.collectLatest { @@ -369,10 +363,10 @@ class MainActivity : EhActivity() { SideDrawer( drawerContent = { if (sheet != null) { + val insets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End) ModalDrawerSheet( - modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp), + modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp).windowInsetsPadding(insets), drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)), - windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Top + WindowInsetsSides.End), ) { sheet(sideDrawerState) } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/BringOutTransition.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/BringOutTransition.kt deleted file mode 100644 index 2551c70c7..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/BringOutTransition.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (C) 2015 Hippo Seven - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.hippo.ehviewer.ui.legacy - -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.view.View -import android.widget.FrameLayout - -class BringOutTransition(contentLayout: FrameLayout, mSearchLayout: SearchLayout) : - ViewTransition(contentLayout, mSearchLayout) { - override fun startAnimations(hiddenView: View, shownView: View) { - mAnimator1 = hiddenView.animate().alpha(0f).scaleY(0.7f).scaleX(0.7f).apply { - setDuration(ANIMATE_TIME).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - hiddenView.visibility = View.GONE - mAnimator1 = null - } - }).start() - } - shownView.alpha = 0f - shownView.scaleX = 0.7f - shownView.scaleY = 0.7f - shownView.visibility = View.VISIBLE - mAnimator2 = shownView.animate().alpha(1f).scaleX(1f).scaleY(1f).apply { - setDuration(ANIMATE_TIME).setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - mAnimator2 = null - } - }).start() - } - } -} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/LayoutManagerUtils.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/LayoutManagerUtils.kt deleted file mode 100644 index 4e9b2e777..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/LayoutManagerUtils.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2015 Hippo Seven - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.hippo.ehviewer.ui.legacy - -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView.LayoutManager -import androidx.recyclerview.widget.StaggeredGridLayoutManager - -object LayoutManagerUtils { - val LayoutManager.firstVisibleItemPosition: Int - get() = when (this) { - is LinearLayoutManager -> findFirstVisibleItemPosition() - is StaggeredGridLayoutManager -> findFirstVisibleItemPositions(null).min() - else -> error("Can't do getFirstVisibleItemPosition for " + javaClass.name) - } -} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/MarginItemDecoration.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/MarginItemDecoration.kt deleted file mode 100644 index 16daf77b2..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/MarginItemDecoration.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (C) 2015 Hippo Seven - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.hippo.ehviewer.ui.legacy - -import android.graphics.Rect -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.ItemDecoration - -class MarginItemDecoration( - margin: Int, - paddingLeft: Int, - paddingTop: Int, - paddingRight: Int, - paddingBottom: Int, -) : ItemDecoration() { - private var mMargin = 0 - private var mPaddingLeft = 0 - private var mPaddingTop = 0 - private var mPaddingRight = 0 - private var mPaddingBottom = 0 - - init { - setMargin(margin, paddingLeft, paddingTop, paddingRight, paddingBottom) - } - - override fun getItemOffsets( - outRect: Rect, - view: View, - parent: RecyclerView, - state: RecyclerView.State, - ) { - outRect[mMargin, mMargin, mMargin] = mMargin - } - - /** - * @param margin gap between two item - * @param paddingLeft gap between RecyclerView left and left item left - * @param paddingTop gap between RecyclerView top and top item top - * @param paddingRight gap between RecyclerView right and right item right - * @param paddingBottom gap between RecyclerView bottom and bottom item bottom - */ - fun setMargin( - margin: Int, - paddingLeft: Int, - paddingTop: Int, - paddingRight: Int, - paddingBottom: Int, - ) { - val halfMargin = margin / 2 - mMargin = halfMargin - mPaddingLeft = paddingLeft - halfMargin - mPaddingTop = paddingTop - halfMargin - mPaddingRight = paddingRight - halfMargin - mPaddingBottom = paddingBottom - halfMargin - } - - fun applyPaddings(view: View) { - view.setPadding(mPaddingLeft, mPaddingTop, mPaddingRight, mPaddingBottom) - } -} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/SearchLayout.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/SearchLayout.kt deleted file mode 100644 index fbd55daf5..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/SearchLayout.kt +++ /dev/null @@ -1,207 +0,0 @@ -package com.hippo.ehviewer.ui.legacy - -import android.content.Context -import android.graphics.Bitmap -import android.net.Uri -import android.util.AttributeSet -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SecondaryTabRow -import androidx.compose.material3.Tab -import androidx.compose.material3.Text -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.AbstractComposeView -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.findViewTreeViewModelStoreOwner -import androidx.lifecycle.get -import com.google.accompanist.themeadapter.material3.Mdc3Theme -import com.hippo.ehviewer.R -import com.hippo.ehviewer.Settings -import com.hippo.ehviewer.client.data.ListUrlBuilder -import com.hippo.ehviewer.client.exception.EhException -import com.hippo.ehviewer.image.Image.Companion.decodeBitmap -import com.hippo.ehviewer.ui.main.AdvancedSearchOption -import com.hippo.ehviewer.ui.main.ImageSearch -import com.hippo.ehviewer.ui.main.NormalSearch -import com.hippo.ehviewer.ui.main.SearchAdvanced -import com.hippo.ehviewer.util.AppConfig -import com.hippo.ehviewer.util.findActivity -import com.hippo.ehviewer.util.pickVisualMedia -import kotlinx.coroutines.launch - -class SearchViewModel : ViewModel() { - var isNormalMode by mutableStateOf(true) // else ImageSearch mode - var isAdvancedMode by mutableStateOf(false) - var mCategory by mutableIntStateOf(Settings.searchCategory) - var mSearchMode by mutableIntStateOf(1) - var advancedState by mutableStateOf(AdvancedSearchOption()) - var uss by mutableStateOf(false) - var osc by mutableStateOf(false) - var path by mutableStateOf("") -} - -class SearchLayout @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0, -) : AbstractComposeView(context, attrs, defStyle) { - private val vm by lazy { - ViewModelProvider(findViewTreeViewModelStoreOwner()!!).get() - } - - @Composable - override fun Content() { - val coroutineScope = rememberCoroutineScope() - val windowSizeClass = calculateWindowSizeClass(activity = context.findActivity()) - fun selectImage() = coroutineScope.launch { - context.pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly)?.let { - vm.path = it.toString() - } - } - Mdc3Theme { - Column( - modifier = Modifier.imePadding().statusBarsPadding().padding(top = 72.dp) - .verticalScroll(rememberScrollState()).navigationBarsPadding().padding(horizontal = dimensionResource(id = R.dimen.search_layout_margin_h)), - ) { - AnimatedVisibility(visible = vm.isNormalMode) { - ElevatedCard(modifier = Modifier.fillMaxWidth().padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) { - Column(modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.search_category_padding_h), vertical = dimensionResource(id = R.dimen.search_category_padding_v))) { - Text(text = stringResource(id = R.string.search_normal), modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) - NormalSearch( - category = vm.mCategory, - onCategoryChanged = { - Settings.searchCategory = it - vm.mCategory = it - }, - searchMode = vm.mSearchMode, - onSearchModeChanged = { vm.mSearchMode = it }, - isAdvanced = vm.isAdvancedMode, - onAdvancedChanged = { vm.isAdvancedMode = it }, - showInfo = { BaseDialogBuilder(context).setMessage(R.string.search_tip).show() }, - maxItemsInEachRow = when (windowSizeClass.widthSizeClass) { - WindowWidthSizeClass.Compact -> 2 - WindowWidthSizeClass.Medium -> 3 - else -> 5 - }, - ) - } - } - } - AnimatedVisibility(visible = vm.isNormalMode && vm.isAdvancedMode) { - ElevatedCard(modifier = Modifier.fillMaxWidth().padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) { - Column(modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.search_category_padding_h), vertical = dimensionResource(id = R.dimen.search_category_padding_v))) { - Text(text = stringResource(id = R.string.search_advance), modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) - SearchAdvanced( - state = vm.advancedState, - onStateChanged = { vm.advancedState = it }, - ) - } - } - } - AnimatedVisibility(visible = !vm.isNormalMode) { - ElevatedCard(modifier = Modifier.fillMaxWidth().padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) { - Column(modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.search_category_padding_h), vertical = dimensionResource(id = R.dimen.search_category_padding_v))) { - Text(text = stringResource(id = R.string.search_image), modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) - ImageSearch( - imagePath = vm.path, - onSelectImage = ::selectImage, - uss = vm.uss, - onUssChecked = { vm.uss = it }, - osc = vm.osc, - onOscChecked = { vm.osc = it }, - ) - } - } - } - SecondaryTabRow( - selectedTabIndex = if (vm.isNormalMode) 0 else 1, - divider = {}, - ) { - Tab( - selected = vm.isNormalMode, - onClick = { vm.isNormalMode = true }, - text = { Text(text = stringResource(id = R.string.keyword_search)) }, - ) - Tab( - selected = !vm.isNormalMode, - onClick = { vm.isNormalMode = false }, - text = { Text(text = stringResource(id = R.string.search_image)) }, - ) - } - } - } - } - - fun setSearchMyTags(isMyTags: Boolean) { - if (isMyTags) vm.mSearchMode = 2 - } - - fun setCategory(category: Int) { - vm.mCategory = category - } - - fun formatListUrlBuilder(urlBuilder: ListUrlBuilder, query: String?) { - urlBuilder.reset() - when (vm.isNormalMode) { - true -> { - when (vm.mSearchMode) { - 1 -> urlBuilder.mode = ListUrlBuilder.MODE_NORMAL - 2 -> urlBuilder.mode = ListUrlBuilder.MODE_SUBSCRIPTION - 3 -> urlBuilder.mode = ListUrlBuilder.MODE_UPLOADER - 4 -> urlBuilder.mode = ListUrlBuilder.MODE_TAG - } - urlBuilder.keyword = query - urlBuilder.category = vm.mCategory - if (vm.isAdvancedMode) { - urlBuilder.advanceSearch = vm.advancedState.advanceSearch - urlBuilder.minRating = vm.advancedState.minRating - val pageFrom = vm.advancedState.fromPage - val pageTo = vm.advancedState.toPage - if (pageTo != -1 && pageTo < 10) { - throw EhException(context.getString(R.string.search_sp_err1)) - } else if (pageFrom != -1 && pageTo != -1 && pageTo - pageFrom < 20) { - throw EhException(context.getString(R.string.search_sp_err2)) - } - urlBuilder.pageFrom = pageFrom - urlBuilder.pageTo = pageTo - } - } - - false -> { - urlBuilder.mode = ListUrlBuilder.MODE_IMAGE_SEARCH - if (vm.path.isBlank()) throw EhException(context.getString(R.string.select_image_first)) - val uri = Uri.parse(vm.path) - val temp = AppConfig.createTempFile() ?: return - val bitmap = context.decodeBitmap(uri) ?: return - temp.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } - urlBuilder.imagePath = temp.path - urlBuilder.isUseSimilarityScan = vm.uss - urlBuilder.isOnlySearchCovers = vm.osc - } - } - } -} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/AdvanceTable.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/AdvanceTable.kt index 47166c558..97a2b9c15 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/AdvanceTable.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/AdvanceTable.kt @@ -1,5 +1,8 @@ package com.hippo.ehviewer.ui.main +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + object AdvanceTable { const val SH = 0x1 const val STO = 0x2 @@ -8,9 +11,10 @@ object AdvanceTable { const val SFT = 0x400 } +@Parcelize data class AdvancedSearchOption( val advanceSearch: Int = 0, val minRating: Int = -1, val fromPage: Int = -1, val toPage: Int = -1, -) +) : Parcelable diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt index bbbffa8fb..d15783e89 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt @@ -65,7 +65,7 @@ fun FabLayout( val coroutineScope = rememberCoroutineScope() val appearState by animateFloatAsState( targetValue = if (hidden) 0f else 1f, - animationSpec = tween(FAB_ANIMATE_TIME), + animationSpec = tween(FAB_ANIMATE_TIME, if (hidden) 0 else FAB_ANIMATE_TIME), label = "hiddenState", ) Box( diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryAdapter.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryAdapter.kt deleted file mode 100644 index 11badba50..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryAdapter.kt +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Copyright 2016 Hippo Seven - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.hippo.ehviewer.ui.scene - -import android.graphics.Rect -import android.view.View -import android.view.ViewGroup -import androidx.annotation.IntDef -import androidx.compose.ui.platform.ComposeView -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.ItemDecoration -import androidx.recyclerview.widget.StaggeredGridLayoutManager -import arrow.core.partially1 -import com.hippo.ehviewer.R -import com.hippo.ehviewer.Settings -import com.hippo.ehviewer.Settings.detailSize -import com.hippo.ehviewer.Settings.thumbSizeDp -import com.hippo.ehviewer.client.data.BaseGalleryInfo -import com.hippo.ehviewer.ui.legacy.AutoStaggeredGridLayoutManager -import com.hippo.ehviewer.ui.legacy.MarginItemDecoration -import com.hippo.ehviewer.ui.legacy.STRATEGY_MIN_SIZE -import com.hippo.ehviewer.ui.legacy.STRATEGY_SUITABLE_SIZE -import com.hippo.ehviewer.util.dp2px -import splitties.init.appCtx - -private val diffCallback = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: BaseGalleryInfo, newItem: BaseGalleryInfo) = oldItem.gid == newItem.gid - override fun areContentsTheSame(oldItem: BaseGalleryInfo, newItem: BaseGalleryInfo) = oldItem == newItem -} - -class GalleryAdapter( - private val recyclerView: RecyclerView, - private val showFavorite: Boolean, - private val onItemClick: (BaseGalleryInfo) -> Unit, - private val onItemLongClick: (BaseGalleryInfo) -> Unit, -) : PagingDataAdapter(diffCallback) { - private val resources = recyclerView.context.resources - private val layoutManager: AutoStaggeredGridLayoutManager = AutoStaggeredGridLayoutManager(0, StaggeredGridLayoutManager.VERTICAL) - private var mListDecoration: ItemDecoration? = null - private var mGirdDecoration: MarginItemDecoration? = null - private var mType = TYPE_INVALID - var tracker: GallerySelectionTracker? = null - - var type: Int - get() = mType - set(type) { - if (type == mType) return - mType = type - when (type) { - TYPE_LIST -> { - val columnWidth = resources.getDimensionPixelOffset( - when (detailSize) { - 0 -> R.dimen.gallery_list_column_width_long - 1 -> R.dimen.gallery_list_column_width_short - else -> error("Unexpected value: $detailSize") - }, - ) - layoutManager.setColumnSize(columnWidth) - layoutManager.setStrategy(STRATEGY_MIN_SIZE) - if (null != mGirdDecoration) { - recyclerView.removeItemDecoration(mGirdDecoration!!) - mGirdDecoration = null - } - if (null == mListDecoration) { - val interval = resources.getDimensionPixelOffset(R.dimen.gallery_list_interval) - mListDecoration = object : ItemDecoration() { - override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { - outRect.set(0, interval / 2, 0, interval / 2) - } - } - recyclerView.addItemDecoration(mListDecoration!!) - } - } - - TYPE_GRID -> { - val columnWidth = dp2px(appCtx, thumbSizeDp.toFloat()) - layoutManager.setColumnSize(columnWidth) - layoutManager.setStrategy(STRATEGY_SUITABLE_SIZE) - if (null != mListDecoration) { - recyclerView.removeItemDecoration(mListDecoration!!) - mListDecoration = null - } - if (null == mGirdDecoration) { - val interval = resources.getDimensionPixelOffset(R.dimen.gallery_grid_interval) - mGirdDecoration = MarginItemDecoration(interval, 0, 0, 0, 0) - recyclerView.addItemDecoration(mGirdDecoration!!) - } - } - } - refresh() - } - - init { - recyclerView.adapter = withLoadStateHeaderAndFooter( - GalleryLoadStateAdapter(::retry), - GalleryLoadStateAdapter(::retry), - ) - recyclerView.layoutManager = layoutManager - type = Settings.listMode - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { - TYPE_LIST -> ListGalleryHolder(ComposeView(parent.context), showFavorite) - TYPE_GRID -> GridGalleryHolder(ComposeView(parent.context)) - else -> error("Unexpected value: $viewType") - } - - override fun getItemViewType(position: Int) = mType - - override fun onBindViewHolder(holder: GalleryHolder, position: Int) { - val gi = getItem(position) ?: return - val selected = tracker?.isSelected(gi.gid) ?: false - holder.galleryId = gi.gid - holder.bind(gi, selected, onItemClick.partially1(gi), onItemLongClick.partially1(gi)) - } - - @IntDef(TYPE_LIST, TYPE_GRID) - @Retention(AnnotationRetention.SOURCE) - annotation class Type - companion object { - const val TYPE_INVALID = -1 - const val TYPE_LIST = 0 - const val TYPE_GRID = 1 - } -} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt index eed33a662..201036d50 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt @@ -110,7 +110,7 @@ import com.hippo.ehviewer.ui.jumpToReaderByPage import com.hippo.ehviewer.ui.legacy.CoilImageGetter import com.hippo.ehviewer.ui.main.GalleryCommentCard import com.hippo.ehviewer.ui.openBrowser -import com.hippo.ehviewer.ui.scene.GalleryListScene.Companion.toStartArgs +import com.hippo.ehviewer.ui.scene.GalleryListFragment.Companion.toStartArgs import com.hippo.ehviewer.ui.tools.LocalDialogState import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState import com.hippo.ehviewer.ui.tools.normalizeSpan diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt index 7e651f81a..cc905a4e8 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt @@ -153,7 +153,7 @@ import com.hippo.ehviewer.ui.main.GalleryTags import com.hippo.ehviewer.ui.modifyFavorites import com.hippo.ehviewer.ui.navToReader import com.hippo.ehviewer.ui.openBrowser -import com.hippo.ehviewer.ui.scene.GalleryListScene.Companion.toStartArgs +import com.hippo.ehviewer.ui.scene.GalleryListFragment.Companion.toStartArgs import com.hippo.ehviewer.ui.startDownload import com.hippo.ehviewer.ui.tools.CrystalCard import com.hippo.ehviewer.ui.tools.FilledTertiaryIconButton diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryHolder.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryHolder.kt index 1a933d914..9964c75f0 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryHolder.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryHolder.kt @@ -1,92 +1,12 @@ -/* - * Copyright 2016 Hippo Seven - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ package com.hippo.ehviewer.ui.scene import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.key import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.unit.dp -import androidx.recyclerview.widget.RecyclerView -import com.hippo.ehviewer.Settings -import com.hippo.ehviewer.Settings.listThumbSize -import com.hippo.ehviewer.client.data.GalleryInfo -import com.hippo.ehviewer.ui.main.GalleryInfoGridItem -import com.hippo.ehviewer.ui.main.GalleryInfoListItem -import com.hippo.ehviewer.ui.setMD3Content -import eu.kanade.tachiyomi.util.system.pxToDp - -abstract class GalleryHolder(composeView: ComposeView) : RecyclerView.ViewHolder(composeView) { - var galleryId = 0L - - abstract fun bind( - galleryInfo: GalleryInfo, - isChecked: Boolean, - onClick: () -> Unit, - onLongClick: () -> Unit, - ) -} - -class ListGalleryHolder(private val composeView: ComposeView, private val showFavourite: Boolean) : GalleryHolder(composeView) { - - private val height = (3 * listThumbSize * 3).pxToDp.dp - private val showPages = Settings.showGalleryPages - - override fun bind(galleryInfo: GalleryInfo, isChecked: Boolean, onClick: () -> Unit, onLongClick: () -> Unit) { - composeView.setMD3Content { - CheckableItem(checked = isChecked) { - // Workaround ComposeView in RecyclerView, since view is reused, remembered value will not update when setContent with similar @Composable lambda, since composer group key is the same - key(galleryInfo.gid) { - GalleryInfoListItem( - onClick = onClick, - onLongClick = onLongClick, - info = galleryInfo, - modifier = Modifier.height(height), - isInFavScene = !showFavourite, - showPages = showPages, - ) - } - } - } - } -} - -class GridGalleryHolder(private val composeView: ComposeView) : GalleryHolder(composeView) { - override fun bind(galleryInfo: GalleryInfo, isChecked: Boolean, onClick: () -> Unit, onLongClick: () -> Unit) { - composeView.setMD3Content { - CheckableItem(checked = isChecked) { - GalleryInfoGridItem( - onClick = onClick, - onLongClick = onLongClick, - info = galleryInfo, - ) - } - } - - // Workaround https://github.com/Ehviewer-Overhauled/Ehviewer/issues/1023 - // See https://issuetracker.google.com/issues/240449681 - composeView.getChildAt(0)?.requestLayout() - } -} @Composable fun CheckableItem(checked: Boolean, content: @Composable () -> Unit) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListFragment.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListFragment.kt new file mode 100644 index 000000000..5b5957b69 --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListFragment.kt @@ -0,0 +1,1002 @@ +package com.hippo.ehviewer.ui.scene + +import android.graphics.Bitmap +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewConfiguration +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.foundation.DraggableItem +import androidx.compose.foundation.clickable +import androidx.compose.foundation.dragContainer +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.foundation.rememberDragDropState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text2.input.rememberTextFieldState +import androidx.compose.foundation.text2.input.setTextAndPlaceCursorAtEnd +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.automirrored.filled.LastPage +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Reorder +import androidx.compose.material.icons.outlined.Bookmarks +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DismissDirection +import androidx.compose.material3.DismissValue +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.material3.rememberDismissState +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.lerp +import androidx.core.os.bundleOf +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController +import androidx.paging.LoadState +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.cachedIn +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemContentType +import androidx.paging.compose.itemKey +import com.google.android.material.datepicker.CalendarConstraints +import com.google.android.material.datepicker.CalendarConstraints.DateValidator +import com.google.android.material.datepicker.CompositeDateValidator +import com.google.android.material.datepicker.DateValidatorPointBackward +import com.google.android.material.datepicker.DateValidatorPointForward +import com.google.android.material.datepicker.MaterialDatePicker +import com.hippo.ehviewer.EhDB +import com.hippo.ehviewer.R +import com.hippo.ehviewer.Settings +import com.hippo.ehviewer.client.EhEngine +import com.hippo.ehviewer.client.EhTagDatabase +import com.hippo.ehviewer.client.EhUrl +import com.hippo.ehviewer.client.EhUtils +import com.hippo.ehviewer.client.data.BaseGalleryInfo +import com.hippo.ehviewer.client.data.ListUrlBuilder +import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_IMAGE_SEARCH +import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_NORMAL +import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_SUBSCRIPTION +import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_TAG +import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_TOPLIST +import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_UPLOADER +import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_WHATS_HOT +import com.hippo.ehviewer.client.exception.CloudflareBypassException +import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser +import com.hippo.ehviewer.client.parser.GalleryPageUrlParser +import com.hippo.ehviewer.dao.QuickSearch +import com.hippo.ehviewer.icons.EhIcons +import com.hippo.ehviewer.icons.big.SadAndroid +import com.hippo.ehviewer.icons.filled.GoTo +import com.hippo.ehviewer.image.Image.Companion.decodeBitmap +import com.hippo.ehviewer.ui.MainActivity +import com.hippo.ehviewer.ui.WebViewActivity +import com.hippo.ehviewer.ui.doGalleryInfoAction +import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder +import com.hippo.ehviewer.ui.legacy.FAB_ANIMATE_TIME +import com.hippo.ehviewer.ui.main.AdvancedSearchOption +import com.hippo.ehviewer.ui.main.FabLayout +import com.hippo.ehviewer.ui.main.GalleryInfoGridItem +import com.hippo.ehviewer.ui.main.GalleryInfoListItem +import com.hippo.ehviewer.ui.main.ImageSearch +import com.hippo.ehviewer.ui.main.NormalSearch +import com.hippo.ehviewer.ui.main.SearchAdvanced +import com.hippo.ehviewer.ui.scene.GalleryListFragment.Companion.toStartArgs +import com.hippo.ehviewer.ui.tools.Deferred +import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn +import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid +import com.hippo.ehviewer.ui.tools.LocalDialogState +import com.hippo.ehviewer.ui.tools.LocalTouchSlopProvider +import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState +import com.hippo.ehviewer.ui.tools.observed +import com.hippo.ehviewer.ui.tools.rememberInVM +import com.hippo.ehviewer.util.AppConfig +import com.hippo.ehviewer.util.ExceptionUtils +import com.hippo.ehviewer.util.findActivity +import com.hippo.ehviewer.util.getParcelableCompat +import com.hippo.ehviewer.util.pickVisualMedia +import com.ramcosta.composedestinations.annotation.Destination +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.lang.withIOContext +import eu.kanade.tachiyomi.util.lang.withUIContext +import eu.kanade.tachiyomi.util.system.pxToDp +import java.io.File +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.roundToInt +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import moe.tarsin.coroutines.runSuspendCatching + +@Destination +@Composable +fun GalleryListScreen(lub: ListUrlBuilder, navigator: NavController) { + val searchFieldState = rememberTextFieldState() + var urlBuilder by rememberSaveable(lub) { mutableStateOf(lub) } + var searchBarOffsetY by remember { mutableStateOf(0) } + var showSearchLayout by rememberSaveable { mutableStateOf(false) } + + var searchNormalMode by rememberSaveable { mutableStateOf(true) } + var searchAdvancedMode by rememberSaveable { mutableStateOf(false) } + var category by rememberSaveable { mutableIntStateOf(Settings.searchCategory) } + var searchMethod by rememberSaveable { mutableIntStateOf(1) } + var advancedSearchOption by rememberSaveable { mutableStateOf(AdvancedSearchOption()) } + var useSimilarityScan by rememberSaveable { mutableStateOf(false) } + var searchCoverOnly by rememberSaveable { mutableStateOf(false) } + var imagePath by rememberSaveable { mutableStateOf("") } + + LaunchedEffect(urlBuilder) { + if (urlBuilder.mode == MODE_SUBSCRIPTION) searchMethod = 2 + if (urlBuilder.category != EhUtils.NONE) category = urlBuilder.category + if (urlBuilder.mode != MODE_TOPLIST) { + var keyword = urlBuilder.keyword.orEmpty() + if (urlBuilder.mode == MODE_TAG) { + keyword = wrapTagKeyword(keyword) + } + if (keyword.isNotBlank()) { + searchFieldState.setTextAndPlaceCursorAtEnd(keyword) + } + } + } + + val animatedSearchLayout by animateFloatMergePredictiveBackAsState( + enable = showSearchLayout, + animationSpec = tween(FAB_ANIMATE_TIME * 2), + ) { showSearchLayout = false } + val context = LocalContext.current + val activity = remember { context.findActivity() } + val windowSizeClass = calculateWindowSizeClass(activity) + val dialogState = LocalDialogState.current + val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + val listState = rememberLazyListState() + val gridState = rememberLazyStaggeredGridState() + val isTopList = remember(urlBuilder) { urlBuilder.mode == MODE_TOPLIST } + val ehHint = stringResource(R.string.gallery_list_search_bar_hint_e_hentai) + val exHint = stringResource(R.string.gallery_list_search_bar_hint_exhentai) + val searchBarHint = remember { if (EhUtils.isExHentai) exHint else ehHint } + val suitableTitle = getSuitableTitleForUrlBuilder(urlBuilder) + val data = rememberInVM(urlBuilder) { + Pager(PagingConfig(25)) { + object : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + override suspend fun load(params: LoadParams) = withIOContext { + if (urlBuilder.mode == MODE_TOPLIST) { + // TODO: Since we know total pages, let pager support jump + val key = (params.key ?: urlBuilder.mJumpTo ?: "0").toInt() + val prev = (key - 1).takeIf { it > 0 } + val next = (key + 1).takeIf { it < TOPLIST_PAGES } + runSuspendCatching { + urlBuilder.setJumpTo(key) + EhEngine.getGalleryList(urlBuilder.build()) + }.onFailure { + return@withIOContext LoadResult.Error(it) + }.onSuccess { + return@withIOContext LoadResult.Page(it.galleryInfoList, prev?.toString(), next?.toString()) + } + } + when (params) { + is LoadParams.Prepend -> urlBuilder.setIndex(params.key, isNext = false) + is LoadParams.Append -> urlBuilder.setIndex(params.key, isNext = true) + is LoadParams.Refresh -> { + val key = params.key + if (key.isNullOrBlank()) { + if (urlBuilder.mJumpTo != null) { + urlBuilder.mNext ?: urlBuilder.setIndex("2", true) + } + } else { + urlBuilder.setIndex(key, false) + } + } + } + val r = runSuspendCatching { + if (MODE_IMAGE_SEARCH == urlBuilder.mode) { + EhEngine.imageSearch( + File(urlBuilder.imagePath!!), + urlBuilder.isUseSimilarityScan, + urlBuilder.isOnlySearchCovers, + ) + } else { + val url = urlBuilder.build() + EhEngine.getGalleryList(url) + } + }.onFailure { + return@withIOContext LoadResult.Error(it) + }.getOrThrow() + urlBuilder.mJumpTo = null + LoadResult.Page(r.galleryInfoList, r.prev, r.next) + } + } + }.flow.cachedIn(viewModelScope) + }.collectAsLazyPagingItems() + val listMode by remember { + Settings.listModeBackField.valueFlow() + }.collectAsState(Settings.listMode) + + val quickSearchList = remember { mutableStateListOf() } + val toplists = (stringArrayResource(id = R.array.toplist_entries) zip stringArrayResource(id = R.array.toplist_values)).toMap() + val quickSearchName = getSuitableTitleForUrlBuilder(urlBuilder, false) + var saveProgress by Settings::qSSaveProgress.observed + + fun launchSnackbar(content: String) = coroutineScope.launch { snackbarHostState.showSnackbar(content) } + fun getFirstVisibleItemIndex() = if (listMode == 0) { + listState.firstVisibleItemIndex + } else { + gridState.firstVisibleItemIndex + } + + LaunchedEffect(Unit) { + val list = withIOContext { EhDB.getAllQuickSearch() } + quickSearchList.addAll(list) + } + + with(activity) { + if (isTopList) { + ProvideSideSheetContent { sheetState -> + TopAppBar( + title = { Text(text = stringResource(id = R.string.toplist)) }, + windowInsets = WindowInsets(0), + ) + toplists.forEach { (name, keyword) -> + Text( + text = name, + modifier = Modifier.clickable { + Settings.recentToplist = keyword + urlBuilder = ListUrlBuilder(MODE_TOPLIST, mKeyword = keyword) + showSearchLayout = false + coroutineScope.launch { sheetState.close() } + }.fillMaxWidth().minimumInteractiveComponentSize().padding(horizontal = 16.dp), + ) + } + } + } else { + ProvideSideSheetContent { sheetState -> + TopAppBar( + title = { Text(text = stringResource(id = R.string.quick_search)) }, + actions = { + IconButton(onClick = { + coroutineScope.launch { + dialogState.awaitPermissionOrCancel( + showCancelButton = false, + title = R.string.quick_search, + ) { + Text(text = stringResource(id = R.string.add_quick_search_tip)) + } + } + }) { + Icon( + imageVector = Icons.AutoMirrored.Default.Help, + contentDescription = stringResource(id = R.string.readme), + ) + } + IconButton(onClick = { + if (data.itemCount == 0) return@IconButton + + if (urlBuilder.mode == MODE_IMAGE_SEARCH) { + launchSnackbar(context.getString(R.string.image_search_not_quick_search)) + return@IconButton + } + + val firstItem = data.itemSnapshotList.items[getFirstVisibleItemIndex()] + val next = firstItem.gid + 1 + quickSearchList.fastForEach { q -> + if (urlBuilder.equalsQuickSearch(q)) { + val nextStr = q.name.substringAfterLast('@', "") + if (nextStr.toLongOrNull() == next) { + launchSnackbar(context.getString(R.string.duplicate_quick_search, q.name)) + return@IconButton + } + } + } + + coroutineScope.launch { + dialogState.awaitInputTextWithCheckBox( + initial = quickSearchName ?: urlBuilder.keyword.orEmpty(), + title = R.string.add_quick_search_dialog_title, + hint = R.string.quick_search, + checked = saveProgress, + checkBoxText = R.string.save_progress, + ) { input, checked -> + var text = input.trim() + if (text.isEmpty()) { + return@awaitInputTextWithCheckBox context.getString(R.string.name_is_empty) + } + + if (checked) { + text += "@$next" + } + if (quickSearchList.fastAny { it.name == text }) { + return@awaitInputTextWithCheckBox context.getString(R.string.duplicate_name) + } + + val quickSearch = urlBuilder.toQuickSearch(text) + quickSearch.position = quickSearchList.size + quickSearchList.add(quickSearch) + launchIO { + EhDB.insertQuickSearch(quickSearch) + } + saveProgress = checked + return@awaitInputTextWithCheckBox null + } + } + }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(id = R.string.add), + ) + } + }, + windowInsets = WindowInsets(0), + ) + Box(modifier = Modifier.fillMaxSize()) { + val quickSearchListState = rememberLazyListState() + val dragDropState = rememberDragDropState(quickSearchListState) { fromIndex, toIndex -> + quickSearchList.apply { + add(toIndex, removeAt(fromIndex)) + } + coroutineScope.launchIO { + val range = if (fromIndex < toIndex) fromIndex..toIndex else toIndex..fromIndex + val list = quickSearchList.slice(range) + list.zip(range).forEach { it.first.position = it.second } + EhDB.updateQuickSearch(list) + } + } + LazyColumn( + modifier = Modifier.fillMaxSize().dragContainer(dragDropState), + state = quickSearchListState, + ) { + itemsIndexed(quickSearchList) { index, item -> + val dismissState = rememberDismissState( + confirmValueChange = { + if (it == DismissValue.DismissedToStart) { + quickSearchList.removeAt(index) + coroutineScope.launchIO { + EhDB.deleteQuickSearch(item) + } + } + true + }, + ) + LocalTouchSlopProvider(Settings.touchSlopFactor.toFloat()) { + SwipeToDismissBox( + state = dismissState, + backgroundContent = {}, + directions = setOf(DismissDirection.EndToStart), + ) { + DraggableItem(dragDropState = dragDropState, index = index) { + Row( + modifier = Modifier.clickable { + if (urlBuilder.mode == MODE_WHATS_HOT) { + navigator.navAnimated(R.id.galleryListScene, ListUrlBuilder(item).toStartArgs()) + } else { + urlBuilder = ListUrlBuilder(item) + } + showSearchLayout = false + coroutineScope.launch { sheetState.close() } + }.fillMaxWidth().minimumInteractiveComponentSize().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = item.name, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.Default.Reorder, + contentDescription = null, + ) + } + } + } + } + } + } + Deferred({ delay(200) }) { + if (quickSearchList.isEmpty()) { + Text( + text = stringResource(id = R.string.quick_search_tip), + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } + } + } + + val refreshState = rememberPullToRefreshState { + data.loadState.refresh is LoadState.NotLoading + } + + if (refreshState.isRefreshing) { + LaunchedEffect(Unit) { + urlBuilder.setIndex(null, true) + urlBuilder.mJumpTo = null + data.refresh() + } + } + + var hidden by remember { mutableStateOf(false) } + val searchBarConnection = remember { + val slop = ViewConfiguration.get(context).scaledTouchSlop + object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + val dy = -consumed.y + if (dy >= slop) { + hidden = true + } else if (dy <= -slop / 2) { + hidden = false + } + searchBarOffsetY = (searchBarOffsetY - dy).roundToInt().coerceIn(-300, 0) + return Offset.Zero // We never consume it + } + } + } + val combinedModifier = Modifier.nestedScroll(searchBarConnection).nestedScroll(refreshState.nestedScrollConnection) + + val openGalleryKeyword = stringResource(R.string.gallery_list_search_bar_open_gallery) + abstract class UrlSuggestion : Suggestion() { + override val keyword = openGalleryKeyword + override val canOpenDirectly = true + override fun onClick() { + navigator.navAnimated(destination, args) + showSearchLayout = false + } + abstract val destination: Int + abstract val args: Bundle + } + + class GalleryDetailUrlSuggestion( + gid: Long, + token: String, + ) : UrlSuggestion() { + override val destination = R.id.galleryDetailScene + override val args = bundleOf(GalleryDetailScene.KEY_ARGS to TokenArgs(gid, token)) + } + + class GalleryPageUrlSuggestion( + gid: Long, + pToken: String, + page: Int, + ) : UrlSuggestion() { + override val destination = R.id.progressScene + override val args = bundleOf( + ProgressFragment.KEY_GID to gid, + ProgressFragment.KEY_PTOKEN to pToken, + ProgressFragment.KEY_PAGE to page, + ) + } + + var expanded by remember { mutableStateOf(false) } + + val searchErr1 = stringResource(R.string.search_sp_err1) + val searchErr2 = stringResource(R.string.search_sp_err2) + val selectImageFirst = stringResource(R.string.select_image_first) + SearchBarScreen( + title = suitableTitle, + searchFieldState = searchFieldState, + searchFieldHint = searchBarHint, + showSearchFab = showSearchLayout, + onApplySearch = { query -> + val builder = ListUrlBuilder() + val oldMode = urlBuilder.mode + if (!showSearchLayout) { + // If it's MODE_SUBSCRIPTION, keep it + val newMode = if (oldMode == MODE_SUBSCRIPTION) MODE_SUBSCRIPTION else MODE_NORMAL + builder.mode = newMode + builder.keyword = query + } else { + if (searchNormalMode) { + when (searchMethod) { + 1 -> builder.mode = MODE_NORMAL + 2 -> builder.mode = MODE_SUBSCRIPTION + 3 -> builder.mode = MODE_UPLOADER + 4 -> builder.mode = MODE_TAG + } + builder.keyword = query + builder.category = category + if (searchAdvancedMode) { + builder.advanceSearch = advancedSearchOption.advanceSearch + builder.minRating = advancedSearchOption.minRating + val pageFrom = advancedSearchOption.fromPage + val pageTo = advancedSearchOption.toPage + if (pageTo != -1 && pageTo < 10) { + activity.showTip(searchErr1, BaseScene.LENGTH_LONG) + return@SearchBarScreen + } else if (pageFrom != -1 && pageTo != -1 && pageTo - pageFrom < 20) { + activity.showTip(searchErr2, BaseScene.LENGTH_LONG) + return@SearchBarScreen + } + builder.pageFrom = pageFrom + builder.pageTo = pageTo + } + } else { + builder.mode = MODE_IMAGE_SEARCH + if (imagePath.isBlank()) { + activity.showTip(selectImageFirst, BaseScene.LENGTH_LONG) + return@SearchBarScreen + } + val uri = Uri.parse(imagePath) + val temp = AppConfig.createTempFile() ?: return@SearchBarScreen + val bitmap = context.decodeBitmap(uri) ?: return@SearchBarScreen + temp.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } + builder.imagePath = temp.path + builder.isUseSimilarityScan = useSimilarityScan + builder.isOnlySearchCovers = searchCoverOnly + } + } + when (oldMode) { + MODE_TOPLIST, MODE_WHATS_HOT -> { + // Wait for search view to hide + delay(300) + withUIContext { navigator.navAnimated(R.id.galleryListScene, builder.toStartArgs()) } + } + else -> urlBuilder = builder + } + showSearchLayout = false + }, + onSearchExpanded = { hidden = true }, + onSearchHidden = { hidden = false }, + refreshState = refreshState, + suggestionProvider = { + GalleryDetailUrlParser.parse(it, false)?.run { + GalleryDetailUrlSuggestion(gid, token) + } ?: GalleryPageUrlParser.parse(it, false)?.run { + GalleryPageUrlSuggestion(gid, pToken, page) + } + }, + searchBarOffsetY = searchBarOffsetY, + snackbarHost = { SnackbarHost(snackbarHostState) }, + trailingIcon = { + IconButton(onClick = { activity.openSideSheet() }) { + Icon(imageVector = Icons.Outlined.Bookmarks, contentDescription = stringResource(id = R.string.quick_search)) + } + IconButton(onClick = { showSearchLayout = !showSearchLayout }) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.rotate(lerp(90f, 0f, animatedSearchLayout)), + ) + } + }, + ) { paddingValues -> + val layoutDirection = LocalLayoutDirection.current + val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h) + val marginV = dimensionResource(id = R.dimen.gallery_list_margin_v) + val realPadding = PaddingValues( + top = paddingValues.calculateTopPadding() + marginV, + bottom = paddingValues.calculateBottomPadding() + marginV, + start = paddingValues.calculateStartPadding(layoutDirection) + marginH, + end = paddingValues.calculateEndPadding(layoutDirection) + marginH, + ) + + Column( + modifier = Modifier.imePadding().verticalScroll(rememberScrollState()) + .padding( + top = realPadding.calculateTopPadding(), + start = realPadding.calculateStartPadding(layoutDirection), + end = realPadding.calculateEndPadding(layoutDirection), + bottom = 8.dp, + ) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Bottom)) + .scale(1 - animatedSearchLayout).alpha(1 - animatedSearchLayout), + ) { + AnimatedVisibility(visible = searchNormalMode) { + ElevatedCard(modifier = Modifier.fillMaxWidth().padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) { + Column(modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.search_category_padding_h), vertical = dimensionResource(id = R.dimen.search_category_padding_v))) { + Text( + text = stringResource(id = R.string.search_normal), + modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + NormalSearch( + category = category, + onCategoryChanged = { + Settings.searchCategory = it + category = it + }, + searchMode = searchMethod, + onSearchModeChanged = { searchMethod = it }, + isAdvanced = searchAdvancedMode, + onAdvancedChanged = { searchAdvancedMode = it }, + showInfo = { BaseDialogBuilder(context).setMessage(R.string.search_tip).show() }, + maxItemsInEachRow = when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> 2 + WindowWidthSizeClass.Medium -> 3 + else -> 5 + }, + ) + } + } + } + AnimatedVisibility(visible = searchNormalMode && searchAdvancedMode) { + ElevatedCard(modifier = Modifier.fillMaxWidth().padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) { + Column(modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.search_category_padding_h), vertical = dimensionResource(id = R.dimen.search_category_padding_v))) { + Text( + text = stringResource(id = R.string.search_advance), + modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + SearchAdvanced( + state = advancedSearchOption, + onStateChanged = { advancedSearchOption = it }, + ) + } + } + } + AnimatedVisibility(visible = !searchNormalMode) { + ElevatedCard(modifier = Modifier.fillMaxWidth().padding(vertical = dimensionResource(id = R.dimen.search_layout_margin_v))) { + Column(modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.search_category_padding_h), vertical = dimensionResource(id = R.dimen.search_category_padding_v))) { + Text( + text = stringResource(id = R.string.search_image), + modifier = Modifier.height(dimensionResource(id = R.dimen.search_category_title_height)), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + ImageSearch( + imagePath = imagePath, + onSelectImage = { + coroutineScope.launch { + val image = context.pickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly) + if (image != null) imagePath = image.toString() + } + }, + uss = useSimilarityScan, + onUssChecked = { useSimilarityScan = it }, + osc = searchCoverOnly, + onOscChecked = { searchCoverOnly = it }, + ) + } + } + } + SecondaryTabRow( + selectedTabIndex = if (searchNormalMode) 0 else 1, + divider = {}, + ) { + Tab( + selected = searchNormalMode, + onClick = { searchNormalMode = true }, + text = { Text(text = stringResource(id = R.string.keyword_search)) }, + ) + Tab( + selected = !searchNormalMode, + onClick = { searchNormalMode = false }, + text = { Text(text = stringResource(id = R.string.search_image)) }, + ) + } + } + + Box(modifier = Modifier.fillMaxSize().scale(animatedSearchLayout).alpha(animatedSearchLayout)) { + when (val loadState = data.loadState.refresh) { + is LoadState.Loading -> if (data.itemCount == 0) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + is LoadState.Error -> { + LaunchedEffect(loadState) { + if (loadState.error.cause is CloudflareBypassException) { + dialogState.awaitPermissionOrCancel(title = R.string.cloudflare_bypass_failed) { + Text(text = stringResource(id = R.string.open_in_webview)) + } + withUIContext { navigator.navAnimated(R.id.webView, bundleOf(WebViewActivity.KEY_URL to EhUrl.host)) } + } + } + Column( + modifier = Modifier.align(Alignment.Center).widthIn(max = 228.dp) + .clip(ShapeDefaults.Small).clickable { data.retry() }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = EhIcons.Big.Default.SadAndroid, + contentDescription = null, + modifier = Modifier.padding(16.dp).size(120.dp), + tint = MaterialTheme.colorScheme.tertiary, + ) + Text( + text = ExceptionUtils.getReadableString(loadState.error), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } + } + is LoadState.NotLoading -> SideEffect { + refreshState.endRefresh() + } + } + if (listMode == 0) { + val height = (3 * Settings.listThumbSize * 3).pxToDp.dp + val showPages = Settings.showGalleryPages + FastScrollLazyColumn( + modifier = combinedModifier, + state = listState, + contentPadding = realPadding, + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.gallery_list_interval)), + ) { + items( + count = data.itemCount, + key = data.itemKey(key = { item -> item.gid }), + contentType = data.itemContentType(), + ) { index -> + val info = data[index] + if (info != null) { + GalleryInfoListItem( + onClick = { + navigator.navAnimated( + R.id.galleryDetailScene, + bundleOf(GalleryDetailScene.KEY_ARGS to GalleryInfoArgs(info)), + ) + }, + onLongClick = { + coroutineScope.launch { + dialogState.doGalleryInfoAction(info, context) + } + }, + info = info, + isInFavScene = false, + showPages = showPages, + modifier = Modifier.height(height), + ) + } + } + } + } else { + val gridInterval = dimensionResource(R.dimen.gallery_grid_interval) + FastScrollLazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(Settings.thumbSizeDp.dp), + modifier = combinedModifier, + state = gridState, + verticalItemSpacing = gridInterval, + horizontalArrangement = Arrangement.spacedBy(gridInterval), + contentPadding = realPadding, + ) { + items( + count = data.itemCount, + key = data.itemKey(key = { item -> item.gid }), + contentType = data.itemContentType(), + ) { index -> + val info = data[index] + if (info != null) { + GalleryInfoGridItem( + onClick = { + navigator.navAnimated( + R.id.galleryDetailScene, + bundleOf(GalleryDetailScene.KEY_ARGS to GalleryInfoArgs(info)), + ) + }, + onLongClick = { + coroutineScope.launch { + dialogState.doGalleryInfoAction(info, context) + } + }, + info = info, + ) + } + } + } + } + } + } + + val gotoTitle = stringResource(R.string.go_to) + val invalidNum = stringResource(R.string.error_invalid_number) + val outOfRange = stringResource(R.string.error_out_of_range) + FabLayout( + hidden = hidden || showSearchLayout, + expanded = expanded, + onExpandChanged = { expanded = it }, + autoCancel = true, + ) { + onClick(Icons.Default.Refresh) { + urlBuilder.setIndex(null, true) + urlBuilder.mJumpTo = null + data.refresh() + } + if (urlBuilder.mode != MODE_WHATS_HOT) { + onClick(EhIcons.Default.GoTo) { + if (isTopList) { + val page = urlBuilder.mJumpTo?.toIntOrNull() ?: 0 + val hint = context.getString(R.string.go_to_hint, page + 1, TOPLIST_PAGES) + val text = dialogState.awaitInputText(title = gotoTitle, hint = hint, isNumber = true) { oriText -> + val text = oriText.trim() + val goTo = runCatching { + text.toInt() - 1 + }.onFailure { + return@awaitInputText invalidNum + }.getOrThrow() + if (goTo !in 0..() + listValidators.add(DateValidatorPointForward.from(fromDate)) + listValidators.add(DateValidatorPointBackward.before(toDate)) + val constraintsBuilder = CalendarConstraints.Builder() + .setStart(fromDate) + .setEnd(toDate) + .setValidator(CompositeDateValidator.allOf(listValidators)) + val datePicker = MaterialDatePicker.Builder.datePicker() + .setCalendarConstraints(constraintsBuilder.build()) + .setTitleText(R.string.go_to) + .setSelection(toDate) + .build() + datePicker.show(activity.supportFragmentManager, "date-picker") + datePicker.addOnPositiveButtonClickListener { time -> + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US).withZone(ZoneOffset.UTC) + val jumpTo = formatter.format(Instant.ofEpochMilli(time)) + urlBuilder.mJumpTo = jumpTo + data.refresh() + } + } + } + onClick(Icons.AutoMirrored.Default.LastPage) { + if (isTopList) { + urlBuilder.mJumpTo = "${TOPLIST_PAGES - 1}" + data.refresh() + } else { + urlBuilder.setIndex("1", false) + data.refresh() + } + } + } + } +} + +class GalleryListFragment : BaseScene() { + override val enableDrawerGestures = true + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val navController = findNavController() + val args = when (arguments?.getString(KEY_ACTION) ?: ACTION_HOMEPAGE) { + ACTION_HOMEPAGE -> ListUrlBuilder() + ACTION_SUBSCRIPTION -> ListUrlBuilder(MODE_SUBSCRIPTION) + ACTION_WHATS_HOT -> ListUrlBuilder(MODE_WHATS_HOT) + ACTION_TOP_LIST -> ListUrlBuilder(MODE_TOPLIST, mKeyword = Settings.recentToplist) + ACTION_LIST_URL_BUILDER -> arguments?.getParcelableCompat(KEY_LIST_URL_BUILDER)?.copy() ?: ListUrlBuilder() + else -> error("Wrong KEY_ACTION:${arguments?.getString(KEY_ACTION)} when handle args!") + } + return ComposeWithMD3 { GalleryListScreen(args, navController) } + } + + companion object { + const val KEY_ACTION = "action" + const val ACTION_HOMEPAGE = "action_homepage" + const val ACTION_SUBSCRIPTION = "action_subscription" + const val ACTION_WHATS_HOT = "action_whats_hot" + const val ACTION_TOP_LIST = "action_top_list" + const val ACTION_LIST_URL_BUILDER = "action_list_url_builder" + const val KEY_LIST_URL_BUILDER = "list_url_builder" + fun ListUrlBuilder.toStartArgs() = bundleOf( + KEY_ACTION to ACTION_LIST_URL_BUILDER, + KEY_LIST_URL_BUILDER to this, + ) + } +} + +private const val TOPLIST_PAGES = 200 + +@Composable +@Stable +private fun getSuitableTitleForUrlBuilder(urlBuilder: ListUrlBuilder, appName: Boolean = true): String? { + val context = LocalContext.current + val keyword = urlBuilder.keyword + val category = urlBuilder.category + val mode = urlBuilder.mode + return if (mode == MODE_WHATS_HOT) { + stringResource(R.string.whats_hot) + } else if (!keyword.isNullOrEmpty()) { + when (mode) { + MODE_TOPLIST -> { + when (keyword) { + "11" -> stringResource(R.string.toplist_alltime) + "12" -> stringResource(R.string.toplist_pastyear) + "13" -> stringResource(R.string.toplist_pastmonth) + "15" -> stringResource(R.string.toplist_yesterday) + else -> null + } + } + MODE_TAG -> { + val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(context) && EhTagDatabase.initialized + wrapTagKeyword(keyword, canTranslate) + } + else -> keyword + } + } else if (category == EhUtils.NONE && urlBuilder.advanceSearch == -1) { + val appNameStr = stringResource(R.string.app_name) + val homepageStr = stringResource(R.string.homepage) + when (mode) { + MODE_NORMAL -> if (appName) appNameStr else homepageStr + MODE_SUBSCRIPTION -> stringResource(R.string.subscription) + else -> null + } + } else if (category.countOneBits() == 1) { + EhUtils.getCategory(category) + } else { + null + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListScene.kt deleted file mode 100644 index 09761160a..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryListScene.kt +++ /dev/null @@ -1,1006 +0,0 @@ -/* - * Copyright 2016 Hippo Seven - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.hippo.ehviewer.ui.scene - -import android.content.Context -import android.content.DialogInterface -import android.os.Bundle -import android.os.Parcelable -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.View -import android.view.ViewConfiguration -import android.view.ViewGroup -import android.widget.TextView -import androidx.activity.OnBackPressedCallback -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.LastPage -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.outlined.Bookmarks -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import androidx.core.content.ContextCompat -import androidx.core.os.bundleOf -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModel -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.setViewTreeViewModelStoreOwner -import androidx.lifecycle.viewModelScope -import androidx.paging.LoadState -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingSource -import androidx.paging.PagingState -import androidx.paging.cachedIn -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.viewbinding.ViewBinding -import com.google.android.material.datepicker.CalendarConstraints -import com.google.android.material.datepicker.CalendarConstraints.DateValidator -import com.google.android.material.datepicker.CompositeDateValidator -import com.google.android.material.datepicker.DateValidatorPointBackward -import com.google.android.material.datepicker.DateValidatorPointForward -import com.google.android.material.datepicker.MaterialDatePicker -import com.hippo.ehviewer.EhDB -import com.hippo.ehviewer.R -import com.hippo.ehviewer.Settings -import com.hippo.ehviewer.client.EhEngine -import com.hippo.ehviewer.client.EhTagDatabase -import com.hippo.ehviewer.client.EhUrl -import com.hippo.ehviewer.client.EhUtils -import com.hippo.ehviewer.client.data.BaseGalleryInfo -import com.hippo.ehviewer.client.data.ListUrlBuilder -import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_IMAGE_SEARCH -import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_NORMAL -import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_SUBSCRIPTION -import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_TAG -import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_TOPLIST -import com.hippo.ehviewer.client.data.ListUrlBuilder.Companion.MODE_WHATS_HOT -import com.hippo.ehviewer.client.exception.CloudflareBypassException -import com.hippo.ehviewer.client.exception.EhException -import com.hippo.ehviewer.client.parser.GalleryDetailUrlParser -import com.hippo.ehviewer.client.parser.GalleryPageUrlParser -import com.hippo.ehviewer.dao.QuickSearch -import com.hippo.ehviewer.databinding.DrawerListRvBinding -import com.hippo.ehviewer.databinding.ItemDrawerListBinding -import com.hippo.ehviewer.databinding.SceneGalleryListBinding -import com.hippo.ehviewer.icons.EhIcons -import com.hippo.ehviewer.icons.filled.GoTo -import com.hippo.ehviewer.ui.WebViewActivity -import com.hippo.ehviewer.ui.doGalleryInfoAction -import com.hippo.ehviewer.ui.legacy.BaseDialogBuilder -import com.hippo.ehviewer.ui.legacy.BringOutTransition -import com.hippo.ehviewer.ui.legacy.EditTextDialogBuilder -import com.hippo.ehviewer.ui.legacy.FAB_ANIMATE_TIME -import com.hippo.ehviewer.ui.legacy.HandlerDrawable -import com.hippo.ehviewer.ui.legacy.LayoutManagerUtils.firstVisibleItemPosition -import com.hippo.ehviewer.ui.legacy.SecondaryFab -import com.hippo.ehviewer.ui.legacy.ViewTransition -import com.hippo.ehviewer.ui.tools.DialogState -import com.hippo.ehviewer.util.ExceptionUtils -import com.hippo.ehviewer.util.getParcelableCompat -import com.hippo.ehviewer.util.getValue -import com.hippo.ehviewer.util.lazyMut -import com.hippo.ehviewer.util.setValue -import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.withIOContext -import eu.kanade.tachiyomi.util.lang.withUIContext -import java.io.File -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter -import java.util.Locale -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.parcelize.Parcelize -import moe.tarsin.coroutines.runSuspendCatching -import rikka.core.res.resolveColor - -class VMStorage1 : ViewModel() { - var urlBuilder = ListUrlBuilder() - val dataFlow = Pager(PagingConfig(25)) { - object : PagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - override suspend fun load(params: LoadParams) = withIOContext { - if (urlBuilder.mode == MODE_TOPLIST) { - // TODO: Since we know total pages, let pager support jump - val key = (params.key ?: urlBuilder.mJumpTo ?: "0").toInt() - val prev = (key - 1).takeIf { it > 0 } - val next = (key + 1).takeIf { it < TOPLIST_PAGES } - runSuspendCatching { - urlBuilder.setJumpTo(key) - EhEngine.getGalleryList(urlBuilder.build()) - }.onFailure { - return@withIOContext LoadResult.Error(it) - }.onSuccess { - return@withIOContext LoadResult.Page(it.galleryInfoList, prev?.toString(), next?.toString()) - } - } - when (params) { - is LoadParams.Prepend -> urlBuilder.setIndex(params.key, isNext = false) - is LoadParams.Append -> urlBuilder.setIndex(params.key, isNext = true) - is LoadParams.Refresh -> { - val key = params.key - if (key.isNullOrBlank()) { - if (urlBuilder.mJumpTo != null) { - urlBuilder.mNext ?: urlBuilder.setIndex("2", true) - } - } else { - urlBuilder.setIndex(key, false) - } - } - } - val r = runSuspendCatching { - if (MODE_IMAGE_SEARCH == urlBuilder.mode) { - EhEngine.imageSearch( - File(urlBuilder.imagePath!!), - urlBuilder.isUseSimilarityScan, - urlBuilder.isOnlySearchCovers, - ) - } else { - val url = urlBuilder.build() - EhEngine.getGalleryList(url) - } - }.onFailure { - return@withIOContext LoadResult.Error(it) - }.getOrThrow() - urlBuilder.mJumpTo = null - LoadResult.Page(r.galleryInfoList, r.prev, r.next) - } - } - }.flow.cachedIn(viewModelScope) -} - -class GalleryListScene : SearchBarScene() { - private val vm: VMStorage1 by viewModels() - private var mUrlBuilder by lazyMut { vm::urlBuilder } - private var mIsTopList = false - - private val stateBackPressedCallback = object : OnBackPressedCallback(false) { - override fun handleOnBackPressed() { - when (mState) { - State.NORMAL -> error("SearchStateOnBackPressedCallback should not be enabled on $mState") - State.SIMPLE_SEARCH, State.SEARCH -> setState(State.NORMAL) - State.SEARCH_SHOW_LIST -> setState(State.SEARCH) - } - } - } - private var _binding: SceneGalleryListBinding? = null - private val binding get() = _binding!! - private var mViewTransition: ViewTransition? = null - private var mAdapter: GalleryAdapter? = null - lateinit var mQuickSearchList: MutableList - private var mHideActionFabSlop = 0 - private var mState = State.NORMAL - - override val fabLayout get() = binding.fabLayout - override val fastScroller get() = binding.fastScroller - override val recyclerView get() = binding.recyclerView - - private fun handleArgs(args: Bundle?) { - val action = args?.getString(KEY_ACTION) ?: ACTION_HOMEPAGE - mUrlBuilder = when (action) { - ACTION_HOMEPAGE -> ListUrlBuilder() - ACTION_SUBSCRIPTION -> ListUrlBuilder(MODE_SUBSCRIPTION) - ACTION_WHATS_HOT -> ListUrlBuilder(MODE_WHATS_HOT) - ACTION_TOP_LIST -> ListUrlBuilder(MODE_TOPLIST, mKeyword = Settings.recentToplist) - ACTION_LIST_URL_BUILDER -> args?.getParcelableCompat(KEY_LIST_URL_BUILDER)?.copy() ?: ListUrlBuilder() - else -> throw IllegalStateException("Wrong KEY_ACTION:${args?.getString(KEY_ACTION)} when handle args!") - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (savedInstanceState == null) { - onInit() - } else { - onRestore(savedInstanceState) - } - } - - override fun onResume() { - super.onResume() - mAdapter?.type = Settings.listMode - } - - private fun onInit() { - handleArgs(arguments) - } - - private fun onRestore(savedInstanceState: Bundle) { - mUrlBuilder = savedInstanceState.getParcelableCompat(KEY_LIST_URL_BUILDER)!! - mState = savedInstanceState.getParcelableCompat(KEY_STATE)!! - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - outState.putParcelable(KEY_LIST_URL_BUILDER, mUrlBuilder) - outState.putParcelable(KEY_STATE, mState) - } - - private fun setSearchBarHint() { - setSearchBarHint(getString(if (EhUtils.isExHentai) R.string.gallery_list_search_bar_hint_exhentai else R.string.gallery_list_search_bar_hint_e_hentai)) - } - - // Update search bar title, drawer checked item - private fun onUpdateUrlBuilder() { - _binding ?: return - var keyword = mUrlBuilder.keyword - val category = mUrlBuilder.category - val mode = mUrlBuilder.mode - mIsTopList = mode == MODE_TOPLIST - - // Update normal search mode and category - binding.searchLayout.setSearchMyTags(mode == MODE_SUBSCRIPTION) - if (category != EhUtils.NONE) { - binding.searchLayout.setCategory(category) - } - - // Update search edit text - if (!mIsTopList) { - if (mode == MODE_TAG) { - keyword = wrapTagKeyword(keyword!!) - } - setSearchBarText(keyword) - } - - // Update title - var title = requireContext().getSuitableTitleForUrlBuilder(mUrlBuilder, true) - if (null == title) { - title = resources.getString(R.string.search) - } - setTitle(title) - } - - private val dialogState = DialogState() - - private val SceneGalleryListBinding.fastScroller get() = contentLayout.fastScroller - private val SceneGalleryListBinding.refreshLayout get() = contentLayout.refreshLayout - private val SceneGalleryListBinding.recyclerView get() = contentLayout.recyclerView - private val SceneGalleryListBinding.progress get() = contentLayout.progress - private val SceneGalleryListBinding.tip get() = contentLayout.tip - - override fun onCreateViewWithToolbar( - inflater: LayoutInflater, - container: ViewGroup, - savedInstanceState: Bundle?, - ): ViewBinding { - _binding = SceneGalleryListBinding.inflate(inflater, container) - requireActivity().onBackPressedDispatcher.addCallback(stateBackPressedCallback) - mHideActionFabSlop = ViewConfiguration.get(requireContext()).scaledTouchSlop - mViewTransition = BringOutTransition(binding.contentLayout.contentView, binding.searchLayout) - binding.searchLayout.consumeWindowInsets = false - binding.searchLayout.setViewTreeViewModelStoreOwner(this) - mAdapter = GalleryAdapter( - binding.recyclerView, - true, - { info -> - navAnimated( - R.id.galleryDetailScene, - bundleOf(GalleryDetailScene.KEY_ARGS to GalleryInfoArgs(info)), - ) - }, - { info -> - lifecycleScope.launchIO { - dialogState.doGalleryInfoAction(info, requireContext()) - } - }, - ).also { adapter -> - viewLifecycleOwner.lifecycleScope.launch { - val drawable = ContextCompat.getDrawable(requireContext(), R.drawable.big_sad_pandroid)!! - drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) - binding.tip.setCompoundDrawables(null, drawable, null, null) - binding.tip.setOnClickListener { mAdapter?.retry() } - binding.refreshLayout.setOnRefreshListener { - mUrlBuilder.setIndex(null, true) - mUrlBuilder.mJumpTo = null - mAdapter?.refresh() - } - val transition = ViewTransition(binding.refreshLayout, binding.progress, binding.tip) - val empty = getString(R.string.gallery_list_empty_hit) - launch { - adapter.loadStateFlow.collectLatest { - when (val state = it.refresh) { - is LoadState.Loading -> { - if (!binding.refreshLayout.isRefreshing) { - transition.showView(1) - } - } - - is LoadState.Error -> { - binding.refreshLayout.isRefreshing = false - binding.tip.text = ExceptionUtils.getReadableString(state.error) - transition.showView(2) - if (state.error.cause is CloudflareBypassException) { - dialogState.awaitPermissionOrCancel(title = R.string.cloudflare_bypass_failed) { - Text(text = stringResource(id = R.string.open_in_webview)) - } - navAnimated(R.id.webView, bundleOf(WebViewActivity.KEY_URL to EhUrl.host)) - } - } - - is LoadState.NotLoading -> { - binding.refreshLayout.isRefreshing = false - if (mAdapter?.itemCount == 0) { - binding.tip.text = empty - transition.showView(2) - } else { - transition.showView(0) - } - } - } - } - } - Settings.needSignInFlow.first { !it } - vm.dataFlow.collectLatest { - adapter.submitData(it) - } - } - } - binding.recyclerView.addOnScrollListener( - object : RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {} - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - if (dy >= mHideActionFabSlop) { - hideActionFab() - } else if (dy <= -mHideActionFabSlop / 2) { - showActionFab() - } - } - }, - ) - binding.fastScroller.attachToRecyclerView(binding.recyclerView) - binding.fastScroller.setHandlerDrawable(HandlerDrawable().apply { setColor(inflater.context.theme.resolveColor(androidx.appcompat.R.attr.colorPrimary)) }) - setOnApplySearch { query: String? -> - onApplySearch(query) - } - setSearchBarHint() - setSuggestionProvider { text -> - GalleryDetailUrlParser.parse(text, false)?.run { - GalleryDetailUrlSuggestion(gid, token) - } ?: GalleryPageUrlParser.parse(text, false)?.run { - GalleryPageUrlSuggestion(gid, pToken, page) - } - } - val isPop = mUrlBuilder.mode == MODE_WHATS_HOT - binding.fabLayout.apply { - val fab = listOf( - Icons.Default.Refresh to { - mUrlBuilder.setIndex(null, true) - mUrlBuilder.mJumpTo = null - mAdapter?.refresh() - }, - EhIcons.Default.GoTo to { - showGoToDialog() - }, - Icons.AutoMirrored.Default.LastPage to { - if (mIsTopList) { - mUrlBuilder.mJumpTo = "${TOPLIST_PAGES - 1}" - mAdapter?.refresh() - } else { - mUrlBuilder.setIndex("1", false) - mAdapter?.refresh() - } - }, - ) - secondaryFab = if (!isPop) { - fab - } else { - fab.take(1) - } - } - binding.fabLayout.addOnExpandStateListener { - if (it) { - lockDrawer() - } else { - unlockDrawer() - } - } - - // Update list url builder - onUpdateUrlBuilder() - - // Restore state - val newState = mState - mState = State.NORMAL - setState(newState, false) - return binding - } - - override fun onRelease() { - super.onRelease() - stateBackPressedCallback.remove() - binding.recyclerView.stopScroll() - _binding = null - mAdapter = null - mViewTransition = null - } - - private fun showQuickSearchTipDialog() { - val context = context ?: return - val builder = BaseDialogBuilder(context) - builder.setMessage(R.string.add_quick_search_tip) - builder.setTitle(R.string.readme) - builder.show() - } - - private fun showAddQuickSearchDialog( - adapter: QsDrawerAdapter, - recyclerView: RecyclerView, - tip: TextView, - ) { - val context = context ?: return - if (mAdapter!!.itemCount == 0) return - - // Can't add image search as quick search - if (MODE_IMAGE_SEARCH == mUrlBuilder.mode) { - showTip(R.string.image_search_not_quick_search, LENGTH_LONG) - return - } - val gi = mAdapter!!.peek(binding.recyclerView.layoutManager!!.firstVisibleItemPosition)!! - val next = gi.gid + 1 - - // Check duplicate - for (q in mQuickSearchList) { - if (mUrlBuilder.equalsQuickSearch(q)) { - val nextStr = q.name.substringAfterLast('@', "").takeIf { it.isNotEmpty() } - if (nextStr?.toLongOrNull() == next) { - showTip(getString(R.string.duplicate_quick_search, q.name), LENGTH_LONG) - return - } - } - } - val builder = EditTextDialogBuilder( - context, - context.getSuitableTitleForUrlBuilder(mUrlBuilder, false), - getString(R.string.quick_search), - ) - builder.setTitle(R.string.add_quick_search_dialog_title) - builder.setPositiveButton(android.R.string.ok, null) - // TODO: It's ugly - val checked = booleanArrayOf(Settings.qSSaveProgress) - val hint = arrayOf(getString(R.string.save_progress)) - builder.setMultiChoiceItems(hint, checked) { _, which, isChecked -> checked[which] = isChecked } - val dialog = builder.show() - dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { - lifecycleScope.launchIO { - var text = builder.text.trim { it <= ' ' } - - // Check name empty - if (text.isEmpty()) { - withUIContext { - builder.setError(getString(R.string.name_is_empty)) - } - return@launchIO - } - if (checked[0]) { - text += "@$next" - Settings.qSSaveProgress = true - } else { - Settings.qSSaveProgress = false - } - - // Check name duplicate - for ((_, name) in mQuickSearchList) { - if (text == name) { - withUIContext { - builder.setError(getString(R.string.duplicate_name)) - } - return@launchIO - } - } - builder.setError(null) - dialog.dismiss() - val quickSearch = mUrlBuilder.toQuickSearch(text) - quickSearch.position = mQuickSearchList.size - EhDB.insertQuickSearch(quickSearch) - mQuickSearchList.add(quickSearch) - withUIContext { - adapter.notifyItemInserted(mQuickSearchList.size - 1) - if (0 == mQuickSearchList.size) { - tip.visibility = View.VISIBLE - recyclerView.visibility = View.GONE - } else { - tip.visibility = View.GONE - recyclerView.visibility = View.VISIBLE - } - } - } - } - } - - override fun onCreateDrawerView(inflater: LayoutInflater): View { - val drawerBinding = DrawerListRvBinding.inflate(inflater) - drawerBinding.recyclerViewDrawer.layoutManager = LinearLayoutManager(context) - val qsDrawerAdapter = QsDrawerAdapter(inflater) - qsDrawerAdapter.setHasStableIds(true) - drawerBinding.recyclerViewDrawer.adapter = qsDrawerAdapter - if (!mIsTopList) { - val itemTouchHelper = - ItemTouchHelper(GalleryListQSItemTouchHelperCallback(qsDrawerAdapter)) - itemTouchHelper.attachToRecyclerView(drawerBinding.recyclerViewDrawer) - drawerBinding.tip.visibility = View.VISIBLE - drawerBinding.recyclerViewDrawer.visibility = View.GONE - } - lifecycleScope.launchIO { - mQuickSearchList = EhDB.getAllQuickSearch() as MutableList - if (mQuickSearchList.isNotEmpty()) { - withUIContext { - drawerBinding.tip.visibility = View.GONE - drawerBinding.recyclerViewDrawer.visibility = View.VISIBLE - } - } - } - drawerBinding.tip.setText(R.string.quick_search_tip) - if (mIsTopList) { - drawerBinding.toolbar.setTitle(R.string.toplist) - } else { - drawerBinding.toolbar.setTitle(R.string.quick_search) - if (mUrlBuilder.mode != MODE_WHATS_HOT) { - drawerBinding.toolbar.inflateMenu(R.menu.drawer_gallery_list) - drawerBinding.toolbar.setOnMenuItemClickListener { item: MenuItem -> - val id = item.itemId - if (id == R.id.action_add) { - showAddQuickSearchDialog( - qsDrawerAdapter, - drawerBinding.recyclerViewDrawer, - drawerBinding.tip, - ) - } else if (id == R.id.action_help) { - showQuickSearchTipDialog() - } - true - } - } - } - return drawerBinding.root - } - - private fun showGoToDialog() { - mAdapter ?: return - if (mIsTopList) { - val page = mUrlBuilder.mJumpTo?.toIntOrNull() ?: 0 - val title = getString(R.string.go_to) - val hint = getString(R.string.go_to_hint, page + 1, TOPLIST_PAGES) - lifecycleScope.launch { - val text = dialogState.awaitInputText( - title = title, - hint = hint, - isNumber = true, - ) { oriText -> - val text = oriText.trim() - val goTo = runCatching { - text.toInt() - 1 - }.onFailure { - return@awaitInputText getString(R.string.error_invalid_number) - }.getOrThrow() - if (goTo !in 0..() - listValidators.add(DateValidatorPointForward.from(fromDate)) - listValidators.add(DateValidatorPointBackward.before(toDate)) - val constraintsBuilder = CalendarConstraints.Builder() - .setStart(fromDate) - .setEnd(toDate) - .setValidator(CompositeDateValidator.allOf(listValidators)) - val datePicker = MaterialDatePicker.Builder.datePicker() - .setCalendarConstraints(constraintsBuilder.build()) - .setTitleText(R.string.go_to) - .setSelection(toDate) - .build() - datePicker.show(requireActivity().supportFragmentManager, "date-picker") - datePicker.addOnPositiveButtonClickListener { time -> - val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US).withZone(ZoneOffset.UTC) - val jumpTo = formatter.format(Instant.ofEpochMilli(time)) - mUrlBuilder.mJumpTo = jumpTo - mAdapter?.refresh() - } - } - } - - private fun showSearchFab(delay: Long) { - lifecycleScope.launch { - delay(delay) - showSearchFab = true - } - } - - private fun showPrimaryFab(delay: Long) { - lifecycleScope.launch { - delay(delay) - _binding ?: return@launch - binding.fabLayout.show() - } - } - - private fun selectSearchFab() { - binding.fabLayout.hide() - showSearchFab(FAB_ANIMATE_TIME.toLong()) - } - - private fun selectActionFab() { - showSearchFab = false - showPrimaryFab(FAB_ANIMATE_TIME.toLong()) - } - - override fun onSearchViewExpanded() { - if (mState == State.NORMAL) { - binding.fabLayout.hide() - } else { - showSearchFab = false - stateBackPressedCallback.isEnabled = false - } - super.onSearchViewExpanded() - } - - override fun onSearchViewHidden() { - super.onSearchViewHidden() - if (mState == State.NORMAL) { - showPrimaryFab(SEARCH_VIEW_ANIMATE_TIME) - } else { - showSearchFab(SEARCH_VIEW_ANIMATE_TIME) - stateBackPressedCallback.isEnabled = true - } - } - - private fun showActionFab() { - _binding ?: return - if (State.NORMAL == mState) { - binding.fabLayout.show() - } - } - - private fun hideActionFab() { - _binding ?: return - if (State.NORMAL == mState) { - binding.fabLayout.hide() - } - } - - private fun setState(state: State, animation: Boolean = true) { - _binding ?: return - if (null == mViewTransition) { - return - } - if (mState != state) { - val oldState = mState - mState = state - onStateChange(state) - when (oldState) { - State.NORMAL -> when (state) { - State.SIMPLE_SEARCH -> { - selectSearchFab() - } - State.SEARCH -> { - mViewTransition!!.showView(1, animation) - selectSearchFab() - } - State.SEARCH_SHOW_LIST -> { - mViewTransition!!.showView(1, animation) - selectSearchFab() - } - else -> error("Unreachable!!!") - } - State.SIMPLE_SEARCH -> when (state) { - State.NORMAL -> selectActionFab() - State.SEARCH -> mViewTransition!!.showView(1, animation) - State.SEARCH_SHOW_LIST -> mViewTransition!!.showView(1, animation) - else -> error("Unreachable!!!") - } - State.SEARCH, State.SEARCH_SHOW_LIST -> if (state == State.NORMAL) { - mViewTransition!!.showView(0, animation) - selectActionFab() - } else if (state == State.SIMPLE_SEARCH) { - mViewTransition!!.showView(0, animation) - } - } - } - } - - @Composable - override fun TrailingIcon() { - dialogState.Intercept() - IconButton(onClick = { openSideSheet() }) { - Icon(imageVector = Icons.Outlined.Bookmarks, contentDescription = stringResource(id = R.string.quick_search)) - } - IconButton(onClick = { - if (mState == State.NORMAL) { - setState(State.SEARCH) - } else { - setState(State.NORMAL) - } - }) { - Icon(imageVector = Icons.Default.Add, contentDescription = null) - } - } - - private fun onApplySearch(query: String?) { - _binding ?: return - lifecycleScope.launchIO { - val builder = ListUrlBuilder() - val oldMode = mUrlBuilder.mode - if (mState == State.SEARCH || mState == State.SEARCH_SHOW_LIST) { - try { - binding.searchLayout.formatListUrlBuilder(builder, query) - } catch (e: EhException) { - showTip(e.message, LENGTH_LONG) - return@launchIO - } - } else { - // If it's MODE_SUBSCRIPTION, keep it - val newMode = if (oldMode == MODE_SUBSCRIPTION) MODE_SUBSCRIPTION else MODE_NORMAL - builder.mode = newMode - builder.keyword = query - } - withUIContext { - when (oldMode) { - MODE_TOPLIST, MODE_WHATS_HOT -> { - // Wait for search view to hide - delay(300) - navAnimated(R.id.galleryListScene, builder.toStartArgs()) - } - - else -> { - mUrlBuilder = builder - onUpdateUrlBuilder() - mAdapter?.refresh() - } - } - setState(State.NORMAL) - } - } - } - - private fun onStateChange(newState: State) { - stateBackPressedCallback.isEnabled = newState != State.NORMAL - if (newState == State.NORMAL || newState == State.SIMPLE_SEARCH) { - unlockDrawer() - } else { - lockDrawer() - } - } - - private class QsDrawerHolder(val binding: ItemDrawerListBinding) : RecyclerView.ViewHolder(binding.root) - - private inner class QsDrawerAdapter(private val mInflater: LayoutInflater) : - RecyclerView.Adapter() { - private val toplists = resources.getStringArray(R.array.toplist_entries) - private val keywords = resources.getStringArray(R.array.toplist_values) - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QsDrawerHolder { - val holder = QsDrawerHolder(ItemDrawerListBinding.inflate(mInflater, parent, false)) - holder.itemView.setOnClickListener { - if (mUrlBuilder.mode == MODE_WHATS_HOT) { - val q = mQuickSearchList[holder.bindingAdapterPosition] - navAnimated(R.id.galleryListScene, ListUrlBuilder().apply { set(q) }.toStartArgs()) - } else { - if (mIsTopList) { - val keyword = keywords[holder.bindingAdapterPosition] - Settings.recentToplist = keyword - mUrlBuilder.keyword = keyword - mUrlBuilder.mJumpTo = null - } else { - val q = mQuickSearchList[holder.bindingAdapterPosition] - mUrlBuilder.set(q) - } - onUpdateUrlBuilder() - mAdapter?.refresh() - } - setState(State.NORMAL) - closeSideSheet() - } - return holder - } - - override fun onBindViewHolder(holder: QsDrawerHolder, position: Int) { - holder.binding.run { - if (!mIsTopList) { - text.text = mQuickSearchList[position].name - } else { - text.text = toplists[position] - option.visibility = View.GONE - } - } - } - - override fun getItemId(position: Int): Long { - if (mIsTopList) { - return position.toLong() - } - return mQuickSearchList[position].id!! - } - - override fun getItemCount(): Int { - return if (!mIsTopList) mQuickSearchList.size else 4 - } - } - - private abstract inner class UrlSuggestion : Suggestion() { - override val keyword = getString(R.string.gallery_list_search_bar_open_gallery) - override val canOpenDirectly = true - override fun onClick() { - navAnimated(destination, args) - if (mState == State.SIMPLE_SEARCH) { - setState(State.NORMAL) - } else if (mState == State.SEARCH_SHOW_LIST) { - setState(State.SEARCH) - } - } - abstract val destination: Int - abstract val args: Bundle - } - - private inner class GalleryDetailUrlSuggestion( - gid: Long, - token: String, - ) : UrlSuggestion() { - override val destination = R.id.galleryDetailScene - override val args = bundleOf(GalleryDetailScene.KEY_ARGS to TokenArgs(gid, token)) - } - - private inner class GalleryPageUrlSuggestion( - gid: Long, - pToken: String, - page: Int, - ) : UrlSuggestion() { - override val destination = R.id.progressScene - override val args = bundleOf( - ProgressFragment.KEY_GID to gid, - ProgressFragment.KEY_PTOKEN to pToken, - ProgressFragment.KEY_PAGE to page, - ) - } - - private inner class GalleryListQSItemTouchHelperCallback(private val mAdapter: QsDrawerAdapter) : - ItemTouchHelper.Callback() { - override fun getMovementFlags( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - ) = makeMovementFlags( - ItemTouchHelper.UP or ItemTouchHelper.DOWN, - ItemTouchHelper.LEFT, - ) - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder, - ): Boolean { - val fromPosition = viewHolder.bindingAdapterPosition - val toPosition = target.bindingAdapterPosition - if (fromPosition == toPosition) { - return false - } - val item = mQuickSearchList.removeAt(fromPosition) - mQuickSearchList.add(toPosition, item) - mAdapter.notifyItemMoved(fromPosition, toPosition) - lifecycleScope.launchIO { - val range = if (fromPosition < toPosition) fromPosition..toPosition else toPosition..fromPosition - val list = mQuickSearchList.slice(range) - list.zip(range).forEach { it.first.position = it.second } - EhDB.updateQuickSearch(list) - } - return true - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val position = viewHolder.bindingAdapterPosition - val quickSearch = mQuickSearchList[position] - BaseDialogBuilder(context!!) - .setMessage(getString(R.string.delete_quick_search, quickSearch.name)) - .setPositiveButton(R.string.delete) { _, _ -> - lifecycleScope.launchIO { - EhDB.deleteQuickSearch(quickSearch) - mQuickSearchList.removeAt(position) - withUIContext { - mAdapter.notifyItemRemoved(position) - } - } - }.setNegativeButton(android.R.string.cancel) { dialog, _ -> - dialog.cancel() - }.setOnCancelListener { - mAdapter.notifyItemChanged(position) - }.show() - } - } - - companion object { - const val KEY_ACTION = "action" - const val ACTION_HOMEPAGE = "action_homepage" - const val ACTION_SUBSCRIPTION = "action_subscription" - const val ACTION_WHATS_HOT = "action_whats_hot" - const val ACTION_TOP_LIST = "action_top_list" - const val ACTION_LIST_URL_BUILDER = "action_list_url_builder" - const val KEY_LIST_URL_BUILDER = "list_url_builder" - const val KEY_STATE = "state" - fun ListUrlBuilder.toStartArgs() = bundleOf( - KEY_ACTION to ACTION_LIST_URL_BUILDER, - KEY_LIST_URL_BUILDER to this, - ) - } -} - -private const val TOPLIST_PAGES = 200 - -@Parcelize -enum class State : Parcelable { - NORMAL, - SIMPLE_SEARCH, - SEARCH, - SEARCH_SHOW_LIST, -} - -private fun Context.getSuitableTitleForUrlBuilder( - urlBuilder: ListUrlBuilder, - appName: Boolean, -): String? { - val keyword = urlBuilder.keyword - val category = urlBuilder.category - val mode = urlBuilder.mode - return if (mode == MODE_WHATS_HOT) { - getString(R.string.whats_hot) - } else if (!keyword.isNullOrEmpty()) { - when (mode) { - MODE_TOPLIST -> { - when (keyword) { - "11" -> getString(R.string.toplist_alltime) - "12" -> getString(R.string.toplist_pastyear) - "13" -> getString(R.string.toplist_pastmonth) - "15" -> getString(R.string.toplist_yesterday) - else -> null - } - } - - MODE_TAG -> { - val canTranslate = Settings.showTagTranslations && EhTagDatabase.isTranslatable(this) && EhTagDatabase.initialized - wrapTagKeyword(keyword, canTranslate) - } - else -> keyword - } - } else if (category == EhUtils.NONE && urlBuilder.advanceSearch == -1) { - when (mode) { - MODE_NORMAL -> getString(if (appName) R.string.app_name else R.string.homepage) - MODE_SUBSCRIPTION -> getString(R.string.subscription) - else -> null - } - } else if (category.countOneBits() == 1) { - EhUtils.getCategory(category) - } else { - null - } -} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GallerySelectionTracker.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GallerySelectionTracker.kt index 78a0b7bf8..c1284afd8 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GallerySelectionTracker.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GallerySelectionTracker.kt @@ -2,8 +2,6 @@ package com.hippo.ehviewer.ui.scene import android.os.Bundle import android.view.MotionEvent -import androidx.core.util.component1 -import androidx.core.util.component2 import androidx.recyclerview.selection.ItemDetailsLookup import androidx.recyclerview.selection.ItemKeyProvider import androidx.recyclerview.selection.SelectionTracker @@ -24,15 +22,7 @@ class GallerySelectionTracker( selectionId, recyclerView, object : ItemKeyProvider(SCOPE_MAPPED) { - override fun getKey(position: Int) = - if (adapter != null) { - val (wrappedAdapter, localPosition) = adapter.getWrappedAdapterAndPosition(position) - (wrappedAdapter as? GalleryAdapter)?.let { - getItems()[localPosition].gid - } - } else { - getItems()[position].gid - } + override fun getKey(position: Int) = getItems()[position].gid override fun getPosition(key: Long) = if (adapter != null) { @@ -54,7 +44,6 @@ class GallerySelectionTracker( }, StorageStrategy.createLongStorage(), ).apply(builder).build() - val selectionSize get() = tracker.selection.size() var isInCustomChoice = false private set diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/MainNav.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/MainNav.kt index 3cbc9e480..9e8bfe382 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/MainNav.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/MainNav.kt @@ -32,8 +32,8 @@ fun NavController.navWithUrl(url: String): Boolean { navAnimated( R.id.galleryListScene, bundleOf( - GalleryListScene.KEY_ACTION to GalleryListScene.ACTION_LIST_URL_BUILDER, - GalleryListScene.KEY_LIST_URL_BUILDER to it, + GalleryListFragment.KEY_ACTION to GalleryListFragment.ACTION_LIST_URL_BUILDER, + GalleryListFragment.KEY_LIST_URL_BUILDER to it, ), ) return true diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SearchBarScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SearchBarScene.kt index 6be3ae4b2..c60bda19c 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SearchBarScene.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SearchBarScene.kt @@ -130,13 +130,14 @@ fun SearchBarScreen( searchFieldState: TextFieldState, searchFieldHint: String? = null, showSearchFab: Boolean = false, - onApplySearch: (String) -> Unit, + onApplySearch: suspend (String) -> Unit, onSearchExpanded: () -> Unit, onSearchHidden: () -> Unit, refreshState: PullToRefreshState? = null, suggestionProvider: SuggestionProvider? = null, searchBarOffsetY: Int, trailingIcon: @Composable () -> Unit, + snackbarHost: @Composable () -> Unit = {}, content: @Composable (PaddingValues) -> Unit, ) { var mSuggestionList by remember { mutableStateOf(emptyList()) } @@ -232,7 +233,9 @@ fun SearchBarScreen( mSearchDatabase.insert(search) } } - onApplySearch(query) + scope.launchIO { + onApplySearch(query) + } } fun deleteKeyword(keyword: String) { @@ -267,10 +270,11 @@ fun SearchBarScreen( .height(SearchBarDefaults.InputFieldHeight + 16.dp), ) }, + snackbarHost = snackbarHost, floatingActionButton = { val hiddenState by animateFloatAsState( targetValue = if (showSearchFab) 1f else 0f, - animationSpec = tween(FAB_ANIMATE_TIME), + animationSpec = tween(FAB_ANIMATE_TIME, if (showSearchFab) FAB_ANIMATE_TIME else 0), label = "hiddenState", ) FloatingActionButton( diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt index 276c1a215..6e24f7a19 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/Dialog.kt @@ -2,6 +2,7 @@ package com.hippo.ehviewer.ui.tools import androidx.annotation.StringRes import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio @@ -162,6 +163,75 @@ class DialogState { } } + suspend fun awaitInputTextWithCheckBox( + initial: String = "", + @StringRes title: Int? = null, + @StringRes hint: Int? = null, + checked: Boolean, + @StringRes checkBoxText: Int, + isNumber: Boolean = false, + invalidator: (suspend (String, Boolean) -> String?)? = null, + ): Pair { + return dialog { cont -> + val coroutineScope = rememberCoroutineScope() + var state by remember(cont) { mutableStateOf(initial) } + var error by remember(cont) { mutableStateOf(null) } + var checkedState by remember { mutableStateOf(checked) } + AlertDialog( + onDismissRequest = { cont.cancel() }, + confirmButton = { + TextButton(onClick = { + if (invalidator == null) { + cont.resume(state to checkedState) + } else { + coroutineScope.launch { + error = invalidator.invoke(state, checkedState) + error ?: cont.resume(state to checkedState) + } + } + }) { + Text(text = stringResource(id = android.R.string.ok)) + } + }, + title = title.ifNotNullThen { Text(text = stringResource(id = title!!)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + value = state, + onValueChange = { state = it }, + label = hint.ifNotNullThen { + Text(text = stringResource(id = hint!!)) + }, + trailingIcon = error.ifNotNullThen { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + ) + }, + supportingText = error.ifNotNullThen { + Text(text = error!!) + }, + isError = error != null, + keyboardOptions = if (isNumber) KeyboardOptions(keyboardType = KeyboardType.Number) else KeyboardOptions.Default, + ) + Row( + modifier = Modifier.clickable { checkedState = !checkedState }.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = checkedState, + onCheckedChange = { checkedState = !checkedState }, + ) + Text(text = stringResource(checkBoxText)) + } + } + }, + ) + } + } + suspend fun awaitPermissionOrCancel( @StringRes confirmText: Int = android.R.string.ok, @StringRes dismissText: Int = android.R.string.cancel, diff --git a/app/src/main/res/layout/scene_gallery_list.xml b/app/src/main/res/layout/scene_gallery_list.xml deleted file mode 100644 index 5f753a72a..000000000 --- a/app/src/main/res/layout/scene_gallery_list.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/layout/widget_content_layout.xml b/app/src/main/res/layout/widget_content_layout.xml deleted file mode 100644 index a84e809b5..000000000 --- a/app/src/main/res/layout/widget_content_layout.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 0b51a231b..185d64f32 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -5,11 +5,11 @@