From 8c9f0fb8380e9d70438812dfbc9322d08da5eac3 Mon Sep 17 00:00:00 2001 From: Iván Ávalos Date: Mon, 11 Sep 2023 23:35:27 -0600 Subject: [anastasis] Add expiration support and refactor edit secret screen --- anastasis/build.gradle.kts | 2 +- .../main/java/net/taler/anastasis/backend/Tasks.kt | 3 + .../net/taler/anastasis/reducers/ReducerManager.kt | 37 +++--- .../taler/anastasis/ui/backup/EditSecretScreen.kt | 127 +++++++++++++++++---- .../net/taler/anastasis/ui/forms/EditSecretForm.kt | 71 ++++++++++-- .../ui/reusable/components/DatePickerField.kt | 55 ++++++--- anastasis/src/main/res/values/strings.xml | 2 + 7 files changed, 236 insertions(+), 61 deletions(-) diff --git a/anastasis/build.gradle.kts b/anastasis/build.gradle.kts index 52a9ec5..ef4b581 100644 --- a/anastasis/build.gradle.kts +++ b/anastasis/build.gradle.kts @@ -6,7 +6,7 @@ plugins { id("com.google.dagger.hilt.android") } -val qtartVersion = "0.9.3-dev.14" +val qtartVersion = "0.9.3-dev.15" @Suppress("UnstableApiUsage") android { diff --git a/anastasis/src/main/java/net/taler/anastasis/backend/Tasks.kt b/anastasis/src/main/java/net/taler/anastasis/backend/Tasks.kt index d401704..84ac668 100644 --- a/anastasis/src/main/java/net/taler/anastasis/backend/Tasks.kt +++ b/anastasis/src/main/java/net/taler/anastasis/backend/Tasks.kt @@ -20,6 +20,7 @@ data class Tasks( val foreground: Int = 0, ) { enum class Type { + None, Background, Foreground, } @@ -28,11 +29,13 @@ data class Tasks( val isForegroundLoading: Boolean get() = foreground > 0 fun addTask(type: Type): Tasks = when (type) { + Type.None -> copy() Type.Background -> copy(background = background + 1) Type.Foreground -> copy(foreground = foreground + 1) } fun removeTask(type: Type): Tasks = when (type) { + Type.None -> copy() Type.Background -> copy(background = if (background == 0) 0 else background - 1) Type.Foreground -> copy(foreground = if(foreground == 0) 0 else foreground - 1) } diff --git a/anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt b/anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt index 214b351..75eaf0c 100644 --- a/anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt +++ b/anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt @@ -33,12 +33,14 @@ import net.taler.anastasis.models.AggregatedPolicyMetaInfo import net.taler.anastasis.models.AuthMethod import net.taler.anastasis.models.AuthenticationProviderStatus import net.taler.anastasis.models.ContinentInfo +import net.taler.anastasis.models.CoreSecret import net.taler.anastasis.models.CountryInfo import net.taler.anastasis.models.Policy import net.taler.anastasis.models.ReducerArgs import net.taler.anastasis.models.ReducerState import net.taler.anastasis.shared.Utils import net.taler.anastasis.shared.Utils.encodeToNativeJson +import net.taler.common.Timestamp import org.json.JSONObject import kotlin.time.Duration.Companion.seconds @@ -253,22 +255,29 @@ class ReducerManager( } } - fun backupSecret( - name: String, - args: ReducerArgs.EnterSecret, + fun enterSecretName(secretName: String) = scope.launch { + state.value?.let { initialState -> + addTask(Tasks.Type.None) + api.reduceAction(initialState, "enter_secret_name") { + put("name", secretName) + } + .onSuccess { onSuccess(it) } + .onError { onError(it) } + } + } + + fun enterSecret( + secret: CoreSecret, + expiration: Timestamp, ) = scope.launch { state.value?.let { initialState -> - addTask() - api.reduceAction(initialState, "enter_secret", args).onSuccess { newState -> - scope.launch { - api.reduceAction(newState, "enter_secret_name") { - put("name", name) - }.onSuccess { newNewState -> - this@ReducerManager.onSuccess(newNewState) - this@ReducerManager.next() - }.onError { onError(it) } - } - }.onError { onError(it) } + addTask(Tasks.Type.None) + api.reduceAction(initialState, "enter_secret", ReducerArgs.EnterSecret( + secret = ReducerArgs.EnterSecret.Secret(secret.value, secret.mime), + expiration = expiration, + )) + .onSuccess { onSuccess(it) } + .onError { onError(it) } } } diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/backup/EditSecretScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/backup/EditSecretScreen.kt index 799ebf2..0e0bdea 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/backup/EditSecretScreen.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/backup/EditSecretScreen.kt @@ -17,6 +17,12 @@ package net.taler.anastasis.ui.backup import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -24,19 +30,28 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atTime +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime import net.taler.anastasis.R import net.taler.anastasis.models.BackupStates -import net.taler.anastasis.models.ReducerArgs +import net.taler.anastasis.models.CoreSecret import net.taler.anastasis.models.ReducerState import net.taler.anastasis.ui.forms.EditSecretForm import net.taler.anastasis.ui.reusable.pages.WizardPage +import net.taler.anastasis.ui.theme.LocalSpacing import net.taler.anastasis.viewmodels.FakeBackupViewModel import net.taler.anastasis.viewmodels.ReducerViewModel import net.taler.anastasis.viewmodels.ReducerViewModelI +import net.taler.common.Amount import net.taler.common.CryptoUtils +import net.taler.common.Timestamp @Composable fun EditSecretScreen( @@ -45,44 +60,106 @@ fun EditSecretScreen( val state by viewModel.reducerState.collectAsState() val reducerState = state as? ReducerState.Backup ?: error("invalid reducer state type") + val coreSecret = reducerState.coreSecret - var secretName by remember { - mutableStateOf(reducerState.secretName ?: "") - } - var secretValue by remember { - mutableStateOf(reducerState.coreSecret?.value?.let { + var secretName by remember { mutableStateOf(reducerState.secretName ?: "") } + var secretValue by remember { mutableStateOf( + coreSecret?.value?.let { CryptoUtils.decodeCrock(it).toString(Charsets.UTF_8) - } ?: "") + } ?: "", + ) } + + val tz = TimeZone.currentSystemDefault() + val secretExpirationDate = remember(reducerState.expiration) { + Instant.fromEpochMilliseconds( + reducerState.expiration?.ms ?: System.currentTimeMillis() + ).toLocalDateTime(tz) } + val uploadFees = reducerState.uploadFees ?: emptyList() WizardPage( title = stringResource(R.string.edit_secret_title), onBackClicked = { viewModel.goHome() }, + enableNext = secretName.isNotEmpty() && coreSecret != null, onPrevClicked = { viewModel.goBack() }, onNextClicked = { - viewModel.reducerManager?.backupSecret( - name = secretName, - args = ReducerArgs.EnterSecret( - secret = ReducerArgs.EnterSecret.Secret( - value = CryptoUtils.encodeCrock(secretValue.toByteArray(Charsets.UTF_8)), - mime = "text/plain", - ), - expiration = null, - ) - ) + viewModel.reducerManager?.next() }, - ) { - EditSecretForm( - modifier = Modifier.fillMaxSize(), - name = secretName, - value = secretValue, - ) { name, value, _ -> - secretName = name - secretValue = value + ) { scroll -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scroll), + ) { + item { + EditSecretForm( + modifier = Modifier.fillMaxSize(), + name = secretName, + value = secretValue, + expirationDate = secretExpirationDate.date, + onSecretNameEdited = { name -> + secretName = name + viewModel.reducerManager?.enterSecretName(name) + }, + onSecretEdited = { value, date -> + secretValue = value + viewModel.reducerManager?.enterSecret( + secret = CoreSecret( + value = CryptoUtils.encodeCrock(value.toByteArray(Charsets.UTF_8)), + mime = "text/plain", + ), + expiration = Timestamp.fromMillis( + date.atTime(secretExpirationDate.time).toInstant(tz) + .toEpochMilliseconds() + ), + ) + }, + ) + } + + item { + if (uploadFees.isNotEmpty()) { + Text( + stringResource(R.string.secret_backup_fees), + modifier = Modifier.padding( + start = LocalSpacing.current.medium, + top = LocalSpacing.current.small, + end = LocalSpacing.current.medium, + bottom = LocalSpacing.current.small, + ), + style = MaterialTheme.typography.labelLarge, + ) + } + } + + items(items = uploadFees) { fee -> + FeeCard( + modifier = Modifier + .padding( + start = LocalSpacing.current.medium, + end = LocalSpacing.current.medium, + bottom = LocalSpacing.current.small, + ).fillMaxSize(), + fee = fee.fee, + ) + } } } } +@Composable +fun FeeCard( + modifier: Modifier = Modifier, + fee: Amount, +) { + ElevatedCard(modifier) { + Text( + fee.toString(), + modifier = Modifier.padding(LocalSpacing.current.medium) + ) + } +} + @Preview @Composable fun EditSecretScreenPreview() { diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditSecretForm.kt b/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditSecretForm.kt index 2c97e92..5ec5269 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditSecretForm.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditSecretForm.kt @@ -23,17 +23,27 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import net.taler.anastasis.R +import net.taler.anastasis.ui.reusable.components.DatePickerField +import net.taler.anastasis.ui.theme.AnastasisTheme import net.taler.anastasis.ui.theme.LocalSpacing -import net.taler.common.Timestamp @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -41,14 +51,16 @@ fun EditSecretForm( modifier: Modifier = Modifier, name: String, value: String, - expiration: Timestamp? = null, + expirationDate: LocalDate, + onSecretNameEdited: (name: String) -> Unit, onSecretEdited: ( - name: String, value: String, - expiration: Timestamp?, + expirationDate: LocalDate, ) -> Unit, ) { val focusRequester2 = remember { FocusRequester() } + val tz = TimeZone.currentSystemDefault() + val currentDate = remember { Clock.System.now().toLocalDateTime(tz).date } Column( modifier = modifier, @@ -59,12 +71,13 @@ fun EditSecretForm( start = LocalSpacing.current.medium, end = LocalSpacing.current.medium, bottom = LocalSpacing.current.small, - ).fillMaxWidth(), + ) + .fillMaxWidth(), value = name, maxLines = 1, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardActions = KeyboardActions(onNext = { focusRequester2.requestFocus() }), - onValueChange = { onSecretEdited(it, value, expiration) }, + onValueChange = { onSecretNameEdited(it) }, label = { Text(stringResource(R.string.secret_name)) }, supportingText = { Text(stringResource(R.string.secret_unique)) }, ) @@ -76,10 +89,52 @@ fun EditSecretForm( start = LocalSpacing.current.medium, end = LocalSpacing.current.medium, bottom = LocalSpacing.current.small, - ).fillMaxWidth(), + ) + .fillMaxWidth(), value = value, - onValueChange = { onSecretEdited(name, it, expiration) }, + onValueChange = { onSecretEdited(it, expirationDate) }, label = { Text(stringResource(R.string.secret_text)) }, ) + + DatePickerField( + modifier = Modifier + .padding( + start = LocalSpacing.current.medium, + end = LocalSpacing.current.medium, + bottom = LocalSpacing.current.small, + + ) + .fillMaxWidth(), + label = stringResource(R.string.secret_expiration), + date = expirationDate, + onDateSelected = { onSecretEdited(value, it) }, + minDate = currentDate, + ) + } +} + +@Preview +@Composable +fun EditSecretFormPreview() { + var name by remember { mutableStateOf("") } + var value by remember { mutableStateOf("") } + var expirationDate by remember { mutableStateOf( + Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + ) } + AnastasisTheme { + Surface { + EditSecretForm( + name = name, + value = value, + expirationDate = expirationDate, + onSecretNameEdited = { n -> + name = n + }, + onSecretEdited = { v, d -> + value = v + expirationDate = d + }, + ) + } } } \ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/DatePickerField.kt b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/DatePickerField.kt index 584ee80..db4bf65 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/DatePickerField.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/DatePickerField.kt @@ -17,6 +17,7 @@ package net.taler.anastasis.ui.reusable.components import android.app.DatePickerDialog +import android.content.Context import android.widget.DatePicker import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -40,6 +41,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn import net.taler.anastasis.shared.Utils import net.taler.anastasis.ui.theme.LocalSpacing @@ -52,20 +55,15 @@ fun DatePickerField( supportingText: (@Composable () -> Unit)? = null, date: LocalDate?, onDateSelected: (date: LocalDate) -> Unit, + minDate: LocalDate? = null, + maxDate: LocalDate? = null, ) { - val now = Utils.currentDate - val dialog = DatePickerDialog( - LocalContext.current, - { _: DatePicker, y: Int, m: Int, d: Int -> - onDateSelected(LocalDate( - year = y, - monthNumber = m + 1, - dayOfMonth = d, - )) - }, - date?.year ?: now.year, - (date?.monthNumber ?: now.monthNumber) - 1, - date?.dayOfMonth ?: now.dayOfMonth, + val dialog = getPickerDialog( + context = LocalContext.current, + initialDate = date, + minDate = minDate, + maxDate = maxDate, + onDateSelected = { onDateSelected(it) }, ) OutlinedTextField( @@ -95,6 +93,37 @@ fun DatePickerField( ) } +private fun getPickerDialog( + context: Context, + initialDate: LocalDate?, + minDate: LocalDate?, + maxDate: LocalDate?, + onDateSelected: (date: LocalDate) -> Unit, +): DatePickerDialog { + val now = Utils.currentDate + val tz = TimeZone.currentSystemDefault() + val dialog = DatePickerDialog( + context, + { _: DatePicker, y: Int, m: Int, d: Int -> + onDateSelected(LocalDate( + year = y, + monthNumber = m + 1, + dayOfMonth = d, + )) + }, + initialDate?.year ?: now.year, + (initialDate?.monthNumber ?: now.monthNumber) - 1, + initialDate?.dayOfMonth ?: now.dayOfMonth, + ) + if (minDate != null) { + dialog.datePicker.minDate = minDate.atStartOfDayIn(tz).toEpochMilliseconds() + } + if (maxDate != null) { + dialog.datePicker.maxDate = maxDate.atStartOfDayIn(tz).toEpochMilliseconds() + } + return dialog +} + @Preview @Composable fun DatePickerFieldPreview() { diff --git a/anastasis/src/main/res/values/strings.xml b/anastasis/src/main/res/values/strings.xml index 0bbf397..f1eba43 100644 --- a/anastasis/src/main/res/values/strings.xml +++ b/anastasis/src/main/res/values/strings.xml @@ -74,6 +74,8 @@ Secret name This should be unique Secret text + Expiration date + Backup fees Your backup is being stored by the following providers: Version %1$d expires at %2$s -- cgit v1.2.3