From c4daf6ba593a57fb25e1f4705b303350bf8a3fa1 Mon Sep 17 00:00:00 2001 From: Iván Ávalos Date: Sun, 30 Jul 2023 01:13:56 -0600 Subject: Policy editing + too many things to list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Iván Ávalos --- anastasis/src/main/AndroidManifest.xml | 2 + .../main/java/net/taler/anastasis/MainActivity.kt | 15 ++ .../src/main/java/net/taler/anastasis/Routes.kt | 1 + .../src/main/java/net/taler/anastasis/Utils.kt | 4 + .../net/taler/anastasis/models/ReducerState.kt | 42 +++- .../net/taler/anastasis/reducers/ReducerManager.kt | 45 +++- .../taler/anastasis/ui/backup/EditMethodDialog.kt | 114 --------- .../anastasis/ui/backup/ReviewPoliciesScreen.kt | 258 +++++++++++++++++++++ .../anastasis/ui/backup/SelectAuthMethodsScreen.kt | 144 +++++------- .../anastasis/ui/common/SelectContinentScreen.kt | 5 +- .../anastasis/ui/common/SelectCountryScreen.kt | 9 +- .../ui/common/SelectUserAttributesScreen.kt | 10 +- .../taler/anastasis/ui/dialogs/EditMethodDialog.kt | 76 ++++++ .../taler/anastasis/ui/dialogs/EditPolicyDialog.kt | 78 +++++++ .../net/taler/anastasis/ui/forms/EditPolicyForm.kt | 117 ++++++++++ .../taler/anastasis/ui/forms/EditQuestionForm.kt | 89 +++++++ .../ui/reusable/components/DropdownTextField.kt | 41 ++-- .../anastasis/ui/reusable/components/Picker.kt | 5 +- .../anastasis/ui/reusable/pages/WizardPage.kt | 17 +- .../taler/anastasis/viewmodels/ReducerViewModel.kt | 30 ++- anastasis/src/main/res/values/strings.xml | 25 +- anastasis/src/main/res/values/themes.xml | 5 +- 22 files changed, 875 insertions(+), 257 deletions(-) delete mode 100644 anastasis/src/main/java/net/taler/anastasis/ui/backup/EditMethodDialog.kt create mode 100644 anastasis/src/main/java/net/taler/anastasis/ui/backup/ReviewPoliciesScreen.kt create mode 100644 anastasis/src/main/java/net/taler/anastasis/ui/dialogs/EditMethodDialog.kt create mode 100644 anastasis/src/main/java/net/taler/anastasis/ui/dialogs/EditPolicyDialog.kt create mode 100644 anastasis/src/main/java/net/taler/anastasis/ui/forms/EditPolicyForm.kt create mode 100644 anastasis/src/main/java/net/taler/anastasis/ui/forms/EditQuestionForm.kt diff --git a/anastasis/src/main/AndroidManifest.xml b/anastasis/src/main/AndroidManifest.xml index ce3333f..ff44d2a 100644 --- a/anastasis/src/main/AndroidManifest.xml +++ b/anastasis/src/main/AndroidManifest.xml @@ -9,6 +9,8 @@ android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:enableOnBackInvokedCallback="true" + android:windowSoftInputMode="adjustResize" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Anastasis" diff --git a/anastasis/src/main/java/net/taler/anastasis/MainActivity.kt b/anastasis/src/main/java/net/taler/anastasis/MainActivity.kt index 18c83cd..8d6a8ad 100644 --- a/anastasis/src/main/java/net/taler/anastasis/MainActivity.kt +++ b/anastasis/src/main/java/net/taler/anastasis/MainActivity.kt @@ -2,7 +2,9 @@ package net.taler.anastasis import android.os.Bundle import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback import androidx.activity.compose.setContent +import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -13,6 +15,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import dagger.hilt.android.AndroidEntryPoint +import net.taler.anastasis.ui.backup.ReviewPoliciesScreen import net.taler.anastasis.ui.backup.SelectAuthMethodsScreen import net.taler.anastasis.ui.common.SelectContinentScreen import net.taler.anastasis.ui.common.SelectCountryScreen @@ -23,8 +26,17 @@ import net.taler.anastasis.viewmodels.ReducerViewModel @AndroidEntryPoint class MainActivity : ComponentActivity() { + val viewModel: ReducerViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (viewModel.goBack()) finish() + } + }) + setContent { AnastasisTheme { Surface( @@ -59,6 +71,9 @@ fun MainNavHost( Routes.SelectAuthMethods.route -> { SelectAuthMethodsScreen() } + Routes.ReviewPoliciesScreen.route -> { + ReviewPoliciesScreen() + } Routes.RestoreInit.route -> { Text("This is the restore session screen!") } diff --git a/anastasis/src/main/java/net/taler/anastasis/Routes.kt b/anastasis/src/main/java/net/taler/anastasis/Routes.kt index 6d00cad..5258e7f 100644 --- a/anastasis/src/main/java/net/taler/anastasis/Routes.kt +++ b/anastasis/src/main/java/net/taler/anastasis/Routes.kt @@ -13,6 +13,7 @@ sealed class Routes( // Backup object SelectAuthMethods: Routes("select_auth_methods") + object ReviewPoliciesScreen: Routes("review_policies") // Restore object RestoreInit: Routes("restore") diff --git a/anastasis/src/main/java/net/taler/anastasis/Utils.kt b/anastasis/src/main/java/net/taler/anastasis/Utils.kt index 8673a05..58e6b89 100644 --- a/anastasis/src/main/java/net/taler/anastasis/Utils.kt +++ b/anastasis/src/main/java/net/taler/anastasis/Utils.kt @@ -26,6 +26,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.toJavaLocalDate import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.json.JSONArray import org.json.JSONObject import java.nio.charset.Charset import java.text.SimpleDateFormat @@ -48,6 +49,9 @@ object Utils { inline fun Json.encodeToNativeJson(value: T): JSONObject = JSONObject(encodeToString(value)) + inline fun Json.encodeToNativeJson(value: Collection): JSONArray = + JSONArray(encodeToString(value)) + fun encodeBase32 (input: String) = input .toByteArray(Charset.defaultCharset()) .encodeToString(Base32.Crockford) diff --git a/anastasis/src/main/java/net/taler/anastasis/models/ReducerState.kt b/anastasis/src/main/java/net/taler/anastasis/models/ReducerState.kt index 83afc88..036d075 100644 --- a/anastasis/src/main/java/net/taler/anastasis/models/ReducerState.kt +++ b/anastasis/src/main/java/net/taler/anastasis/models/ReducerState.kt @@ -1,9 +1,19 @@ package net.taler.anastasis.models +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountBalance +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Mail +import androidx.compose.material.icons.filled.QuestionAnswer +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material.icons.filled.Sms +import androidx.compose.material.icons.filled.Token +import androidx.compose.ui.graphics.vector.ImageVector import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonClassDiscriminator +import net.taler.anastasis.R import net.taler.common.Amount import net.taler.common.Timestamp @@ -159,12 +169,38 @@ data class CoreSecret( @Serializable data class AuthMethod( - val type: String, + val type: Type, val instructions: String, val challenge: String, @SerialName("mime_type") val mimeType: String? = null, -) +) { + @Serializable + enum class Type( + val icon: ImageVector, + val nameRes: Int, + ) { + @SerialName("question") + Question(Icons.Default.QuestionAnswer, R.string.auth_method_question), + + @SerialName("sms") + Sms(Icons.Default.Sms, R.string.auth_method_sms), + + @SerialName("email") + Email(Icons.Default.Email, R.string.auth_method_email), + + @SerialName("iban") + Iban(Icons.Default.AccountBalance, R.string.auth_method_iban), + + @SerialName("mail") + Mail(Icons.Default.Mail, R.string.auth_method_mail), + + @SerialName("totp") + Totp(Icons.Default.Token, R.string.auth_method_totp), + + Unknown(Icons.Default.QuestionMark, R.string.unknown), + } +} @Serializable data class ChallengeInfo( @@ -283,7 +319,7 @@ enum class RecoveryStates { @Serializable data class MethodSpec( - val type: String, + val type: AuthMethod.Type, @SerialName("usage_fee") val usageFee: String, ) 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 0aed98f..fd527f6 100644 --- a/anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt +++ b/anastasis/src/main/java/net/taler/anastasis/reducers/ReducerManager.kt @@ -24,11 +24,14 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import net.taler.anastasis.Utils +import net.taler.anastasis.Utils.encodeToNativeJson import net.taler.anastasis.backend.AnastasisReducerApi import net.taler.anastasis.models.AuthenticationProviderStatus import net.taler.anastasis.models.ContinentInfo 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 org.json.JSONObject @@ -64,6 +67,15 @@ class ReducerManager( } } + fun next() = scope.launch { + state.value?.let { initialState -> + api.reduceAction(initialState, "next") + .onSuccess { newState -> + state.value = newState + } + } + } + fun selectContinent(continent: ContinentInfo) = scope.launch { state.value?.let { initialState -> api.reduceAction(initialState, "select_continent") { @@ -147,10 +159,41 @@ class ReducerManager( fun deleteAuthentication(index: Int) = scope.launch { state.value?.let { initialState -> api.reduceAction(initialState, "delete_authentication") { - put("index", index) + put("authentication_method", index) + }.onSuccess { newState -> + state.value = newState + } + } + } + + fun addPolicy(policy: Policy) = scope.launch { + state.value?.let { initialState -> + api.reduceAction(initialState, "add_policy") { + put("policy", Json.encodeToNativeJson(policy.methods)) + }.onSuccess { newState -> + state.value = newState + }.onError { Log.d("ReducerManager", "$it") } + } + } + + fun updatePolicy(index: Int, policy: Policy) = scope.launch { + state.value?.let { initialState -> + api.reduceAction(initialState, "update_policy") { + put("policy_index", index) + put("policy", Json.encodeToNativeJson(policy.methods)) }.onSuccess { newState -> state.value = newState } } } + + fun deletePolicy(index: Int) = scope.launch { + state.value?.let { initialState -> + api.reduceAction(initialState, "delete_policy") { + put("policy_index", index) + }.onSuccess { newState -> + state.value = newState + } + } + } } \ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/backup/EditMethodDialog.kt b/anastasis/src/main/java/net/taler/anastasis/ui/backup/EditMethodDialog.kt deleted file mode 100644 index 6d30443..0000000 --- a/anastasis/src/main/java/net/taler/anastasis/ui/backup/EditMethodDialog.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * This file is part of GNU Taler - * (C) 2023 Taler Systems S.A. - * - * GNU Taler is free software; you can redistribute it and/or modify it under the - * terms of the GNU General Public License as published by the Free Software - * Foundation; either version 3, or (at your option) any later version. - * - * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - * A PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * GNU Taler; see the file COPYING. If not, see - */ - -package net.taler.anastasis.ui.backup - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -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.res.stringResource -import net.taler.anastasis.R -import net.taler.anastasis.Utils -import net.taler.anastasis.models.AuthMethod - -@Composable -fun EditMethodDialog( - type: String? = null, - method: AuthMethod? = null, - onMethodEdited: (method: AuthMethod) -> Unit, - onCancel: () -> Unit, -) { - var localMethod by remember { mutableStateOf(method?.copy( - challenge = Utils.decodeBase32(method.challenge), - )) } - AlertDialog( - onDismissRequest = onCancel, - title = { Text(stringResource(R.string.add_challenge)) }, - text = { - when(type ?: method?.type) { - "question" -> EditQuestionForm( - method = localMethod, - onMethodEdited = { - localMethod = it - }, - ) - } - }, - dismissButton = { - TextButton(onClick = { - onCancel() - }) { - Text(stringResource(R.string.cancel)) - } - }, - confirmButton = { - TextButton(onClick = { - localMethod?.let { onMethodEdited( - it.copy( - challenge = Utils.encodeBase32(it.challenge) - ) - ) } - }) { - Text(stringResource(R.string.add)) - } - } - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun EditQuestionForm( - method: AuthMethod? = null, - onMethodEdited: (method: AuthMethod) -> Unit, -) { - val localMethod = method ?: AuthMethod( - type = "question", - instructions = "", - challenge = "", - mimeType = "plain/text", - ) - - Column { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = localMethod.instructions, - onValueChange = { - onMethodEdited(localMethod.copy(instructions = it)) - }, - label = { Text(stringResource(R.string.question)) }, - ) - - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = localMethod.challenge, - onValueChange = { - onMethodEdited(localMethod.copy(challenge = it)) - }, - label = { Text(stringResource(R.string.answer)) }, - ) - } - -} \ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/backup/ReviewPoliciesScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/backup/ReviewPoliciesScreen.kt new file mode 100644 index 0000000..38853c7 --- /dev/null +++ b/anastasis/src/main/java/net/taler/anastasis/ui/backup/ReviewPoliciesScreen.kt @@ -0,0 +1,258 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see + */ + +package net.taler.anastasis.ui.backup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.hilt.navigation.compose.hiltViewModel +import net.taler.anastasis.R +import net.taler.anastasis.models.AuthMethod +import net.taler.anastasis.models.AuthenticationProviderStatus +import net.taler.anastasis.models.Policy +import net.taler.anastasis.models.ReducerState +import net.taler.anastasis.ui.dialogs.EditPolicyDialog +import net.taler.anastasis.ui.reusable.pages.WizardPage +import net.taler.anastasis.ui.theme.LocalSpacing +import net.taler.anastasis.viewmodels.ReducerViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReviewPoliciesScreen( + viewModel: ReducerViewModel = hiltViewModel(), +) { + val state by viewModel.reducerState.collectAsState() + val reducerState = state as? ReducerState.Backup + ?: error("invalid reducer type") + + val policies = reducerState.policies ?: emptyList() + // Get only providers with "ok" status + val providers = reducerState.authenticationProviders?.filter { + it.value is AuthenticationProviderStatus.Ok + }?.mapValues { it.value as AuthenticationProviderStatus.Ok } ?: emptyMap() + val methods = reducerState.authenticationMethods ?: emptyList() + + var showEditDialog by remember { mutableStateOf(false) } + var editingPolicy by remember { mutableStateOf(null) } + var editingPolicyIndex by remember { mutableStateOf(null) } + val reset = { + showEditDialog = false + editingPolicy = null + editingPolicyIndex = null + } + + if (showEditDialog) { + EditPolicyDialog( + policy = editingPolicy, + methods = methods, + providers = providers, + onCancel = { reset() }, + onPolicyEdited = { + editingPolicyIndex?.let { index -> + viewModel.reducerManager.updatePolicy(index, it) + } ?: run { + viewModel.reducerManager.addPolicy(it) + } + reset() + } + ) + } + + WizardPage( + title = stringResource(R.string.review_policies_title), + onBackClicked = { viewModel.goHome() }, + onPrevClicked = { viewModel.goBack() }, + onNextClicked = { + viewModel.reducerManager.next() + } + ) { + Scaffold( + floatingActionButton = { + FloatingActionButton( + onClick = { showEditDialog = true }, + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(R.string.add), + ) + } + } + ) { padding -> + LazyColumn( + modifier = Modifier + .padding(padding) + .fillMaxWidth() + ) { + items(count = policies.size) { index -> + val policy = policies[index] + PolicyCard( + modifier = Modifier.padding( + start = LocalSpacing.current.small, + end = LocalSpacing.current.small, + bottom = LocalSpacing.current.small, + ), + policy = policy, + methods = methods, + providers = providers, + index = index, + onEdit = { + editingPolicy = policy + editingPolicyIndex = index + showEditDialog = true + }, + ) { + viewModel.reducerManager.deletePolicy(index) + } + } + } + } + } +} + +@Composable +fun PolicyCard( + modifier: Modifier = Modifier, + methods: List, + providers: Map, + policy: Policy, + index: Int, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + ElevatedCard( + modifier = modifier, + ) { + Column(modifier = Modifier.padding(LocalSpacing.current.medium)) { + var expanded by remember { mutableStateOf(false) } + Row( + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.policy_n, index + 1), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(Modifier.weight(1f)) + Box { + IconButton(onClick = { expanded = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.menu), + tint = MaterialTheme.colorScheme.onBackground, + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem(onClick = { + onEdit() + expanded = false + }) { + Text(stringResource(R.string.edit)) + } + DropdownMenuItem(onClick = onDelete) { + Text(stringResource(R.string.delete)) + } + } + } + } + } + Column { + policy.methods.forEach { m -> + val method = methods[m.authenticationMethod] + val provider = providers[m.provider] as? AuthenticationProviderStatus.Ok + if (provider != null) { + PolicyMethodCard( + modifier = Modifier + .padding(top = LocalSpacing.current.small) + .fillMaxWidth(), + method = method, + provider = provider, + ) + } + } + } + } + } +} + +@Composable +fun PolicyMethodCard( + modifier: Modifier = Modifier, + method: AuthMethod, + provider: AuthenticationProviderStatus.Ok, +) { + OutlinedCard( + modifier = modifier, + ) { + Row( + modifier = Modifier.padding(LocalSpacing.current.medium), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + method.type.icon, + tint = MaterialTheme.colorScheme.onBackground, + contentDescription = stringResource(method.type.nameRes), + ) + Spacer(Modifier.width(LocalSpacing.current.medium)) + Column { + Text(method.instructions, style = MaterialTheme.typography.labelLarge) + Spacer(Modifier.height(LocalSpacing.current.small)) + Text( + stringResource(R.string.provider), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + Text( + provider.businessName, + style = MaterialTheme.typography.labelMedium, + ) + } + } + } +} \ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/backup/SelectAuthMethodsScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/backup/SelectAuthMethodsScreen.kt index 3bdd129..6d8f0ff 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/backup/SelectAuthMethodsScreen.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/backup/SelectAuthMethodsScreen.kt @@ -27,13 +27,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountBalance import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.filled.Mail -import androidx.compose.material.icons.filled.QuestionMark -import androidx.compose.material.icons.filled.Sms -import androidx.compose.material.icons.filled.Token import androidx.compose.material3.Divider import androidx.compose.material3.ElevatedCard import androidx.compose.material3.Icon @@ -48,6 +42,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -57,6 +53,7 @@ import net.taler.anastasis.models.AuthMethod import net.taler.anastasis.models.AuthenticationProviderStatus import net.taler.anastasis.models.ReducerArgs import net.taler.anastasis.models.ReducerState +import net.taler.anastasis.ui.dialogs.EditMethodDialog import net.taler.anastasis.ui.reusable.components.ActionCard import net.taler.anastasis.ui.reusable.pages.WizardPage import net.taler.anastasis.ui.theme.LocalSpacing @@ -66,27 +63,23 @@ import net.taler.anastasis.viewmodels.ReducerViewModel fun SelectAuthMethodsScreen( viewModel: ReducerViewModel = hiltViewModel(), ) { - val reducerState by viewModel.reducerState.collectAsState() + val state by viewModel.reducerState.collectAsState() + val reducerState = state as? ReducerState.Backup + ?: error("invalid reducer state type") - val authProviders = when (val state = reducerState) { - is ReducerState.Backup -> state.authenticationProviders - else -> error("invalid reducer state type") - } ?: emptyMap() + val authProviders = reducerState.authenticationProviders ?: emptyMap() + val selectedMethods = reducerState.authenticationMethods ?: emptyList() - // Get only methods of providers with "ok" status + // Get only known methods of providers with "ok" status val availableMethods = authProviders.flatMap { entry -> if (entry.value is AuthenticationProviderStatus.Ok) { (entry.value as AuthenticationProviderStatus.Ok).methods.map { it.type } + .filter { it != AuthMethod.Type.Unknown } } else emptyList() }.distinct() - val selectedMethods = when (val state = reducerState) { - is ReducerState.Backup -> state.authenticationMethods - else -> error("invalid reducer state type") - } ?: emptyList() - var showEditDialog by remember { mutableStateOf(false) } - var methodType by remember { mutableStateOf(null) } + var methodType by remember { mutableStateOf(null) } var method by remember { mutableStateOf(null) } val reset = { @@ -114,14 +107,14 @@ fun SelectAuthMethodsScreen( WizardPage( title = stringResource(R.string.select_auth_methods_title), - onBackClicked = { - viewModel.goHome() - }, - onPrevClicked = { - viewModel.reducerManager.back() - }, - ) { + onBackClicked = { viewModel.goHome() }, + onPrevClicked = { viewModel.goBack() }, + onNextClicked = { + viewModel.reducerManager.next() + } + ) { scroll -> AuthMethods( + nestedScrollConnection = scroll, availableMethods = availableMethods, selectedMethods = selectedMethods, onAddMethod = { @@ -137,63 +130,30 @@ fun SelectAuthMethodsScreen( @Composable private fun AuthMethods( - availableMethods: List, + nestedScrollConnection: NestedScrollConnection, + availableMethods: List, selectedMethods: List, - onAddMethod: (type: String) -> Unit, + onAddMethod: (type: AuthMethod.Type) -> Unit, onDeleteMethod: (index: Int) -> Unit, ) { LazyColumn( modifier = Modifier - .padding(LocalSpacing.current.medium) + .nestedScroll(nestedScrollConnection) .fillMaxWidth(), verticalArrangement = Arrangement.Top, ) { items(items = availableMethods) { method -> - when (method) { - "question" -> ActionCard( - modifier = Modifier - .padding(bottom = LocalSpacing.current.small) - .fillMaxWidth(), - icon = { Icon(Icons.Default.QuestionMark, contentDescription = null) }, - headline = stringResource(R.string.auth_method_question) - ) { onAddMethod("question") } - "sms" -> ActionCard( - modifier = Modifier - .padding(bottom = LocalSpacing.current.small) - .fillMaxWidth(), - icon = { Icon(Icons.Default.Sms, contentDescription = null) }, - headline = stringResource(R.string.auth_method_sms) - ) { onAddMethod("sms") } - "email" -> ActionCard( - modifier = Modifier - .padding(bottom = LocalSpacing.current.small) - .fillMaxWidth(), - icon = { Icon(Icons.Default.Email, contentDescription = null) }, - headline = stringResource(R.string.auth_method_email) - ) { onAddMethod("email") } - "iban" -> ActionCard( - modifier = Modifier - .padding(bottom = LocalSpacing.current.small) - .fillMaxWidth(), - icon = { Icon(Icons.Default.AccountBalance, contentDescription = null) }, - headline = stringResource(R.string.auth_method_question) - ) { onAddMethod("iban") } - "mail" -> ActionCard( - modifier = Modifier - .padding(bottom = LocalSpacing.current.small) - .fillMaxWidth(), - icon = { Icon(Icons.Default.Mail, contentDescription = null) }, - headline = stringResource(R.string.auth_method_mail) - ) { onAddMethod("mail") } - "totp" -> ActionCard( - modifier = Modifier - .padding(bottom = LocalSpacing.current.small) - .fillMaxWidth(), - icon = { Icon(Icons.Default.Token, contentDescription = null) }, - headline = stringResource(R.string.auth_method_totp) - ) { onAddMethod("totp") } - else -> {} - } + AddMethodCard( + modifier = Modifier + .padding( + start = LocalSpacing.current.medium, + end = LocalSpacing.current.medium, + bottom = LocalSpacing.current.small, + ) + .fillMaxWidth(), + type = method, + onClick = { onAddMethod(method) }, + ) } item { @@ -203,7 +163,11 @@ private fun AuthMethods( items(count = selectedMethods.size) { i -> ChallengeCard( modifier = Modifier - .padding(bottom = LocalSpacing.current.small) + .padding( + start = LocalSpacing.current.medium, + end = LocalSpacing.current.medium, + bottom = LocalSpacing.current.small, + ) .fillMaxWidth(), authMethod = selectedMethods[i], onDelete = { onDeleteMethod(i) }, @@ -212,6 +176,28 @@ private fun AuthMethods( } } +@Composable +private fun AddMethodCard( + modifier: Modifier = Modifier, + type: AuthMethod.Type, + onClick: (type: AuthMethod.Type) -> Unit, +) { + ActionCard( + modifier = modifier, + icon = { Icon(type.icon, contentDescription = null) }, + headline = when (type) { + AuthMethod.Type.Question -> stringResource(R.string.add_auth_method_question) + AuthMethod.Type.Sms -> stringResource(R.string.add_auth_method_sms) + AuthMethod.Type.Email -> stringResource(R.string.add_auth_method_email) + AuthMethod.Type.Iban -> stringResource(R.string.add_auth_method_iban) + AuthMethod.Type.Mail -> stringResource(R.string.add_auth_method_mail) + AuthMethod.Type.Totp -> stringResource(R.string.add_auth_method_totp) + AuthMethod.Type.Unknown -> error("unknown auth method type") + }, + onClick = { onClick(type) }, + ) +} + @Composable private fun ChallengeCard( modifier: Modifier = Modifier, @@ -223,15 +209,7 @@ private fun ChallengeCard( ) { Column(modifier = Modifier.padding(LocalSpacing.current.medium)) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon(when (authMethod.type) { - "question" -> Icons.Default.QuestionMark - "sms" -> Icons.Default.Sms - "email" -> Icons.Default.Email - "iban" -> Icons.Default.AccountBalance - "mail" -> Icons.Default.Mail - "totp" -> Icons.Default.Token - else -> error("unknown auth method") - }, contentDescription = null) + Icon(authMethod.type.icon, contentDescription = null) Spacer(modifier = Modifier.width(12.dp)) Column { Text(authMethod.instructions, style = MaterialTheme.typography.titleMedium) diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectContinentScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectContinentScreen.kt index 3fabd1a..303b82e 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectContinentScreen.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectContinentScreen.kt @@ -54,9 +54,7 @@ fun SelectContinentScreen( title = stringResource(R.string.select_continent_title), showPrev = false, enableNext = selectedContinent != null, - onBackClicked = { - viewModel.goHome() - }, + onBackClicked = { viewModel.goHome() }, onNextClicked = { selectedContinent?.let { viewModel.reducerManager.selectContinent(it) @@ -71,6 +69,7 @@ fun SelectContinentScreen( ) { Picker( label = stringResource(R.string.continent), + initialOption = selectedContinent?.name, options = continents.map { it.name }.toSet(), onOptionChanged = { option -> continents.find { it.name == option }?.let { continent -> diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectCountryScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectCountryScreen.kt index 639519f..4f5d753 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectCountryScreen.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectCountryScreen.kt @@ -53,12 +53,8 @@ fun SelectCountryScreen( WizardPage( title = stringResource(R.string.select_country_title), enableNext = selectedCountry != null, - onBackClicked = { - viewModel.goHome() - }, - onPrevClicked = { - viewModel.reducerManager.back() - }, + onBackClicked = { viewModel.goHome() }, + onPrevClicked = { viewModel.goBack() }, onNextClicked = { selectedCountry?.let { viewModel.reducerManager.selectCountry(it) @@ -73,6 +69,7 @@ fun SelectCountryScreen( ) { Picker( label = stringResource(R.string.country), + initialOption = selectedCountry?.name, options = countries.map { it.name }.toSet(), onOptionChanged = { option -> countries.find { it.name == option }?.let { country -> diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectUserAttributesScreen.kt b/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectUserAttributesScreen.kt index 51d971f..6de45b5 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectUserAttributesScreen.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/common/SelectUserAttributesScreen.kt @@ -35,6 +35,7 @@ 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.hilt.navigation.compose.hiltViewModel import net.taler.anastasis.R @@ -61,12 +62,8 @@ fun SelectUserAttributesScreen( WizardPage( title = stringResource(R.string.select_user_attributes_title), - onBackClicked = { - viewModel.goHome() - }, - onPrevClicked = { - viewModel.reducerManager.back() - }, + onBackClicked = { viewModel.goHome() }, + onPrevClicked = { viewModel.goBack() }, onNextClicked = { viewModel.reducerManager.enterUserAttributes(values) }, @@ -74,6 +71,7 @@ fun SelectUserAttributesScreen( LazyColumn( modifier = Modifier .fillMaxSize() + .nestedScroll(it) .padding(LocalSpacing.current.medium), verticalArrangement = Arrangement.Top, ) { diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/dialogs/EditMethodDialog.kt b/anastasis/src/main/java/net/taler/anastasis/ui/dialogs/EditMethodDialog.kt new file mode 100644 index 0000000..f8b580e --- /dev/null +++ b/anastasis/src/main/java/net/taler/anastasis/ui/dialogs/EditMethodDialog.kt @@ -0,0 +1,76 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see + */ + +package net.taler.anastasis.ui.dialogs + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.res.stringResource +import net.taler.anastasis.R +import net.taler.anastasis.Utils +import net.taler.anastasis.models.AuthMethod +import net.taler.anastasis.ui.forms.EditQuestionForm + +@Composable +fun EditMethodDialog( + type: AuthMethod.Type? = null, + method: AuthMethod? = null, + onMethodEdited: (method: AuthMethod) -> Unit, + onCancel: () -> Unit, +) { + var localMethod by remember { mutableStateOf(method?.copy( + challenge = Utils.decodeBase32(method.challenge), + )) } + AlertDialog( + onDismissRequest = onCancel, + title = { Text(stringResource(R.string.add_challenge)) }, + text = { + when(type ?: method?.type) { + AuthMethod.Type.Question -> EditQuestionForm( + method = localMethod, + onMethodEdited = { + localMethod = it + }, + ) + else -> {} + } + }, + dismissButton = { + TextButton(onClick = { + onCancel() + }) { + Text(stringResource(R.string.cancel)) + } + }, + confirmButton = { + TextButton(onClick = { + localMethod?.let { onMethodEdited( + it.copy( + challenge = Utils.encodeBase32(it.challenge) + ) + ) } + }) { + Text(stringResource(R.string.add)) + } + } + ) +} diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/dialogs/EditPolicyDialog.kt b/anastasis/src/main/java/net/taler/anastasis/ui/dialogs/EditPolicyDialog.kt new file mode 100644 index 0000000..e9f0e2b --- /dev/null +++ b/anastasis/src/main/java/net/taler/anastasis/ui/dialogs/EditPolicyDialog.kt @@ -0,0 +1,78 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see + */ + +package net.taler.anastasis.ui.dialogs + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.res.stringResource +import net.taler.anastasis.R +import net.taler.anastasis.models.AuthMethod +import net.taler.anastasis.models.AuthenticationProviderStatus +import net.taler.anastasis.models.Policy +import net.taler.anastasis.ui.forms.EditPolicyForm + +@Composable +fun EditPolicyDialog( + policy: Policy? = null, + methods: List, + providers: Map, + onPolicyEdited: (policy: Policy) -> Unit, + onCancel: () -> Unit, +) { + var localPolicy by remember { mutableStateOf(policy) } + + AlertDialog( + onDismissRequest = onCancel, + title = { Text(stringResource(if (policy != null) + R.string.edit_policy else R.string.add_policy)) }, + text = { + EditPolicyForm( + modifier = Modifier.fillMaxWidth(), + policy = localPolicy, + methods = methods, + providers = providers, + onPolicyEdited = { + localPolicy = it + } + ) + }, + dismissButton = { + TextButton(onClick = { + onCancel() + }) { + Text(stringResource(R.string.cancel)) + } + }, + confirmButton = { + TextButton(onClick = { + localPolicy?.let { onPolicyEdited(it) } + }) { + Text(stringResource(if (policy != null) + R.string.edit else R.string.add)) + } + } + ) + +} diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditPolicyForm.kt b/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditPolicyForm.kt new file mode 100644 index 0000000..4cc3661 --- /dev/null +++ b/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditPolicyForm.kt @@ -0,0 +1,117 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see + */ + +package net.taler.anastasis.ui.forms + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import net.taler.anastasis.models.AuthMethod +import net.taler.anastasis.models.AuthenticationProviderStatus +import net.taler.anastasis.models.Policy +import net.taler.anastasis.ui.reusable.components.DropdownTextField +import net.taler.anastasis.ui.theme.LocalSpacing + +@Composable +fun EditPolicyForm( + modifier: Modifier = Modifier, + policy: Policy?, + methods: List, + providers: Map, + onPolicyEdited: (policy: Policy) -> Unit, +) { + val localPolicy = policy ?: Policy(methods = listOf()) + val localMethods = localPolicy.methods.associateBy { it.authenticationMethod } + val submitLocalMethods = { it: Map -> + onPolicyEdited( + localPolicy.copy( + methods = it.flatMap { entry -> + listOf(entry.value) + } + ) + ) + } + + LazyColumn( + modifier = modifier, + ) { + items(count = methods.size) { index -> + val method = methods[index] + // Get only the providers that support this method type + val methodProviders = providers.filterValues { provider -> + method.type in provider.methods.map { it.type } + }.keys.toList() + val selectedProvider = localMethods[index]?.provider + val checked = selectedProvider != null + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + enabled = checked, + checked = checked, + onCheckedChange = { + if (it) selectedProvider?.let { prov -> + submitLocalMethods( + localMethods.toMutableMap().apply { + this[index] = Policy.PolicyMethod( + authenticationMethod = index, + provider = prov, + ) + } + ) + } else { + submitLocalMethods( + localMethods.toMutableMap().apply { + remove(index) + } + ) + } + }, + ) + DropdownTextField( + modifier = Modifier.padding(bottom = LocalSpacing.current.small), + label = method.instructions, + leadingIcon = { + Icon( + method.type.icon, + contentDescription = stringResource(method.type.nameRes), + ) + }, + selectedIndex = selectedProvider?.let{ methodProviders.indexOf(it) }, + options = methodProviders, + onOptionSelected = { + submitLocalMethods( + localMethods.toMutableMap().apply { + this[index] = Policy.PolicyMethod( + authenticationMethod = index, + provider = methodProviders[it], + ) + } + ) + }, + ) + } + } + } +} \ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditQuestionForm.kt b/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditQuestionForm.kt new file mode 100644 index 0000000..38017b9 --- /dev/null +++ b/anastasis/src/main/java/net/taler/anastasis/ui/forms/EditQuestionForm.kt @@ -0,0 +1,89 @@ +/* + * This file is part of GNU Taler + * (C) 2023 Taler Systems S.A. + * + * GNU Taler is free software; you can redistribute it and/or modify it under the + * terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3, or (at your option) any later version. + * + * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + * A PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * GNU Taler; see the file COPYING. If not, see + */ + +package net.taler.anastasis.ui.forms + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import net.taler.anastasis.R +import net.taler.anastasis.models.AuthMethod + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditQuestionForm( + method: AuthMethod? = null, + onMethodEdited: (method: AuthMethod) -> Unit, +) { + val localMethod = method ?: AuthMethod( + type = AuthMethod.Type.Question, + instructions = "", + challenge = "", + mimeType = "plain/text", + ) + + val focusRequester1 = remember { FocusRequester() } + val focusRequester2 = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + + Column { + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester1) + .fillMaxWidth(), + value = localMethod.instructions, + maxLines = 1, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusRequester2.requestFocus() }), + onValueChange = { + onMethodEdited(localMethod.copy(instructions = it)) + }, + label = { Text(stringResource(R.string.question)) }, + ) + + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester2) + .fillMaxWidth(), + value = localMethod.challenge, + maxLines = 1, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChange = { + onMethodEdited(localMethod.copy(challenge = it)) + }, + label = { Text(stringResource(R.string.answer)) }, + ) + } + + LaunchedEffect(Unit) { + focusRequester1.requestFocus() + } + +} \ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/DropdownTextField.kt b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/DropdownTextField.kt index 0fe727a..cbed1e9 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/DropdownTextField.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/DropdownTextField.kt @@ -34,45 +34,38 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Size import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.toSize -import androidx.compose.ui.window.PopupProperties @OptIn(ExperimentalMaterial3Api::class) @Composable fun DropdownTextField( modifier: Modifier = Modifier, label: String, - options: Set, - onOptionChanged: (String) -> Unit, + leadingIcon: (@Composable () -> Unit)? = null, + selectedIndex: Int? = null, + options: List, + onOptionSelected: (index: Int) -> Unit, ) { - var filteredOptions by remember { mutableStateOf(options.toList()) } - var inputValue by remember { mutableStateOf(options.first()) } var expanded by remember { mutableStateOf(false) } var size by remember { mutableStateOf(Size.Zero) } - val focusRequester = remember { FocusRequester() } Box( modifier = Modifier - .wrapContentSize(Alignment.Center) - .focusRequester(focusRequester), + .wrapContentSize(Alignment.Center), ) { OutlinedTextField( modifier = modifier .onGloballyPositioned { coordinates -> size = coordinates.size.toSize() }, - value = inputValue, - onValueChange = { value -> - inputValue = value - expanded = true - filteredOptions = options.filter { it.contains(value) } - }, + readOnly = true, + leadingIcon = leadingIcon, + value = if (selectedIndex != null) options[selectedIndex] else "", + onValueChange = {}, singleLine = true, label = { Text(label) }, trailingIcon = { @@ -85,29 +78,23 @@ fun DropdownTextField( colors = ExposedDropdownMenuDefaults.textFieldColors() ) - if (filteredOptions.isNotEmpty()) { + if (options.isNotEmpty()) { DropdownMenu( expanded = expanded, onDismissRequest = { expanded = false }, - /* - * TODO: we should NOT disable focus, but this will be necessary - * until Google fixes ExposedDropdownMenuBox focus crash. - */ - properties = PopupProperties(focusable = false), modifier = Modifier .width(with(LocalDensity.current) { size.width.toDp() }), ) { - filteredOptions.forEach { s -> + options.forEachIndexed { i, s -> DropdownMenuItem( text = { Text(text = s) }, onClick = { - inputValue = s expanded = false - onOptionChanged(s) + onOptionSelected(i) } ) } @@ -122,8 +109,8 @@ fun DropdownTextFieldComposable() { Surface { DropdownTextField( label = "Continent", - options = setOf("Europe", "India", "Asia", "North America"), - onOptionChanged = {}, + options = listOf("Europe", "India", "Asia", "North America"), + onOptionSelected = {}, ) } } \ No newline at end of file diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/Picker.kt b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/Picker.kt index cd8e798..3285f08 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/Picker.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/components/Picker.kt @@ -45,11 +45,13 @@ import net.taler.anastasis.ui.theme.LocalSpacing fun Picker( modifier: Modifier = Modifier, label: String, + leadingIcon: (@Composable () -> Unit)? = null, + initialOption: String? = null, options: Set, onOptionChanged: (String) -> Unit, ) { var filteredOptions by remember { mutableStateOf(options.toList()) } - var inputValue by remember { mutableStateOf("") } + var inputValue by remember { mutableStateOf(initialOption ?: "") } val keyboardController = LocalSoftwareKeyboardController.current Column( @@ -58,6 +60,7 @@ fun Picker( ) { OutlinedTextField( modifier = Modifier.fillMaxWidth(), + leadingIcon = leadingIcon, value = inputValue, onValueChange = { value -> inputValue = value diff --git a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/pages/WizardPage.kt b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/pages/WizardPage.kt index e6a24d6..f0b4556 100644 --- a/anastasis/src/main/java/net/taler/anastasis/ui/reusable/pages/WizardPage.kt +++ b/anastasis/src/main/java/net/taler/anastasis/ui/reusable/pages/WizardPage.kt @@ -3,6 +3,7 @@ package net.taler.anastasis.ui.reusable.pages import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -23,15 +24,18 @@ import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.taler.anastasis.R -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun WizardPage( modifier: Modifier = Modifier, @@ -43,9 +47,13 @@ fun WizardPage( onBackClicked: () -> Unit = {}, onNextClicked: () -> Unit = {}, onPrevClicked: () -> Unit = {}, - content: @Composable () -> Unit, + content: @Composable (nestedScrollConnection: NestedScrollConnection) -> Unit, ) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + Scaffold( + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { LargeTopAppBar( title = { Text(title) }, @@ -53,7 +61,8 @@ fun WizardPage( IconButton(onClick = onBackClicked) { Icon(Icons.Default.ArrowBack, stringResource(R.string.back)) } - } + }, + scrollBehavior = scrollBehavior, ) }, ) { @@ -63,7 +72,7 @@ fun WizardPage( Box(modifier = Modifier .weight(1f) .fillMaxWidth()) { - content() + content(scrollBehavior.nestedScrollConnection) } Divider() Row( diff --git a/anastasis/src/main/java/net/taler/anastasis/viewmodels/ReducerViewModel.kt b/anastasis/src/main/java/net/taler/anastasis/viewmodels/ReducerViewModel.kt index 050e0f5..d157707 100644 --- a/anastasis/src/main/java/net/taler/anastasis/viewmodels/ReducerViewModel.kt +++ b/anastasis/src/main/java/net/taler/anastasis/viewmodels/ReducerViewModel.kt @@ -56,7 +56,7 @@ class ReducerViewModel @Inject constructor(): ViewModel() { reducerManager.startSyncingProviders() Routes.SelectAuthMethods.route } - BackupStates.PoliciesReviewing -> TODO() + BackupStates.PoliciesReviewing -> Routes.ReviewPoliciesScreen.route BackupStates.SecretEditing -> TODO() BackupStates.TruthsPaying -> TODO() BackupStates.PoliciesPaying -> TODO() @@ -79,6 +79,34 @@ class ReducerViewModel @Inject constructor(): ViewModel() { } } + fun goBack(): Boolean = when (val state = reducerState.value) { + is ReducerState.Backup -> when (state.backupState) { + BackupStates.ContinentSelecting -> { + goHome() + false + } + else -> { + reducerManager.back() + false + } + } + is ReducerState.Recovery -> when(state.recoveryState) { + RecoveryStates.ContinentSelecting -> { + goHome() + false + } + else -> { + reducerManager.back() + false + } + } + is ReducerState.Error -> { + reducerManager.back() + false + } + else -> true + } + fun goHome() { _reducerState.value = null } diff --git a/anastasis/src/main/res/values/strings.xml b/anastasis/src/main/res/values/strings.xml index 627fc50..51bcd53 100644 --- a/anastasis/src/main/res/values/strings.xml +++ b/anastasis/src/main/res/values/strings.xml @@ -20,6 +20,9 @@ Continent Security question Answer + Policy %1$d + Provider + Unknown Where do you live? @@ -28,13 +31,21 @@ Authentication methods - Add a question challenge - Add a SMS challenge - Add an e-mail challenge - Add an IBAN provider - Add a physical mail provider - Add a TOTP challenge + Recovery policies + Add a question challenge + Add a SMS challenge + Add an e-mail challenge + Add an IBAN provider + Add a physical mail provider + Add a TOTP challenge + Question + SMS + E-mail + IBAN + Mail + TOTP Manage backup providers Add challenge - Edit challenge + Add policy + Edit policy \ No newline at end of file diff --git a/anastasis/src/main/res/values/themes.xml b/anastasis/src/main/res/values/themes.xml index a5484d9..c413fd9 100644 --- a/anastasis/src/main/res/values/themes.xml +++ b/anastasis/src/main/res/values/themes.xml @@ -1,5 +1,8 @@ - \ No newline at end of file -- cgit v1.2.3