ソースを参照

Migrate History screen to Compose (#6922)

* Migrate History screen to Compose

- Migrate screen
- Strip logic from presenter into use cases and repository
- Setup for other screen being able to migrate to Compose with Theme

* Changes from review comments
Andreas 2 年 前
コミット
c475acd1ea
40 ファイル変更973 行追加655 行削除
  1. 18 0
      app/build.gradle.kts
  2. 43 0
      app/src/main/java/eu/kanade/data/history/local/HistoryPagingSource.kt
  3. 137 0
      app/src/main/java/eu/kanade/data/history/repository/HistoryRepositoryImpl.kt
  4. 26 0
      app/src/main/java/eu/kanade/domain/DomainModule.kt
  5. 12 0
      app/src/main/java/eu/kanade/domain/history/interactor/DeleteHistoryTable.kt
  6. 22 0
      app/src/main/java/eu/kanade/domain/history/interactor/GetHistory.kt
  7. 14 0
      app/src/main/java/eu/kanade/domain/history/interactor/GetNextChapterForManga.kt
  8. 21 0
      app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryById.kt
  9. 12 0
      app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryByMangaId.kt
  10. 22 0
      app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt
  11. 49 0
      app/src/main/java/eu/kanade/presentation/components/EmptyScreen.kt
  12. 35 0
      app/src/main/java/eu/kanade/presentation/components/MangaCover.kt
  13. 298 0
      app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt
  14. 20 0
      app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt
  15. 5 0
      app/src/main/java/eu/kanade/presentation/util/Constants.kt
  16. 5 0
      app/src/main/java/eu/kanade/presentation/util/LazyListState.kt
  17. 2 0
      app/src/main/java/eu/kanade/tachiyomi/App.kt
  18. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt
  19. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt
  20. 34 6
      app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt
  21. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryUpsertResolver.kt
  22. 8 3
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt
  23. 1 1
      app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt
  24. 21 0
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/ClearHistoryDialogController.kt
  25. 0 51
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryAdapter.kt
  26. 43 183
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt
  27. 0 71
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryHolder.kt
  28. 0 42
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryItem.kt
  29. 97 119
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryPresenter.kt
  30. 0 54
      app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/RemoveHistoryDialog.kt
  31. 4 0
      app/src/main/res/layout/compose_controller.xml
  32. 0 33
      app/src/main/res/layout/history_controller.xml
  33. 0 85
      app/src/main/res/layout/history_item.xml
  34. 1 0
      app/src/main/res/values/strings.xml
  35. 1 1
      app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt
  36. 3 0
      gradle/androidx.versions.toml
  37. 9 0
      gradle/compose.versions.toml
  38. 1 1
      gradle/kotlinx.versions.toml
  39. 3 2
      gradle/libs.versions.toml
  40. 3 0
      settings.gradle.kts

+ 18 - 0
app/build.gradle.kts

@@ -109,6 +109,7 @@ android {
 
     buildFeatures {
         viewBinding = true
+        compose = true
 
         // Disable some unused things
         aidl = false
@@ -122,6 +123,10 @@ android {
         checkReleaseBuilds = false
     }
 
+    composeOptions {
+        kotlinCompilerExtensionVersion = compose.versions.compose.get()
+    }
+
     compileOptions {
         sourceCompatibility = JavaVersion.VERSION_1_8
         targetCompatibility = JavaVersion.VERSION_1_8
@@ -133,6 +138,16 @@ android {
 }
 
 dependencies {
+    implementation(compose.foundation)
+    implementation(compose.material3.core)
+    implementation(compose.material3.adapter)
+    implementation(compose.animation)
+    implementation(compose.ui.tooling)
+
+    implementation(androidx.paging.runtime)
+    implementation(androidx.paging.compose)
+
+
     implementation(kotlinx.reflect)
 
     implementation(kotlinx.bundles.coroutines)
@@ -262,6 +277,9 @@ tasks {
             "-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi",
             "-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi",
             "-Xopt-in=coil.annotation.ExperimentalCoilApi",
+            "-Xopt-in=androidx.compose.material3.ExperimentalMaterial3Api",
+            "-Xopt-in=androidx.compose.ui.ExperimentalComposeUiApi",
+            "-Xopt-in=androidx.compose.foundation.ExperimentalFoundationApi"
         )
     }
 

+ 43 - 0
app/src/main/java/eu/kanade/data/history/local/HistoryPagingSource.kt

@@ -0,0 +1,43 @@
+package eu.kanade.data.history.local
+
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import eu.kanade.domain.history.repository.HistoryRepository
+import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
+import logcat.logcat
+
+class HistoryPagingSource(
+    private val repository: HistoryRepository,
+    private val query: String
+) : PagingSource<Int, MangaChapterHistory>() {
+
+    override fun getRefreshKey(state: PagingState<Int, MangaChapterHistory>): Int? {
+        return state.anchorPosition?.let { anchorPosition ->
+            val anchorPage = state.closestPageToPosition(anchorPosition)
+            anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
+        }
+    }
+
+    override suspend fun load(params: LoadParams<Int>): LoadResult.Page<Int, MangaChapterHistory> {
+        val nextPageNumber = params.key ?: 0
+        logcat { "Loading page $nextPageNumber" }
+
+        val response = repository.getHistory(PAGE_SIZE, nextPageNumber, query)
+
+        val nextKey = if (response.size == 25) {
+            nextPageNumber + 1
+        } else {
+            null
+        }
+
+        return LoadResult.Page(
+            data = response,
+            prevKey = null,
+            nextKey = nextKey
+        )
+    }
+
+    companion object {
+        const val PAGE_SIZE = 25
+    }
+}

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

@@ -0,0 +1,137 @@
+package eu.kanade.data.history.repository
+
+import eu.kanade.data.history.local.HistoryPagingSource
+import eu.kanade.domain.history.repository.HistoryRepository
+import eu.kanade.tachiyomi.data.database.DatabaseHelper
+import eu.kanade.tachiyomi.data.database.models.Chapter
+import eu.kanade.tachiyomi.data.database.models.History
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.data.database.tables.HistoryTable
+import eu.kanade.tachiyomi.util.system.logcat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.withContext
+import rx.Subscription
+import rx.schedulers.Schedulers
+import java.util.*
+
+class HistoryRepositoryImpl(
+    private val db: DatabaseHelper
+) : HistoryRepository {
+
+    /**
+     * Used to observe changes in the History table
+     * as RxJava isn't supported in Paging 3
+     */
+    private var subscription: Subscription? = null
+
+    /**
+     * Paging Source for history table
+     */
+    override fun getHistory(query: String): HistoryPagingSource {
+        subscription?.unsubscribe()
+        val pagingSource = HistoryPagingSource(this, query)
+        subscription = db.db
+            .observeChangesInTable(HistoryTable.TABLE)
+            .observeOn(Schedulers.io())
+            .subscribe {
+                pagingSource.invalidate()
+            }
+        return pagingSource
+    }
+
+    override suspend fun getHistory(limit: Int, page: Int, query: String) = coroutineScope {
+        withContext(Dispatchers.IO) {
+            // Set date limit for recent manga
+            val calendar = Calendar.getInstance().apply {
+                time = Date()
+                add(Calendar.YEAR, -50)
+            }
+
+            db.getRecentManga(calendar.time, limit, page * limit, query)
+                .executeAsBlocking()
+        }
+    }
+
+    override suspend fun getNextChapterForManga(manga: Manga, chapter: Chapter): Chapter? = coroutineScope {
+        withContext(Dispatchers.IO) {
+            if (!chapter.read) {
+                return@withContext chapter
+            }
+
+            val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
+                Manga.CHAPTER_SORTING_SOURCE -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) }
+                Manga.CHAPTER_SORTING_NUMBER -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) }
+                Manga.CHAPTER_SORTING_UPLOAD_DATE -> { c1, c2 -> c1.date_upload.compareTo(c2.date_upload) }
+                else -> throw NotImplementedError("Unknown sorting method")
+            }
+
+            val chapters = db.getChapters(manga)
+                .executeAsBlocking()
+                .sortedWith { c1, c2 -> sortFunction(c1, c2) }
+
+            val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id }
+            return@withContext when (manga.sorting) {
+                Manga.CHAPTER_SORTING_SOURCE -> chapters.getOrNull(currChapterIndex + 1)
+                Manga.CHAPTER_SORTING_NUMBER -> {
+                    val chapterNumber = chapter.chapter_number
+
+                    ((currChapterIndex + 1) until chapters.size)
+                        .map { chapters[it] }
+                        .firstOrNull {
+                            it.chapter_number > chapterNumber &&
+                                it.chapter_number <= chapterNumber + 1
+                        }
+                }
+                Manga.CHAPTER_SORTING_UPLOAD_DATE -> {
+                    chapters.drop(currChapterIndex + 1)
+                        .firstOrNull { it.date_upload >= chapter.date_upload }
+                }
+                else -> throw NotImplementedError("Unknown sorting method")
+            }
+        }
+    }
+
+    override suspend fun resetHistory(history: History): Boolean = coroutineScope {
+        withContext(Dispatchers.IO) {
+            try {
+                history.last_read = 0
+                db.upsertHistoryLastRead(history)
+                    .executeAsBlocking()
+                true
+            } catch (e: Throwable) {
+                logcat(throwable = e)
+                false
+            }
+        }
+    }
+
+    override suspend fun resetHistoryByMangaId(mangaId: Long): Boolean = coroutineScope {
+        withContext(Dispatchers.IO) {
+            try {
+                val history = db.getHistoryByMangaId(mangaId)
+                    .executeAsBlocking()
+                history.forEach { it.last_read = 0 }
+                db.upsertHistoryLastRead(history)
+                    .executeAsBlocking()
+                true
+            } catch (e: Throwable) {
+                logcat(throwable = e)
+                false
+            }
+        }
+    }
+
+    override suspend fun deleteAllHistory(): Boolean = coroutineScope {
+        withContext(Dispatchers.IO) {
+            try {
+                db.dropHistoryTable()
+                    .executeAsBlocking()
+                true
+            } catch (e: Throwable) {
+                logcat(throwable = e)
+                false
+            }
+        }
+    }
+}

+ 26 - 0
app/src/main/java/eu/kanade/domain/DomainModule.kt

@@ -0,0 +1,26 @@
+package eu.kanade.domain
+
+import eu.kanade.data.history.repository.HistoryRepositoryImpl
+import eu.kanade.domain.history.interactor.DeleteHistoryTable
+import eu.kanade.domain.history.interactor.GetHistory
+import eu.kanade.domain.history.interactor.GetNextChapterForManga
+import eu.kanade.domain.history.interactor.RemoveHistoryById
+import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId
+import eu.kanade.domain.history.repository.HistoryRepository
+import uy.kohesive.injekt.api.InjektModule
+import uy.kohesive.injekt.api.InjektRegistrar
+import uy.kohesive.injekt.api.addFactory
+import uy.kohesive.injekt.api.addSingletonFactory
+import uy.kohesive.injekt.api.get
+
+class DomainModule : InjektModule {
+
+    override fun InjektRegistrar.registerInjectables() {
+        addSingletonFactory<HistoryRepository> { HistoryRepositoryImpl(get()) }
+        addFactory { DeleteHistoryTable(get()) }
+        addFactory { GetHistory(get()) }
+        addFactory { GetNextChapterForManga(get()) }
+        addFactory { RemoveHistoryById(get()) }
+        addFactory { RemoveHistoryByMangaId(get()) }
+    }
+}

+ 12 - 0
app/src/main/java/eu/kanade/domain/history/interactor/DeleteHistoryTable.kt

@@ -0,0 +1,12 @@
+package eu.kanade.domain.history.interactor
+
+import eu.kanade.domain.history.repository.HistoryRepository
+
+class DeleteHistoryTable(
+    private val repository: HistoryRepository
+) {
+
+    suspend fun await(): Boolean {
+        return repository.deleteAllHistory()
+    }
+}

+ 22 - 0
app/src/main/java/eu/kanade/domain/history/interactor/GetHistory.kt

@@ -0,0 +1,22 @@
+package eu.kanade.domain.history.interactor
+
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import eu.kanade.data.history.local.HistoryPagingSource
+import eu.kanade.domain.history.repository.HistoryRepository
+import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
+import kotlinx.coroutines.flow.Flow
+
+class GetHistory(
+    private val repository: HistoryRepository
+) {
+
+    fun subscribe(query: String): Flow<PagingData<MangaChapterHistory>> {
+        return Pager(
+            PagingConfig(pageSize = HistoryPagingSource.PAGE_SIZE)
+        ) {
+            repository.getHistory(query)
+        }.flow
+    }
+}

+ 14 - 0
app/src/main/java/eu/kanade/domain/history/interactor/GetNextChapterForManga.kt

@@ -0,0 +1,14 @@
+package eu.kanade.domain.history.interactor
+
+import eu.kanade.domain.history.repository.HistoryRepository
+import eu.kanade.tachiyomi.data.database.models.Chapter
+import eu.kanade.tachiyomi.data.database.models.Manga
+
+class GetNextChapterForManga(
+    private val repository: HistoryRepository
+) {
+
+    suspend fun await(manga: Manga, chapter: Chapter): Chapter? {
+        return repository.getNextChapterForManga(manga, chapter)
+    }
+}

+ 21 - 0
app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryById.kt

@@ -0,0 +1,21 @@
+package eu.kanade.domain.history.interactor
+
+import eu.kanade.domain.history.repository.HistoryRepository
+import eu.kanade.tachiyomi.data.database.models.History
+import eu.kanade.tachiyomi.data.database.models.HistoryImpl
+
+class RemoveHistoryById(
+    private val repository: HistoryRepository
+) {
+
+    suspend fun await(history: History): Boolean {
+        // Workaround for list not freaking out when changing reference varaible
+        val history = HistoryImpl().apply {
+            id = history.id
+            chapter_id = history.chapter_id
+            last_read = history.last_read
+            time_read = history.time_read
+        }
+        return repository.resetHistory(history)
+    }
+}

+ 12 - 0
app/src/main/java/eu/kanade/domain/history/interactor/RemoveHistoryByMangaId.kt

@@ -0,0 +1,12 @@
+package eu.kanade.domain.history.interactor
+
+import eu.kanade.domain.history.repository.HistoryRepository
+
+class RemoveHistoryByMangaId(
+    private val repository: HistoryRepository
+) {
+
+    suspend fun await(mangaId: Long): Boolean {
+        return repository.resetHistoryByMangaId(mangaId)
+    }
+}

+ 22 - 0
app/src/main/java/eu/kanade/domain/history/repository/HistoryRepository.kt

@@ -0,0 +1,22 @@
+package eu.kanade.domain.history.repository
+
+import eu.kanade.data.history.local.HistoryPagingSource
+import eu.kanade.tachiyomi.data.database.models.Chapter
+import eu.kanade.tachiyomi.data.database.models.History
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
+
+interface HistoryRepository {
+
+    fun getHistory(query: String): HistoryPagingSource
+
+    suspend fun getHistory(limit: Int, page: Int, query: String): List<MangaChapterHistory>
+
+    suspend fun getNextChapterForManga(manga: Manga, chapter: Chapter): Chapter?
+
+    suspend fun resetHistory(history: History): Boolean
+
+    suspend fun resetHistoryByMangaId(mangaId: Long): Boolean
+
+    suspend fun deleteAllHistory(): Boolean
+}

+ 49 - 0
app/src/main/java/eu/kanade/presentation/components/EmptyScreen.kt

@@ -0,0 +1,49 @@
+package eu.kanade.presentation.components
+
+import android.view.ViewGroup
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.viewinterop.AndroidView
+import eu.kanade.tachiyomi.widget.EmptyView
+
+@Composable
+fun EmptyScreen(
+    @StringRes textResource: Int,
+    actions: List<EmptyView.Action>? = null,
+) {
+    EmptyScreen(
+        message = stringResource(id = textResource),
+        actions = actions,
+    )
+}
+
+@Composable
+fun EmptyScreen(
+    message: String,
+    actions: List<EmptyView.Action>? = null,
+) {
+    Box(
+        modifier = Modifier
+            .fillMaxSize()
+    ) {
+        AndroidView(
+            factory = { context ->
+                EmptyView(context).apply {
+                    layoutParams = ViewGroup.LayoutParams(
+                        ViewGroup.LayoutParams.WRAP_CONTENT,
+                        ViewGroup.LayoutParams.WRAP_CONTENT,
+                    )
+                }
+            },
+            modifier = Modifier
+                .align(Alignment.Center),
+        ) { view ->
+            view.show(message, actions)
+        }
+    }
+}

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

@@ -0,0 +1,35 @@
+package eu.kanade.presentation.components
+
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import eu.kanade.tachiyomi.data.database.models.Manga
+
+enum class MangaCoverAspect(val ratio: Float) {
+    SQUARE(1f / 1f),
+    COVER(2f / 3f)
+}
+
+@Composable
+fun MangaCover(
+    modifier: Modifier = Modifier,
+    manga: Manga,
+    aspect: MangaCoverAspect,
+    contentDescription: String = "",
+    shape: Shape = RoundedCornerShape(4.dp)
+) {
+    AsyncImage(
+        model = manga,
+        contentDescription = contentDescription,
+        modifier = modifier
+            .aspectRatio(aspect.ratio)
+            .clip(shape),
+        contentScale = ContentScale.Crop
+    )
+}

+ 298 - 0
app/src/main/java/eu/kanade/presentation/history/HistoryScreen.kt

@@ -0,0 +1,298 @@
+package eu.kanade.presentation.history
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.CircularProgressIndicator
+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
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
+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.core.text.buildSpannedString
+import androidx.paging.compose.LazyPagingItems
+import androidx.paging.compose.collectAsLazyPagingItems
+import androidx.paging.compose.items
+import eu.kanade.presentation.components.EmptyScreen
+import eu.kanade.presentation.components.MangaCover
+import eu.kanade.presentation.components.MangaCoverAspect
+import eu.kanade.presentation.theme.TachiyomiTheme
+import eu.kanade.presentation.util.horizontalPadding
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
+import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.ui.recent.history.HistoryPresenter
+import eu.kanade.tachiyomi.ui.recent.history.UiModel
+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.*
+
+val chapterFormatter = DecimalFormat(
+    "#.###",
+    DecimalFormatSymbols()
+        .apply { decimalSeparator = '.' },
+)
+
+@Composable
+fun HistoryScreen(
+    composeView: ComposeView,
+    presenter: HistoryPresenter,
+    onClickItem: (MangaChapterHistory) -> Unit,
+    onClickResume: (MangaChapterHistory) -> Unit,
+    onClickDelete: (MangaChapterHistory, Boolean) -> Unit,
+) {
+    val nestedSrollInterop = rememberNestedScrollInteropConnection(composeView)
+    TachiyomiTheme {
+        val state by presenter.state.collectAsState()
+        val history = state.list?.collectAsLazyPagingItems()
+        when {
+            history == null -> {
+                CircularProgressIndicator()
+            }
+            history.itemCount == 0 -> {
+                EmptyScreen(
+                    textResource = R.string.information_no_recent_manga
+                )
+            }
+            else -> {
+                HistoryContent(
+                    nestedScroll = nestedSrollInterop,
+                    history = history,
+                    onClickItem = onClickItem,
+                    onClickResume = onClickResume,
+                    onClickDelete = onClickDelete,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+fun HistoryContent(
+    history: LazyPagingItems<UiModel>,
+    onClickItem: (MangaChapterHistory) -> Unit,
+    onClickResume: (MangaChapterHistory) -> Unit,
+    onClickDelete: (MangaChapterHistory, Boolean) -> Unit,
+    preferences: PreferencesHelper = Injekt.get(),
+    nestedScroll: NestedScrollConnection
+) {
+    val relativeTime: Int = remember { preferences.relativeTime().get() }
+    val dateFormat: DateFormat = remember { preferences.dateFormat() }
+
+    val (removeState, setRemoveState) = remember { mutableStateOf<MangaChapterHistory?>(null) }
+
+    val scrollState = rememberLazyListState()
+    LazyColumn(
+        modifier = Modifier
+            .nestedScroll(nestedScroll),
+        state = scrollState,
+    ) {
+        items(history) { item ->
+            when (item) {
+                is UiModel.Header -> {
+                    HistoryHeader(
+                        modifier = Modifier
+                            .animateItemPlacement(),
+                        date = item.date,
+                        relativeTime = relativeTime,
+                        dateFormat = dateFormat
+                    )
+                }
+                is UiModel.History -> {
+                    val value = item.item
+                    HistoryItem(
+                        modifier = Modifier.animateItemPlacement(),
+                        history = value,
+                        onClickItem = { onClickItem(value) },
+                        onClickResume = { onClickResume(value) },
+                        onClickDelete = { setRemoveState(value) },
+                    )
+                }
+                null -> {}
+            }
+        }
+        item {
+            Spacer(
+                modifier = Modifier
+                    .navigationBarsPadding()
+            )
+        }
+    }
+
+    if (removeState != null) {
+        RemoveHistoryDialog(
+            onPositive = { all ->
+                onClickDelete(removeState, all)
+                setRemoveState(null)
+            },
+            onNegative = { setRemoveState(null) }
+        )
+    }
+}
+
+@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: MangaChapterHistory,
+    onClickItem: () -> Unit,
+    onClickResume: () -> Unit,
+    onClickDelete: () -> Unit,
+) {
+    Row(
+        modifier = modifier
+            .clickable(onClick = onClickItem)
+            .height(96.dp)
+            .padding(horizontal = horizontalPadding, vertical = 8.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        MangaCover(
+            modifier = Modifier.fillMaxHeight(),
+            manga = history.manga,
+            aspect = MangaCoverAspect.COVER
+        )
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .padding(start = horizontalPadding, end = 8.dp),
+        ) {
+            val textStyle = MaterialTheme.typography.bodyMedium.copy(
+                color = MaterialTheme.colorScheme.onSurface,
+            )
+            Text(
+                text = history.manga.title,
+                maxLines = 2,
+                overflow = TextOverflow.Ellipsis,
+                style = textStyle.copy(fontWeight = FontWeight.SemiBold)
+            )
+            Row {
+                Text(
+                    text = buildSpannedString {
+                        if (history.chapter.chapter_number > -1) {
+                            append(
+                                stringResource(
+                                    R.string.history_prefix,
+                                    chapterFormatter.format(history.chapter.chapter_number)
+                                )
+                            )
+                        }
+                        append(Date(history.history.last_read).toTimestampString())
+                    }.toString(),
+                    modifier = Modifier.padding(top = 2.dp),
+                    style = textStyle
+                )
+            }
+        }
+        IconButton(onClick = onClickDelete) {
+            Icon(
+                imageVector = Icons.Outlined.Delete,
+                contentDescription = stringResource(id = R.string.action_delete),
+                tint = MaterialTheme.colorScheme.onSurface,
+            )
+        }
+        IconButton(onClick = onClickResume) {
+            Icon(
+                imageVector = Icons.Filled.PlayArrow,
+                contentDescription = stringResource(id = R.string.action_resume),
+                tint = MaterialTheme.colorScheme.onSurface,
+            )
+        }
+    }
+}
+
+@Composable
+fun RemoveHistoryDialog(
+    onPositive: (Boolean) -> Unit,
+    onNegative: () -> Unit
+) {
+    val (removeEverything, removeEverythingState) = remember { mutableStateOf(false) }
+
+    AlertDialog(
+        title = {
+            Text(text = stringResource(id = R.string.action_remove))
+        },
+        text = {
+            Column {
+                Text(text = stringResource(id = R.string.dialog_with_checkbox_remove_description))
+                Row(
+                    modifier = Modifier.toggleable(value = removeEverything, onValueChange = removeEverythingState),
+                    verticalAlignment = Alignment.CenterVertically
+                ) {
+                    Checkbox(
+                        checked = removeEverything,
+                        onCheckedChange = removeEverythingState,
+                    )
+                    Text(
+                        text = stringResource(id = R.string.dialog_with_checkbox_reset)
+                    )
+                }
+            }
+        },
+        onDismissRequest = onNegative,
+        confirmButton = {
+            TextButton(onClick = { onPositive(removeEverything) }) {
+                Text(text = stringResource(id = R.string.action_remove))
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onNegative) {
+                Text(text = stringResource(id = R.string.action_cancel))
+            }
+        },
+    )
+}

+ 20 - 0
app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt

@@ -0,0 +1,20 @@
+package eu.kanade.presentation.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import com.google.android.material.composethemeadapter3.createMdc3Theme
+
+@Composable
+fun TachiyomiTheme(content: @Composable () -> Unit) {
+    val context = LocalContext.current
+    var (colorScheme, typography) = createMdc3Theme(
+        context = context
+    )
+
+    MaterialTheme(
+        colorScheme = colorScheme!!,
+        typography = typography!!,
+        content = content
+    )
+}

+ 5 - 0
app/src/main/java/eu/kanade/presentation/util/Constants.kt

@@ -0,0 +1,5 @@
+package eu.kanade.presentation.util
+
+import androidx.compose.ui.unit.dp
+
+val horizontalPadding = 16.dp

+ 5 - 0
app/src/main/java/eu/kanade/presentation/util/LazyListState.kt

@@ -0,0 +1,5 @@
+package eu.kanade.presentation.util
+
+import androidx.compose.foundation.lazy.LazyListState
+
+fun LazyListState.isScrolledToEnd() = layoutInfo.visibleItemsInfo.lastOrNull()?.index == layoutInfo.totalItemsCount - 1

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/App.kt

@@ -24,6 +24,7 @@ import coil.decode.GifDecoder
 import coil.decode.ImageDecoderDecoder
 import coil.disk.DiskCache
 import coil.util.DebugLogger
+import eu.kanade.domain.DomainModule
 import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
 import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer
 import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder
@@ -74,6 +75,7 @@ open class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory {
         }
 
         Injekt.importModule(AppModule(this))
+        Injekt.importModule(DomainModule())
 
         setupAcra()
         setupNotificationChannels()

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/backup/full/FullBackupManager.kt

@@ -294,7 +294,7 @@ class FullBackupManager(context: Context) : AbstractBackupManager(context) {
                 }
             }
         }
-        databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking()
+        databaseHelper.upsertHistoryLastRead(historyToBeUpdated).executeAsBlocking()
     }
 
     /**

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/LegacyBackupManager.kt

@@ -168,7 +168,7 @@ class LegacyBackupManager(context: Context, version: Int = CURRENT_VERSION) : Ab
                 }
             }
         }
-        databaseHelper.updateHistoryLastRead(historyToBeUpdated).executeAsBlocking()
+        databaseHelper.upsertHistoryLastRead(historyToBeUpdated).executeAsBlocking()
     }
 
     /**

+ 34 - 6
app/src/main/java/eu/kanade/tachiyomi/data/database/queries/HistoryQueries.kt

@@ -5,7 +5,7 @@ import com.pushtorefresh.storio.sqlite.queries.RawQuery
 import eu.kanade.tachiyomi.data.database.DbProvider
 import eu.kanade.tachiyomi.data.database.models.History
 import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
-import eu.kanade.tachiyomi.data.database.resolvers.HistoryLastReadPutResolver
+import eu.kanade.tachiyomi.data.database.resolvers.HistoryUpsertResolver
 import eu.kanade.tachiyomi.data.database.resolvers.MangaChapterHistoryGetResolver
 import eu.kanade.tachiyomi.data.database.tables.HistoryTable
 import java.util.Date
@@ -64,9 +64,9 @@ interface HistoryQueries : DbProvider {
      * Inserts history object if not yet in database
      * @param history history object
      */
-    fun updateHistoryLastRead(history: History) = db.put()
+    fun upsertHistoryLastRead(history: History) = db.put()
         .`object`(history)
-        .withPutResolver(HistoryLastReadPutResolver())
+        .withPutResolver(HistoryUpsertResolver())
         .prepare()
 
     /**
@@ -74,12 +74,40 @@ interface HistoryQueries : DbProvider {
      * Inserts history object if not yet in database
      * @param historyList history object list
      */
-    fun updateHistoryLastRead(historyList: List<History>) = db.put()
+    fun upsertHistoryLastRead(historyList: List<History>) = db.put()
         .objects(historyList)
-        .withPutResolver(HistoryLastReadPutResolver())
+        .withPutResolver(HistoryUpsertResolver())
         .prepare()
 
-    fun deleteHistory() = db.delete()
+    fun resetHistoryLastRead(historyId: Long) = db.executeSQL()
+        .withQuery(
+            RawQuery.builder()
+                .query(
+                    """
+                UPDATE ${HistoryTable.TABLE} 
+                SET history_last_read = 0
+                WHERE ${HistoryTable.COL_ID} = $historyId  
+                    """.trimIndent()
+                )
+                .build()
+        )
+        .prepare()
+
+    fun resetHistoryLastRead(historyIds: List<Long>) = db.executeSQL()
+        .withQuery(
+            RawQuery.builder()
+                .query(
+                    """
+                UPDATE ${HistoryTable.TABLE} 
+                SET history_last_read = 0
+                WHERE ${HistoryTable.COL_ID} in ${historyIds.joinToString(",", "(", ")")}  
+                    """.trimIndent()
+                )
+                .build()
+        )
+        .prepare()
+
+    fun dropHistoryTable() = db.delete()
         .byQuery(
             DeleteQuery.builder()
                 .table(HistoryTable.TABLE)

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryLastReadPutResolver.kt → app/src/main/java/eu/kanade/tachiyomi/data/database/resolvers/HistoryUpsertResolver.kt

@@ -11,7 +11,7 @@ import eu.kanade.tachiyomi.data.database.mappers.HistoryPutResolver
 import eu.kanade.tachiyomi.data.database.models.History
 import eu.kanade.tachiyomi.data.database.tables.HistoryTable
 
-class HistoryLastReadPutResolver : HistoryPutResolver() {
+class HistoryUpsertResolver : HistoryPutResolver() {
 
     /**
      * Updates last_read time of chapter

+ 8 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt

@@ -97,14 +97,19 @@ import kotlin.math.max
 class ReaderActivity : BaseRxActivity<ReaderPresenter>() {
 
     companion object {
-        fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent {
+
+        fun newIntent(context: Context, mangaId: Long?, chapterId: Long?): Intent {
             return Intent(context, ReaderActivity::class.java).apply {
-                putExtra("manga", manga.id)
-                putExtra("chapter", chapter.id)
+                putExtra("manga", mangaId)
+                putExtra("chapter", chapterId)
                 addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
             }
         }
 
+        fun newIntent(context: Context, manga: Manga, chapter: Chapter): Intent {
+            return newIntent(context, manga.id, chapter.id)
+        }
+
         private const val ENABLED_BUTTON_IMAGE_ALPHA = 255
         private const val DISABLED_BUTTON_IMAGE_ALPHA = 64
 

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt

@@ -449,7 +449,7 @@ class ReaderPresenter(
     private fun saveChapterHistory(chapter: ReaderChapter) {
         if (!incognitoMode) {
             val history = History.create(chapter.chapter).apply { last_read = Date().time }
-            db.updateHistoryLastRead(history).asRxCompletable()
+            db.upsertHistoryLastRead(history).asRxCompletable()
                 .onErrorComplete()
                 .subscribeOn(Schedulers.io())
                 .subscribe()

+ 21 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/ClearHistoryDialogController.kt

@@ -0,0 +1,21 @@
+package eu.kanade.tachiyomi.ui.recent.history
+
+import android.app.Dialog
+import android.os.Bundle
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.ui.base.controller.DialogController
+
+class ClearHistoryDialogController : DialogController() {
+    override fun onCreateDialog(savedViewState: Bundle?): Dialog {
+        return MaterialAlertDialogBuilder(activity!!)
+            .setMessage(R.string.clear_history_confirmation)
+            .setPositiveButton(android.R.string.ok) { _, _ ->
+                (targetController as? HistoryController)
+                    ?.presenter
+                    ?.deleteAllHistory()
+            }
+            .setNegativeButton(android.R.string.cancel, null)
+            .create()
+    }
+}

+ 0 - 51
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryAdapter.kt

@@ -1,51 +0,0 @@
-package eu.kanade.tachiyomi.ui.recent.history
-
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.IFlexible
-import eu.kanade.tachiyomi.source.SourceManager
-import uy.kohesive.injekt.injectLazy
-import java.text.DecimalFormat
-import java.text.DecimalFormatSymbols
-
-/**
- * Adapter of HistoryHolder.
- * Connection between Fragment and Holder
- * Holder updates should be called from here.
- *
- * @param controller a HistoryController object
- * @constructor creates an instance of the adapter.
- */
-class HistoryAdapter(controller: HistoryController) :
-    FlexibleAdapter<IFlexible<*>>(null, controller, true) {
-
-    val sourceManager: SourceManager by injectLazy()
-
-    val resumeClickListener: OnResumeClickListener = controller
-    val removeClickListener: OnRemoveClickListener = controller
-    val itemClickListener: OnItemClickListener = controller
-
-    /**
-     * DecimalFormat used to display correct chapter number
-     */
-    val decimalFormat = DecimalFormat(
-        "#.###",
-        DecimalFormatSymbols()
-            .apply { decimalSeparator = '.' },
-    )
-
-    init {
-        setDisplayHeadersAtStartUp(true)
-    }
-
-    interface OnResumeClickListener {
-        fun onResumeClick(position: Int)
-    }
-
-    interface OnRemoveClickListener {
-        fun onRemoveClick(position: Int)
-    }
-
-    interface OnItemClickListener {
-        fun onItemClick(position: Int)
-    }
-}

+ 43 - 183
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryController.kt

@@ -1,192 +1,65 @@
 package eu.kanade.tachiyomi.ui.recent.history
 
-import android.app.Dialog
-import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.Menu
 import android.view.MenuInflater
 import android.view.MenuItem
 import android.view.View
 import androidx.appcompat.widget.SearchView
-import androidx.recyclerview.widget.LinearLayoutManager
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import dev.chrisbanes.insetter.applyInsetter
-import eu.davidea.flexibleadapter.FlexibleAdapter
+import eu.kanade.presentation.history.HistoryScreen
 import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.backup.BackupRestoreService
-import eu.kanade.tachiyomi.data.database.DatabaseHelper
-import eu.kanade.tachiyomi.data.database.models.History
-import eu.kanade.tachiyomi.data.database.models.Manga
-import eu.kanade.tachiyomi.databinding.HistoryControllerBinding
-import eu.kanade.tachiyomi.ui.base.controller.DialogController
+import eu.kanade.tachiyomi.data.database.models.Chapter
+import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
 import eu.kanade.tachiyomi.ui.base.controller.NucleusController
 import eu.kanade.tachiyomi.ui.base.controller.RootController
 import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
-import eu.kanade.tachiyomi.ui.browse.source.browse.ProgressItem
-import eu.kanade.tachiyomi.ui.main.MainActivity
 import eu.kanade.tachiyomi.ui.manga.MangaController
 import eu.kanade.tachiyomi.ui.reader.ReaderActivity
-import eu.kanade.tachiyomi.util.system.logcat
 import eu.kanade.tachiyomi.util.system.toast
-import eu.kanade.tachiyomi.util.view.onAnimationsFinished
-import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
-import logcat.LogPriority
 import reactivecircus.flowbinding.appcompat.queryTextChanges
-import uy.kohesive.injekt.injectLazy
 
 /**
  * Fragment that shows recently read manga.
  */
 class HistoryController :
-    NucleusController<HistoryControllerBinding, HistoryPresenter>(),
-    RootController,
-    FlexibleAdapter.OnUpdateListener,
-    FlexibleAdapter.EndlessScrollListener,
-    HistoryAdapter.OnRemoveClickListener,
-    HistoryAdapter.OnResumeClickListener,
-    HistoryAdapter.OnItemClickListener,
-    RemoveHistoryDialog.Listener {
+    NucleusController<ComposeControllerBinding, HistoryPresenter>(),
+    RootController {
 
-    private val db: DatabaseHelper by injectLazy()
-
-    /**
-     * Adapter containing the recent manga.
-     */
-    var adapter: HistoryAdapter? = null
-        private set
-
-    /**
-     * Endless loading item.
-     */
-    private var progressItem: ProgressItem? = null
-
-    /**
-     * Search query.
-     */
     private var query = ""
 
-    override fun getTitle(): String? {
-        return resources?.getString(R.string.label_recent_manga)
-    }
+    override fun getTitle(): String? = resources?.getString(R.string.label_recent_manga)
 
-    override fun createPresenter(): HistoryPresenter {
-        return HistoryPresenter()
-    }
+    override fun createPresenter(): HistoryPresenter = HistoryPresenter()
 
-    override fun createBinding(inflater: LayoutInflater) = HistoryControllerBinding.inflate(inflater)
+    override fun createBinding(inflater: LayoutInflater): ComposeControllerBinding =
+        ComposeControllerBinding.inflate(inflater)
 
     override fun onViewCreated(view: View) {
         super.onViewCreated(view)
 
-        binding.recycler.applyInsetter {
-            type(navigationBars = true) {
-                padding()
-            }
-        }
-
-        // Initialize adapter
-        binding.recycler.layoutManager = LinearLayoutManager(view.context)
-        adapter = HistoryAdapter(this@HistoryController)
-        binding.recycler.setHasFixedSize(true)
-        binding.recycler.adapter = adapter
-        adapter?.fastScroller = binding.fastScroller
-    }
-
-    override fun onDestroyView(view: View) {
-        adapter = null
-        super.onDestroyView(view)
-    }
-
-    /**
-     * Populate adapter with chapters
-     *
-     * @param mangaHistory list of manga history
-     */
-    fun onNextManga(mangaHistory: List<HistoryItem>, cleanBatch: Boolean = false) {
-        if (adapter?.itemCount ?: 0 == 0) {
-            resetProgressItem()
-        }
-        if (cleanBatch) {
-            adapter?.updateDataSet(mangaHistory)
-        } else {
-            adapter?.onLoadMoreComplete(mangaHistory)
-        }
-        binding.recycler.onAnimationsFinished {
-            (activity as? MainActivity)?.ready = true
-        }
-    }
-
-    /**
-     * Safely error if next page load fails
-     */
-    fun onAddPageError(error: Throwable) {
-        adapter?.onLoadMoreComplete(null)
-        adapter?.endlessTargetCount = 1
-        logcat(LogPriority.ERROR, error)
-    }
-
-    override fun onUpdateEmptyView(size: Int) {
-        if (size > 0) {
-            binding.emptyView.hide()
-        } else {
-            binding.emptyView.show(R.string.information_no_recent_manga)
-        }
-    }
-
-    /**
-     * Sets a new progress item and reenables the scroll listener.
-     */
-    private fun resetProgressItem() {
-        progressItem = ProgressItem()
-        adapter?.endlessTargetCount = 0
-        adapter?.setEndlessScrollListener(this, progressItem!!)
-    }
-
-    override fun onLoadMore(lastPosition: Int, currentPage: Int) {
-        val view = view ?: return
-        if (BackupRestoreService.isRunning(view.context.applicationContext)) {
-            onAddPageError(Throwable())
-            return
-        }
-        val adapter = adapter ?: return
-        presenter.requestNext(adapter.itemCount - adapter.headerItems.size, query)
-    }
-
-    override fun noMoreLoad(newItemsSize: Int) {}
-
-    override fun onResumeClick(position: Int) {
-        val activity = activity ?: return
-        val (manga, chapter, _) = (adapter?.getItem(position) as? HistoryItem)?.mch ?: return
-
-        val nextChapter = presenter.getNextChapter(chapter, manga)
-        if (nextChapter != null) {
-            val intent = ReaderActivity.newIntent(activity, manga, nextChapter)
-            startActivity(intent)
-        } else {
-            activity.toast(R.string.no_next_chapter)
-        }
-    }
-
-    override fun onRemoveClick(position: Int) {
-        val (manga, _, history) = (adapter?.getItem(position) as? HistoryItem)?.mch ?: return
-        RemoveHistoryDialog(this, manga, history).showDialog(router)
-    }
-
-    override fun onItemClick(position: Int) {
-        val manga = (adapter?.getItem(position) as? HistoryItem)?.mch?.manga ?: return
-        router.pushController(MangaController(manga).withFadeTransaction())
-    }
-
-    override fun removeHistory(manga: Manga, history: History, all: Boolean) {
-        if (all) {
-            // Reset last read of chapter to 0L
-            presenter.removeAllFromHistory(manga.id!!)
-        } else {
-            // Remove all chapters belonging to manga from library
-            presenter.removeFromHistory(history)
+        binding.root.setContent {
+            HistoryScreen(
+                composeView = binding.root,
+                presenter = presenter,
+                onClickItem = { (manga, _, _) ->
+                    router.pushController(MangaController(manga).withFadeTransaction())
+                },
+                onClickResume = { (manga, chapter, _) ->
+                    presenter.getNextChapterForManga(manga, chapter)
+                },
+                onClickDelete = { (manga, _, history), all ->
+                    if (all) {
+                        // Reset last read of chapter to 0L
+                        presenter.removeAllFromHistory(manga.id!!)
+                    } else {
+                        // Remove all chapters belonging to manga from library
+                        presenter.removeFromHistory(history)
+                    }
+                },
+            )
         }
     }
 
@@ -201,46 +74,33 @@ class HistoryController :
             searchView.clearFocus()
         }
         searchView.queryTextChanges()
-            .drop(1) // Drop first event after subscribed
             .filter { router.backstack.lastOrNull()?.controller == this }
             .onEach {
                 query = it.toString()
-                presenter.updateList(query)
+                presenter.search(query)
             }
             .launchIn(viewScope)
-
-        // Fixes problem with the overflow icon showing up in lieu of search
-        searchItem.fixExpand(
-            onExpand = { invalidateMenuOnExpand() },
-        )
     }
 
     override fun onOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
+        return when (item.itemId) {
             R.id.action_clear_history -> {
-                val ctrl = ClearHistoryDialogController()
-                ctrl.targetController = this@HistoryController
-                ctrl.showDialog(router)
+                val dialog = ClearHistoryDialogController()
+                dialog.targetController = this@HistoryController
+                dialog.showDialog(router)
+                true
             }
+            else -> super.onOptionsItemSelected(item)
         }
-
-        return super.onOptionsItemSelected(item)
     }
 
-    class ClearHistoryDialogController : DialogController() {
-        override fun onCreateDialog(savedViewState: Bundle?): Dialog {
-            return MaterialAlertDialogBuilder(activity!!)
-                .setMessage(R.string.clear_history_confirmation)
-                .setPositiveButton(android.R.string.ok) { _, _ ->
-                    (targetController as? HistoryController)?.clearHistory()
-                }
-                .setNegativeButton(android.R.string.cancel, null)
-                .create()
+    fun openChapter(chapter: Chapter?) {
+        val activity = activity ?: return
+        if (chapter != null) {
+            val intent = ReaderActivity.newIntent(activity, chapter.manga_id, chapter.id)
+            startActivity(intent)
+        } else {
+            activity.toast(R.string.no_next_chapter)
         }
     }
-
-    private fun clearHistory() {
-        db.deleteHistory().executeAsBlocking()
-        activity?.toast(R.string.clear_history_completed)
-    }
 }

+ 0 - 71
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryHolder.kt

@@ -1,71 +0,0 @@
-package eu.kanade.tachiyomi.ui.recent.history
-
-import android.view.View
-import coil.dispose
-import coil.load
-import eu.davidea.viewholders.FlexibleViewHolder
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
-import eu.kanade.tachiyomi.databinding.HistoryItemBinding
-import eu.kanade.tachiyomi.util.lang.toTimestampString
-import java.util.Date
-
-/**
- * Holder that contains recent manga item
- * Uses R.layout.item_recently_read.
- * UI related actions should be called from here.
- *
- * @param view the inflated view for this holder.
- * @param adapter the adapter handling this holder.
- * @constructor creates a new recent chapter holder.
- */
-class HistoryHolder(
-    view: View,
-    val adapter: HistoryAdapter,
-) : FlexibleViewHolder(view, adapter) {
-
-    private val binding = HistoryItemBinding.bind(view)
-
-    init {
-        binding.holder.setOnClickListener {
-            adapter.itemClickListener.onItemClick(bindingAdapterPosition)
-        }
-
-        binding.remove.setOnClickListener {
-            adapter.removeClickListener.onRemoveClick(bindingAdapterPosition)
-        }
-
-        binding.resume.setOnClickListener {
-            adapter.resumeClickListener.onResumeClick(bindingAdapterPosition)
-        }
-    }
-
-    /**
-     * Set values of view
-     *
-     * @param item item containing history information
-     */
-    fun bind(item: MangaChapterHistory) {
-        // Retrieve objects
-        val (manga, chapter, history) = item
-
-        // Set manga title
-        binding.mangaTitle.text = manga.title
-
-        // Set chapter number + timestamp
-        if (chapter.chapter_number > -1f) {
-            val formattedNumber = adapter.decimalFormat.format(chapter.chapter_number.toDouble())
-            binding.mangaSubtitle.text = itemView.context.getString(
-                R.string.recent_manga_time,
-                formattedNumber,
-                Date(history.last_read).toTimestampString(),
-            )
-        } else {
-            binding.mangaSubtitle.text = Date(history.last_read).toTimestampString()
-        }
-
-        // Set cover
-        binding.cover.dispose()
-        binding.cover.load(item.manga)
-    }
-}

+ 0 - 42
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryItem.kt

@@ -1,42 +0,0 @@
-package eu.kanade.tachiyomi.ui.recent.history
-
-import android.view.View
-import androidx.recyclerview.widget.RecyclerView
-import eu.davidea.flexibleadapter.FlexibleAdapter
-import eu.davidea.flexibleadapter.items.AbstractSectionableItem
-import eu.davidea.flexibleadapter.items.IFlexible
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
-import eu.kanade.tachiyomi.ui.recent.DateSectionItem
-
-class HistoryItem(val mch: MangaChapterHistory, header: DateSectionItem) :
-    AbstractSectionableItem<HistoryHolder, DateSectionItem>(header) {
-
-    override fun getLayoutRes(): Int {
-        return R.layout.history_item
-    }
-
-    override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): HistoryHolder {
-        return HistoryHolder(view, adapter as HistoryAdapter)
-    }
-
-    override fun bindViewHolder(
-        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
-        holder: HistoryHolder,
-        position: Int,
-        payloads: List<Any?>?,
-    ) {
-        holder.bind(mch)
-    }
-
-    override fun equals(other: Any?): Boolean {
-        if (other is HistoryItem) {
-            return mch.manga.id == other.mch.manga.id
-        }
-        return false
-    }
-
-    override fun hashCode(): Int {
-        return mch.manga.id!!.hashCode()
-    }
-}

+ 97 - 119
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/HistoryPresenter.kt

@@ -1,157 +1,135 @@
 package eu.kanade.tachiyomi.ui.recent.history
 
 import android.os.Bundle
-import eu.kanade.tachiyomi.data.database.DatabaseHelper
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import androidx.paging.insertSeparators
+import androidx.paging.map
+import eu.kanade.domain.history.interactor.DeleteHistoryTable
+import eu.kanade.domain.history.interactor.GetHistory
+import eu.kanade.domain.history.interactor.GetNextChapterForManga
+import eu.kanade.domain.history.interactor.RemoveHistoryById
+import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId
+import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.database.models.Chapter
 import eu.kanade.tachiyomi.data.database.models.History
 import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.database.models.MangaChapterHistory
-import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
-import eu.kanade.tachiyomi.ui.recent.DateSectionItem
+import eu.kanade.tachiyomi.util.lang.launchIO
+import eu.kanade.tachiyomi.util.lang.launchUI
 import eu.kanade.tachiyomi.util.lang.toDateKey
-import rx.Observable
-import rx.Subscription
-import rx.android.schedulers.AndroidSchedulers
-import uy.kohesive.injekt.injectLazy
-import java.text.DateFormat
-import java.util.Calendar
-import java.util.Date
-import java.util.TreeMap
+import eu.kanade.tachiyomi.util.system.toast
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.util.*
 
 /**
  * Presenter of HistoryFragment.
  * Contains information and data for fragment.
  * Observable updates should be called from here.
  */
-class HistoryPresenter : BasePresenter<HistoryController>() {
-
-    private val db: DatabaseHelper by injectLazy()
-    private val preferences: PreferencesHelper by injectLazy()
-
-    private val relativeTime: Int = preferences.relativeTime().get()
-    private val dateFormat: DateFormat = preferences.dateFormat()
-
-    private var recentMangaSubscription: Subscription? = null
+class HistoryPresenter(
+    private val getHistory: GetHistory = Injekt.get(),
+    private val getNextChapterForManga: GetNextChapterForManga = Injekt.get(),
+    private val deleteHistoryTable: DeleteHistoryTable = Injekt.get(),
+    private val removeHistoryById: RemoveHistoryById = Injekt.get(),
+    private val removeHistoryByMangaId: RemoveHistoryByMangaId = Injekt.get(),
+) : BasePresenter<HistoryController>() {
+
+    private var _query: MutableStateFlow<String> = MutableStateFlow("")
+    private var _state: MutableStateFlow<HistoryState> = MutableStateFlow(HistoryState.EMPTY)
+    val state: StateFlow<HistoryState> = _state
 
     override fun onCreate(savedState: Bundle?) {
         super.onCreate(savedState)
 
-        // Used to get a list of recently read manga
-        updateList()
-    }
-
-    fun requestNext(offset: Int, search: String = "") {
-        getRecentMangaObservable(offset = offset, search = search)
-            .subscribeLatestCache(
-                { view, mangas ->
-                    view.onNextManga(mangas)
-                },
-                HistoryController::onAddPageError,
-            )
+        presenterScope.launchIO {
+            _state.update { state ->
+                state.copy(
+                    list = _query.flatMapLatest { query ->
+                        getHistory.subscribe(query)
+                            .map { pagingData ->
+                                pagingData
+                                    .map {
+                                        UiModel.History(it)
+                                    }
+                                    .insertSeparators { before, after ->
+                                        val beforeDate =
+                                            before?.item?.history?.last_read?.toDateKey()
+                                        val afterDate =
+                                            after?.item?.history?.last_read?.toDateKey()
+                                        when {
+                                            beforeDate == null && afterDate != null -> UiModel.Header(
+                                                afterDate,
+                                            )
+                                            beforeDate != null && afterDate != null -> UiModel.Header(
+                                                afterDate,
+                                            )
+                                            // Return null to avoid adding a separator between two items.
+                                            else -> null
+                                        }
+                                    }
+                            }
+                    }
+                        .cachedIn(presenterScope),
+                )
+            }
+        }
     }
 
-    /**
-     * Get recent manga observable
-     * @return list of history
-     */
-    private fun getRecentMangaObservable(limit: Int = 25, offset: Int = 0, search: String = ""): Observable<List<HistoryItem>> {
-        // Set date limit for recent manga
-        val cal = Calendar.getInstance().apply {
-            time = Date()
-            add(Calendar.YEAR, -50)
+    fun search(query: String) {
+        presenterScope.launchIO {
+            _query.emit(query)
         }
-
-        return db.getRecentManga(cal.time, limit, offset, search).asRxObservable()
-            .map { recents ->
-                val map = TreeMap<Date, MutableList<MangaChapterHistory>> { d1, d2 -> d2.compareTo(d1) }
-                val byDay = recents
-                    .groupByTo(map) { it.history.last_read.toDateKey() }
-                byDay.flatMap { entry ->
-                    val dateItem = DateSectionItem(entry.key, relativeTime, dateFormat)
-                    entry.value.map { HistoryItem(it, dateItem) }
-                }
-            }
-            .observeOn(AndroidSchedulers.mainThread())
     }
 
-    /**
-     * Reset last read of chapter to 0L
-     * @param history history belonging to chapter
-     */
     fun removeFromHistory(history: History) {
-        history.last_read = 0L
-        db.updateHistoryLastRead(history).asRxObservable()
-            .subscribe()
-    }
-
-    /**
-     * Pull a list of history from the db
-     * @param search a search query to use for filtering
-     */
-    fun updateList(search: String = "") {
-        recentMangaSubscription?.unsubscribe()
-        recentMangaSubscription = getRecentMangaObservable(search = search)
-            .subscribeLatestCache(
-                { view, mangas ->
-                    view.onNextManga(mangas, true)
-                },
-                HistoryController::onAddPageError,
-            )
+        presenterScope.launchIO {
+            removeHistoryById.await(history)
+        }
     }
 
-    /**
-     * Removes all chapters belonging to manga from history.
-     * @param mangaId id of manga
-     */
     fun removeAllFromHistory(mangaId: Long) {
-        db.getHistoryByMangaId(mangaId).asRxSingle()
-            .map { list ->
-                list.forEach { it.last_read = 0L }
-                db.updateHistoryLastRead(list).executeAsBlocking()
-            }
-            .subscribe()
+        presenterScope.launchIO {
+            removeHistoryByMangaId.await(mangaId)
+        }
     }
 
-    /**
-     * Retrieves the next chapter of the given one.
-     *
-     * @param chapter the chapter of the history object.
-     * @param manga the manga of the chapter.
-     */
-    fun getNextChapter(chapter: Chapter, manga: Manga): Chapter? {
-        if (!chapter.read) {
-            return chapter
+    fun getNextChapterForManga(manga: Manga, chapter: Chapter) {
+        presenterScope.launchIO {
+            val chapter = getNextChapterForManga.await(manga, chapter)
+            view?.openChapter(chapter)
         }
+    }
 
-        val sortFunction: (Chapter, Chapter) -> Int = when (manga.sorting) {
-            Manga.CHAPTER_SORTING_SOURCE -> { c1, c2 -> c2.source_order.compareTo(c1.source_order) }
-            Manga.CHAPTER_SORTING_NUMBER -> { c1, c2 -> c1.chapter_number.compareTo(c2.chapter_number) }
-            Manga.CHAPTER_SORTING_UPLOAD_DATE -> { c1, c2 -> c1.date_upload.compareTo(c2.date_upload) }
-            else -> throw NotImplementedError("Unknown sorting method")
+    fun deleteAllHistory() {
+        presenterScope.launchIO {
+            val result = deleteHistoryTable.await()
+            if (!result) return@launchIO
+            launchUI {
+                view?.activity?.toast(R.string.clear_history_completed)
+            }
         }
+    }
+}
 
-        val chapters = db.getChapters(manga).executeAsBlocking()
-            .sortedWith { c1, c2 -> sortFunction(c1, c2) }
+sealed class UiModel {
+    data class History(val item: MangaChapterHistory) : UiModel()
+    data class Header(val date: Date) : UiModel()
+}
 
-        val currChapterIndex = chapters.indexOfFirst { chapter.id == it.id }
-        return when (manga.sorting) {
-            Manga.CHAPTER_SORTING_SOURCE -> chapters.getOrNull(currChapterIndex + 1)
-            Manga.CHAPTER_SORTING_NUMBER -> {
-                val chapterNumber = chapter.chapter_number
+data class HistoryState(
+    val list: Flow<PagingData<UiModel>>? = null,
+) {
 
-                ((currChapterIndex + 1) until chapters.size)
-                    .map { chapters[it] }
-                    .firstOrNull {
-                        it.chapter_number > chapterNumber &&
-                            it.chapter_number <= chapterNumber + 1
-                    }
-            }
-            Manga.CHAPTER_SORTING_UPLOAD_DATE -> {
-                chapters.drop(currChapterIndex + 1)
-                    .firstOrNull { it.date_upload >= chapter.date_upload }
-            }
-            else -> throw NotImplementedError("Unknown sorting method")
-        }
+    companion object {
+        val EMPTY = HistoryState(null)
     }
 }

+ 0 - 54
app/src/main/java/eu/kanade/tachiyomi/ui/recent/history/RemoveHistoryDialog.kt

@@ -1,54 +0,0 @@
-package eu.kanade.tachiyomi.ui.recent.history
-
-import android.app.Dialog
-import android.os.Bundle
-import com.bluelinelabs.conductor.Controller
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import eu.kanade.tachiyomi.R
-import eu.kanade.tachiyomi.data.database.models.History
-import eu.kanade.tachiyomi.data.database.models.Manga
-import eu.kanade.tachiyomi.ui.base.controller.DialogController
-import eu.kanade.tachiyomi.widget.DialogCheckboxView
-
-class RemoveHistoryDialog<T>(bundle: Bundle? = null) : DialogController(bundle)
-        where T : Controller, T : RemoveHistoryDialog.Listener {
-
-    private var manga: Manga? = null
-
-    private var history: History? = null
-
-    constructor(target: T, manga: Manga, history: History) : this() {
-        this.manga = manga
-        this.history = history
-        targetController = target
-    }
-
-    override fun onCreateDialog(savedViewState: Bundle?): Dialog {
-        val activity = activity!!
-
-        // Create custom view
-        val dialogCheckboxView = DialogCheckboxView(activity).apply {
-            setDescription(R.string.dialog_with_checkbox_remove_description)
-            setOptionDescription(R.string.dialog_with_checkbox_reset)
-        }
-
-        return MaterialAlertDialogBuilder(activity)
-            .setTitle(R.string.action_remove)
-            .setView(dialogCheckboxView)
-            .setPositiveButton(R.string.action_remove) { _, _ -> onPositive(dialogCheckboxView.isChecked()) }
-            .setNegativeButton(android.R.string.cancel, null)
-            .create()
-    }
-
-    private fun onPositive(checked: Boolean) {
-        val target = targetController as? Listener ?: return
-        val manga = manga ?: return
-        val history = history ?: return
-
-        target.removeHistory(manga, history, checked)
-    }
-
-    interface Listener {
-        fun removeHistory(manga: Manga, history: History, all: Boolean)
-    }
-}

+ 4 - 0
app/src/main/res/layout/compose_controller.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.compose.ui.platform.ComposeView xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent" />

+ 0 - 33
app/src/main/res/layout/history_controller.xml

@@ -1,33 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <androidx.recyclerview.widget.RecyclerView
-        android:id="@+id/recycler"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:clipToPadding="false"
-        android:paddingTop="4dp"
-        android:paddingBottom="@dimen/action_toolbar_list_padding"
-        tools:listitem="@layout/history_item" />
-
-    <eu.kanade.tachiyomi.widget.MaterialFastScroll
-        android:id="@+id/fast_scroller"
-        android:layout_width="wrap_content"
-        android:layout_height="match_parent"
-        android:layout_gravity="end"
-        app:fastScrollerBubbleEnabled="false"
-        tools:visibility="visible" />
-
-    <eu.kanade.tachiyomi.widget.EmptyView
-        android:id="@+id/empty_view"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="center"
-        android:visibility="gone" />
-
-</FrameLayout>

+ 0 - 85
app/src/main/res/layout/history_item.xml

@@ -1,85 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/holder"
-    android:layout_width="match_parent"
-    android:layout_height="96dp"
-    android:paddingStart="16dp"
-    android:paddingTop="8dp"
-    android:paddingEnd="8dp"
-    android:paddingBottom="8dp"
-    android:background="?attr/selectableItemBackground"
-    android:orientation="horizontal">
-
-    <com.google.android.material.imageview.ShapeableImageView
-        android:id="@+id/cover"
-        android:layout_width="0dp"
-        android:layout_height="match_parent"
-        android:contentDescription="@string/description_cover"
-        android:scaleType="centerCrop"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintDimensionRatio="h,3:2"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        app:shapeAppearance="@style/ShapeAppearanceOverlay.Cover"
-        tools:src="@mipmap/ic_launcher" />
-
-    <LinearLayout
-        android:layout_width="0dp"
-        android:layout_height="wrap_content"
-        android:layout_marginStart="16dp"
-        android:layout_marginEnd="8dp"
-        android:orientation="vertical"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toStartOf="@+id/remove"
-        app:layout_constraintStart_toEndOf="@+id/cover"
-        app:layout_constraintTop_toTopOf="parent">
-
-        <TextView
-            android:id="@+id/manga_title"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:ellipsize="end"
-            android:maxLines="2"
-            android:textAppearance="?attr/textAppearanceTitleSmall"
-            tools:text="Title" />
-
-        <TextView
-            android:id="@+id/manga_subtitle"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_marginTop="2dp"
-            android:textAppearance="?attr/textAppearanceBodyMedium"
-            android:textColor="?android:attr/textColorSecondary"
-            tools:text="Subtitle" />
-
-    </LinearLayout>
-
-    <ImageButton
-        android:id="@+id/remove"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:background="?attr/selectableItemBackgroundBorderless"
-        android:contentDescription="@string/action_resume"
-        android:padding="8dp"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toStartOf="@+id/resume"
-        app:layout_constraintTop_toTopOf="parent"
-        app:srcCompat="@drawable/ic_delete_24dp"
-        app:tint="?android:attr/textColorPrimary" />
-
-    <ImageButton
-        android:id="@+id/resume"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:background="?attr/selectableItemBackgroundBorderless"
-        android:contentDescription="@string/action_resume"
-        android:padding="8dp"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        app:srcCompat="@drawable/ic_play_arrow_24dp"
-        app:tint="?android:attr/textColorPrimary" />
-
-</androidx.constraintlayout.widget.ConstraintLayout>

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -702,6 +702,7 @@
     <string name="updating_library">Updating library</string>
 
     <!-- History fragment -->
+    <string name="history_prefix">Ch. %1$s - </string>
     <string name="recent_manga_time">Ch. %1$s - %2$s</string>
     <string name="pref_clear_history">Clear history</string>
     <string name="clear_history_completed">History deleted</string>

+ 1 - 1
app/src/test/java/eu/kanade/tachiyomi/data/backup/BackupTest.kt

@@ -344,7 +344,7 @@ class BackupTest {
 
     private fun clearDatabase() {
         db.deleteMangas().executeAsBlocking()
-        db.deleteHistory().executeAsBlocking()
+        db.dropHistoryTable().executeAsBlocking()
     }
 
     private fun getSingleHistory(chapter: Chapter): DHistory {

+ 3 - 0
gradle/androidx.versions.toml

@@ -21,6 +21,9 @@ lifecycle-runtimektx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", ve
 work-runtime = "androidx.work:work-runtime-ktx:2.6.0"
 guava = "com.google.guava:guava:31.1-android"
 
+paging-runtime = "androidx.paging:paging-runtime:3.1.1"
+paging-compose = "androidx.paging:paging-compose:1.0.0-alpha14"
+
 [bundles]
 lifecycle = ["lifecycle-common", "lifecycle-process", "lifecycle-runtimektx"]
 workmanager = ["work-runtime", "guava"]

+ 9 - 0
gradle/compose.versions.toml

@@ -0,0 +1,9 @@
+[versions]
+compose = "1.2.0-alpha07"
+
+[libraries]
+foundation = { module = "androidx.compose.foundation:foundation", version.ref="compose" }
+material3-core = "androidx.compose.material3:material3:1.0.0-alpha09"
+material3-adapter = "com.google.android.material:compose-theme-adapter-3:1.0.6"
+animation = { module = "androidx.compose.animation:animation", version.ref="compose" }
+ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref="compose" }

+ 1 - 1
gradle/kotlinx.versions.toml

@@ -1,5 +1,5 @@
 [versions]
-kotlin_version = "1.6.20"
+kotlin_version = "1.6.10"
 coroutines_version = "1.6.1"
 serialization_version = "1.3.2"
 

+ 3 - 2
gradle/libs.versions.toml

@@ -49,6 +49,7 @@ injekt-core = "com.github.inorichi.injekt:injekt-core:65b0440"
 
 coil-core = { module = "io.coil-kt:coil", version.ref = "coil_version" }
 coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil_version" }
+coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil_version" }
 
 subsamplingscaleimageview = "com.github.tachiyomiorg:subsampling-scale-image-view:846abe0"
 image-decoder = "com.github.tachiyomiorg:image-decoder:7481a4a"
@@ -100,12 +101,12 @@ okhttp = ["okhttp-core","okhttp-logging","okhttp-dnsoverhttps"]
 js-engine = ["quickjs-android", "duktape-android"]
 sqlite = ["sqlitektx", "sqlite-android"]
 nucleus = ["nucleus-core","nucleus-supportv7"]
-coil = ["coil-core","coil-gif",]
+coil = ["coil-core","coil-gif","coil-compose"]
 flowbinding = ["flowbinding-android","flowbinding-appcompat","flowbinding-recyclerview","flowbinding-swiperefreshlayout","flowbinding-viewpager"]
 conductor = ["conductor-core","conductor-viewpager","conductor-support-preference"]
 shizuku = ["shizuku-api","shizuku-provider"]
 robolectric = ["robolectric-core","robolectric-playservices"]
 
 [plugins]
-kotlinter = { id = "org.jmailen.kotlinter", version = "3.10.0"}
+kotlinter = { id = "org.jmailen.kotlinter", version = "3.6.0"}
 versionsx = { id = "com.github.ben-manes.versions", version = "0.42.0"}

+ 3 - 0
settings.gradle.kts

@@ -22,6 +22,9 @@ dependencyResolutionManagement {
         create("androidx") {
             from(files("gradle/androidx.versions.toml"))
         }
+        create("compose") {
+            from(files("gradle/compose.versions.toml"))
+        }
     }
     repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
     repositories {