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 package eu.kanade.presentation.category
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.CategoryFloatingActionButton
import eu.kanade.presentation.category.components.biometric.BiometricTimesContent import eu.kanade.presentation.category.components.biometric.BiometricTimesContent
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.Scaffold import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.presentation.util.plus import eu.kanade.presentation.util.plus
import eu.kanade.presentation.util.topPaddingValues import eu.kanade.presentation.util.topPaddingValues
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesPresenter import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesScreenState
import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesPresenter.Dialog import eu.kanade.tachiyomi.ui.category.biometric.TimeRangeItem
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
import kotlin.time.Duration
@Composable @Composable
fun BiometricTimesScreen( fun BiometricTimesScreen(
presenter: BiometricTimesPresenter, state: BiometricTimesScreenState.Success,
onClickCreate: () -> Unit,
onClickDelete: (TimeRangeItem) -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
openCreateDialog: (Duration?) -> Unit,
) { ) {
val lazyListState = rememberLazyListState() val lazyListState = rememberLazyListState()
Scaffold( Scaffold(
@ -41,51 +37,23 @@ fun BiometricTimesScreen(
floatingActionButton = { floatingActionButton = {
CategoryFloatingActionButton( CategoryFloatingActionButton(
lazyListState = lazyListState, lazyListState = lazyListState,
onCreate = { presenter.dialog = Dialog.Create }, onCreate = onClickCreate,
) )
}, },
) { paddingValues -> ) { paddingValues ->
val context = LocalContext.current if (state.isEmpty) {
when { EmptyScreen(
presenter.isLoading -> LoadingScreen() textResource = R.string.biometric_lock_times_empty,
presenter.isEmpty -> EmptyScreen(textResource = R.string.biometric_lock_times_empty) modifier = Modifier.padding(paddingValues),
else -> { )
BiometricTimesContent( return@Scaffold
state = presenter,
lazyListState = lazyListState,
paddingValues = paddingValues + topPaddingValues + PaddingValues(horizontal = horizontalPadding),
)
}
} }
val onDismissRequest = { presenter.dialog = null } BiometricTimesContent(
when (val dialog = presenter.dialog) { timeRanges = state.timeRanges,
Dialog.Create -> { lazyListState = lazyListState,
LaunchedEffect(Unit) { paddingValues = paddingValues + topPaddingValues + PaddingValues(horizontal = horizontalPadding),
openCreateDialog(null) onClickDelete = onClickDelete,
} )
}
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)
}
}
}
}
} }
} }

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.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.presentation.category.BiometricTimesState
import eu.kanade.presentation.components.LazyColumn import eu.kanade.presentation.components.LazyColumn
import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesPresenter import eu.kanade.tachiyomi.ui.category.biometric.TimeRangeItem
@Composable @Composable
fun BiometricTimesContent( fun BiometricTimesContent(
state: BiometricTimesState, timeRanges: List<TimeRangeItem>,
lazyListState: LazyListState, lazyListState: LazyListState,
paddingValues: PaddingValues, paddingValues: PaddingValues,
onClickDelete: (TimeRangeItem) -> Unit,
) { ) {
val timeRanges = state.timeRanges
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
contentPadding = paddingValues, contentPadding = paddingValues,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(timeRanges) { timeRange -> items(timeRanges, key = { it.formattedString }) { timeRange ->
BiometricTimesListItem( BiometricTimesListItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItemPlacement(),
timeRange = timeRange, 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 package eu.kanade.tachiyomi.ui.category.biometric
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import com.google.android.material.timepicker.MaterialTimePicker import androidx.compose.runtime.CompositionLocalProvider
import eu.kanade.presentation.category.BiometricTimesScreen import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.tachiyomi.R import eu.kanade.presentation.util.LocalRouter
import eu.kanade.tachiyomi.ui.base.controller.FullComposeController import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
import eu.kanade.tachiyomi.ui.main.MainActivity
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
/** /**
* Controller to manage the lock times for the biometric lock. * Controller to manage the lock times for the biometric lock.
*/ */
class BiometricTimesController : FullComposeController<BiometricTimesPresenter>() { class BiometricTimesController : BasicFullComposeController() {
override fun createPresenter() = BiometricTimesPresenter()
@Composable @Composable
override fun ComposeContent() { override fun ComposeContent() {
BiometricTimesScreen( CompositionLocalProvider(LocalRouter provides router) {
presenter = presenter, Navigator(screen = BiometricTimesScreen())
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)
}
} }
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()
}
}