Voyager on History tab (#8481)

This commit is contained in:
Ivan Iskandar 2022-11-09 21:26:29 +07:00 committed by GitHub
parent ba00d9e5d2
commit bc3bb82651
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 233 additions and 186 deletions

View file

@ -34,3 +34,5 @@ class PreferenceMutableState<T>(
return { preference.set(it) }
}
}
fun <T> Preference<T>.asState(scope: CoroutineScope) = PreferenceMutableState(this, scope)

View file

@ -8,9 +8,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun LoadingScreen() {
fun LoadingScreen(modifier: Modifier = Modifier) {
Box(
modifier = Modifier.fillMaxSize(),
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()

View file

@ -1,110 +1,80 @@
package eu.kanade.presentation.history
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import eu.kanade.domain.history.model.HistoryWithRelations
import eu.kanade.presentation.components.AppBarTitle
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.presentation.history.components.HistoryContent
import eu.kanade.presentation.history.components.HistoryDeleteAllDialog
import eu.kanade.presentation.history.components.HistoryDeleteDialog
import eu.kanade.presentation.history.components.HistoryToolbar
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.history.HistoryPresenter
import eu.kanade.tachiyomi.ui.history.HistoryPresenter.Dialog
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.util.system.toast
import eu.kanade.tachiyomi.ui.history.HistoryScreenModel
import eu.kanade.tachiyomi.ui.history.HistoryState
import eu.kanade.tachiyomi.widget.TachiyomiBottomNavigationView
import kotlinx.coroutines.flow.collectLatest
import java.util.Date
@Composable
fun HistoryScreen(
presenter: HistoryPresenter,
onClickCover: (HistoryWithRelations) -> Unit,
onClickResume: (HistoryWithRelations) -> Unit,
state: HistoryState,
snackbarHostState: SnackbarHostState,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
onSearchQueryChange: (String?) -> Unit,
onClickCover: (mangaId: Long) -> Unit,
onClickResume: (mangaId: Long, chapterId: Long) -> Unit,
onDialogChange: (HistoryScreenModel.Dialog?) -> Unit,
) {
val context = LocalContext.current
Scaffold(
topBar = { scrollBehavior ->
HistoryToolbar(
state = presenter,
incognitoMode = presenter.isIncognitoMode,
downloadedOnlyMode = presenter.isDownloadOnly,
SearchToolbar(
titleContent = { AppBarTitle(stringResource(R.string.history)) },
searchQuery = state.searchQuery,
onChangeSearchQuery = onSearchQueryChange,
actions = {
IconButton(onClick = { onDialogChange(HistoryScreenModel.Dialog.DeleteAll) }) {
Icon(
Icons.Outlined.DeleteSweep,
contentDescription = stringResource(R.string.pref_clear_history),
)
}
},
downloadedOnlyMode = downloadedOnlyMode,
incognitoMode = incognitoMode,
scrollBehavior = scrollBehavior,
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
contentWindowInsets = TachiyomiBottomNavigationView.withBottomNavInset(ScaffoldDefaults.contentWindowInsets),
) { contentPadding ->
val items by presenter.getHistory().collectAsState(initial = null)
val contentPaddingWithNavBar = TachiyomiBottomNavigationView.withBottomNavPadding(contentPadding)
items.let {
state.list.let {
if (it == null) {
LoadingScreen()
LoadingScreen(modifier = Modifier.padding(contentPadding))
} else if (it.isEmpty()) {
EmptyScreen(
textResource = R.string.information_no_recent_manga,
modifier = Modifier.padding(contentPaddingWithNavBar),
modifier = Modifier.padding(contentPadding),
)
} else {
HistoryContent(
history = it,
contentPadding = contentPaddingWithNavBar,
onClickCover = onClickCover,
onClickResume = onClickResume,
onClickDelete = { item -> presenter.dialog = Dialog.Delete(item) },
contentPadding = contentPadding,
onClickCover = { history -> onClickCover(history.mangaId) },
onClickResume = { history -> onClickResume(history.mangaId, history.chapterId) },
onClickDelete = { item -> onDialogChange(HistoryScreenModel.Dialog.Delete(item)) },
)
}
}
LaunchedEffect(items) {
if (items != null) {
(presenter.view?.activity as? MainActivity)?.ready = true
}
}
}
val onDismissRequest = { presenter.dialog = null }
when (val dialog = presenter.dialog) {
is Dialog.Delete -> {
HistoryDeleteDialog(
onDismissRequest = onDismissRequest,
onDelete = { all ->
if (all) {
presenter.removeAllFromHistory(dialog.history.mangaId)
} else {
presenter.removeFromHistory(dialog.history)
}
},
)
}
is Dialog.DeleteAll -> {
HistoryDeleteAllDialog(
onDismissRequest = onDismissRequest,
onDelete = {
presenter.removeAllHistory()
},
)
}
null -> {}
}
LaunchedEffect(Unit) {
presenter.events.collectLatest { event ->
when (event) {
HistoryPresenter.Event.InternalError -> context.toast(R.string.internal_error)
HistoryPresenter.Event.NoNextChapterFound -> context.toast(R.string.no_next_chapter)
is HistoryPresenter.Event.OpenChapter -> {
val intent = ReaderActivity.newIntent(context, event.chapter.mangaId, event.chapter.id)
context.startActivity(intent)
}
}
}
}
}

View file

@ -1,36 +0,0 @@
package eu.kanade.presentation.history.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import eu.kanade.presentation.components.AppBarTitle
import eu.kanade.presentation.components.SearchToolbar
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.history.HistoryPresenter
import eu.kanade.tachiyomi.ui.history.HistoryState
@Composable
fun HistoryToolbar(
state: HistoryState,
scrollBehavior: TopAppBarScrollBehavior,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
) {
SearchToolbar(
titleContent = { AppBarTitle(stringResource(R.string.history)) },
searchQuery = state.searchQuery,
onChangeSearchQuery = { state.searchQuery = it },
actions = {
IconButton(onClick = { state.dialog = HistoryPresenter.Dialog.DeleteAll }) {
Icon(Icons.Outlined.DeleteSweep, contentDescription = stringResource(R.string.pref_clear_history))
}
},
downloadedOnlyMode = downloadedOnlyMode,
incognitoMode = incognitoMode,
scrollBehavior = scrollBehavior,
)
}

View file

@ -5,6 +5,8 @@ import android.view.LayoutInflater
import android.view.View
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
import eu.kanade.tachiyomi.util.view.setComposeContent
import nucleus.presenter.Presenter
@ -21,7 +23,9 @@ abstract class FullComposeController<P : Presenter<*>>(bundle: Bundle? = null) :
binding.root.apply {
setComposeContent {
ComposeContent()
CompositionLocalProvider(LocalRouter provides router) {
ComposeContent()
}
}
}
}
@ -52,7 +56,9 @@ abstract class BasicFullComposeController(bundle: Bundle? = null) :
binding.root.apply {
setComposeContent {
ComposeContent()
CompositionLocalProvider(LocalRouter provides router) {
ComposeContent()
}
}
}
}

View file

@ -1,30 +1,26 @@
package eu.kanade.tachiyomi.ui.history
import androidx.compose.runtime.Composable
import eu.kanade.presentation.history.HistoryScreen
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController
import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.domain.history.interactor.GetNextChapters
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
import eu.kanade.tachiyomi.ui.base.controller.RootController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.launchIO
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class HistoryController : FullComposeController<HistoryPresenter>(), RootController {
override fun createPresenter() = HistoryPresenter()
class HistoryController : BasicFullComposeController(), RootController {
@Composable
override fun ComposeContent() {
HistoryScreen(
presenter = presenter,
onClickCover = { history ->
router.pushController(MangaController(history.mangaId))
},
onClickResume = { history ->
presenter.getNextChapterForManga(history.mangaId, history.chapterId)
},
)
Navigator(screen = HistoryScreen)
}
fun resumeLastChapterRead() {
presenter.resumeLastChapterRead()
val context = activity ?: return
viewScope.launchIO {
val chapter = Injekt.get<GetNextChapters>().await(onlyUnread = false).firstOrNull()
HistoryScreen.openChapter(context, chapter)
}
}
}

View file

@ -0,0 +1,97 @@
package eu.kanade.tachiyomi.ui.history
import android.content.Context
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.chapter.model.Chapter
import eu.kanade.presentation.history.HistoryScreen
import eu.kanade.presentation.history.components.HistoryDeleteAllDialog
import eu.kanade.presentation.history.components.HistoryDeleteDialog
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import kotlinx.coroutines.flow.collectLatest
object HistoryScreen : Screen {
private val snackbarHostState = SnackbarHostState()
@Composable
override fun Content() {
val router = LocalRouter.currentOrThrow
val context = LocalContext.current
val screenModel = rememberScreenModel { HistoryScreenModel() }
val state by screenModel.state.collectAsState()
HistoryScreen(
state = state,
snackbarHostState = snackbarHostState,
incognitoMode = screenModel.isIncognitoMode,
downloadedOnlyMode = screenModel.isDownloadOnly,
onSearchQueryChange = screenModel::updateSearchQuery,
onClickCover = { router.pushController(MangaController(it)) },
onClickResume = screenModel::getNextChapterForManga,
onDialogChange = screenModel::setDialog,
)
val onDismissRequest = { screenModel.setDialog(null) }
when (val dialog = state.dialog) {
is HistoryScreenModel.Dialog.Delete -> {
HistoryDeleteDialog(
onDismissRequest = onDismissRequest,
onDelete = { all ->
if (all) {
screenModel.removeAllFromHistory(dialog.history.mangaId)
} else {
screenModel.removeFromHistory(dialog.history)
}
},
)
}
is HistoryScreenModel.Dialog.DeleteAll -> {
HistoryDeleteAllDialog(
onDismissRequest = onDismissRequest,
onDelete = screenModel::removeAllHistory,
)
}
null -> {}
}
LaunchedEffect(state.list) {
if (state.list != null) {
(context as? MainActivity)?.ready = true
}
}
LaunchedEffect(Unit) {
screenModel.events.collectLatest { e ->
when (e) {
HistoryScreenModel.Event.InternalError ->
snackbarHostState.showSnackbar(context.getString(R.string.internal_error))
HistoryScreenModel.Event.HistoryCleared ->
snackbarHostState.showSnackbar(context.getString(R.string.clear_history_completed))
is HistoryScreenModel.Event.OpenChapter -> openChapter(context, e.chapter)
}
}
}
}
suspend fun openChapter(context: Context, chapter: Chapter?) {
if (chapter != null) {
val intent = ReaderActivity.newIntent(context, chapter.mangaId, chapter.id)
context.startActivity(intent)
} else {
snackbarHostState.showSnackbar(context.getString(R.string.no_next_chapter))
}
}
}

View file

@ -1,11 +1,10 @@
package eu.kanade.tachiyomi.ui.history
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import eu.kanade.core.prefs.asState
import eu.kanade.core.util.insertSeparators
import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.chapter.model.Chapter
@ -14,51 +13,53 @@ import eu.kanade.domain.history.interactor.GetNextChapters
import eu.kanade.domain.history.interactor.RemoveHistory
import eu.kanade.domain.history.model.HistoryWithRelations
import eu.kanade.presentation.history.HistoryUiModel
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.launchIO
import eu.kanade.tachiyomi.util.lang.toDateKey
import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import logcat.LogPriority
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
class HistoryPresenter(
private val state: HistoryStateImpl = HistoryState() as HistoryStateImpl,
class HistoryScreenModel(
private val getHistory: GetHistory = Injekt.get(),
private val getNextChapters: GetNextChapters = Injekt.get(),
private val removeHistory: RemoveHistory = Injekt.get(),
preferences: BasePreferences = Injekt.get(),
) : BasePresenter<HistoryController>(), HistoryState by state {
) : StateScreenModel<HistoryState>(HistoryState()) {
private val _events: Channel<Event> = Channel(Int.MAX_VALUE)
private val _events: Channel<Event> = Channel(Channel.UNLIMITED)
val events: Flow<Event> = _events.receiveAsFlow()
val isDownloadOnly: Boolean by preferences.downloadedOnly().asState()
val isIncognitoMode: Boolean by preferences.incognitoMode().asState()
val isDownloadOnly: Boolean by preferences.downloadedOnly().asState(coroutineScope)
val isIncognitoMode: Boolean by preferences.incognitoMode().asState(coroutineScope)
@Composable
fun getHistory(): Flow<List<HistoryUiModel>> {
val query = searchQuery ?: ""
return remember(query) {
getHistory.subscribe(query)
init {
coroutineScope.launch {
state.map { it.searchQuery }
.distinctUntilChanged()
.catch { error ->
logcat(LogPriority.ERROR, error)
_events.send(Event.InternalError)
}
.map { pagingData ->
pagingData.toHistoryUiModels()
.flatMapLatest { query ->
getHistory.subscribe(query ?: "")
.distinctUntilChanged()
.catch { error ->
logcat(LogPriority.ERROR, error)
_events.send(Event.InternalError)
}
.map { it.toHistoryUiModels() }
.flowOn(Dispatchers.IO)
}
.collect { newList -> mutableState.update { it.copy(list = newList) } }
}
}
@ -76,67 +77,59 @@ class HistoryPresenter(
}
fun getNextChapterForManga(mangaId: Long, chapterId: Long) {
presenterScope.launchIO {
coroutineScope.launchIO {
sendNextChapterEvent(getNextChapters.await(mangaId, chapterId, onlyUnread = false))
}
}
fun resumeLastChapterRead() {
presenterScope.launchIO {
sendNextChapterEvent(getNextChapters.await(onlyUnread = false))
}
}
private suspend fun sendNextChapterEvent(chapters: List<Chapter>) {
val chapter = chapters.firstOrNull()
_events.send(if (chapter != null) Event.OpenChapter(chapter) else Event.NoNextChapterFound)
_events.send(Event.OpenChapter(chapter))
}
fun removeFromHistory(history: HistoryWithRelations) {
presenterScope.launchIO {
coroutineScope.launchIO {
removeHistory.await(history)
}
}
fun removeAllFromHistory(mangaId: Long) {
presenterScope.launchIO {
coroutineScope.launchIO {
removeHistory.await(mangaId)
}
}
fun removeAllHistory() {
presenterScope.launchIO {
coroutineScope.launchIO {
val result = removeHistory.awaitAll()
if (!result) return@launchIO
withUIContext {
view?.activity?.toast(R.string.clear_history_completed)
}
_events.send(Event.HistoryCleared)
}
}
fun updateSearchQuery(query: String?) {
mutableState.update { it.copy(searchQuery = query) }
}
fun setDialog(dialog: Dialog?) {
mutableState.update { it.copy(dialog = dialog) }
}
sealed class Dialog {
object DeleteAll : Dialog()
data class Delete(val history: HistoryWithRelations) : Dialog()
}
sealed class Event {
data class OpenChapter(val chapter: Chapter?) : Event()
object InternalError : Event()
object NoNextChapterFound : Event()
data class OpenChapter(val chapter: Chapter) : Event()
object HistoryCleared : Event()
}
}
@Stable
interface HistoryState {
var searchQuery: String?
var dialog: HistoryPresenter.Dialog?
}
fun HistoryState(): HistoryState {
return HistoryStateImpl()
}
class HistoryStateImpl : HistoryState {
override var searchQuery: String? by mutableStateOf(null)
override var dialog: HistoryPresenter.Dialog? by mutableStateOf(null)
}
@Immutable
data class HistoryState(
val searchQuery: String? = null,
val list: List<HistoryUiModel>? = null,
val dialog: HistoryScreenModel.Dialog? = null,
)

View file

@ -9,6 +9,7 @@ import android.os.Parcelable
import android.util.AttributeSet
import android.view.ViewPropertyAnimator
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.runtime.Composable
@ -16,6 +17,7 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
@ -26,6 +28,7 @@ import com.google.android.material.bottomnavigation.BottomNavigationView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.applySystemAnimatorScale
import eu.kanade.tachiyomi.util.system.pxToDp
import kotlin.math.max
class TachiyomiBottomNavigationView @JvmOverloads constructor(
context: Context,
@ -173,5 +176,21 @@ class TachiyomiBottomNavigationView @JvmOverloads constructor(
bottom = max(origin.calculateBottomPadding(), bottomNavPadding),
)
}
/**
* @see withBottomNavPadding
*/
@ReadOnlyComposable
@Composable
fun withBottomNavInset(origin: WindowInsets): WindowInsets {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
return WindowInsets(
left = origin.getLeft(density, layoutDirection),
top = origin.getTop(density),
right = origin.getRight(density, layoutDirection),
bottom = max(origin.getBottom(density), with(density) { bottomNavPadding.roundToPx() }),
)
}
}
}