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 <lokeshgidra@google.com>
This commit is contained in:
1 parent
4eed03ebed
commit
bac60fbec8
17 files changed
+1251
-704
No files matched your search
@@ -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(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <a href="https://m3.material.io/components/navigation-drawer/overview" class="external" target="_blank">Material Design navigation drawer</a>.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 
|
||||
*
|
||||
* @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()
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ object EhDB {
|
||||
deleteGalleryInfo(galleryInfo)
|
||||
}
|
||||
|
||||
suspend fun removeLocalFavorites(galleryInfoList: List<BaseGalleryInfo>) {
|
||||
suspend fun removeLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
|
||||
galleryInfoList.forEach {
|
||||
removeLocalFavorites(it)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ object EhDB {
|
||||
db.localFavoritesDao().upsert(LocalFavoriteInfo(galleryInfo.gid))
|
||||
}
|
||||
|
||||
suspend fun putLocalFavorites(galleryInfoList: List<BaseGalleryInfo>) {
|
||||
suspend fun putLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
|
||||
galleryInfoList.forEach {
|
||||
putLocalFavorites(it)
|
||||
}
|
||||
|
||||
@@ -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<Boolean>
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<Unit>(extraBufferCapacity = 1)
|
||||
private var openSideSheetFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
private var recomposeFlow = MutableSharedFlow<Unit>(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<NavHostFragment>()
|
||||
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<NavHostFragment>()
|
||||
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) }
|
||||
|
||||
@@ -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<OnExpandStateListener>()
|
||||
|
||||
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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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<MainActivity>() }
|
||||
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<String, BaseGalleryInfo>() {
|
||||
override fun getRefreshKey(state: PagingState<String, BaseGalleryInfo>): String? = null
|
||||
override suspend fun load(params: LoadParams<String>) = 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<Long, BaseGalleryInfo>() }
|
||||
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<CalendarConstraints.DateValidator>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, BaseGalleryInfo>() {
|
||||
override fun getRefreshKey(state: PagingState<String, BaseGalleryInfo>): String? = null
|
||||
override suspend fun load(params: LoadParams<String>) = 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<SecondaryFab>(
|
||||
// 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<DateValidator>()
|
||||
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
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Int>,
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean>,
|
||||
enable: Boolean,
|
||||
animationSpec: AnimationSpec<Float> = spring(),
|
||||
onBack: () -> Unit,
|
||||
): State<Float> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Density.(Constraints) -> List<Int>>(
|
||||
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..<size) {
|
||||
this[i] += this[i - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerticalGridFastScroller(
|
||||
state: LazyGridState,
|
||||
@@ -370,6 +403,158 @@ fun VerticalGridFastScroller(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerticalStaggeredGridFastScroller(
|
||||
state: LazyStaggeredGridState,
|
||||
columns: StaggeredGridCells,
|
||||
arrangement: Arrangement.Horizontal,
|
||||
contentPadding: PaddingValues,
|
||||
modifier: Modifier = Modifier,
|
||||
thumbAllowed: () -> 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<Unit>(
|
||||
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
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
android:label="GalleryPreviewsScene" />
|
||||
<fragment
|
||||
android:id="@+id/nav_favourite"
|
||||
android:name="com.hippo.ehviewer.ui.scene.FavoritesScene"
|
||||
android:name="com.hippo.ehviewer.ui.scene.FavoritesFragment"
|
||||
android:label="FavoritesScene" />
|
||||
<fragment
|
||||
android:id="@+id/nav_history"
|
||||
|
||||
Reference in new issue
Block a user