Browse Source

Use custom QueryPagingSource (#7321)

* Use custom QueryPagingSource

- Adds placeholder to make the list jump around less
- Fixes issue where SQLDelight QueryPagingSource would throw IndexOutOfBounds

* Review Changes
Andreas 2 năm trước cách đây
mục cha
commit
3fd9e021fa

+ 2 - 6
app/src/main/java/eu/kanade/data/AndroidDatabaseHandler.kt

@@ -2,8 +2,6 @@ package eu.kanade.data
 
 import androidx.paging.PagingSource
 import com.squareup.sqldelight.Query
-import com.squareup.sqldelight.Transacter
-import com.squareup.sqldelight.android.paging3.QueryPagingSource
 import com.squareup.sqldelight.db.SqlDriver
 import com.squareup.sqldelight.runtime.coroutines.asFlow
 import com.squareup.sqldelight.runtime.coroutines.mapToList
@@ -63,13 +61,11 @@ class AndroidDatabaseHandler(
 
     override fun <T : Any> subscribeToPagingSource(
         countQuery: Database.() -> Query<Long>,
-        transacter: Database.() -> Transacter,
         queryProvider: Database.(Long, Long) -> Query<T>,
     ): PagingSource<Long, T> {
         return QueryPagingSource(
-            countQuery = countQuery(db),
-            transacter = transacter(db),
-            dispatcher = queryDispatcher,
+            handler = this,
+            countQuery = countQuery,
             queryProvider = { limit, offset ->
                 queryProvider.invoke(db, limit, offset)
             },

+ 0 - 2
app/src/main/java/eu/kanade/data/DatabaseHandler.kt

@@ -2,7 +2,6 @@ package eu.kanade.data
 
 import androidx.paging.PagingSource
 import com.squareup.sqldelight.Query
-import com.squareup.sqldelight.Transacter
 import eu.kanade.tachiyomi.Database
 import kotlinx.coroutines.flow.Flow
 
@@ -33,7 +32,6 @@ interface DatabaseHandler {
 
     fun <T : Any> subscribeToPagingSource(
         countQuery: Database.() -> Query<Long>,
-        transacter: Database.() -> Transacter,
         queryProvider: Database.(Long, Long) -> Query<T>,
     ): PagingSource<Long, T>
 }

+ 72 - 0
app/src/main/java/eu/kanade/data/QueryPagingSource.kt

@@ -0,0 +1,72 @@
+package eu.kanade.data
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import com.squareup.sqldelight.Query
+import eu.kanade.tachiyomi.Database
+import kotlin.properties.Delegates
+
+class QueryPagingSource<RowType : Any>(
+    val handler: DatabaseHandler,
+    val countQuery: Database.() -> Query<Long>,
+    val queryProvider: Database.(Long, Long) -> Query<RowType>,
+) : PagingSource<Long, RowType>(), Query.Listener {
+
+    override val jumpingSupported: Boolean = true
+
+    private var currentQuery: Query<RowType>? by Delegates.observable(null) { _, old, new ->
+        old?.removeListener(this)
+        new?.addListener(this)
+    }
+
+    init {
+        registerInvalidatedCallback {
+            currentQuery?.removeListener(this)
+            currentQuery = null
+        }
+    }
+
+    override suspend fun load(params: LoadParams<Long>): LoadResult<Long, RowType> {
+        try {
+            val key = params.key ?: 0L
+            val loadSize = params.loadSize
+            val count = handler.awaitOne { countQuery() }
+
+            val (offset, limit) = when (params) {
+                is LoadParams.Prepend -> key - loadSize to loadSize.toLong()
+                else -> key to loadSize.toLong()
+            }
+
+            val data = handler.awaitList {
+                queryProvider(limit, offset)
+                    .also { currentQuery = it }
+            }
+
+            val (prevKey, nextKey) = when (params) {
+                is LoadParams.Append -> { offset - loadSize to offset + loadSize }
+                else -> { offset to offset + loadSize }
+            }
+
+            return LoadResult.Page(
+                data = data,
+                prevKey = if (offset <= 0L || prevKey < 0L) null else prevKey,
+                nextKey = if (offset + loadSize >= count) null else nextKey,
+                itemsBefore = maxOf(0L, offset).toInt(),
+                itemsAfter = maxOf(0L, count - (offset + loadSize)).toInt(),
+            )
+        } catch (e: Exception) {
+            return LoadResult.Error(throwable = e)
+        }
+    }
+
+    override fun getRefreshKey(state: PagingState<Long, RowType>): Long? {
+        return state.anchorPosition?.let { anchorPosition ->
+            val anchorPage = state.closestPageToPosition(anchorPosition)
+            anchorPage?.prevKey ?: anchorPage?.nextKey
+        }
+    }
+
+    override fun queryResultsChanged() {
+        invalidate()
+    }
+}

+ 0 - 1
app/src/main/java/eu/kanade/data/history/HistoryRepositoryImpl.kt

@@ -19,7 +19,6 @@ class HistoryRepositoryImpl(
     override fun getHistory(query: String): PagingSource<Long, HistoryWithRelations> {
         return handler.subscribeToPagingSource(
             countQuery = { historyViewQueries.countHistory(query) },
-            transacter = { historyViewQueries },
             queryProvider = { limit, offset ->
                 historyViewQueries.history(query, limit, offset, historyWithRelationsMapper)
             },

+ 1 - 1
app/src/main/java/eu/kanade/presentation/components/MangaCover.kt

@@ -14,7 +14,7 @@ import coil.compose.AsyncImage
 import eu.kanade.presentation.util.rememberResourceBitmapPainter
 import eu.kanade.tachiyomi.R
 
-enum class MangaCover(private val ratio: Float) {
+enum class MangaCover(val ratio: Float) {
     Square(1f / 1f),
     Book(2f / 3f);
 

+ 38 - 109
app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt

@@ -1,24 +1,21 @@
 package eu.kanade.presentation.history
 
-import androidx.compose.foundation.clickable
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.asPaddingValues
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.selection.toggleable
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Delete
 import androidx.compose.material3.AlertDialog
 import androidx.compose.material3.Checkbox
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
@@ -29,12 +26,11 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush.Companion.linearGradient
 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
 import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.paging.LoadState
 import androidx.paging.compose.LazyPagingItems
@@ -43,22 +39,20 @@ import androidx.paging.compose.items
 import eu.kanade.domain.history.model.HistoryWithRelations
 import eu.kanade.presentation.components.EmptyScreen
 import eu.kanade.presentation.components.LoadingScreen
-import eu.kanade.presentation.components.MangaCover
 import eu.kanade.presentation.components.ScrollbarLazyColumn
-import eu.kanade.presentation.util.horizontalPadding
+import eu.kanade.presentation.history.components.HistoryHeader
+import eu.kanade.presentation.history.components.HistoryItem
+import eu.kanade.presentation.history.components.HistoryItemShimmer
 import eu.kanade.presentation.util.plus
+import eu.kanade.presentation.util.shimmerGradient
 import eu.kanade.presentation.util.topPaddingValues
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.ui.recent.history.HistoryPresenter
 import eu.kanade.tachiyomi.ui.recent.history.HistoryState
-import eu.kanade.tachiyomi.util.lang.toRelativeString
-import eu.kanade.tachiyomi.util.lang.toTimestampString
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 import java.text.DateFormat
-import java.text.DecimalFormat
-import java.text.DecimalFormatSymbols
 import java.util.Date
 
 @Composable
@@ -93,10 +87,7 @@ fun HistoryContent(
     preferences: PreferencesHelper = Injekt.get(),
     nestedScroll: NestedScrollConnection,
 ) {
-    if (history.loadState.refresh is LoadState.Loading) {
-        LoadingScreen()
-        return
-    } else if (history.loadState.refresh is LoadState.NotLoading && history.itemCount == 0) {
+    if (history.loadState.refresh is LoadState.NotLoading && history.itemCount == 0) {
         EmptyScreen(textResource = R.string.information_no_recent_manga)
         return
     }
@@ -107,6 +98,29 @@ fun HistoryContent(
     var removeState by remember { mutableStateOf<HistoryWithRelations?>(null) }
 
     val scrollState = rememberLazyListState()
+
+    val transition = rememberInfiniteTransition()
+
+    val translateAnimation = transition.animateFloat(
+        initialValue = 0f,
+        targetValue = 1000f,
+        animationSpec = infiniteRepeatable(
+            animation = tween(
+                durationMillis = 1000,
+                easing = LinearEasing,
+            ),
+        ),
+    )
+
+    val brush = linearGradient(
+        colors = shimmerGradient,
+        start = Offset(0f, 0f),
+        end = Offset(
+            x = translateAnimation.value,
+            y = 00f,
+        ),
+    )
+
     ScrollbarLazyColumn(
         modifier = Modifier
             .nestedScroll(nestedScroll),
@@ -134,7 +148,9 @@ fun HistoryContent(
                         onClickDelete = { removeState = value },
                     )
                 }
-                null -> {}
+                null -> {
+                    HistoryItemShimmer(brush = brush)
+                }
             }
         }
     }
@@ -150,88 +166,6 @@ fun HistoryContent(
     }
 }
 
-@Composable
-fun HistoryHeader(
-    modifier: Modifier = Modifier,
-    date: Date,
-    relativeTime: Int,
-    dateFormat: DateFormat,
-) {
-    Text(
-        modifier = modifier
-            .padding(horizontal = horizontalPadding, vertical = 8.dp),
-        text = date.toRelativeString(
-            LocalContext.current,
-            relativeTime,
-            dateFormat,
-        ),
-        style = MaterialTheme.typography.bodyMedium.copy(
-            color = MaterialTheme.colorScheme.onSurfaceVariant,
-            fontWeight = FontWeight.SemiBold,
-        ),
-    )
-}
-
-@Composable
-fun HistoryItem(
-    modifier: Modifier = Modifier,
-    history: HistoryWithRelations,
-    onClickCover: () -> Unit,
-    onClickResume: () -> Unit,
-    onClickDelete: () -> Unit,
-) {
-    Row(
-        modifier = modifier
-            .clickable(onClick = onClickResume)
-            .height(96.dp)
-            .padding(horizontal = horizontalPadding, vertical = 8.dp),
-        verticalAlignment = Alignment.CenterVertically,
-    ) {
-        MangaCover.Book(
-            modifier = Modifier
-                .fillMaxHeight()
-                .clickable(onClick = onClickCover),
-            data = history.coverData,
-        )
-        Column(
-            modifier = Modifier
-                .weight(1f)
-                .padding(start = horizontalPadding, end = 8.dp),
-        ) {
-            val textStyle = MaterialTheme.typography.bodyMedium
-            Text(
-                text = history.title,
-                maxLines = 2,
-                overflow = TextOverflow.Ellipsis,
-                style = textStyle.copy(fontWeight = FontWeight.SemiBold),
-            )
-            Row {
-                Text(
-                    text = if (history.chapterNumber > -1) {
-                        stringResource(
-                            R.string.recent_manga_time,
-                            chapterFormatter.format(history.chapterNumber),
-                            history.readAt?.toTimestampString() ?: "",
-                        )
-                    } else {
-                        history.readAt?.toTimestampString() ?: ""
-                    },
-                    modifier = Modifier.padding(top = 4.dp),
-                    style = textStyle,
-                )
-            }
-        }
-
-        IconButton(onClick = onClickDelete) {
-            Icon(
-                imageVector = Icons.Outlined.Delete,
-                contentDescription = stringResource(R.string.action_delete),
-                tint = MaterialTheme.colorScheme.onSurface,
-            )
-        }
-    }
-}
-
 @Composable
 fun RemoveHistoryDialog(
     onPositive: (Boolean) -> Unit,
@@ -282,11 +216,6 @@ fun RemoveHistoryDialog(
     )
 }
 
-private val chapterFormatter = DecimalFormat(
-    "#.###",
-    DecimalFormatSymbols().apply { decimalSeparator = '.' },
-)
-
 sealed class HistoryUiModel {
     data class Header(val date: Date) : HistoryUiModel()
     data class Item(val item: HistoryWithRelations) : HistoryUiModel()

+ 36 - 0
app/src/main/java/eu/kanade/presentation/history/components/HistoryHeader.kt

@@ -0,0 +1,36 @@
+package eu.kanade.presentation.history.components
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import eu.kanade.presentation.util.horizontalPadding
+import eu.kanade.tachiyomi.util.lang.toRelativeString
+import java.text.DateFormat
+import java.util.Date
+
+@Composable
+fun HistoryHeader(
+    modifier: Modifier = Modifier,
+    date: Date,
+    relativeTime: Int,
+    dateFormat: DateFormat,
+) {
+    Text(
+        modifier = modifier
+            .padding(horizontal = horizontalPadding, vertical = 8.dp),
+        text = date.toRelativeString(
+            LocalContext.current,
+            relativeTime,
+            dateFormat,
+        ),
+        style = MaterialTheme.typography.bodyMedium.copy(
+            color = MaterialTheme.colorScheme.onSurfaceVariant,
+            fontWeight = FontWeight.SemiBold,
+        ),
+    )
+}

+ 143 - 0
app/src/main/java/eu/kanade/presentation/history/components/HistoryItem.kt

@@ -0,0 +1,143 @@
+package eu.kanade.presentation.history.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import eu.kanade.domain.history.model.HistoryWithRelations
+import eu.kanade.presentation.components.MangaCover
+import eu.kanade.presentation.util.horizontalPadding
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.util.lang.toTimestampString
+import java.text.DecimalFormat
+import java.text.DecimalFormatSymbols
+
+private val HISTORY_ITEM_HEIGHT = 96.dp
+
+@Composable
+fun HistoryItem(
+    modifier: Modifier = Modifier,
+    history: HistoryWithRelations,
+    onClickCover: () -> Unit,
+    onClickResume: () -> Unit,
+    onClickDelete: () -> Unit,
+) {
+    Row(
+        modifier = modifier
+            .clickable(onClick = onClickResume)
+            .height(HISTORY_ITEM_HEIGHT)
+            .padding(horizontal = horizontalPadding, vertical = 8.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        MangaCover.Book(
+            modifier = Modifier
+                .fillMaxHeight()
+                .clickable(onClick = onClickCover),
+            data = history.coverData,
+        )
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .padding(start = horizontalPadding, end = 8.dp),
+        ) {
+            val textStyle = MaterialTheme.typography.bodyMedium
+            Text(
+                text = history.title,
+                maxLines = 2,
+                overflow = TextOverflow.Ellipsis,
+                style = textStyle.copy(fontWeight = FontWeight.SemiBold),
+            )
+            Text(
+                text = if (history.chapterNumber > -1) {
+                    stringResource(
+                        R.string.recent_manga_time,
+                        chapterFormatter.format(history.chapterNumber),
+                        history.readAt?.toTimestampString() ?: "",
+                    )
+                } else {
+                    history.readAt?.toTimestampString() ?: ""
+                },
+                modifier = Modifier.padding(top = 4.dp),
+                style = textStyle,
+            )
+        }
+
+        IconButton(onClick = onClickDelete) {
+            Icon(
+                imageVector = Icons.Outlined.Delete,
+                contentDescription = stringResource(R.string.action_delete),
+                tint = MaterialTheme.colorScheme.onSurface,
+            )
+        }
+    }
+}
+
+@Composable
+fun HistoryItemShimmer(brush: Brush) {
+    Row(
+        modifier = Modifier
+            .height(HISTORY_ITEM_HEIGHT)
+            .padding(horizontal = horizontalPadding, vertical = 8.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        Box(
+            modifier = Modifier
+                .fillMaxHeight()
+                .aspectRatio(MangaCover.Book.ratio)
+                .clip(RoundedCornerShape(4.dp))
+                .drawBehind {
+                    drawRect(brush = brush)
+                },
+        )
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .padding(start = horizontalPadding, end = 8.dp),
+        ) {
+            Box(
+                modifier = Modifier
+                    .drawBehind {
+                        drawRect(brush = brush)
+                    }
+                    .height(14.dp)
+                    .fillMaxWidth(0.70f),
+            )
+            Box(
+                modifier = Modifier
+                    .padding(top = 4.dp)
+                    .height(14.dp)
+                    .fillMaxWidth(0.45f)
+                    .drawBehind {
+                        drawRect(brush = brush)
+                    },
+            )
+        }
+    }
+}
+
+private val chapterFormatter = DecimalFormat(
+    "#.###",
+    DecimalFormatSymbols().apply { decimalSeparator = '.' },
+)

+ 9 - 0
app/src/main/java/eu/kanade/presentation/util/Shimmer.kt

@@ -0,0 +1,9 @@
+package eu.kanade.presentation.util
+
+import androidx.compose.ui.graphics.Color
+
+val shimmerGradient = listOf(
+    Color.LightGray.copy(alpha = 0.8f),
+    Color.LightGray.copy(alpha = 0.2f),
+    Color.LightGray.copy(alpha = 0.8f),
+)