From 206aab67552943a4b27f3dcfe4cab3eb5ea012f1 Mon Sep 17 00:00:00 2001 From: Jobobby04 Date: Fri, 11 Nov 2022 00:11:35 -0500 Subject: [PATCH] Use Voyager on Biometric Times screen --- .../category/BiometricTimesScreen.kt | 72 +++------- .../category/BiometricTimesState.kt | 28 ---- .../biometric/BiometricTimesContent.kt | 11 +- .../biometric/BiometricTimesController.kt | 42 +----- .../biometric/BiometricTimesPresenter.kt | 91 ------------ .../biometric/BiometricTimesScreen.kt | 95 +++++++++++++ .../biometric/BiometricTimesScreenModel.kt | 131 ++++++++++++++++++ 7 files changed, 258 insertions(+), 212 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/presentation/category/BiometricTimesState.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesPresenter.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesScreen.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesScreenModel.kt diff --git a/app/src/main/java/eu/kanade/presentation/category/BiometricTimesScreen.kt b/app/src/main/java/eu/kanade/presentation/category/BiometricTimesScreen.kt index a93ff3fae..2da4ddd0c 100644 --- a/app/src/main/java/eu/kanade/presentation/category/BiometricTimesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/category/BiometricTimesScreen.kt @@ -1,33 +1,29 @@ package eu.kanade.presentation.category import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import eu.kanade.presentation.category.components.CategoryDeleteDialog import eu.kanade.presentation.category.components.CategoryFloatingActionButton import eu.kanade.presentation.category.components.biometric.BiometricTimesContent import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.EmptyScreen -import eu.kanade.presentation.components.LoadingScreen import eu.kanade.presentation.components.Scaffold import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.plus import eu.kanade.presentation.util.topPaddingValues import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesPresenter -import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesPresenter.Dialog -import eu.kanade.tachiyomi.util.system.toast -import kotlinx.coroutines.flow.collectLatest -import kotlin.time.Duration +import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesScreenState +import eu.kanade.tachiyomi.ui.category.biometric.TimeRangeItem @Composable fun BiometricTimesScreen( - presenter: BiometricTimesPresenter, + state: BiometricTimesScreenState.Success, + onClickCreate: () -> Unit, + onClickDelete: (TimeRangeItem) -> Unit, navigateUp: () -> Unit, - openCreateDialog: (Duration?) -> Unit, ) { val lazyListState = rememberLazyListState() Scaffold( @@ -41,51 +37,23 @@ fun BiometricTimesScreen( floatingActionButton = { CategoryFloatingActionButton( lazyListState = lazyListState, - onCreate = { presenter.dialog = Dialog.Create }, + onCreate = onClickCreate, ) }, ) { paddingValues -> - val context = LocalContext.current - when { - presenter.isLoading -> LoadingScreen() - presenter.isEmpty -> EmptyScreen(textResource = R.string.biometric_lock_times_empty) - else -> { - BiometricTimesContent( - state = presenter, - lazyListState = lazyListState, - paddingValues = paddingValues + topPaddingValues + PaddingValues(horizontal = horizontalPadding), - ) - } + if (state.isEmpty) { + EmptyScreen( + textResource = R.string.biometric_lock_times_empty, + modifier = Modifier.padding(paddingValues), + ) + return@Scaffold } - val onDismissRequest = { presenter.dialog = null } - when (val dialog = presenter.dialog) { - Dialog.Create -> { - LaunchedEffect(Unit) { - openCreateDialog(null) - } - } - is Dialog.Delete -> { - CategoryDeleteDialog( - onDismissRequest = onDismissRequest, - onDelete = { presenter.deleteTimeRanges(dialog.timeRange) }, - title = stringResource(R.string.delete_time_range), - text = stringResource(R.string.delete_time_range_confirmation, dialog.timeRange.formattedString), - ) - } - else -> {} - } - LaunchedEffect(Unit) { - presenter.events.collectLatest { event -> - when (event) { - is BiometricTimesPresenter.Event.TimeConflicts -> { - context.toast(R.string.biometric_lock_time_conflicts) - } - is BiometricTimesPresenter.Event.InternalError -> { - context.toast(R.string.internal_error) - } - } - } - } + BiometricTimesContent( + timeRanges = state.timeRanges, + lazyListState = lazyListState, + paddingValues = paddingValues + topPaddingValues + PaddingValues(horizontal = horizontalPadding), + onClickDelete = onClickDelete, + ) } } diff --git a/app/src/main/java/eu/kanade/presentation/category/BiometricTimesState.kt b/app/src/main/java/eu/kanade/presentation/category/BiometricTimesState.kt deleted file mode 100644 index d73cc864b..000000000 --- a/app/src/main/java/eu/kanade/presentation/category/BiometricTimesState.kt +++ /dev/null @@ -1,28 +0,0 @@ -package eu.kanade.presentation.category - -import androidx.compose.runtime.Stable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesPresenter -import eu.kanade.tachiyomi.ui.category.biometric.TimeRangeItem - -@Stable -interface BiometricTimesState { - val isLoading: Boolean - var dialog: BiometricTimesPresenter.Dialog? - val timeRanges: List - val isEmpty: Boolean -} - -fun BiometricTimesState(): BiometricTimesState { - return BiometricTimesStateImpl() -} - -class BiometricTimesStateImpl : BiometricTimesState { - override var isLoading: Boolean by mutableStateOf(true) - override var dialog: BiometricTimesPresenter.Dialog? by mutableStateOf(null) - override var timeRanges: List by mutableStateOf(emptyList()) - override val isEmpty: Boolean by derivedStateOf { timeRanges.isEmpty() } -} diff --git a/app/src/main/java/eu/kanade/presentation/category/components/biometric/BiometricTimesContent.kt b/app/src/main/java/eu/kanade/presentation/category/components/biometric/BiometricTimesContent.kt index e0be2e38c..2e2f20ade 100644 --- a/app/src/main/java/eu/kanade/presentation/category/components/biometric/BiometricTimesContent.kt +++ b/app/src/main/java/eu/kanade/presentation/category/components/biometric/BiometricTimesContent.kt @@ -7,27 +7,26 @@ import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import eu.kanade.presentation.category.BiometricTimesState import eu.kanade.presentation.components.LazyColumn -import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesPresenter +import eu.kanade.tachiyomi.ui.category.biometric.TimeRangeItem @Composable fun BiometricTimesContent( - state: BiometricTimesState, + timeRanges: List, lazyListState: LazyListState, paddingValues: PaddingValues, + onClickDelete: (TimeRangeItem) -> Unit, ) { - val timeRanges = state.timeRanges LazyColumn( state = lazyListState, contentPadding = paddingValues, verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(timeRanges) { timeRange -> + items(timeRanges, key = { it.formattedString }) { timeRange -> BiometricTimesListItem( modifier = Modifier.animateItemPlacement(), timeRange = timeRange, - onDelete = { state.dialog = BiometricTimesPresenter.Dialog.Delete(timeRange) }, + onDelete = { onClickDelete(timeRange) }, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesController.kt index a5daeada7..3f6d75ee2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesController.kt @@ -1,48 +1,20 @@ package eu.kanade.tachiyomi.ui.category.biometric import androidx.compose.runtime.Composable -import com.google.android.material.timepicker.MaterialTimePicker -import eu.kanade.presentation.category.BiometricTimesScreen -import eu.kanade.tachiyomi.R -import eu.kanade.tachiyomi.ui.base.controller.FullComposeController -import eu.kanade.tachiyomi.ui.main.MainActivity -import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes +import androidx.compose.runtime.CompositionLocalProvider +import cafe.adriel.voyager.navigator.Navigator +import eu.kanade.presentation.util.LocalRouter +import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController /** * Controller to manage the lock times for the biometric lock. */ -class BiometricTimesController : FullComposeController() { - - override fun createPresenter() = BiometricTimesPresenter() +class BiometricTimesController : BasicFullComposeController() { @Composable override fun ComposeContent() { - BiometricTimesScreen( - presenter = presenter, - navigateUp = router::popCurrentController, - openCreateDialog = ::showTimePicker, - ) - } - - private fun showTimePicker(startTime: Duration? = null) { - val picker = MaterialTimePicker.Builder() - .setTitleText(if (startTime == null) R.string.biometric_lock_start_time else R.string.biometric_lock_end_time) - .setInputMode(MaterialTimePicker.INPUT_MODE_CLOCK) - .build() - picker.addOnPositiveButtonClickListener { - val timeRange = picker.hour.hours + picker.minute.minutes - if (startTime != null) { - presenter.dialog = null - presenter.createTimeRange(TimeRange(startTime, timeRange)) - } else { - showTimePicker(timeRange) - } + CompositionLocalProvider(LocalRouter provides router) { + Navigator(screen = BiometricTimesScreen()) } - picker.addOnDismissListener { - presenter.dialog = null - } - picker.show((activity as MainActivity).supportFragmentManager, null) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesPresenter.kt deleted file mode 100644 index c8dc82aba..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesPresenter.kt +++ /dev/null @@ -1,91 +0,0 @@ -package eu.kanade.tachiyomi.ui.category.biometric - -import android.app.Application -import android.os.Bundle -import eu.kanade.presentation.category.BiometricTimesState -import eu.kanade.presentation.category.BiometricTimesStateImpl -import eu.kanade.tachiyomi.core.security.SecurityPreferences -import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter -import eu.kanade.tachiyomi.util.lang.launchIO -import eu.kanade.tachiyomi.util.preference.plusAssign -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.consumeAsFlow -import uy.kohesive.injekt.Injekt -import uy.kohesive.injekt.api.get - -/** - * Presenter of [BiometricTimesController]. Used to manage the categories of the library. - */ -class BiometricTimesPresenter( - private val state: BiometricTimesStateImpl = BiometricTimesState() as BiometricTimesStateImpl, - private val preferences: SecurityPreferences = Injekt.get(), -) : BasePresenter(), BiometricTimesState by state { - - private val _events: Channel = Channel(Int.MAX_VALUE) - val events = _events.consumeAsFlow() - - override fun onCreate(savedState: Bundle?) { - super.onCreate(savedState) - presenterScope.launchIO { - // todo usecase - preferences.authenticatorTimeRanges().changes() - .collectLatest { - val context = view?.activity ?: Injekt.get() - state.isLoading = false - state.timeRanges = it.toList() - .mapNotNull(TimeRange::fromPreferenceString) - .map { TimeRangeItem(it, it.getFormattedString(context)) } - } - } - } - - /** - * Creates and adds a new category to the database. - * - * @param name The name of the category to create. - */ - fun createTimeRange(timeRange: TimeRange) { - // todo usecase - presenterScope.launchIO { - // Do not allow duplicate categories. - if (timeRangeConflicts(timeRange)) { - _events.send(Event.TimeConflicts) - return@launchIO - } - - preferences.authenticatorTimeRanges() += timeRange.toPreferenceString() - } - } - - /** - * Deletes the given categories from the database. - * - * @param timeRanges The list of categories to delete. - */ - fun deleteTimeRanges(timeRange: TimeRangeItem) { - // todo usecase - presenterScope.launchIO { - preferences.authenticatorTimeRanges().set( - state.timeRanges.filterNot { it == timeRange }.map { it.timeRange.toPreferenceString() }.toSet(), - ) - } - } - - /** - * Returns true if a category with the given name already exists. - */ - private fun timeRangeConflicts(timeRange: TimeRange): Boolean { - return timeRanges.any { timeRange.conflictsWith(it.timeRange) } - } - - sealed class Event { - object TimeConflicts : Event() - object InternalError : Event() - } - - sealed class Dialog { - object Create : Dialog() - data class Delete(val timeRange: TimeRangeItem) : Dialog() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesScreen.kt new file mode 100644 index 000000000..f388183ab --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesScreen.kt @@ -0,0 +1,95 @@ +package eu.kanade.tachiyomi.ui.category.biometric + +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 androidx.compose.ui.res.stringResource +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.currentOrThrow +import com.google.android.material.timepicker.MaterialTimePicker +import eu.kanade.presentation.category.BiometricTimesScreen +import eu.kanade.presentation.category.components.CategoryDeleteDialog +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.util.LocalRouter +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.main.MainActivity +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.collectLatest +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +class BiometricTimesScreen : Screen { + + @Composable + override fun Content() { + val context = LocalContext.current + val router = LocalRouter.currentOrThrow + val screenModel = rememberScreenModel { BiometricTimesScreenModel() } + + val state by screenModel.state.collectAsState() + + if (state is BiometricTimesScreenState.Loading) { + LoadingScreen() + return + } + + val successState = state as BiometricTimesScreenState.Success + + BiometricTimesScreen( + state = successState, + onClickCreate = { screenModel.showDialog(BiometricTimesDialog.Create) }, + onClickDelete = { screenModel.showDialog(BiometricTimesDialog.Delete(it)) }, + navigateUp = router::popCurrentController, + ) + + fun showTimePicker(startTime: Duration? = null) { + val activity = context as? MainActivity ?: return + val picker = MaterialTimePicker.Builder() + .setTitleText(if (startTime == null) R.string.biometric_lock_start_time else R.string.biometric_lock_end_time) + .setInputMode(MaterialTimePicker.INPUT_MODE_CLOCK) + .build() + picker.addOnPositiveButtonClickListener { + val timeRange = picker.hour.hours + picker.minute.minutes + if (startTime != null) { + screenModel.dismissDialog() + screenModel.createTimeRange(TimeRange(startTime, timeRange)) + } else { + showTimePicker(timeRange) + } + } + picker.addOnDismissListener { + screenModel.dismissDialog() + } + picker.show(activity.supportFragmentManager, null) + } + + when (val dialog = successState.dialog) { + null -> {} + BiometricTimesDialog.Create -> { + LaunchedEffect(Unit) { + showTimePicker() + } + } + is BiometricTimesDialog.Delete -> { + CategoryDeleteDialog( + onDismissRequest = screenModel::dismissDialog, + onDelete = { screenModel.deleteTimeRanges(dialog.timeRange) }, + title = stringResource(R.string.delete_time_range), + text = stringResource(R.string.delete_time_range_confirmation, dialog.timeRange.formattedString), + ) + } + } + + LaunchedEffect(Unit) { + screenModel.events.collectLatest { event -> + if (event is BiometricTimesEvent.LocalizedMessage) { + context.toast(event.stringRes) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesScreenModel.kt new file mode 100644 index 000000000..0717b928f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/biometric/BiometricTimesScreenModel.kt @@ -0,0 +1,131 @@ +package eu.kanade.tachiyomi.ui.category.biometric + +import android.app.Application +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.coroutineScope +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.core.security.SecurityPreferences +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.preference.plusAssign +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.update +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +/** + * Presenter of [BiometricTimesController]. Used to manage the categories of the library. + */ +class BiometricTimesScreenModel( + private val preferences: SecurityPreferences = Injekt.get(), +) : StateScreenModel(BiometricTimesScreenState.Loading) { + + private val _events: Channel = Channel(Int.MAX_VALUE) + val events = _events.consumeAsFlow() + + init { + coroutineScope.launchIO { + // todo usecase + preferences.authenticatorTimeRanges().changes() + .collectLatest { times -> + val context = Injekt.get() + mutableState.update { + BiometricTimesScreenState.Success( + timeRanges = times.toList() + .mapNotNull(TimeRange::fromPreferenceString) + .map { TimeRangeItem(it, it.getFormattedString(context)) }, + ) + } + } + } + } + + /** + * Creates and adds a new category to the database. + * + * @param name The name of the category to create. + */ + fun createTimeRange(timeRange: TimeRange) { + // todo usecase + coroutineScope.launchIO { + // Do not allow duplicate categories. + if (timeRangeConflicts(timeRange)) { + _events.send(BiometricTimesEvent.TimeConflicts) + return@launchIO + } + + preferences.authenticatorTimeRanges() += timeRange.toPreferenceString() + } + } + + /** + * Deletes the given categories from the database. + * + * @param timeRanges The list of categories to delete. + */ + fun deleteTimeRanges(timeRange: TimeRangeItem) { + // todo usecase + coroutineScope.launchIO { + val state = state.value as? BiometricTimesScreenState.Success ?: return@launchIO + preferences.authenticatorTimeRanges().set( + state.timeRanges.filterNot { it == timeRange }.map { it.timeRange.toPreferenceString() }.toSet(), + ) + } + } + + /** + * Returns true if a category with the given name already exists. + */ + private fun timeRangeConflicts(timeRange: TimeRange): Boolean { + val state = state.value as? BiometricTimesScreenState.Success ?: return false + return state.timeRanges.any { timeRange.conflictsWith(it.timeRange) } + } + + fun showDialog(dialog: BiometricTimesDialog) { + mutableState.update { + when (it) { + BiometricTimesScreenState.Loading -> it + is BiometricTimesScreenState.Success -> it.copy(dialog = dialog) + } + } + } + + fun dismissDialog() { + mutableState.update { + when (it) { + BiometricTimesScreenState.Loading -> it + is BiometricTimesScreenState.Success -> it.copy(dialog = null) + } + } + } +} + +sealed class BiometricTimesEvent { + sealed class LocalizedMessage(@StringRes val stringRes: Int) : BiometricTimesEvent() + object TimeConflicts : LocalizedMessage(R.string.biometric_lock_time_conflicts) + object InternalError : LocalizedMessage(R.string.internal_error) +} + +sealed class BiometricTimesDialog { + object Create : BiometricTimesDialog() + data class Delete(val timeRange: TimeRangeItem) : BiometricTimesDialog() +} + +sealed class BiometricTimesScreenState { + + @Immutable + object Loading : BiometricTimesScreenState() + + @Immutable + data class Success( + val timeRanges: List, + val dialog: BiometricTimesDialog? = null, + ) : BiometricTimesScreenState() { + + val isEmpty: Boolean + get() = timeRanges.isEmpty() + } +}