From bac60fbec8ef0deea80cf7c558985bf32b4208ea Mon Sep 17 00:00:00 2001 From: Vladimir Dostoyevskiy <99822064+VDostoyevskiy@users.noreply.github.com> Date: Mon, 20 Nov 2023 15:09:35 +0800 Subject: [PATCH] Compose FavouriteScreen (#295) * Restore FavouriteScene SideSheet dragToOpen * Initial move to Compose FavScene * Refine state * Refine state * Introduce ProvideSideSheetContent with extra checkers * Make sideSheet activity private * Dont allow provide a null AssistContent * Restore trailingIcon to openSideSheet * Refine * Cleanup * Implement searchBarConnection * Reduce allocation for animation * Initial compose FabLayout API * Restore showGoToDialog * Refine * Refine * Refine * PullTORefresh * PullTORefresh * Refine Dsl * Insetless placeholder * let pullToRefresh use insetless padding as well * Support drawer gestures * Fix tip color and ripple * Make pullToRefresh optional * Composite container color * Fix padding * Fix misadd * Fix gesture * Fixes --------- Co-authored-by: Lokesh Gidra --- .../compose/material3/NavigationDrawer2.kt | 131 ++++- .../main/kotlin/com/hippo/ehviewer/EhDB.kt | 4 +- .../kotlin/com/hippo/ehviewer/Settings.kt | 4 +- .../ehviewer/client/data/FavListUrlBuilder.kt | 18 +- .../com/hippo/ehviewer/ui/MainActivity.kt | 118 +++- .../com/hippo/ehviewer/ui/legacy/FabLayout.kt | 5 +- .../com/hippo/ehviewer/ui/main/FabLayout.kt | 111 ++++ .../hippo/ehviewer/ui/scene/ActionBuilder.kt | 8 +- .../ehviewer/ui/scene/FavoritesFragment.kt | 516 +++++++++++++++++ .../hippo/ehviewer/ui/scene/FavoritesScene.kt | 518 ------------------ .../ui/scene/GalleryCommentsFragment.kt | 14 +- .../ehviewer/ui/scene/GalleryDetailScene.kt | 8 +- .../hippo/ehviewer/ui/scene/SearchBarScene.kt | 229 ++++---- .../com/hippo/ehviewer/ui/tools/LazyGrid.kt | 43 ++ .../hippo/ehviewer/ui/tools/PredictiveBack.kt | 16 +- .../ehviewer/ui/tools/VerticalFastScroller.kt | 210 +++++++ app/src/main/res/navigation/nav_graph.xml | 2 +- 17 files changed, 1251 insertions(+), 704 deletions(-) create mode 100644 app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt create mode 100644 app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesFragment.kt delete mode 100644 app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt diff --git a/app/src/main/kotlin/androidx/compose/material3/NavigationDrawer2.kt b/app/src/main/kotlin/androidx/compose/material3/NavigationDrawer2.kt index 4d1231191..bb6d43fdc 100644 --- a/app/src/main/kotlin/androidx/compose/material3/NavigationDrawer2.kt +++ b/app/src/main/kotlin/androidx/compose/material3/NavigationDrawer2.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.gestures.snapTo import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.Stable @@ -28,12 +29,14 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.Saver 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.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.addPointerInputChange +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Density @@ -228,25 +231,6 @@ fun rememberDrawerState( } } -/** - * Material Design navigation drawer. - * - * Navigation drawers provide ergonomic access to destinations in an app. - * - * Modal navigation drawers block interaction with the rest of an app’s content with a scrim. - * They are elevated above most of the app’s UI and don’t affect the screen’s layout grid. - * - * ![Navigation drawer image](https://developer.android.com/images/reference/androidx/compose/material3/navigation-drawer.png) - * - * @sample androidx.compose.material3.samples.ModalNavigationDrawerSample - * - * @param drawerContent content inside this drawer - * @param modifier the [Modifier] to be applied to this drawer - * @param drawerState state of the drawer - * @param gesturesEnabled whether or not the drawer can be interacted by gestures - * @param scrimColor color of the scrim that obscures content when the drawer is open - * @param content content of the rest of the UI - */ @Composable fun ModalNavigationDrawer( drawerContent: @Composable () -> Unit, @@ -258,7 +242,7 @@ fun ModalNavigationDrawer( ) { val scope = rememberCoroutineScope() val density = LocalDensity.current - val minValue = -with(density) { ContainerWidth.toPx() } + var minValue by remember { mutableStateOf(-with(density) { ContainerWidth.toPx() }) } val maxValue = 0f SideEffect { @@ -279,13 +263,107 @@ fun ModalNavigationDrawer( awaitEachGesture { fun canConsume(slop: Float) = (slop > 0 && drawerState.isClosed) || (slop < 0 && drawerState.isOpen) val down = awaitFirstDown(requireUnconsumed = false) - var overSlop: Float + var ignore = false val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over -> - overSlop = over * multiplier - if (canConsume(overSlop)) { + val overSlop = over * multiplier + val canConsume = canConsume(overSlop) + if (canConsume && !ignore) { velocityTracker.addPointerInputChange(change) change.consume() drawerState.anchoredDraggableState.dispatchRawDelta(overSlop) + } else { + ignore = true + } + } + if (drag != null) { + horizontalDrag(drag.id) { + velocityTracker.addPointerInputChange(it) + drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier) + } + val velocity = velocityTracker.calculateVelocity() + velocityTracker.resetTracking() + scope.launch { + drawerState.anchoredDraggableState.settle(velocity.x * multiplier) + } + } + } + } + } else { + Modifier + } + Box(modifier.fillMaxSize().then(dragModifier)) { + Box { + content() + } + Scrim( + open = drawerState.isOpen, + onClose = { + if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) { + scope.launch { drawerState.close() } + } + }, + fraction = { + calculateFraction(minValue, maxValue, drawerState.requireOffset()) + }, + color = scrimColor, + ) + Box( + modifier = Modifier.offset { + IntOffset(drawerState.requireOffset().roundToInt(), 0) + }.onGloballyPositioned { + val w = it.size.width + if (w != 0) { + minValue = -it.size.width.toFloat() + } + }, + ) { + drawerContent() + } + } +} + +@Composable +fun SideDrawer( + drawerContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed), + gesturesEnabled: Boolean = true, + scrimColor: Color = DrawerDefaults.scrimColor, + content: @Composable () -> Unit, +) { + val scope = rememberCoroutineScope() + val density = LocalDensity.current + var minValue by remember { mutableStateOf(with(density) { ContainerWidth.toPx() }) } + val maxValue = 0f + + SideEffect { + drawerState.density = density + drawerState.anchoredDraggableState.updateAnchors( + DraggableAnchors { + DrawerValue.Closed at minValue + DrawerValue.Open at maxValue + }, + ) + } + + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val velocityTracker = remember { VelocityTracker() } + val dragModifier = if (gesturesEnabled) { + Modifier.pointerInput(isRtl) { + val multiplier = if (isRtl) -1 else 1 + awaitEachGesture { + fun canConsume(slop: Float) = (slop > 0 && drawerState.isOpen) || (slop < 0 && drawerState.isClosed) + val down = awaitFirstDown(requireUnconsumed = false) + var ignore = false + val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over -> + val overSlop = over * multiplier + val canConsume = canConsume(overSlop) + if (canConsume && !ignore) { + velocityTracker.addPointerInputChange(change) + change.consume() + drawerState.anchoredDraggableState.dispatchRawDelta(overSlop) + } else { + ignore = true } } if (drag != null) { @@ -324,7 +402,12 @@ fun ModalNavigationDrawer( Box( modifier = Modifier.offset { IntOffset(drawerState.requireOffset().roundToInt(), 0) - }, + }.onGloballyPositioned { + val w = it.size.width + if (w != 0) { + minValue = it.size.width.toFloat() + } + }.align(Alignment.TopEnd).systemBarsPadding(), ) { drawerContent() } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/EhDB.kt b/app/src/main/kotlin/com/hippo/ehviewer/EhDB.kt index d7a293d4a..8aa99213c 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/EhDB.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/EhDB.kt @@ -130,7 +130,7 @@ object EhDB { deleteGalleryInfo(galleryInfo) } - suspend fun removeLocalFavorites(galleryInfoList: List) { + suspend fun removeLocalFavorites(galleryInfoList: Collection) { galleryInfoList.forEach { removeLocalFavorites(it) } @@ -146,7 +146,7 @@ object EhDB { db.localFavoritesDao().upsert(LocalFavoriteInfo(galleryInfo.gid)) } - suspend fun putLocalFavorites(galleryInfoList: List) { + suspend fun putLocalFavorites(galleryInfoList: Collection) { galleryInfoList.forEach { putLocalFavorites(it) } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt b/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt index f67ad7705..946b6ff58 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt @@ -25,6 +25,9 @@ object Settings : DataStorePreferences(null) { var favCount by intArrayPref("fav_count", 10).emitTo(_favFlow) var favCloudCount by intPref("fav_cloud", 0).emitTo(_favFlow) + val listModeBackField = intPref("list_mode_2", 0) + var listMode by listModeBackField + val needSignInFlow: Flow var downloadScheme by stringOrNullPref("image_scheme", null) var downloadAuthority by stringOrNullPref("image_authority", null) @@ -38,7 +41,6 @@ object Settings : DataStorePreferences(null) { var preloadImage by intPref("preload_image_2", 5) var downloadTimeout by intPref("download_timeout", 60) var theme by intPref("theme_2", -1).observed { updateWhenThemeChanges() } - var listMode by intPref("list_mode_2", 0) var detailSize by intPref("detail_size_2", 0) var thumbResolution by intPref("thumb_resolution_2", 0) var readCacheSize by intPref("read_cache_size_2", 640) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt index fbc4d574f..80f65c3de 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt @@ -22,16 +22,16 @@ import com.hippo.ehviewer.client.ehUrl import kotlinx.parcelize.Parcelize @Parcelize -class FavListUrlBuilder( - var mPrev: String? = null, - var mNext: String? = null, +data class FavListUrlBuilder( + val favCat: Int = FAV_CAT_ALL, + val keyword: String? = null, var jumpTo: String? = null, - var keyword: String? = null, - var favCat: Int = FAV_CAT_ALL, + var prev: String? = null, + var next: String? = null, ) : Parcelable { fun setIndex(index: String?, isNext: Boolean) { - mNext = index.takeIf { isNext } - mPrev = index.takeUnless { isNext } + next = index.takeIf { isNext } + prev = index.takeUnless { isNext } } fun build() = ehUrl { @@ -42,8 +42,8 @@ class FavListUrlBuilder( addQueryParameter("favcat", "all") } addQueryParameterIfNotBlank("f_search", keyword) - addQueryParameterIfNotBlank("prev", mPrev) - addQueryParameterIfNotBlank("next", mNext) + addQueryParameterIfNotBlank("prev", prev) + addQueryParameterIfNotBlank("next", next) addQueryParameterIfNotBlank("seek", jumpTo) }.toString() 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 e2bbab1ec..e86b3ea83 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt @@ -31,6 +31,7 @@ import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime @@ -40,6 +41,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Download @@ -49,18 +51,24 @@ import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Whatshot +import androidx.compose.material3.DrawerState2 import androidx.compose.material3.DrawerValue import androidx.compose.material3.Icon import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.SideDrawer import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.currentRecomposeScope import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -140,6 +148,37 @@ class MainActivity : EhActivity() { private lateinit var navController: NavController private val isInitializedFlow = MutableStateFlow(false) + private var sideSheet by mutableStateOf<(@Composable ColumnScope.(DrawerState2) -> Unit)?>(null) + + @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 + } + } + } + + private var shareUrl: String? = null + + @Composable + fun ProvideAssistContent(url: String) { + val urlState by rememberUpdatedState(url) + DisposableEffect(urlState) { + shareUrl = urlState + onDispose { + shareUrl = null + } + } + } + private fun saveImageToTempFile(uri: Uri): File? { val bitmap = runCatching { decodeBitmap(uri) @@ -221,6 +260,7 @@ class MainActivity : EhActivity() { var drawerLocked by mutableStateOf(false) private var openDrawerFlow = MutableSharedFlow(extraBufferCapacity = 1) + private var openSideSheetFlow = MutableSharedFlow(extraBufferCapacity = 1) private var recomposeFlow = MutableSharedFlow(extraBufferCapacity = 1) fun recompose() { @@ -311,33 +351,60 @@ class MainActivity : EhActivity() { drawerState = drawerState, gesturesEnabled = !drawerLocked || drawerState.isOpen, ) { - val insets = buildWindowInsets { - set( - WindowInsetsCompat.Type.statusBars(), - WindowInsets.statusBars, - ) - set( - WindowInsetsCompat.Type.navigationBars(), - WindowInsets.navigationBars, - ) - set( - WindowInsetsCompat.Type.ime(), - WindowInsets.ime, - ) + val sheet = sideSheet + val sideDrawerState = rememberDrawerState(DrawerValue.Closed) + LaunchedEffect(Unit) { + openSideSheetFlow.collectLatest { + sideDrawerState.open() + } } - AndroidViewBinding(factory = { inflater, parent, attachToParent -> - ActivityMainBinding.inflate(inflater, parent, attachToParent).apply { - val navHostFragment = fragmentContainer.getFragment() - navController = navHostFragment.navController.apply { - graph = navInflater.inflate(R.navigation.nav_graph).apply { - check(launchPage in 0..3) - setStartDestination(navItems[launchPage].first) + BackHandler(sideDrawerState.isOpen) { + scope.launch { + sideDrawerState.close() + } + } + SideDrawer( + drawerContent = { + if (sheet != null) { + ModalDrawerSheet( + modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp), + drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)), + ) { + sheet(sideDrawerState) } } - isInitializedFlow.value = true + }, + drawerState = sideDrawerState, + gesturesEnabled = sheet != null, + ) { + val insets = buildWindowInsets { + set( + WindowInsetsCompat.Type.statusBars(), + WindowInsets.statusBars, + ) + set( + WindowInsetsCompat.Type.navigationBars(), + WindowInsets.navigationBars, + ) + set( + WindowInsetsCompat.Type.ime(), + WindowInsets.ime, + ) + } + AndroidViewBinding(factory = { inflater, parent, attachToParent -> + ActivityMainBinding.inflate(inflater, parent, attachToParent).apply { + val navHostFragment = fragmentContainer.getFragment() + navController = navHostFragment.navController.apply { + graph = navInflater.inflate(R.navigation.nav_graph).apply { + check(launchPage in 0..3) + setStartDestination(navItems[launchPage].first) + } + } + isInitializedFlow.value = true + } + }) { + ViewCompat.dispatchApplyWindowInsets(root, insets) } - }) { - ViewCompat.dispatchApplyWindowInsets(root, insets) } } } @@ -485,6 +552,10 @@ class MainActivity : EhActivity() { openDrawerFlow.tryEmit(Unit) } + fun openSideSheet() { + openSideSheetFlow.tryEmit(Unit) + } + fun showTip(@StringRes id: Int, length: Int, useToast: Boolean = false) { showTip(getString(id), length, useToast) } @@ -508,7 +579,6 @@ class MainActivity : EhActivity() { } } - var shareUrl: String? = null override fun onProvideAssistContent(outContent: AssistContent?) { super.onProvideAssistContent(outContent) shareUrl?.let { outContent?.webUri = Uri.parse(shareUrl) } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/FabLayout.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/FabLayout.kt index dc1037955..08b971917 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/FabLayout.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/FabLayout.kt @@ -47,8 +47,7 @@ class FabLayout @JvmOverloads constructor( var autoCancel by mutableStateOf(true) private var hidden by mutableStateOf(false) - private val expandedBackingField = mutableStateOf(false) - var expanded by expandedBackingField + var expanded by mutableStateOf(false) private val listeners = mutableListOf() fun addOnExpandStateListener(listener: OnExpandStateListener) { @@ -95,7 +94,7 @@ class FabLayout @JvmOverloads constructor( contentAlignment = Alignment.BottomEnd, ) { Box(contentAlignment = Alignment.Center) { - val animatedProgress by animateFloatMergePredictiveBackAsState(expandedBackingField) + val animatedProgress by animateFloatMergePredictiveBackAsState(expanded) { expanded = false } FloatingActionButton( onClick = { expanded = !expanded }, modifier = Modifier.rotate(lerp(-90f, 0f, appearState)).scale(appearState), 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 new file mode 100644 index 000000000..af59c7221 --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt @@ -0,0 +1,111 @@ +package com.hippo.ehviewer.ui.main + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.hippo.ehviewer.ui.legacy.FAB_ANIMATE_TIME +import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState +import eu.kanade.tachiyomi.util.lang.launchIO +import moe.tarsin.coroutines.runSuspendCatching + +fun interface FabBuilder { + fun onClick(icon: ImageVector, that: suspend () -> Unit) +} + +@Composable +fun FabLayout( + hidden: Boolean, + expanded: Boolean, + onExpandChanged: (Boolean) -> Unit, + autoCancel: Boolean, + fabBuilder: FabBuilder.() -> Unit, +) { + val secondaryFab = remember(fabBuilder) { + buildList { + fabBuilder { icon, action -> + add(icon to action) + } + } + } + if (expanded) { + if (autoCancel) { + Spacer( + modifier = Modifier.fillMaxSize().pointerInput(expanded) { + detectTapGestures { + onExpandChanged(false) + } + }, + ) + } + } + val coroutineScope = rememberCoroutineScope() + val appearState by animateFloatAsState( + targetValue = if (hidden) 0f else 1f, + animationSpec = tween(FAB_ANIMATE_TIME), + label = "hiddenState", + ) + Box( + modifier = Modifier.fillMaxSize().navigationBarsPadding().padding(16.dp), + contentAlignment = Alignment.BottomEnd, + ) { + Box(contentAlignment = Alignment.Center) { + val animatedProgress by animateFloatMergePredictiveBackAsState(expanded) { onExpandChanged(false) } + FloatingActionButton( + onClick = { onExpandChanged(!expanded) }, + modifier = Modifier.rotate(lerp(-90f, 0f, appearState)).scale(appearState), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.rotate(lerp(135f, 0f, animatedProgress)), + ) + } + secondaryFab.run { + forEachIndexed { index, (imageVector, onClick) -> + SmallFloatingActionButton( + onClick = { + coroutineScope.launchIO { + runSuspendCatching { + onClick() + } + onExpandChanged(false) + } + }, + modifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + val distance = lerp(150 * (size - index) + 50, 0, animatedProgress) + placeable.placeRelative(0, -distance, -(size - index).toFloat()) + } + }.rotate(lerp(90f, 0f, appearState)).scale(appearState), + ) { + Icon(imageVector = imageVector, contentDescription = null) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/ActionBuilder.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/ActionBuilder.kt index 205d378c8..4ed584e6a 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/ActionBuilder.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/ActionBuilder.kt @@ -1,13 +1,9 @@ package com.hippo.ehviewer.ui.scene -interface ActionScope { +fun interface ActionScope { infix fun String.thenDo(that: suspend () -> Unit) } inline fun buildAction(builder: ActionScope.() -> Unit) = buildList { - builder(object : ActionScope { - override fun String.thenDo(that: suspend () -> Unit) { - add(this to that) - } - }) + builder { that -> add(this to that) } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesFragment.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesFragment.kt new file mode 100644 index 000000000..bb3bfc76e --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesFragment.kt @@ -0,0 +1,516 @@ +package com.hippo.ehviewer.ui.scene + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.clickable +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.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text2.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.DriveFileMove +import androidx.compose.material.icons.automirrored.filled.LastPage +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.FolderSpecial +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ShapeDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.currentRecomposeScope +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +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.clip +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.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +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.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.EhCookieStore +import com.hippo.ehviewer.client.EhEngine +import com.hippo.ehviewer.client.data.BaseGalleryInfo +import com.hippo.ehviewer.client.data.FavListUrlBuilder +import com.hippo.ehviewer.icons.EhIcons +import com.hippo.ehviewer.icons.big.SadAndroid +import com.hippo.ehviewer.icons.filled.GoTo +import com.hippo.ehviewer.ui.MainActivity +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.startDownload +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.rememberInVM +import com.hippo.ehviewer.util.ExceptionUtils +import com.hippo.ehviewer.util.findActivity +import com.hippo.ehviewer.util.mapToLongArray +import com.ramcosta.composedestinations.annotation.Destination +import eu.kanade.tachiyomi.util.lang.withIOContext +import eu.kanade.tachiyomi.util.system.pxToDp +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.CancellationException +import kotlinx.coroutines.launch +import moe.tarsin.coroutines.runSuspendCatching + +@Destination +@Composable +fun FavouritesScreen(navigator: NavController) { + // Meta State + var urlBuilder by rememberSaveable { mutableStateOf(FavListUrlBuilder(favCat = Settings.recentFavCat)) } + var searchBarOffsetY by remember { mutableStateOf(0) } + + // Derived State + val keyword = remember(urlBuilder) { urlBuilder.keyword.orEmpty() } + val localFavName = stringResource(R.string.local_favorites) + val cloudFavName = stringResource(R.string.cloud_favorites) + val favCatName = remember(urlBuilder) { + when (val favCat = urlBuilder.favCat) { + in 0..9 -> Settings.favCat[favCat] + FavListUrlBuilder.FAV_CAT_LOCAL -> localFavName + else -> cloudFavName + } + } + val favTitle = stringResource(R.string.favorites_title, favCatName) + val favTitleWithKeyword = stringResource(R.string.favorites_title_2, favCatName, keyword) + val title = remember(urlBuilder) { if (keyword.isBlank()) favTitle else favTitleWithKeyword } + val context = LocalContext.current + val dialogState = LocalDialogState.current + val activity = remember(context) { context.findActivity() } + val coroutineScope = rememberCoroutineScope() + val localFavCountFlow = rememberInVM { EhDB.localFavCount } + val searchBarHint = stringResource(R.string.search_bar_hint, favCatName) + + fun switchFav(newCat: Int, keyword: String? = null) { + urlBuilder = urlBuilder.copy( + keyword = keyword, + favCat = newCat, + jumpTo = null, + ).apply { setIndex(null, true) } + Settings.recentFavCat = urlBuilder.favCat + } + + with(activity) { + ProvideSideSheetContent { sheetState -> + val localFavCount by localFavCountFlow.collectAsState(0) + TopAppBar(title = { Text(text = stringResource(id = R.string.collections)) }) + val scope = currentRecomposeScope + LaunchedEffect(Unit) { + Settings.favChangesFlow.collect { + scope.invalidate() + } + } + val localFav = stringResource(id = R.string.local_favorites) to localFavCount + val faves = if (EhCookieStore.hasSignedIn()) { + arrayOf( + localFav, + stringResource(id = R.string.cloud_favorites) to Settings.favCloudCount, + *Settings.favCat.zip(Settings.favCount.toTypedArray()).toTypedArray(), + ) + } else { + arrayOf(localFav) + } + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + faves.forEachIndexed { index, (name, count) -> + ListItem( + headlineContent = { Text(text = name) }, + trailingContent = { Text(text = count.toString(), style = MaterialTheme.typography.bodyLarge) }, + modifier = Modifier.clickable { + switchFav(index - 2) + coroutineScope.launch { sheetState.close() } + }, + ) + } + } + } + } + + val searchFieldState = rememberTextFieldState() + val data = rememberInVM(urlBuilder) { + if (urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) { + Pager(PagingConfig(20, jumpThreshold = 40)) { + if (keyword.isBlank()) { + EhDB.localFavLazyList + } else { + EhDB.searchLocalFav(keyword) + } + } + } else { + Pager(PagingConfig(25)) { + object : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + override suspend fun load(params: LoadParams) = withIOContext { + 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.jumpTo != null) { + urlBuilder.next ?: urlBuilder.setIndex("2", true) + } + } else { + urlBuilder.setIndex(key, false) + } + } + } + val r = runSuspendCatching { + EhEngine.getFavorites(urlBuilder.build()) + }.onFailure { + return@withIOContext LoadResult.Error(it) + }.getOrThrow() + Settings.favCat = r.catArray + Settings.favCount = r.countArray + Settings.favCloudCount = r.countArray.sum() + urlBuilder.jumpTo = null + LoadResult.Page(r.galleryInfoList, r.prev, r.next) + } + } + } + }.flow.cachedIn(viewModelScope) + }.collectAsLazyPagingItems() + + val refreshState = rememberPullToRefreshState { + data.loadState.refresh is LoadState.NotLoading + } + + if (refreshState.isRefreshing) { + LaunchedEffect(Unit) { + data.refresh() + } + } + + val searchBarConnection = remember { + object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + searchBarOffsetY = (searchBarOffsetY + consumed.y).roundToInt().coerceIn(-300, 0) + return Offset.Zero // We never consume it + } + } + } + val combinedModifier = Modifier.nestedScroll(searchBarConnection).nestedScroll(refreshState.nestedScrollConnection) + + var expanded by remember { mutableStateOf(false) } + var hidden by remember { mutableStateOf(false) } + var selectMode by remember { mutableStateOf(false) } + val checkedInfoMap = remember { mutableStateMapOf() } + SideEffect { + if (checkedInfoMap.isEmpty()) selectMode = false + if (selectMode) expanded = true + } + + SearchBarScreen( + title = title, + searchFieldState = searchFieldState, + searchFieldHint = searchBarHint, + onApplySearch = { switchFav(urlBuilder.favCat, it) }, + onSearchExpanded = { hidden = true }, + onSearchHidden = { hidden = false }, + refreshState = refreshState, + searchBarOffsetY = searchBarOffsetY, + trailingIcon = { + IconButton(onClick = { activity.openSideSheet() }) { + Icon(imageVector = Icons.Outlined.FolderSpecial, contentDescription = null) + } + }, + ) { + val realPadding = PaddingValues( + top = it.calculateTopPadding() + 8.dp, + bottom = it.calculateBottomPadding(), + ) + val listMode by remember { + Settings.listModeBackField.valueFlow() + }.collectAsState(Settings.listMode) + val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + when (val state = data.loadState.refresh) { + is LoadState.Loading -> if (data.itemCount == 0) CircularProgressIndicator() + is LoadState.Error -> { + Column( + modifier = Modifier.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(state.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 = Modifier.padding(horizontal = marginH) then combinedModifier, + 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) { + val checked = info.gid in checkedInfoMap + CheckableItem(checked = checked) { + GalleryInfoListItem( + onClick = { + if (selectMode) { + if (checked) { + checkedInfoMap.remove(info.gid) + } else { + checkedInfoMap[info.gid] = info + } + } else { + navigator.navAnimated( + R.id.galleryDetailScene, + bundleOf(GalleryDetailScene.KEY_ARGS to GalleryInfoArgs(info)), + ) + } + }, + onLongClick = { + selectMode = true + checkedInfoMap[info.gid] = info + }, + info = info, + isInFavScene = true, + showPages = showPages, + modifier = Modifier.height(height), + ) + } + } + } + } + } else { + val gridInterval = dimensionResource(R.dimen.gallery_grid_interval) + FastScrollLazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(Settings.thumbSizeDp.dp), + modifier = Modifier.padding(horizontal = marginH) then combinedModifier, + 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) { + val checked = info.gid in checkedInfoMap + CheckableItem(checked = checked) { + GalleryInfoGridItem( + onClick = { + if (selectMode) { + if (checked) { + checkedInfoMap.remove(info.gid) + } else { + checkedInfoMap[info.gid] = info + } + } else { + navigator.navAnimated( + R.id.galleryDetailScene, + bundleOf(GalleryDetailScene.KEY_ARGS to GalleryInfoArgs(info)), + ) + } + }, + onLongClick = { + selectMode = true + checkedInfoMap[info.gid] = info + }, + info = info, + ) + } + } + } + } + } + } + } + + // Explicitly reallocate fabBuilder lambda to recompose secondary fab + val select = selectMode + FabLayout( + hidden = hidden, + expanded = expanded, + onExpandChanged = { + expanded = it + checkedInfoMap.clear() + selectMode = false + }, + autoCancel = !selectMode, + ) { + if (!select) { + onClick(EhIcons.Default.GoTo) { + val local = LocalDateTime.of(2007, 3, 21, 0, 0) + val fromDate = local.atZone(ZoneId.ofOffset("UTC", ZoneOffset.UTC)).toInstant().toEpochMilli() + val toDate = MaterialDatePicker.todayInUtcMilliseconds() + val listValidators = ArrayList() + 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: Long -> + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US).withZone(ZoneOffset.UTC) + val jumpTo = formatter.format(Instant.ofEpochMilli(time)) + urlBuilder = urlBuilder.copy(jumpTo = jumpTo) + } + } + onClick(Icons.Default.Refresh) { + switchFav(urlBuilder.favCat) + } + onClick(Icons.AutoMirrored.Default.LastPage) { + urlBuilder = urlBuilder.copy().apply { setIndex("1-0", false) } + } + } else { + onClick(Icons.Default.DoneAll) { + val info = data.itemSnapshotList.filterNotNull().associateBy { it.gid } + checkedInfoMap.putAll(info) + throw CancellationException() + } + onClick(Icons.Default.Download) { + val info = checkedInfoMap.run { values.also { clear() } } + dialogState.startDownload(context, false, *info.toTypedArray()) + } + onClick(Icons.Default.Delete) { + val info = checkedInfoMap.run { values.also { clear() } } + dialogState.awaitPermissionOrCancel(title = R.string.delete_favorites_dialog_title) { + Text(text = stringResource(R.string.delete_favorites_dialog_message, info.size)) + } + val srcCat = urlBuilder.favCat + if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) { // Delete local fav + EhDB.removeLocalFavorites(info) + } else { + val delList = info.mapToLongArray(BaseGalleryInfo::gid) + EhEngine.modifyFavorites(delList, srcCat, -1) + } + data.refresh() + } + onClick(Icons.AutoMirrored.Default.DriveFileMove) { + // First is local favorite, the other 10 is cloud favorite + val array = if (EhCookieStore.hasSignedIn()) { + arrayOf(localFavName, *Settings.favCat) + } else { + arrayOf(localFavName) + } + val index = dialogState.showSelectItem(*array, title = R.string.move_favorites_dialog_title) + val srcCat = urlBuilder.favCat + val dstCat = if (index == 0) FavListUrlBuilder.FAV_CAT_LOCAL else index - 1 + val info = checkedInfoMap.run { values.also { clear() } } + if (srcCat != dstCat) { + if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) { + // Move from local to cloud + EhDB.removeLocalFavorites(info) + val galleryList = info.map { it.gid to it.token!! } + runSuspendCatching { + EhEngine.addFavorites(galleryList, dstCat) + } + } else if (dstCat == FavListUrlBuilder.FAV_CAT_LOCAL) { + // Move from cloud to local + EhDB.putLocalFavorites(info) + } else { + // Move from cloud to cloud + val gidArray = info.mapToLongArray(BaseGalleryInfo::gid) + runSuspendCatching { + EhEngine.modifyFavorites(gidArray, srcCat, dstCat) + } + } + data.refresh() + } + } + } + } +} + +class FavoritesFragment : BaseScene() { + override val enableDrawerGestures = true + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return ComposeWithMD3 { + val navController = remember { findNavController() } + FavouritesScreen(navController) + } + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt deleted file mode 100644 index aaa9284b6..000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt +++ /dev/null @@ -1,518 +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.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.DriveFileMove -import androidx.compose.material.icons.automirrored.filled.Redo -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.DoneAll -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.outlined.FolderSpecial -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.currentRecomposeScope -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -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.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.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.EhCookieStore -import com.hippo.ehviewer.client.EhEngine -import com.hippo.ehviewer.client.data.BaseGalleryInfo -import com.hippo.ehviewer.client.data.FavListUrlBuilder -import com.hippo.ehviewer.databinding.SceneFavoritesBinding -import com.hippo.ehviewer.ui.legacy.HandlerDrawable -import com.hippo.ehviewer.ui.legacy.SecondaryFab -import com.hippo.ehviewer.ui.legacy.ViewTransition -import com.hippo.ehviewer.ui.startDownload -import com.hippo.ehviewer.ui.tools.DialogState -import com.hippo.ehviewer.util.ExceptionUtils -import com.hippo.ehviewer.util.getValue -import com.hippo.ehviewer.util.lazyMut -import com.hippo.ehviewer.util.mapToLongArray -import com.hippo.ehviewer.util.setValue -import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.lang.withIOContext -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.CancellationException -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import moe.tarsin.coroutines.runSuspendCatching -import rikka.core.res.resolveColor - -// Note that we do not really follow mvvm structure, just use it as ... storage -class VMStorage : ViewModel() { - var urlBuilder = FavListUrlBuilder(favCat = Settings.recentFavCat) - private val cloudDataFlow = Pager(PagingConfig(25)) { - object : PagingSource() { - override fun getRefreshKey(state: PagingState): String? = null - override suspend fun load(params: LoadParams) = withIOContext { - 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.jumpTo != null) { - urlBuilder.mNext ?: urlBuilder.setIndex("2", true) - } - } else { - urlBuilder.setIndex(key, false) - } - } - } - val r = runSuspendCatching { - EhEngine.getFavorites(urlBuilder.build()) - }.onFailure { - return@withIOContext LoadResult.Error(it) - }.getOrThrow() - Settings.favCat = r.catArray - Settings.favCount = r.countArray - Settings.favCloudCount = r.countArray.sum() - urlBuilder.jumpTo = null - LoadResult.Page(r.galleryInfoList, r.prev, r.next) - } - } - }.flow.cachedIn(viewModelScope) - - private val localFavDataFlow = Pager(PagingConfig(20, jumpThreshold = 40)) { - val keyword = urlBuilder.keyword - if (keyword.isNullOrBlank()) { - EhDB.localFavLazyList - } else { - EhDB.searchLocalFav(keyword) - } - }.flow.cachedIn(viewModelScope) - fun dataflow() = if (urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) localFavDataFlow else cloudDataFlow - val localFavCount = EhDB.localFavCount -} - -class FavoritesScene : SearchBarScene() { - private val vm: VMStorage by viewModels() - private var urlBuilder by lazyMut { vm::urlBuilder } - private var _binding: SceneFavoritesBinding? = null - private val binding get() = _binding!! - private var mAdapter: GalleryAdapter? = null - private val tracker get() = mAdapter!!.tracker!! - private var showNormalFabsJob: Job? = null - - private val dialogState = DialogState() - - override val fabLayout get() = binding.fabLayout - override val fastScroller get() = binding.fastScroller - override val recyclerView get() = binding.recyclerView - - private var onDrawerItemClick: ((Int) -> Unit)? = null - - private var collectJob: Job? = null - - private fun switchFav(newCat: Int, keyword: String? = null) { - _binding ?: return - urlBuilder.keyword = keyword - urlBuilder.favCat = newCat - urlBuilder.jumpTo = null - urlBuilder.setIndex(null, true) - collectJob?.cancel() - collectJob = viewLifecycleOwner.lifecycleScope.launchIO { - vm.dataflow().collectLatest { - mAdapter?.submitData(it) - } - } - mAdapter?.refresh() - val favCatName: String = when (val favCat = urlBuilder.favCat) { - in 0..9 -> Settings.favCat[favCat] - FavListUrlBuilder.FAV_CAT_LOCAL -> getString(R.string.local_favorites) - else -> getString(R.string.cloud_favorites) - } - if (keyword.isNullOrEmpty()) { - setTitle(getString(R.string.favorites_title, favCatName)) - setSearchBarText(null) - } else { - setTitle(getString(R.string.favorites_title_2, favCatName, keyword)) - } - setSearchBarHint(getString(R.string.search_bar_hint, favCatName)) - Settings.recentFavCat = urlBuilder.favCat - } - - @Composable - override fun TrailingIcon() { - dialogState.Intercept() - IconButton(onClick = { openSideSheet() }) { - Icon(imageVector = Icons.Outlined.FolderSpecial, contentDescription = stringResource(id = R.string.collections)) - } - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - mAdapter?.let { tracker.saveSelection(outState) } - } - - override fun onResume() { - super.onResume() - mAdapter?.type = Settings.listMode - } - - private val SceneFavoritesBinding.fastScroller get() = contentLayout.fastScroller - private val SceneFavoritesBinding.refreshLayout get() = contentLayout.refreshLayout - private val SceneFavoritesBinding.recyclerView get() = contentLayout.recyclerView - private val SceneFavoritesBinding.progress get() = contentLayout.progress - private val SceneFavoritesBinding.tip get() = contentLayout.tip - - override fun onCreateViewWithToolbar( - inflater: LayoutInflater, - container: ViewGroup, - savedInstanceState: Bundle?, - ): ViewBinding { - _binding = SceneFavoritesBinding.inflate(inflater, container) - val fabActions = listOf( - // Normal - Icons.AutoMirrored.Filled.Redo to { - showGoToDialog() - }, - Icons.Default.Refresh to { - switchFav(urlBuilder.favCat) - }, - Icons.Default.ArrowDownward to { - urlBuilder.setIndex("1-0", false) - mAdapter?.refresh() - }, - - // Select - Icons.Default.DoneAll to { - tracker.selectAll() - throw CancellationException() - }, - Icons.Default.Download to { - dialogState.startDownload(requireContext(), false, *takeCheckedInfo().toTypedArray()) - }, - Icons.Default.Delete to { - dialogState.awaitPermissionOrCancel(title = R.string.delete_favorites_dialog_title) { - Text(text = stringResource(R.string.delete_favorites_dialog_message, checkedSize())) - } - val info = takeCheckedInfo() - val srcCat = urlBuilder.favCat - if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) { // Delete local fav - EhDB.removeLocalFavorites(info) - } else { - val delList = info.mapToLongArray(BaseGalleryInfo::gid) - EhEngine.modifyFavorites(delList, srcCat, -1) - } - mAdapter?.refresh() - }, - Icons.AutoMirrored.Default.DriveFileMove to { - // First is local favorite, the other 10 is cloud favorite - val localFav = getString(R.string.local_favorites) - val array = if (EhCookieStore.hasSignedIn()) { - arrayOf(localFav, *Settings.favCat) - } else { - arrayOf(localFav) - } - val index = dialogState.showSelectItem(*array, title = R.string.move_favorites_dialog_title) - val srcCat = urlBuilder.favCat - val dstCat = if (index == 0) FavListUrlBuilder.FAV_CAT_LOCAL else index - 1 - val info = takeCheckedInfo() - if (srcCat != dstCat) { - if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) { - // Move from local to cloud - EhDB.removeLocalFavorites(info) - val galleryList = info.map { it.gid to it.token!! } - runSuspendCatching { - EhEngine.addFavorites(galleryList, dstCat) - } - } else if (dstCat == FavListUrlBuilder.FAV_CAT_LOCAL) { - // Move from cloud to local - EhDB.putLocalFavorites(info) - } else { - // Move from cloud to cloud - val gidArray = info.mapToLongArray(BaseGalleryInfo::gid) - runSuspendCatching { - EhEngine.modifyFavorites(gidArray, srcCat, dstCat) - } - } - mAdapter?.refresh() - } - }, - ) - fun updateJumpFab() { - val isLocalFav = urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL - binding.fabLayout.secondaryFab = if (isLocalFav) listOf(fabActions[1]) else fabActions.take(3) - } - onDrawerItemClick = { - // Skip if in search mode - if (!tracker.isInCustomChoice) { - switchFav(it - 2) - updateJumpFab() - closeSideSheet() - } - } - setOnApplySearch { - if (!tracker.isInCustomChoice) { - switchFav(urlBuilder.favCat, it) - } - } - binding.fastScroller.attachToRecyclerView(binding.recyclerView) - binding.fastScroller.setHandlerDrawable(HandlerDrawable().apply { setColor(inflater.context.theme.resolveColor(androidx.appcompat.R.attr.colorPrimary)) }) - binding.fabLayout.run { - updateJumpFab() - addOnExpandStateListener { - if (tracker.isInCustomChoice && !it) tracker.clearSelection() - } - } - binding.recyclerView.run { - mAdapter = GalleryAdapter( - this@run, - false, - { info -> - navAnimated( - R.id.galleryDetailScene, - bundleOf(GalleryDetailScene.KEY_ARGS to GalleryInfoArgs(info)), - ) - }, - {}, - ).also { adapter -> - val drawable = ContextCompat.getDrawable(context, 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 { switchFav(urlBuilder.favCat) } - val transition = ViewTransition(binding.refreshLayout, binding.progress, binding.tip) - val empty = getString(R.string.gallery_list_empty_hit) - viewLifecycleOwner.lifecycleScope.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) - } - is LoadState.NotLoading -> { - binding.refreshLayout.isRefreshing = false - if (mAdapter?.itemCount == 0) { - binding.tip.text = empty - transition.showView(2) - } else { - transition.showView(0) - } - } - } - } - } - adapter.tracker = GallerySelectionTracker( - "favorite-selection", - this, - { adapter.snapshot().items }, - { (this as? GalleryHolder)?.galleryId }, - ).apply { - addCustomChoiceListener( - onIntoCustomChoiceListener = { - showNormalFabsJob?.cancel() - binding.fabLayout.secondaryFab = fabActions.drop(3) - binding.fabLayout.autoCancel = false - binding.fabLayout.expanded = true - binding.refreshLayout.isEnabled = false - lockDrawer() - }, - onOutOfCustomChoiceListener = { - // Delay showing normal fab to avoid mutation - showNormalFabsJob = lifecycleScope.launch { - delay(300) - updateJumpFab() - } - binding.fabLayout.autoCancel = true - binding.fabLayout.expanded = false - binding.refreshLayout.isEnabled = true - unlockDrawer() - }, - ) - restoreSelection(savedInstanceState) - } - } - switchFav(Settings.recentFavCat) - } - val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() { - override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { - return if (tracker.isInCustomChoice) { - 0 - } else { - makeMovementFlags(0, ItemTouchHelper.LEFT) - } - } - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder, - ) = false - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - val position = viewHolder.bindingAdapterPosition - val info = mAdapter!!.peek(position)!! - lifecycleScope.launchIO { - dialogState.awaitPermissionOrCancel( - confirmText = R.string.delete, - title = R.string.delete_favorites_dialog_title, - onDismiss = { mAdapter!!.notifyItemChanged(position) }, - ) { - Text(text = stringResource(id = R.string.delete_favorites_dialog_message, 1)) - } - if (urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) { - EhDB.removeLocalFavorites(info) - } else { - EhEngine.modifyFavorites(info.gid, info.token) - } - } - } - }) - itemTouchHelper.attachToRecyclerView(binding.recyclerView) - return binding - } - - override fun onRelease() { - super.onRelease() - binding.recyclerView.stopScroll() - mAdapter = null - _binding = null - showNormalFabsJob = null - onDrawerItemClick = null - } - - override fun onCreateDrawerView(inflater: LayoutInflater) = ComposeWithMD3 { - val localFavCount by vm.localFavCount.collectAsState(0) - ElevatedCard { - TopAppBar(title = { Text(text = stringResource(id = R.string.collections)) }) - val scope = currentRecomposeScope - LaunchedEffect(Unit) { - Settings.favChangesFlow.collect { - scope.invalidate() - } - } - val localFav = stringResource(id = R.string.local_favorites) to localFavCount - val faves = if (EhCookieStore.hasSignedIn()) { - arrayOf( - localFav, - stringResource(id = R.string.cloud_favorites) to Settings.favCloudCount, - *Settings.favCat.zip(Settings.favCount.toTypedArray()).toTypedArray(), - ) - } else { - arrayOf(localFav) - } - Column(modifier = Modifier.verticalScroll(rememberScrollState())) { - faves.forEachIndexed { index, (name, count) -> - ListItem( - headlineContent = { Text(text = name) }, - trailingContent = { Text(text = count.toString(), style = MaterialTheme.typography.bodyLarge) }, - modifier = Modifier.clickable { onDrawerItemClick?.invoke(index) }, - ) - } - } - } - } - - override fun onSearchViewExpanded() { - binding.fabLayout.hide() - super.onSearchViewExpanded() - } - - override fun onSearchViewHidden() { - super.onSearchViewHidden() - lifecycleScope.launch { - delay(300) - _binding ?: return@launch - binding.fabLayout.show() - } - } - - private fun showGoToDialog() { - context ?: return - val local = LocalDateTime.of(2007, 3, 21, 0, 0) - val fromDate = local.atZone(ZoneId.ofOffset("UTC", ZoneOffset.UTC)).toInstant().toEpochMilli() - val toDate = MaterialDatePicker.todayInUtcMilliseconds() - val listValidators = ArrayList() - 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: Long -> - val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US).withZone(ZoneOffset.UTC) - val jumpTo = formatter.format(Instant.ofEpochMilli(time)) - urlBuilder.jumpTo = jumpTo - mAdapter?.refresh() - } - } - - private fun takeCheckedInfo() = tracker.getAndClearSelection() - - private fun checkedSize() = tracker.selectionSize -} 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 5adb0d3e6..e39a02a00 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 @@ -15,6 +15,7 @@ */ package com.hippo.ehviewer.ui.scene +import android.annotation.SuppressLint import android.content.Context import android.graphics.Typeface import android.os.Bundle @@ -75,6 +76,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned @@ -83,6 +85,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalTextToolbar +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue @@ -176,9 +179,8 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: NavController val dialogState = LocalDialogState.current val coroutineScope = rememberCoroutineScope() - val commentingBackField = rememberSaveable { mutableStateOf(false) } - var commenting by commentingBackField - val animationProgress by animateFloatMergePredictiveBackAsState(enable = commentingBackField) + var commenting by rememberSaveable { mutableStateOf(false) } + val animationProgress by animateFloatMergePredictiveBackAsState(enable = commenting) { commenting = false } val userCommentBackField = remember { mutableStateOf(TextFieldValue()) } var userComment by userCommentBackField @@ -431,9 +433,15 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: NavController } } } + + @SuppressLint("PrivateResource") + val overlay = colorResource(com.google.android.material.R.color.m3_popupmenu_overlay_color) + val surface = MaterialTheme.colorScheme.surface + val background = overlay.compositeOver(surface) PullToRefreshContainer( state = refreshState, modifier = Modifier.align(Alignment.TopCenter).padding(top = paddingValues.calculateTopPadding()), + containerColor = background, ) Surface( modifier = Modifier.align(Alignment.BottomCenter).layout { measurable, constraints -> 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 0a8bdcb4b..dcf6f2f36 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 @@ -74,7 +74,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -255,11 +254,8 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: NavController) ).setAction(R.string.read) { context.navToReader(gi.findBaseInfo(), page) }.show() } } - DisposableEffect(galleryDetailUrl) { - activity.shareUrl = galleryDetailUrl - onDispose { - activity.shareUrl = null - } + with(activity) { + ProvideAssistContent(galleryDetailUrl) } var getDetailError by rememberSaveable { mutableStateOf("") } val dialogState = LocalDialogState.current 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 962418c75..0d5e26353 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 @@ -1,5 +1,6 @@ package com.hippo.ehviewer.ui.scene +import android.annotation.SuppressLint import android.os.Bundle import android.os.Parcelable import android.util.SparseArray @@ -10,14 +11,21 @@ import androidx.annotation.CallSuper import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.union import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -36,30 +44,37 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCompositionContext import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.WindowInfo +import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp @@ -118,8 +133,9 @@ fun SearchBarScreen( onApplySearch: (String) -> Unit, onSearchExpanded: () -> Unit, onSearchHidden: () -> Unit, + refreshState: PullToRefreshState? = null, suggestionProvider: SuggestionProvider? = null, - searchBarOffsetY: MutableState, + searchBarOffsetY: Int, trailingIcon: @Composable () -> Unit, content: @Composable (PaddingValues) -> Unit, ) { @@ -231,102 +247,122 @@ fun SearchBarScreen( } } - var searchbarOfs by searchBarOffsetY - Scaffold( - topBar = { - SearchBar( - modifier = Modifier.fillMaxWidth().layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height) { - placeable.placeRelative(0, searchbarOfs) - } - }, - state = searchFieldState, - onSearch = { - hideSearchView() - onApplySearch() - }, - active = active, - onActiveChange = { - if (it) { - searchbarOfs = 0 - onSearchViewExpanded() - } else { - onSearchViewHidden() - } - active = it - }, - title = title?.let { { Text(it) } }, - placeholder = searchFieldHint?.let { { Text(it) } }, - leadingIcon = { - if (active) { - IconButton(onClick = { hideSearchView() }) { - Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null) - } - } else { - IconButton(onClick = { activity.openDrawer() }) { - Icon(Icons.Default.Menu, contentDescription = null) - } - } - }, - trailingIcon = { - if (active) { - if (searchFieldState.text.isNotEmpty()) { - IconButton(onClick = { searchFieldState.clearText() }) { - Icon(Icons.Default.Close, contentDescription = null) - } - } - } else { - Row { - trailingIcon() - } - } - }, - ) { - LazyColumn( - contentPadding = WindowInsets.navigationBars.union(WindowInsets.ime) - .asPaddingValues(), + val searchbarOfs by rememberUpdatedState(searchBarOffsetY) + val scrollAwayModifier = if (!active) { + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, searchbarOfs) + } + } + } else { + Modifier + } + Box(modifier = Modifier.fillMaxSize()) { + Scaffold( + topBar = { + // Placeholder, fill immutable SearchBar padding + Spacer( + modifier = Modifier.statusBarsPadding().displayCutoutPadding() + .height(SearchBarDefaults.InputFieldHeight + 16.dp), + ) + }, + floatingActionButton = { + val hiddenState by animateFloatAsState( + targetValue = if (showSearchFab) 1f else 0f, + animationSpec = tween(FAB_ANIMATE_TIME), + label = "hiddenState", + ) + FloatingActionButton( + onClick = ::onApplySearch, + modifier = Modifier.rotate(lerp(90f, 0f, hiddenState)).scale(hiddenState), ) { - items(mSuggestionList) { - ListItem( - headlineContent = { Text(text = it.keyword) }, - supportingContent = it.hint.ifNotNullThen { Text(text = it.hint!!) }, - leadingContent = it.canOpenDirectly.ifTrueThen { - Icon( - imageVector = Icons.AutoMirrored.Default.MenuBook, - contentDescription = null, - ) - }, - trailingContent = it.canDelete.ifTrueThen { - IconButton(onClick = { deleteKeyword(it.keyword) }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - ) - } - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.clickable { it.onClick() }, - ) + Icon(imageVector = Icons.Default.Search, contentDescription = null) + } + }, + content = content, + ) + if (refreshState != null) { + @SuppressLint("PrivateResource") + val overlay = colorResource(com.google.android.material.R.color.m3_popupmenu_overlay_color) + val surface = MaterialTheme.colorScheme.surface + val background = overlay.compositeOver(surface) + PullToRefreshContainer( + state = refreshState, + modifier = Modifier.align(Alignment.TopCenter).safeDrawingPadding() + .padding(top = 48.dp) then scrollAwayModifier, + containerColor = background, + ) + } + SearchBar( + modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter) then scrollAwayModifier, + state = searchFieldState, + onSearch = { + hideSearchView() + onApplySearch() + }, + active = active, + onActiveChange = { + if (it) { + onSearchViewExpanded() + } else { + onSearchViewHidden() + } + active = it + }, + title = title?.let { { Text(it) } }, + placeholder = searchFieldHint?.let { { Text(it) } }, + leadingIcon = { + if (active) { + IconButton(onClick = { hideSearchView() }) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null) + } + } else { + IconButton(onClick = { activity.openDrawer() }) { + Icon(Icons.Default.Menu, contentDescription = null) } } + }, + trailingIcon = { + if (active) { + if (searchFieldState.text.isNotEmpty()) { + IconButton(onClick = { searchFieldState.clearText() }) { + Icon(Icons.Default.Close, contentDescription = null) + } + } + } else { + Row { + trailingIcon() + } + } + }, + ) { + LazyColumn(contentPadding = WindowInsets.navigationBars.union(WindowInsets.ime).asPaddingValues()) { + items(mSuggestionList) { + ListItem( + headlineContent = { Text(text = it.keyword) }, + supportingContent = it.hint.ifNotNullThen { Text(text = it.hint!!) }, + leadingContent = it.canOpenDirectly.ifTrueThen { + Icon( + imageVector = Icons.AutoMirrored.Default.MenuBook, + contentDescription = null, + ) + }, + trailingContent = it.canDelete.ifTrueThen { + IconButton(onClick = { deleteKeyword(it.keyword) }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + ) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable { it.onClick() }, + ) + } } - }, - floatingActionButton = { - val hiddenState by animateFloatAsState( - targetValue = if (showSearchFab) 1f else 0f, - animationSpec = tween(FAB_ANIMATE_TIME), - label = "hiddenState", - ) - FloatingActionButton( - onClick = ::onApplySearch, - modifier = Modifier.rotate(lerp(90f, 0f, hiddenState)).scale(hiddenState), - ) { - Icon(imageVector = Icons.Default.Search, contentDescription = null) - } - }, - content = content, - ) + } + } } abstract class SearchBarScene : BaseScene() { @@ -348,11 +384,10 @@ abstract class SearchBarScene : BaseScene() { override val enableDrawerGestures = true override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - val ofs = mutableIntStateOf(0) + var ofs by mutableIntStateOf(0) val onScrollListener = object : OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - var toChange by ofs - toChange = (toChange - dy).coerceIn(-300, 0) + ofs = (ofs - dy).coerceIn(-300, 0) } } return ComposeWithMD3 { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/LazyGrid.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/LazyGrid.kt index 68a22a7f6..5c33ae55e 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/LazyGrid.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/LazyGrid.kt @@ -7,10 +7,16 @@ import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Composable @@ -49,3 +55,40 @@ fun FastScrollLazyVerticalGrid( ) } } + +@Composable +fun FastScrollLazyVerticalStaggeredGrid( + columns: StaggeredGridCells, + modifier: Modifier = Modifier, + state: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + thumbAllowed: () -> Boolean = { true }, + thumbColor: Color = MaterialTheme.colorScheme.primary, + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalItemSpacing: Dp = 0.dp, + horizontalArrangement: Arrangement.Horizontal = Arrangement.Start, + userScrollEnabled: Boolean = true, + content: LazyStaggeredGridScope.() -> Unit, +) { + VerticalStaggeredGridFastScroller( + state = state, + columns = columns, + arrangement = horizontalArrangement, + contentPadding = contentPadding, + thumbAllowed = thumbAllowed, + thumbColor = thumbColor, + topContentPadding = contentPadding.calculateTopPadding(), + ) { + LazyVerticalStaggeredGrid( + columns = columns, + modifier = modifier, + state = state, + contentPadding = contentPadding, + reverseLayout = reverseLayout, + verticalItemSpacing = verticalItemSpacing, + horizontalArrangement = horizontalArrangement, + userScrollEnabled = userScrollEnabled, + content = content, + ) + } +} diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/PredictiveBack.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/PredictiveBack.kt index 280aa254d..9727cf9f0 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/PredictiveBack.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/PredictiveBack.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -30,15 +29,13 @@ import kotlinx.coroutines.CancellationException */ @Composable fun animateFloatMergePredictiveBackAsState( - enable: MutableState, + enable: Boolean, animationSpec: AnimationSpec = spring(), + onBack: () -> Unit, ): State { - // Principal Value - var principal by enable - // Natural animator state correspond with principal value val coState by animateFloatAsState( - targetValue = if (principal) 0f else 1f, + targetValue = if (enable) 0f else 1f, animationSpec = animationSpec, label = "animationProgress", ) @@ -46,16 +43,15 @@ fun animateFloatMergePredictiveBackAsState( val ret = remember { mutableFloatStateOf(1F) } var animationProgress by ret // Update UI animation state - animationProgress = if (principal || !isAtLeastU) coState else max(animationProgress, coState) + animationProgress = if (enable || !isAtLeastU) coState else max(animationProgress, coState) - PredictiveBackHandler(principal) { progress -> + PredictiveBackHandler(enable) { progress -> try { progress.collect { animationProgress = it.progress } - principal = false + onBack() } catch (e: CancellationException) { - principal = true animationProgress = 0F } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/VerticalFastScroller.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/VerticalFastScroller.kt index c00110652..953759843 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/VerticalFastScroller.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/VerticalFastScroller.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.systemGestureExclusion import androidx.compose.material3.MaterialTheme @@ -218,6 +220,37 @@ private fun rememberColumnWidthSums( } } +@Composable +private fun rememberColumnWidthSums( + columns: StaggeredGridCells, + horizontalArrangement: Arrangement.Horizontal, + contentPadding: PaddingValues, +) = remember List>( + columns, + horizontalArrangement, + contentPadding, +) { + { + constraints -> + require(constraints.maxWidth != Constraints.Infinity) { + "LazyVerticalGrid's width should be bound by parent" + } + val horizontalPadding = contentPadding.calculateStartPadding(LayoutDirection.Ltr) + + contentPadding.calculateEndPadding(LayoutDirection.Ltr) + val gridWidth = constraints.maxWidth - horizontalPadding.roundToPx() + with(columns) { + calculateCrossAxisCellSizes( + gridWidth, + horizontalArrangement.spacing.roundToPx(), + ).toMutableList().apply { + for (i in 1.. Boolean = { true }, + thumbColor: Color = MaterialTheme.colorScheme.primary, + topContentPadding: Dp = Dp.Hairline, + bottomContentPadding: Dp = Dp.Hairline, + endContentPadding: Dp = Dp.Hairline, + content: @Composable () -> Unit, +) { + val slotSizesSums = rememberColumnWidthSums( + columns = columns, + horizontalArrangement = arrangement, + contentPadding = contentPadding, + ) + + SubcomposeLayout(modifier = modifier) { constraints -> + val contentPlaceable = subcompose("content", content).map { it.measure(constraints) } + val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0 + val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0 + + val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val scrollerPlaceable = subcompose("scroller") { + val layoutInfo = state.layoutInfo + val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount + if (!showScroller) return@subcompose + val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() } + var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) } + + val dragInteractionSource = remember { MutableInteractionSource() } + val isThumbDragged by dragInteractionSource.collectIsDraggedAsState() + val scrolled = remember { + MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + } + + val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() } + val heightPx = contentHeight.toFloat() - thumbTopPadding - thumbBottomPadding - state.layoutInfo.afterContentPadding + val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() } + val trackHeightPx = heightPx - thumbHeightPx + + val columnCount = remember { slotSizesSums(constraints).size } + + // When thumb dragged + LaunchedEffect(thumbOffsetY) { + if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect + val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx + val scrollItem = layoutInfo.totalItemsCount * scrollRatio + // I can't think of anything else rn but this'll do + val scrollItemWhole = scrollItem.toInt() + val columnNum = ((scrollItemWhole + 1) % columnCount).takeIf { it != 0 } ?: columnCount + val scrollItemFraction = if (scrollItemWhole == 0) scrollItem else scrollItem % scrollItemWhole + val offsetPerItem = 1f / columnCount + val offsetRatio = (offsetPerItem * scrollItemFraction) + (offsetPerItem * (columnNum - 1)) + + // TODO: Sometimes item height is not available when scrolling up + val scrollItemSize = (1..columnCount).maxOf { num -> + val actualIndex = if (num != columnNum) { + scrollItemWhole + num - columnCount + } else { + scrollItemWhole + } + layoutInfo.visibleItemsInfo.find { it.index == actualIndex }?.size?.height ?: 0 + } + val scrollItemOffset = scrollItemSize * offsetRatio + + state.scrollToItem(index = scrollItemWhole, scrollOffset = scrollItemOffset.roundToInt()) + scrolled.tryEmit(Unit) + } + + // When list scrolled + LaunchedEffect(state.firstVisibleItemScrollOffset) { + if (state.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect + val scrollOffset = computeScrollOffset(state = state) + val scrollRange = computeScrollRange(state = state) + val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx) + thumbOffsetY = trackHeightPx * proportion + thumbTopPadding + scrolled.tryEmit(Unit) + } + + // Thumb alpha + val alpha = remember { Animatable(0f) } + val isThumbVisible = alpha.value > 0f + LaunchedEffect(scrolled, alpha) { + scrolled + .sample(100) + .collectLatest { + if (thumbAllowed()) { + alpha.snapTo(1f) + alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec) + } else { + alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec) + } + } + } + + Box( + modifier = Modifier + .offset { IntOffset(0, thumbOffsetY.roundToInt()) } + .then( + // Recompose opts + if (isThumbVisible && !state.isScrollInProgress) { + Modifier.draggable( + interactionSource = dragInteractionSource, + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + val newOffsetY = thumbOffsetY + delta + thumbOffsetY = newOffsetY.coerceIn( + thumbTopPadding, + thumbTopPadding + trackHeightPx, + ) + }, + ) + } else { + Modifier + }, + ) + .then( + // Exclude thumb from gesture area only when needed + if (isThumbVisible && !isThumbDragged && !state.isScrollInProgress) { + Modifier.systemGestureExclusion() + } else { + Modifier + }, + ) + .height(ThumbLength) + .padding(horizontal = 8.dp) + .padding(end = endContentPadding) + .width(ThumbThickness) + .alpha(alpha.value) + .background(color = thumbColor, shape = ThumbShape), + ) + }.map { it.measure(scrollerConstraints) } + val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0 + + layout(contentWidth, contentHeight) { + contentPlaceable.fastForEach { + it.place(0, 0) + } + scrollerPlaceable.fastForEach { + it.placeRelative(contentWidth - scrollerWidth, 0) + } + } + } +} + private fun computeScrollOffset(state: LazyGridState): Int { if (state.layoutInfo.totalItemsCount == 0) return 0 val visibleItems = state.layoutInfo.visibleItemsInfo @@ -385,6 +570,21 @@ private fun computeScrollOffset(state: LazyGridState): Int { return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt() } +private fun computeScrollOffset(state: LazyStaggeredGridState): Int { + if (state.layoutInfo.totalItemsCount == 0) return 0 + val visibleItems = state.layoutInfo.visibleItemsInfo + val startChild = visibleItems.first() + val endChild = visibleItems.last() + val minPosition = min(startChild.index, endChild.index) + val maxPosition = max(startChild.index, endChild.index) + val itemsBefore = minPosition.coerceAtLeast(0) + val startDecoratedTop = startChild.offset.y + val laidOutArea = abs((endChild.offset.y + endChild.size.height) - startDecoratedTop) + val itemRange = abs(minPosition - maxPosition) + 1 + val avgSizePerRow = laidOutArea.toFloat() / itemRange + return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt() +} + private fun computeScrollRange(state: LazyGridState): Int { if (state.layoutInfo.totalItemsCount == 0) return 0 val visibleItems = state.layoutInfo.visibleItemsInfo @@ -395,6 +595,16 @@ private fun computeScrollRange(state: LazyGridState): Int { return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt() } +private fun computeScrollRange(state: LazyStaggeredGridState): Int { + if (state.layoutInfo.totalItemsCount == 0) return 0 + val visibleItems = state.layoutInfo.visibleItemsInfo + val startChild = visibleItems.first() + val endChild = visibleItems.last() + val laidOutArea = (endChild.offset.y + endChild.size.height) - startChild.offset.y + val laidOutRange = abs(startChild.index - endChild.index) + 1 + return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt() +} + private fun computeScrollOffset(state: LazyListState): Int { if (state.layoutInfo.totalItemsCount == 0) return 0 val visibleItems = state.layoutInfo.visibleItemsInfo diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index 48ad7573a..0b51a231b 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -57,7 +57,7 @@ android:label="GalleryPreviewsScene" />