Use Voyager on Biometric Times screen

This commit is contained in:
Jobobby04 2022-11-11 00:11:35 -05:00
parent fd99a5f502
commit 206aab6755
7 changed files with 258 additions and 212 deletions

View File

@ -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,
)
}
}

View File

@ -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<TimeRangeItem>
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<TimeRangeItem> by mutableStateOf(emptyList())
override val isEmpty: Boolean by derivedStateOf { timeRanges.isEmpty() }
}

View File

@ -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<TimeRangeItem>,
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) },
)
}
}

View File

@ -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<BiometricTimesPresenter>() {
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)
}
}

View File

@ -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<BiometricTimesController>(), BiometricTimesState by state {
private val _events: Channel<Event> = 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<Application>()
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()
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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>(BiometricTimesScreenState.Loading) {
private val _events: Channel<BiometricTimesEvent> = Channel(Int.MAX_VALUE)
val events = _events.consumeAsFlow()
init {
coroutineScope.launchIO {
// todo usecase
preferences.authenticatorTimeRanges().changes()
.collectLatest { times ->
val context = Injekt.get<Application>()
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<TimeRangeItem>,
val dialog: BiometricTimesDialog? = null,
) : BiometricTimesScreenState() {
val isEmpty: Boolean
get() = timeRanges.isEmpty()
}
}