From 7a3418ae1d1e07213556a0b36f96b8140253c8ac Mon Sep 17 00:00:00 2001 From: Jonas Lochmann Date: Mon, 1 May 2023 02:00:00 +0200 Subject: [PATCH] Add new parent setup UI --- .../sync/network/StatusOfMailAddress.kt | 5 +- .../io/timelimit/android/ui/MainActivity.kt | 7 + .../timelimit/android/ui/ScreenMultiplexer.kt | 12 + .../android/ui/model/ActivityCommand.kt | 1 + .../timelimit/android/ui/model/MainModel.kt | 7 +- .../io/timelimit/android/ui/model/Screen.kt | 33 ++ .../io/timelimit/android/ui/model/State.kt | 43 +- .../mailauthentication/MailAuthentication.kt | 4 + .../android/ui/model/setup/SetupHandling.kt | 3 + .../ui/model/setup/SetupParentHandling.kt | 411 ++++++++++++++++++ .../model/setup/SetupSelectConnectedMode.kt | 2 +- .../setup/parent/ConfirmNewParentAccount.kt | 63 +++ .../setup/parent/ParentBaseConfiguration.kt | 105 +++++ .../ui/setup/parent/ParentSetupConsent.kt | 123 ++++++ .../setup/parent/SetupParentModeFragment.kt | 221 ---------- .../ui/setup/parent/SetupParentModeModel.kt | 225 ---------- .../ui/setup/parent/SignInWrongMailAddress.kt | 51 +++ .../ui/setup/parent/SignupBlockedScreen.kt | 42 ++ .../android/ui/view/NotifyPermissionCard.kt | 64 ++- .../timelimit/android/ui/view/SetPassword.kt | 100 +++++ .../io/timelimit/android/ui/view/SwitchRow.kt | 17 +- .../res/layout/fragment_setup_parent_mode.xml | 173 -------- app/src/main/res/values-de/strings.xml | 13 + app/src/main/res/values/strings.xml | 13 + 24 files changed, 1108 insertions(+), 630 deletions(-) create mode 100644 app/src/main/java/io/timelimit/android/ui/model/setup/SetupParentHandling.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/setup/parent/ConfirmNewParentAccount.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/setup/parent/ParentBaseConfiguration.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/setup/parent/ParentSetupConsent.kt delete mode 100644 app/src/main/java/io/timelimit/android/ui/setup/parent/SetupParentModeFragment.kt delete mode 100644 app/src/main/java/io/timelimit/android/ui/setup/parent/SetupParentModeModel.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/setup/parent/SignInWrongMailAddress.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/setup/parent/SignupBlockedScreen.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/view/SetPassword.kt delete mode 100644 app/src/main/res/layout/fragment_setup_parent_mode.xml diff --git a/app/src/main/java/io/timelimit/android/sync/network/StatusOfMailAddress.kt b/app/src/main/java/io/timelimit/android/sync/network/StatusOfMailAddress.kt index 2291ba5..2383344 100644 --- a/app/src/main/java/io/timelimit/android/sync/network/StatusOfMailAddress.kt +++ b/app/src/main/java/io/timelimit/android/sync/network/StatusOfMailAddress.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -16,13 +16,14 @@ package io.timelimit.android.sync.network import android.util.JsonReader +import java.io.Serializable data class StatusOfMailAddressResponse( val mail: String, val status: StatusOfMailAddress, val canCreateFamily: Boolean, val alwaysPro: Boolean -) { +): Serializable { companion object { fun parse(reader: JsonReader): StatusOfMailAddressResponse { var mail: String? = null diff --git a/app/src/main/java/io/timelimit/android/ui/MainActivity.kt b/app/src/main/java/io/timelimit/android/ui/MainActivity.kt index 60fe275..6e5fd92 100644 --- a/app/src/main/java/io/timelimit/android/ui/MainActivity.kt +++ b/app/src/main/java/io/timelimit/android/ui/MainActivity.kt @@ -15,6 +15,7 @@ */ package io.timelimit.android.ui +import android.Manifest import android.app.NotificationManager import android.content.Context import android.content.Intent @@ -26,6 +27,7 @@ import android.provider.Settings import android.util.Log import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedContent @@ -123,6 +125,10 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder, U2fManager.De override var ignoreStop: Boolean = false override val showPasswordRecovery: Boolean = true + private val requestNotifyPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) mainModel.reportPermissionsChanged() + } + @OptIn(ExperimentalAnimationApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -162,6 +168,7 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder, U2fManager.De } catch (ex: Exception) { message.errorHandler() } + ActivityCommand.RequestNotifyPermission -> requestNotifyPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } } } diff --git a/app/src/main/java/io/timelimit/android/ui/ScreenMultiplexer.kt b/app/src/main/java/io/timelimit/android/ui/ScreenMultiplexer.kt index 53430c1..301338e 100644 --- a/app/src/main/java/io/timelimit/android/ui/ScreenMultiplexer.kt +++ b/app/src/main/java/io/timelimit/android/ui/ScreenMultiplexer.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.fragment.app.FragmentManager import io.timelimit.android.ui.account.DeleteRegistrationScreen +import io.timelimit.android.ui.authentication.AuthenticateByMailScreen import io.timelimit.android.ui.diagnose.deviceowner.DeviceOwnerScreen import io.timelimit.android.ui.manage.category.blocked_times.BlockedTimesScreen import io.timelimit.android.ui.manage.child.usagehistory.UsageHistoryScreen @@ -28,6 +29,11 @@ import io.timelimit.android.ui.model.Screen import io.timelimit.android.ui.overview.overview.OverviewScreen import io.timelimit.android.ui.setup.selectmode.SelectConnectedModeScreen import io.timelimit.android.ui.setup.SetupDevicePermissionsScreen +import io.timelimit.android.ui.setup.parent.ConfirmNewParentAccount +import io.timelimit.android.ui.setup.parent.ParentBaseConfiguration +import io.timelimit.android.ui.setup.parent.ParentSetupConsent +import io.timelimit.android.ui.setup.parent.SignInWrongMailAddress +import io.timelimit.android.ui.setup.parent.SignupBlockedScreen import io.timelimit.android.ui.setup.privacy.SetupConnectedModePrivacyScreen import io.timelimit.android.ui.setup.selectmode.SelectModeScreen @@ -52,5 +58,11 @@ fun ScreenMultiplexer( is Screen.DeleteRegistration -> DeleteRegistrationScreen(screen.content, modifier) is Screen.ManageBlockedTimes -> BlockedTimesScreen(screen.content, screen.intro, modifier) is Screen.ChildUsageHistory -> UsageHistoryScreen(screen.content, modifier) + is Screen.SetupParentMailAuthentication -> AuthenticateByMailScreen(screen.content, modifier) + is Screen.SignupBlocked -> SignupBlockedScreen(modifier) + is Screen.SignInWrongMailAddress -> SignInWrongMailAddress(modifier) + is Screen.ConfirmNewParentAccount -> ConfirmNewParentAccount(confirm = screen.confirm, reject = screen.reject, modifier = modifier) + is Screen.ParentBaseConfiguration -> ParentBaseConfiguration(content = screen.content, modifier = modifier) + is Screen.ParentSetupConsent -> ParentSetupConsent(content = screen.content, errorDialog = screen.errorDialog, modifier = modifier) } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/ActivityCommand.kt b/app/src/main/java/io/timelimit/android/ui/model/ActivityCommand.kt index a8481e4..2c0d254 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/ActivityCommand.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/ActivityCommand.kt @@ -24,4 +24,5 @@ sealed class ActivityCommand { object ShowMissingPremiumDialog: ActivityCommand() class LaunchSystemSettings(val permission: SystemPermission): ActivityCommand() class TriggerUninstall(val packageName: String, val errorHandler: () -> Unit): ActivityCommand() + object RequestNotifyPermission: ActivityCommand() } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/MainModel.kt b/app/src/main/java/io/timelimit/android/ui/model/MainModel.kt index 36d7336..9b2c7c8 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/MainModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/MainModel.kt @@ -63,6 +63,7 @@ class MainModel(application: Application): AndroidViewModel(application) { private val activityCommandInternal = Channel() private val authenticationScreenClosed = MutableSharedFlow(extraBufferCapacity = 1) + private val permissionsChanged = MutableSharedFlow(extraBufferCapacity = 1) private val authenticationModelApi = object: AuthenticationModelApi { override val authenticatedParentOnly: Flow = @@ -114,7 +115,7 @@ class MainModel(application: Application): AndroidViewModel(application) { Case.simple<_, _, State.ManageChild> { state -> ManageChildHandling.processState(logic, activityCommandInternal, authenticationModelApi, state, updateMethod(::updateState)) }, Case.simple<_, _, State.ManageDevice> { state -> ManageDeviceHandling.processState(logic, activityCommandInternal, authenticationModelApi, state, updateMethod(::updateState)) }, Case.simple<_, _, State.DiagnoseScreen.DeviceOwner> { DeviceOwnerHandling.processState(logic, scope, authenticationModelApi, state) }, - Case.simple<_, _, State.Setup> { state -> SetupHandling.handle(logic, activityCommandInternal, state, updateMethod(::updateState)) }, + Case.simple<_, _, State.Setup> { state -> SetupHandling.handle(logic, activityCommandInternal, permissionsChanged, state, updateMethod(::updateState)) }, Case.simple<_, _, State.DeleteAccount> { AccountDeletion.handle(logic, scope, share(it), updateMethod(::updateState)) }, Case.simple<_, _, FragmentState> { state -> state.transform { @@ -141,5 +142,9 @@ class MainModel(application: Application): AndroidViewModel(application) { authenticationScreenClosed.tryEmit(Unit) } + fun reportPermissionsChanged() { + permissionsChanged.tryEmit(Unit) + } + private fun updateState(method: (State) -> State): Unit = state.update(method) } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/Screen.kt b/app/src/main/java/io/timelimit/android/ui/model/Screen.kt index 1225e93..6046f11 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/Screen.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/Screen.kt @@ -24,10 +24,12 @@ import io.timelimit.android.ui.manage.device.manage.permission.PermissionScreenC import io.timelimit.android.ui.model.account.AccountDeletion import io.timelimit.android.ui.model.diagnose.DeviceOwnerHandling import io.timelimit.android.ui.model.intro.IntroHandling +import io.timelimit.android.ui.model.mailauthentication.MailAuthentication import io.timelimit.android.ui.model.main.OverviewHandling import io.timelimit.android.ui.model.managechild.ManageCategoryBlockedTimes import io.timelimit.android.ui.model.managechild.ManageChildUsageHistory import io.timelimit.android.ui.model.managedevice.ManageDeviceUser +import io.timelimit.android.ui.model.setup.SetupParentHandling sealed class Screen( val state: State, @@ -270,6 +272,37 @@ sealed class Screen( ): Screen(state), ScreenWithSnackbar, ScreenWithTitle { override val title: Title = Title.StringResource(R.string.account_deletion_title) } + + class SetupParentMailAuthentication( + state: State.Setup.ParentMailAuthentication, + val content: MailAuthentication.Screen, + override val snackbarHostState: SnackbarHostState + ): Screen(state), ScreenWithSnackbar + + class SignupBlocked(state: State.Setup.SignUpBlocked): Screen(state) + + class SignInWrongMailAddress(state: State.Setup.SignInWrongMailAddress): Screen(state) + class ConfirmNewParentAccount( + state: State.Setup.ConfirmNewParentAccount, + val reject: () -> Unit, + val confirm: () -> Unit + ): Screen(state) + + class ParentBaseConfiguration( + state: State.Setup.ParentBaseConfiguration, + val content: SetupParentHandling.ParentBaseConfiguration + ): Screen(state), ScreenWithTitle { + override val title: Title get() = + if (content.newUserDetails != null) Title.StringResource(R.string.setup_parent_mode_create_family) + else Title.StringResource(R.string.setup_parent_mode_add_device) + } + + class ParentSetupConsent( + state: State.Setup.ParentConsent, + val content: SetupParentHandling.ParentSetupConsent, + override val snackbarHostState: SnackbarHostState, + val errorDialog: Pair Unit>? + ): Screen(state), ScreenWithSnackbar } interface ScreenWithAuthenticationFab diff --git a/app/src/main/java/io/timelimit/android/ui/model/State.kt b/app/src/main/java/io/timelimit/android/ui/model/State.kt index b7136d1..45fe879 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/State.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/State.kt @@ -22,6 +22,8 @@ import androidx.compose.material.icons.filled.Phone import androidx.fragment.app.Fragment import io.timelimit.android.R import io.timelimit.android.integration.platform.SystemPermission +import io.timelimit.android.sync.network.StatusOfMailAddress +import io.timelimit.android.sync.network.StatusOfMailAddressResponse import io.timelimit.android.ui.contacts.ContactsFragment import io.timelimit.android.ui.diagnose.* import io.timelimit.android.ui.diagnose.exitreason.DiagnoseExitReasonFragment @@ -48,9 +50,11 @@ import io.timelimit.android.ui.manage.parent.u2fkey.ManageParentU2FKeyFragment import io.timelimit.android.ui.manage.parent.u2fkey.ManageParentU2FKeyFragmentArgs import io.timelimit.android.ui.model.account.AccountDeletion import io.timelimit.android.ui.model.diagnose.DeviceOwnerHandling +import io.timelimit.android.ui.model.mailauthentication.MailAuthentication import io.timelimit.android.ui.model.main.OverviewHandling import io.timelimit.android.ui.model.managechild.ManageCategoryBlockedTimes import io.timelimit.android.ui.model.managechild.ManageChildUsageHistory +import io.timelimit.android.ui.model.setup.SetupParentHandling import io.timelimit.android.ui.overview.uninstall.UninstallFragment import io.timelimit.android.ui.parentmode.ParentModeFragment import io.timelimit.android.ui.payment.PurchaseFragment @@ -58,8 +62,8 @@ import io.timelimit.android.ui.payment.StayAwesomeFragment import io.timelimit.android.ui.setup.* import io.timelimit.android.ui.setup.child.SetupRemoteChildFragment import io.timelimit.android.ui.setup.device.SetupDeviceFragment -import io.timelimit.android.ui.setup.parent.SetupParentModeFragment import io.timelimit.android.ui.user.create.AddUserFragment +import io.timelimit.android.ui.view.NotifyPermissionCard import java.io.Serializable sealed class State (val previous: State?): Serializable { @@ -270,7 +274,42 @@ sealed class State (val previous: State?): Serializable { class ConnectedPrivacy(previousSelectMode: SelectMode): Setup(previousSelectMode) class SelectConnectedMode(previousConnectedPrivacy: ConnectedPrivacy): Setup(previousConnectedPrivacy) class RemoteChild(previous: SelectConnectedMode): FragmentStateLegacy(previous = previous, fragmentClass = SetupRemoteChildFragment::class.java) - class ParentMode(previous: SelectConnectedMode): FragmentStateLegacy(previous = previous, fragmentClass = SetupParentModeFragment::class.java) + sealed class ParentModeSetup(previous: State): Setup(previous) + data class ParentMailAuthentication( + val previousSelectConnectedMode: SelectConnectedMode, + val content: MailAuthentication.State = MailAuthentication.State.initial + ): ParentModeSetup(previous = previousSelectConnectedMode) + class SignUpBlocked(previous: ParentMailAuthentication): ParentModeSetup(previous) + class SignInWrongMailAddress(previous: ConfirmNewParentAccount): ParentModeSetup(previous) + data class ConfirmNewParentAccount( + val previousParentMailAuthentication: ParentMailAuthentication, + val mailAuthToken: String, + val mailStatus: StatusOfMailAddressResponse + ): ParentModeSetup(previous = previousParentMailAuthentication) + data class ParentBaseConfiguration( + val previousState: State, + val previousParentMailAuthentication: ParentMailAuthentication, + val mailAuthToken: String, + val mailStatus: StatusOfMailAddressResponse, + val deviceName: String, + val newUser: SetupParentHandling.NewUserDetails? + ): ParentModeSetup(previousState) { + init { + if ((newUser != null) != (mailStatus.status == StatusOfMailAddress.MailAddressWithoutFamily)) + throw IllegalStateException() + } + } + data class ParentConsent( + val baseConfig: ParentBaseConfiguration, + val backgroundSync: Boolean, + val notificationAccess: NotifyPermissionCard.Status, + val enableUpdates: Boolean, + val error: String? + ): ParentModeSetup(baseConfig) { + init { + if (baseConfig.newUser != null && !baseConfig.newUser.ready) throw IllegalStateException() + } + } } class ParentMode: FragmentStateLegacy(previous = null, fragmentClass = ParentModeFragment::class.java) object Purchase { diff --git a/app/src/main/java/io/timelimit/android/ui/model/mailauthentication/MailAuthentication.kt b/app/src/main/java/io/timelimit/android/ui/model/mailauthentication/MailAuthentication.kt index 6162227..3b7b3c8 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/mailauthentication/MailAuthentication.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/mailauthentication/MailAuthentication.kt @@ -35,6 +35,10 @@ import java.io.Serializable object MailAuthentication { sealed class State: java.io.Serializable { + companion object { + val initial = State.EnterMailAddress() + } + abstract val error: ErrorDialog? abstract fun withError(error: ErrorDialog?): State diff --git a/app/src/main/java/io/timelimit/android/ui/model/setup/SetupHandling.kt b/app/src/main/java/io/timelimit/android/ui/model/setup/SetupHandling.kt index 957ff8d..4bdad6d 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/setup/SetupHandling.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/setup/SetupHandling.kt @@ -23,11 +23,13 @@ import io.timelimit.android.ui.model.flow.Case import io.timelimit.android.ui.model.flow.splitConflated import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharedFlow object SetupHandling { fun handle( logic: AppLogic, activityCommand: SendChannel, + permissionsChanged: SharedFlow, stateLive: Flow, updateState: ((State.Setup) -> State) -> Unit ): Flow = stateLive.splitConflated( @@ -35,5 +37,6 @@ object SetupHandling { Case.simple<_, _, State.Setup.DevicePermissions> { SetupLocalModePermissions.handle(logic, scope, activityCommand, it, updateMethod(updateState)) }, Case.simple<_, _, State.Setup.ConnectedPrivacy> { SetupConnectedModePrivacy.handle(logic, it, updateMethod(updateState)) }, Case.simple<_, _, State.Setup.SelectConnectedMode> { SetupSelectConnectedMode.handle(it, updateMethod(updateState)) }, + Case.simple<_, _, State.Setup.ParentModeSetup> { SetupParentHandling.handle(logic, activityCommand, permissionsChanged, it, updateMethod(updateState)) } ) } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/setup/SetupParentHandling.kt b/app/src/main/java/io/timelimit/android/ui/model/setup/SetupParentHandling.kt new file mode 100644 index 0000000..9c5cced --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/model/setup/SetupParentHandling.kt @@ -0,0 +1,411 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program 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 version 3 of the License. + * + * This program 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 this program. If not, see . + */ +package io.timelimit.android.ui.model.setup + +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarHostState +import androidx.compose.material.SnackbarResult +import io.timelimit.android.R +import io.timelimit.android.async.Threads +import io.timelimit.android.coroutines.executeAndWait +import io.timelimit.android.data.backup.DatabaseBackup +import io.timelimit.android.data.devicename.DeviceName +import io.timelimit.android.logic.AppLogic +import io.timelimit.android.sync.ApplyServerDataStatus +import io.timelimit.android.sync.network.NewDeviceInfo +import io.timelimit.android.sync.network.ParentPassword +import io.timelimit.android.sync.network.ServerDataStatus +import io.timelimit.android.sync.network.StatusOfMailAddress +import io.timelimit.android.sync.network.api.ConflictHttpError +import io.timelimit.android.sync.network.api.UnauthorizedHttpError +import io.timelimit.android.ui.diagnose.exception.ExceptionUtil +import io.timelimit.android.ui.model.ActivityCommand +import io.timelimit.android.ui.model.Screen +import io.timelimit.android.ui.model.State +import io.timelimit.android.ui.model.flow.Case +import io.timelimit.android.ui.model.flow.splitConflated +import io.timelimit.android.ui.model.mailauthentication.MailAuthentication +import io.timelimit.android.ui.setup.SetupUnprovisionedCheck +import io.timelimit.android.ui.view.NotifyPermissionCard +import io.timelimit.android.update.UpdateIntegration +import io.timelimit.android.update.UpdateUtil +import io.timelimit.android.work.PeriodicSyncInBackgroundWorker +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.io.Serializable + +object SetupParentHandling { + data class NewUserDetails( + val parentName: String, + val password: String, + val password2: String + ): Serializable { + companion object { + val empty = NewUserDetails("", "", "") + } + + private val nameReady get() = parentName.isNotBlank() + private val passwordReady get() = password == password2 && password.isNotEmpty() + val ready get() = nameReady && passwordReady + } + + data class ParentBaseConfiguration( + val mail: String, + val showLimitedProInfo: Boolean, + val deviceName: String, + val newUserDetails: NewUserDetails?, + val actions: Actions + ) { + data class Actions( + val newUserActions: NewUserActions?, + val updateDeviceName: (String) -> Unit, + val next: (() -> Unit)? + ) + + data class NewUserActions( + val updateParentName: (String) -> Unit, + val updatePassword: (String) -> Unit, + val updatePassword2: (String) -> Unit, + ) + } + + data class ParentSetupConsent( + val backgroundSync: Boolean, + val notificationAccess: NotifyPermissionCard.Status, + val showEnableUpdates: Boolean, + val enableUpdates: Boolean, + val actions: Actions? + ) { + data class Actions( + val updateBackgroundSync: (Boolean) -> Unit, + val updateNotificationAccess: (NotifyPermissionCard.Status) -> Unit, + val updateEnableUpdates: (Boolean) -> Unit, + val requestNotifyPermission: () -> Unit, + val skipNotifyPermission: () -> Unit, + val next: (() -> Unit)? + ) + } + + fun handle( + logic: AppLogic, + activityCommand: SendChannel, + permissionsChanged: SharedFlow, + stateLive: Flow, + updateState: ((State.Setup.ParentModeSetup) -> State) -> Unit + ): Flow = stateLive.splitConflated( + Case.simple<_, _, State.Setup.ParentMailAuthentication> { handleMailAuthentication(logic, scope, share(it), updateMethod(updateState)) }, + Case.simple<_, _, State.Setup.SignUpBlocked> { handleSignUpBlocked(it) }, + Case.simple<_, _, State.Setup.SignInWrongMailAddress> { handleSignUpWrongMailAddress(it) }, + Case.simple<_, _, State.Setup.ConfirmNewParentAccount> { handleConfirmNewParentAccount(logic, it, updateMethod(updateState)) }, + Case.simple<_, _, State.Setup.ParentBaseConfiguration> { handleParentBaseConfiguration(it, updateMethod(updateState)) }, + Case.simple<_, _, State.Setup.ParentConsent> { handleParentConsent(logic, scope, activityCommand, permissionsChanged, share(it), updateMethod(updateState)) }, + ) + + private fun handleMailAuthentication( + logic: AppLogic, + scope: CoroutineScope, + stateLive: SharedFlow, + updateState: ((State.Setup.ParentMailAuthentication) -> State) -> Unit + ): Flow = flow { + val snackbarHostState = SnackbarHostState() + + val nestedLice = MailAuthentication.handle( + logic = logic, + scope = scope, + snackbarHostState = snackbarHostState, + stateLive = stateLive.map { it.content }, + updateState = { modifier -> updateState { it.copy(content = modifier(it.content)) } }, + processAuthToken = { token -> + val status = logic.serverLogic.getServerConfigCoroutine().api.getStatusByMailToken(token) + + val deviceName = Threads.database.executeAndWait { + DeviceName.getDeviceNameSync(logic.context) + } + + if (status.status == StatusOfMailAddress.MailAddressWithFamily) updateState { + val prev = it.copy(content = MailAuthentication.State.initial) + + State.Setup.ParentBaseConfiguration( + previousState = prev, + previousParentMailAuthentication = prev, + mailAuthToken = token, + mailStatus = status, + deviceName = deviceName, + newUser = null + ) + } else if (status.canCreateFamily) updateState { State.Setup.ConfirmNewParentAccount(it, token, status) } + else updateState { State.Setup.SignUpBlocked(it.copy(content = MailAuthentication.State.initial)) } + } + ) + + emitAll(combine(stateLive, nestedLice) { state, nested -> + Screen.SetupParentMailAuthentication(state, nested, snackbarHostState) + }) + } + + private fun handleSignUpBlocked( + stateLive: Flow + ): Flow = stateLive.map { Screen.SignupBlocked(it) } + + private fun handleSignUpWrongMailAddress( + stateLive: Flow + ): Flow = stateLive.map { Screen.SignInWrongMailAddress(it) } + + private fun handleConfirmNewParentAccount( + logic: AppLogic, + stateLive: Flow, + updateState: ((State.Setup.ConfirmNewParentAccount) -> State) -> Unit + ): Flow = flow { + val deviceName = Threads.database.executeAndWait { + DeviceName.getDeviceNameSync(logic.context) + } + + val confirm = { updateState { + State.Setup.ParentBaseConfiguration( + previousState = it, + previousParentMailAuthentication = it.previousParentMailAuthentication, + mailAuthToken = it.mailAuthToken, + mailStatus = it.mailStatus, + deviceName = deviceName, + newUser = NewUserDetails.empty + ) + } } + + val reject = { updateState { + State.Setup.SignInWrongMailAddress(it) + } } + + emitAll(stateLive.map { Screen.ConfirmNewParentAccount(it, confirm = confirm, reject = reject) }) + } + + private fun handleParentBaseConfiguration( + stateLive: Flow, + updateState: ((State.Setup.ParentBaseConfiguration) -> State) -> Unit + ): Flow { + fun deviceReady(state: State.Setup.ParentBaseConfiguration) = state.deviceName.isNotBlank() + fun newUserReady(state: State.Setup.ParentBaseConfiguration) = state.newUser == null || state.newUser.ready + + fun ready(state: State.Setup.ParentBaseConfiguration) = newUserReady(state) && deviceReady(state) + + val next = { updateState { + if (ready(it)) State.Setup.ParentConsent( + it, + backgroundSync = false, + notificationAccess = NotifyPermissionCard.Status.Unknown, + enableUpdates = false, + error = null + ) + else it + }} + + return stateLive.map { state -> + val newUserDetails = if (state.newUser != null) NewUserDetails( + parentName = state.newUser.parentName, + password = state.newUser.password, + password2 = state.newUser.password2 + ) to ParentBaseConfiguration.NewUserActions( + updateParentName = { v -> updateState { s -> s.copy(newUser = s.newUser?.copy(parentName = v)) } }, + updatePassword = { v -> updateState { s -> s.copy(newUser = s.newUser?.copy(password = v)) } }, + updatePassword2 = { v -> updateState { s -> s.copy(newUser = s.newUser?.copy(password2 = v)) } } + ) else null + + val actions = ParentBaseConfiguration.Actions( + newUserActions = newUserDetails?.second, + updateDeviceName = { v -> updateState { it.copy(deviceName = v) } }, + next = if (ready(state)) next else null + ) + + val content = ParentBaseConfiguration( + mail = state.mailStatus.mail, + showLimitedProInfo = !state.mailStatus.alwaysPro, + deviceName = state.deviceName, + newUserDetails = newUserDetails?.first, + actions = actions + ) + + Screen.ParentBaseConfiguration( + state = state, + content = content + ) + } + } + + private fun handleParentConsent( + logic: AppLogic, + scope: CoroutineScope, + activityCommand: SendChannel, + permissionsChanged: SharedFlow, + stateLive: SharedFlow, + updateState: ((State.Setup.ParentConsent) -> State) -> Unit + ): Flow = flow { + val isWorkingLive = MutableStateFlow(false) + val snackbarHostState = SnackbarHostState() + var lastError: Job? = null + + val actions = ParentSetupConsent.Actions( + updateBackgroundSync = { v -> updateState { it.copy(backgroundSync = v) } }, + updateNotificationAccess = { v -> updateState { it.copy(notificationAccess = v) } }, + updateEnableUpdates = { v -> updateState { it.copy(enableUpdates = v) } }, + requestNotifyPermission = { + activityCommand.trySend(ActivityCommand.RequestNotifyPermission) + }, + skipNotifyPermission = { updateState { it.copy(notificationAccess = NotifyPermissionCard.Status.SkipGrant) } }, + next = { scope.launch { if (isWorkingLive.compareAndSet(expect = false, update = true)) try { + val state = stateLive.first() + val database = logic.database + val serverConfig = logic.serverLogic.getServerConfigCoroutine() + val api = serverConfig.api + + lastError?.cancel(); lastError = null + + val deviceModelName = Threads.database.executeAndWait { + DeviceName.getDeviceNameSync(logic.context) + } + + val mailToken = state.baseConfig.mailAuthToken + val parentDevice = NewDeviceInfo(model = deviceModelName) + val deviceName = state.baseConfig.deviceName + + val result = if (state.baseConfig.newUser != null) { + val parentPassword = ParentPassword.createCoroutine(state.baseConfig.newUser.password, null) + val parentName = state.baseConfig.newUser.parentName + val timeZone = logic.timeApi.getSystemTimeZone().id + + val registerResponse = api.createFamilyByMailToken( + mailToken = mailToken, + parentPassword = parentPassword, + parentDevice = parentDevice, + deviceName = deviceName, + parentName = parentName, + timeZone = timeZone + ) + + Result( + deviceAuthToken = registerResponse.deviceAuthToken, + ownDeviceId = registerResponse.ownDeviceId, + serverDataStatus = registerResponse.data + ) + } else { + val signInResponse = api.signInToFamilyByMailToken( + mailToken = mailToken, + parentDevice = parentDevice, + deviceName = deviceName + ) + + Result( + deviceAuthToken = signInResponse.deviceAuthToken, + ownDeviceId = signInResponse.ownDeviceId, + serverDataStatus = signInResponse.data + ) + } + + Threads.database.executeAndWait { + database.runInTransaction { + SetupUnprovisionedCheck.checkSync(logic.database) + + database.deleteAllData() + + database.config().setCustomServerUrlSync(serverConfig.customServerUrl) + database.config().setOwnDeviceIdSync(result.ownDeviceId) + database.config().setDeviceAuthTokenSync(result.deviceAuthToken) + database.config().setEnableBackgroundSync(state.backgroundSync) + + ApplyServerDataStatus.applyServerDataStatusSync(result.serverDataStatus, logic.database, logic.platformIntegration) + } + } + + DatabaseBackup.with(logic.context).tryCreateDatabaseBackupAsync() + + if (state.backgroundSync) { + PeriodicSyncInBackgroundWorker.enable(logic.context) + } + + UpdateUtil.setEnableChecks(logic.context, state.enableUpdates) + + updateState { State.LaunchState } + } catch (ex: ConflictHttpError) { + snackbarHostState.showSnackbar(logic.context.getString(R.string.error_server_rejected)) + + updateState { it.baseConfig.previousParentMailAuthentication } + } catch (ex: UnauthorizedHttpError) { + snackbarHostState.showSnackbar(logic.context.getString(R.string.error_server_rejected)) + + updateState { it.baseConfig.previousParentMailAuthentication } + } catch (ex: Exception) { + lastError = launch { + val result = snackbarHostState.showSnackbar( + logic.context.getString(R.string.error_network), + logic.context.getString(R.string.generic_show_details), + SnackbarDuration.Long + ) + + if (result == SnackbarResult.ActionPerformed) updateState { + it.copy(error = ExceptionUtil.format(ex)) + } + } + } finally { + isWorkingLive.value = false + } } } + ) + + scope.launch { permissionsChanged.collect { + updateState { + it.copy(notificationAccess = NotifyPermissionCard.updateStatus(it.notificationAccess, logic.context)) + } + } } + + val showEnableUpdates = UpdateIntegration.doesSupportUpdates(logic.context) + + emitAll(combine(stateLive, isWorkingLive) { state, isWorking -> + val notificationAccess = NotifyPermissionCard.updateStatus(state.notificationAccess, logic.context) + + val content = ParentSetupConsent( + backgroundSync = state.backgroundSync, + notificationAccess = notificationAccess, + enableUpdates = state.enableUpdates, + showEnableUpdates = showEnableUpdates, + actions = if (isWorking) null else actions.copy( + next = if (NotifyPermissionCard.canProceed(notificationAccess)) actions.next else null + ) + ) + + Screen.ParentSetupConsent( + state = state, + content = content, + snackbarHostState = snackbarHostState, + errorDialog = state.error?.let { error -> + Pair(error) { updateState { it.copy(error = null) } } + } + ) + }) + } + + internal data class Result( + val deviceAuthToken: String, + val ownDeviceId: String, + val serverDataStatus: ServerDataStatus + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/model/setup/SetupSelectConnectedMode.kt b/app/src/main/java/io/timelimit/android/ui/model/setup/SetupSelectConnectedMode.kt index 0934ad0..233591e 100644 --- a/app/src/main/java/io/timelimit/android/ui/model/setup/SetupSelectConnectedMode.kt +++ b/app/src/main/java/io/timelimit/android/ui/model/setup/SetupSelectConnectedMode.kt @@ -28,7 +28,7 @@ object SetupSelectConnectedMode { return stateLive.map { state -> Screen.SetupSelectConnectedModeScreen( state = state, - mailLogin = { updateState { State.Setup.ParentMode(it) } }, + mailLogin = { updateState { State.Setup.ParentMailAuthentication(it) } }, codeLogin = { updateState { State.Setup.RemoteChild(it) } } ) } diff --git a/app/src/main/java/io/timelimit/android/ui/setup/parent/ConfirmNewParentAccount.kt b/app/src/main/java/io/timelimit/android/ui/setup/parent/ConfirmNewParentAccount.kt new file mode 100644 index 0000000..ceafa17 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/setup/parent/ConfirmNewParentAccount.kt @@ -0,0 +1,63 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program 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 version 3 of the License. + * + * This program 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 this program. If not, see . + */ +package io.timelimit.android.ui.setup.parent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.timelimit.android.R + +@Composable +fun ConfirmNewParentAccount( + confirm: () -> Unit, + reject: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically) + ) { + Text( + stringResource(R.string.setup_parent_confirm_new_account_text), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally) + ) { + OutlinedButton(onClick = confirm) { + Text(stringResource(R.string.generic_no)) + } + + Button(onClick = reject) { + Text(stringResource(R.string.generic_yes)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/setup/parent/ParentBaseConfiguration.kt b/app/src/main/java/io/timelimit/android/ui/setup/parent/ParentBaseConfiguration.kt new file mode 100644 index 0000000..6f99876 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/setup/parent/ParentBaseConfiguration.kt @@ -0,0 +1,105 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program 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 version 3 of the License. + * + * This program 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 this program. If not, see . + */ +package io.timelimit.android.ui.setup.parent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import io.timelimit.android.R +import io.timelimit.android.ui.model.setup.SetupParentHandling +import io.timelimit.android.ui.view.EnterTextField +import io.timelimit.android.ui.view.SetPassword + +@Composable +fun ParentBaseConfiguration( + content: SetupParentHandling.ParentBaseConfiguration, + modifier: Modifier = Modifier +) { + Column( + modifier + .verticalScroll(rememberScrollState()) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (content.newUserDetails != null) { + run { + Text(stringResource(R.string.setup_parent_mode_explaination_user_name)) + + TextField( + value = content.newUserDetails.parentName, + onValueChange = content.actions.newUserActions?.updateParentName ?: {}, + enabled = content.actions.newUserActions?.updateParentName != null, + label = { + Text(stringResource(R.string.setup_parent_mode_field_name_hint)) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + } + + run { + Text(stringResource(R.string.setup_parent_mode_explaination_password, content.mail)) + + SetPassword( + password1 = content.newUserDetails.password, + password2 = content.newUserDetails.password2, + updatePassword1 = content.actions.newUserActions?.updatePassword ?: {}, + updatePassword2 = content.actions.newUserActions?.updatePassword2 ?: {}, + enabled = content.actions.newUserActions != null + ) + } + } + + run { + Text(stringResource(R.string.setup_parent_mode_explaination_device_title)) + + EnterTextField( + value = content.deviceName, + onValueChange = content.actions.updateDeviceName, + label = { + Text(stringResource(R.string.setup_parent_mode_explaination_device_title_field_hint)) + }, + modifier = Modifier.fillMaxWidth(), + onConfirmInput = content.actions.next ?: {} + ) + } + + if (content.showLimitedProInfo) Text(stringResource(R.string.purchase_demo_temporarily_notice)) + + Button( + onClick = content.actions.next ?: {}, + enabled = content.actions.next != null, + modifier = Modifier.align(Alignment.End) + ) { + Text(stringResource(R.string.wiazrd_next)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/setup/parent/ParentSetupConsent.kt b/app/src/main/java/io/timelimit/android/ui/setup/parent/ParentSetupConsent.kt new file mode 100644 index 0000000..ff7cf1c --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/setup/parent/ParentSetupConsent.kt @@ -0,0 +1,123 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program 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 version 3 of the License. + * + * This program 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 this program. If not, see . + */ +package io.timelimit.android.ui.setup.parent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.timelimit.android.BuildConfig +import io.timelimit.android.R +import io.timelimit.android.ui.diagnose.exception.DiagnoseExceptionDialog +import io.timelimit.android.ui.model.setup.SetupParentHandling +import io.timelimit.android.ui.view.NotifyPermissionCard +import io.timelimit.android.ui.view.SwitchRow +import io.timelimit.android.update.UpdateUtil + +@Composable +fun ParentSetupConsent( + content: SetupParentHandling.ParentSetupConsent, + errorDialog: Pair Unit>?, + modifier: Modifier = Modifier +) { + Column( + modifier + .verticalScroll(rememberScrollState()) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ConsentCard( + stringResource(R.string.device_background_sync_title), + stringResource(R.string.device_background_sync_text), + stringResource(R.string.device_background_sync_checkbox), + checked = content.backgroundSync, + onCheckedChanged = content.actions?.updateBackgroundSync ?: {/* do nothing */}, + enabled = content.actions?.updateBackgroundSync != null + ) + + if (content.showEnableUpdates) ConsentCard( + stringResource(R.string.update), + stringResource(R.string.update_privacy, BuildConfig.updateServer), + stringResource(R.string.update_enable_switch), + checked = content.enableUpdates, + onCheckedChanged = content.actions?.updateEnableUpdates ?: {/* do nothing */}, + enabled = content.actions?.updateEnableUpdates != null + ) + + NotifyPermissionCard.View( + status = content.notificationAccess, + listener = object: NotifyPermissionCard.Listener { + override fun onGrantClicked() { content.actions?.requestNotifyPermission?.invoke() } + override fun onSkipClicked() { content.actions?.skipNotifyPermission?.invoke() } + }, + enabled = content.actions != null + ) + + Button( + onClick = content.actions?.next ?: {}, + enabled = content.actions?.next != null, + modifier = Modifier.align(Alignment.End) + ) { + Text(stringResource(R.string.wiazrd_next)) + } + } + + if (errorDialog != null) DiagnoseExceptionDialog(errorDialog.first, errorDialog.second) +} + +@Composable +fun ConsentCard( + title: String, + text: String, + switch: String, + checked: Boolean, + onCheckedChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + Card { + Column( + modifier.fillMaxWidth().padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + title, + style = MaterialTheme.typography.h5 + ) + + Text(text) + + SwitchRow( + label = switch, + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChanged, + reverse = true + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/setup/parent/SetupParentModeFragment.kt b/app/src/main/java/io/timelimit/android/ui/setup/parent/SetupParentModeFragment.kt deleted file mode 100644 index 98c6443..0000000 --- a/app/src/main/java/io/timelimit/android/ui/setup/parent/SetupParentModeFragment.kt +++ /dev/null @@ -1,221 +0,0 @@ -/* - * TimeLimit Copyright 2019 - 2023 Jonas Lochmann - * - * This program 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 version 3 of the License. - * - * This program 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 this program. If not, see . - */ -package io.timelimit.android.ui.setup.parent - -import android.Manifest -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.Fragment -import androidx.lifecycle.* -import io.timelimit.android.R -import io.timelimit.android.async.Threads -import io.timelimit.android.coroutines.executeAndWait -import io.timelimit.android.coroutines.runAsync -import io.timelimit.android.data.devicename.DeviceName -import io.timelimit.android.databinding.FragmentSetupParentModeBinding -import io.timelimit.android.livedata.* -import io.timelimit.android.logic.DefaultAppLogic -import io.timelimit.android.sync.network.StatusOfMailAddress -import io.timelimit.android.ui.authentication.AuthenticateByMailFragment -import io.timelimit.android.ui.authentication.AuthenticateByMailFragmentListener -import io.timelimit.android.ui.update.UpdateConsentCard -import io.timelimit.android.ui.view.NotifyPermissionCard - -class SetupParentModeFragment : Fragment(), AuthenticateByMailFragmentListener { - companion object { - private const val STATUS_NOTIFY_PERMISSION = "notify permission" - } - - private val model: SetupParentModeModel by lazy { ViewModelProviders.of(this).get(SetupParentModeModel::class.java) } - private var notifyPermission = MutableLiveData() - - private val requestNotifyPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> - if (isGranted) notifyPermission.value = NotifyPermissionCard.Status.Granted - else Toast.makeText(requireContext(), R.string.notify_permission_rejected_toast, Toast.LENGTH_SHORT).show() - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - if (savedInstanceState != null) { - notifyPermission.value = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - savedInstanceState.getSerializable(STATUS_NOTIFY_PERMISSION, NotifyPermissionCard.Status::class.java)!! - else - savedInstanceState.getSerializable(STATUS_NOTIFY_PERMISSION)!! as NotifyPermissionCard.Status - } - - notifyPermission.value = NotifyPermissionCard.updateStatus(notifyPermission.value ?: NotifyPermissionCard.Status.Unknown, requireContext()) - } - - override fun onResume() { - super.onResume() - - notifyPermission.value = NotifyPermissionCard.updateStatus(notifyPermission.value ?: NotifyPermissionCard.Status.Unknown, requireContext()) - } - - override fun onSaveInstanceState(outState: Bundle) { - super.onSaveInstanceState(outState) - - outState.putSerializable(STATUS_NOTIFY_PERMISSION, notifyPermission.value) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - val binding = FragmentSetupParentModeBinding.inflate(layoutInflater, container, false) - - model.mailAuthToken.switchMap { - mailAuthToken -> - - if (mailAuthToken == null) { - liveDataFromNonNullValue(1) // show login screen - } else { - // show form or loading indicator or error screen - model.statusOfMailAddress.switchMap { - status -> - - if (status == null) { - liveDataFromNonNullValue(2) // loading screen - } else if (status.status == StatusOfMailAddress.MailAddressWithoutFamily && status.canCreateFamily == false) { - liveDataFromNonNullValue(3) // signup disabled screen - } else { - model.isDoingSetup.map { - if (it!!) { - 2 // loading screen - } else { - 0 // the form - } - } - } - } - } - }.observe(viewLifecycleOwner, Observer { - binding.switcher.displayedChild = it!! - }) - - model.statusOfMailAddress.observe(viewLifecycleOwner, Observer { - if (it != null) { - binding.isNewFamily = when (it.status) { - StatusOfMailAddress.MailAddressWithoutFamily -> true - StatusOfMailAddress.MailAddressWithFamily -> false - } - - binding.showLimitedProInfo = !it.alwaysPro - binding.mail = it.mail - } - }) - - val isPasswordValid = model.statusOfMailAddress.switchMap { - if (it == null) { - liveDataFromNonNullValue(false) - } else { - when (it.status) { - StatusOfMailAddress.MailAddressWithFamily -> liveDataFromNonNullValue(true) - StatusOfMailAddress.MailAddressWithoutFamily -> binding.password.passwordOk - } - } - } - - val isNotifyPermissionValid = notifyPermission.map { NotifyPermissionCard.canProceed(it) } - - val isPreNameValid = model.statusOfMailAddress.switchMap { - if (it == null) { - liveDataFromNonNullValue(false) - } else { - when (it.status) { - StatusOfMailAddress.MailAddressWithFamily -> liveDataFromNonNullValue(true) - StatusOfMailAddress.MailAddressWithoutFamily -> binding.prename.getTextLive().map { prename -> prename.isNotBlank() } - } - } - } - - val isDeviceNameValid = binding.deviceName.getTextLive().map { it.isNotBlank() } - - val isInputValid = isPasswordValid.and(isNotifyPermissionValid).and(isPreNameValid).and(isDeviceNameValid) - - isInputValid.ignoreUnchanged().observe(viewLifecycleOwner, Observer { - binding.enableOkButton = it!! - }) - - if (savedInstanceState == null) { - val ctx = requireContext() - - runAsync { - // provide an useful default value - val deviceName = Threads.database.executeAndWait { DeviceName.getDeviceNameSync(ctx) } - - binding.deviceName.setText(deviceName) - } - } - - binding.ok.setOnClickListener { - val status = model.statusOfMailAddress.value - - if (status == null) { - throw IllegalStateException() - } - - when (status.status) { - StatusOfMailAddress.MailAddressWithoutFamily -> { - model.createFamily( - parentPassword = binding.password.readPassword(), - parentName = binding.prename.text.toString(), - deviceName = binding.deviceName.text.toString(), - enableBackgroundSync = binding.backgroundSyncCheckbox.isChecked, - enableUpdateChecks = binding.update.enableSwitch.isChecked - ) - } - StatusOfMailAddress.MailAddressWithFamily -> { - model.addDeviceToFamily( - deviceName = binding.deviceName.text.toString(), - enableBackgroundSync = binding.backgroundSyncCheckbox.isChecked, - enableUpdateChecks = binding.update.enableSwitch.isChecked - ) - } - } - } - - UpdateConsentCard.bind( - view = binding.update, - lifecycleOwner = viewLifecycleOwner, - database = DefaultAppLogic.with(requireContext()).database - ) - - NotifyPermissionCard.bind(object: NotifyPermissionCard.Listener { - override fun onGrantClicked() { requestNotifyPermission.launch(Manifest.permission.POST_NOTIFICATIONS) } - override fun onSkipClicked() { notifyPermission.value = NotifyPermissionCard.Status.SkipGrant } - }, binding.notifyPermissionCard) - - notifyPermission.observe(viewLifecycleOwner) { NotifyPermissionCard.bind(it, binding.notifyPermissionCard) } - - return binding.root - } - - override fun onLoginSucceeded(mailAuthToken: String) = model.setMailToken(mailAuthToken) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - if (savedInstanceState == null) { - childFragmentManager.beginTransaction() - .replace(R.id.mail_auth_container, AuthenticateByMailFragment()) - .commit() - } - } -} diff --git a/app/src/main/java/io/timelimit/android/ui/setup/parent/SetupParentModeModel.kt b/app/src/main/java/io/timelimit/android/ui/setup/parent/SetupParentModeModel.kt deleted file mode 100644 index 71253fb..0000000 --- a/app/src/main/java/io/timelimit/android/ui/setup/parent/SetupParentModeModel.kt +++ /dev/null @@ -1,225 +0,0 @@ -/* - * TimeLimit Copyright 2019 - 2023 Jonas Lochmann - * - * This program 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 version 3 of the License. - * - * This program 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 this program. If not, see . - */ -package io.timelimit.android.ui.setup.parent - -import android.app.Application -import android.util.Log -import android.widget.Toast -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.map -import io.timelimit.android.BuildConfig -import io.timelimit.android.R -import io.timelimit.android.async.Threads -import io.timelimit.android.coroutines.executeAndWait -import io.timelimit.android.coroutines.runAsync -import io.timelimit.android.data.backup.DatabaseBackup -import io.timelimit.android.data.devicename.DeviceName -import io.timelimit.android.livedata.castDown -import io.timelimit.android.logic.DefaultAppLogic -import io.timelimit.android.sync.ApplyServerDataStatus -import io.timelimit.android.sync.network.NewDeviceInfo -import io.timelimit.android.sync.network.ParentPassword -import io.timelimit.android.sync.network.StatusOfMailAddressResponse -import io.timelimit.android.sync.network.api.ConflictHttpError -import io.timelimit.android.sync.network.api.UnauthorizedHttpError -import io.timelimit.android.ui.setup.SetupUnprovisionedCheck -import io.timelimit.android.update.UpdateUtil -import io.timelimit.android.work.PeriodicSyncInBackgroundWorker - -class SetupParentModeModel(application: Application): AndroidViewModel(application) { - companion object { - private const val LOG_TAG = "SetupParentModeModel" - } - - private val logic = DefaultAppLogic.with(application) - - private val mailAuthTokenInternal = MutableLiveData().apply { value = null } - private val statusOfMailAddressInternal = MutableLiveData().apply { value = null } - private val isDoingSetupInternal = MutableLiveData().apply { value = false } - - val mailAuthToken = mailAuthTokenInternal.castDown() - val statusOfMailAddress = statusOfMailAddressInternal.castDown() - val isDoingSetup = isDoingSetupInternal.castDown() - val isSetupDone = logic.database.config().getOwnDeviceId().map { it != null } - - fun setMailToken(mailAuthToken: String) { - if (this.mailAuthTokenInternal.value == null) { - this.mailAuthTokenInternal.value = mailAuthToken - this.statusOfMailAddressInternal.value = null - - runAsync { - try { - val api = logic.serverLogic.getServerConfigCoroutine().api - val status = api.getStatusByMailToken(mailAuthToken) - - statusOfMailAddressInternal.value = status - } catch (ex: Exception) { - Toast.makeText(getApplication(), R.string.error_network, Toast.LENGTH_SHORT).show() - - mailAuthTokenInternal.value = null - } - } - } - } - - fun createFamily(parentPassword: String, parentName: String, deviceName: String, enableBackgroundSync: Boolean, enableUpdateChecks: Boolean) { - val database = logic.database - - if (isDoingSetup.value!!) { - return - } - - isDoingSetupInternal.value = true - - runAsync { - try { - val api = logic.serverLogic.getServerConfigCoroutine().api - val deviceModelName = Threads.database.executeAndWait { DeviceName.getDeviceNameSync(getApplication()) } - - val registerResponse = api.createFamilyByMailToken( - mailToken = mailAuthToken.value!!, - parentPassword = ParentPassword.createCoroutine(parentPassword, null), - parentDevice = NewDeviceInfo( - model = deviceModelName - ), - deviceName = deviceName, - parentName = parentName, - timeZone = logic.timeApi.getSystemTimeZone().id - ) - - val clientStatusResponse = registerResponse.data - - Threads.database.executeAndWait { - logic.database.runInTransaction { - val customServerUrl = logic.database.config().getCustomServerUrlSync() - - SetupUnprovisionedCheck.checkSync(logic.database) - - database.deleteAllData() - - database.config().setCustomServerUrlSync(customServerUrl) - database.config().setOwnDeviceIdSync(registerResponse.ownDeviceId) - database.config().setDeviceAuthTokenSync(registerResponse.deviceAuthToken) - database.config().setEnableBackgroundSync(enableBackgroundSync) - - ApplyServerDataStatus.applyServerDataStatusSync(clientStatusResponse, logic.database, logic.platformIntegration) - } - } - - DatabaseBackup.with(getApplication()).tryCreateDatabaseBackupAsync() - - if (enableBackgroundSync) { - PeriodicSyncInBackgroundWorker.enable(getApplication()) - } - - UpdateUtil.setEnableChecks(getApplication(), enableUpdateChecks) - - // the fragment detects the success and leaves this screen - } catch (ex: ConflictHttpError) { - mailAuthTokenInternal.value = null - isDoingSetupInternal.value = false - - Toast.makeText(getApplication(), R.string.error_server_rejected, Toast.LENGTH_SHORT).show() - } catch (ex: UnauthorizedHttpError) { - isDoingSetupInternal.value = false - mailAuthTokenInternal.value = null - - Toast.makeText(getApplication(), R.string.error_server_rejected, Toast.LENGTH_SHORT).show() - } catch (ex: Exception) { - if (BuildConfig.DEBUG) { - Log.w(LOG_TAG, "error during setup", ex) - } - - isDoingSetupInternal.value = false - - Toast.makeText(getApplication(), R.string.error_network, Toast.LENGTH_SHORT).show() - } - } - } - - fun addDeviceToFamily(deviceName: String, enableBackgroundSync: Boolean, enableUpdateChecks: Boolean) { - val database = logic.database - - if (isDoingSetup.value!!) { - return - } - - isDoingSetupInternal.value = true - - runAsync { - try { - val api = logic.serverLogic.getServerConfigCoroutine().api - val deviceModelName = Threads.database.executeAndWait { DeviceName.getDeviceNameSync(getApplication()) } - - val registerResponse = api.signInToFamilyByMailToken( - mailToken = mailAuthToken.value!!, - parentDevice = NewDeviceInfo( - model = deviceModelName - ), - deviceName = deviceName - ) - - val clientStatusResponse = registerResponse.data - - Threads.database.executeAndWait { - logic.database.runInTransaction { - val customServerUrl = logic.database.config().getCustomServerUrlSync() - - SetupUnprovisionedCheck.checkSync(logic.database) - - database.deleteAllData() - - database.config().setCustomServerUrlSync(customServerUrl) - database.config().setOwnDeviceIdSync(registerResponse.ownDeviceId) - database.config().setDeviceAuthTokenSync(registerResponse.deviceAuthToken) - database.config().setEnableBackgroundSync(enableBackgroundSync) - - ApplyServerDataStatus.applyServerDataStatusSync(clientStatusResponse, logic.database, logic.platformIntegration) - } - } - - DatabaseBackup.with(getApplication()).tryCreateDatabaseBackupAsync() - - if (enableBackgroundSync) { - PeriodicSyncInBackgroundWorker.enable(getApplication()) - } - - UpdateUtil.setEnableChecks(getApplication(), enableUpdateChecks) - - // the fragment detects the success and leaves this screen - } catch (ex: ConflictHttpError) { - isDoingSetupInternal.value = false - mailAuthTokenInternal.value = null - - Toast.makeText(getApplication(), R.string.error_server_rejected, Toast.LENGTH_SHORT).show() - } catch (ex: UnauthorizedHttpError) { - isDoingSetupInternal.value = false - mailAuthTokenInternal.value = null - - Toast.makeText(getApplication(), R.string.error_server_rejected, Toast.LENGTH_SHORT).show() - } catch (ex: Exception) { - if (BuildConfig.DEBUG) { - Log.w(LOG_TAG, "error during setup", ex) - } - - isDoingSetupInternal.value = false - - Toast.makeText(getApplication(), R.string.error_network, Toast.LENGTH_SHORT).show() - } - } - } -} diff --git a/app/src/main/java/io/timelimit/android/ui/setup/parent/SignInWrongMailAddress.kt b/app/src/main/java/io/timelimit/android/ui/setup/parent/SignInWrongMailAddress.kt new file mode 100644 index 0000000..2b428a3 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/setup/parent/SignInWrongMailAddress.kt @@ -0,0 +1,51 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program 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 version 3 of the License. + * + * This program 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 this program. If not, see . + */ +package io.timelimit.android.ui.setup.parent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.timelimit.android.R + +@Composable +fun SignInWrongMailAddress(modifier: Modifier) { + Column( + modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically) + ) { + Text( + stringResource(R.string.setup_parent_mode_error_wrong_mail_address_title), + style = MaterialTheme.typography.h5, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Text( + stringResource(R.string.setup_parent_mode_error_wrong_mail_address_text), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/setup/parent/SignupBlockedScreen.kt b/app/src/main/java/io/timelimit/android/ui/setup/parent/SignupBlockedScreen.kt new file mode 100644 index 0000000..e41bc32 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/setup/parent/SignupBlockedScreen.kt @@ -0,0 +1,42 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program 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 version 3 of the License. + * + * This program 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 this program. If not, see . + */ +package io.timelimit.android.ui.setup.parent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.timelimit.android.R + +@Composable +fun SignupBlockedScreen(modifier: Modifier) { + Column( + modifier.padding(16.dp), + verticalArrangement = Arrangement.SpaceAround + ) { + Text( + stringResource(R.string.setup_parent_mode_error_signup_disabled), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/view/NotifyPermissionCard.kt b/app/src/main/java/io/timelimit/android/ui/view/NotifyPermissionCard.kt index d77b937..05c9ad5 100644 --- a/app/src/main/java/io/timelimit/android/ui/view/NotifyPermissionCard.kt +++ b/app/src/main/java/io/timelimit/android/ui/view/NotifyPermissionCard.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,7 +18,26 @@ package io.timelimit.android.ui.view import android.app.NotificationManager import android.content.Context import android.os.Build +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.core.content.getSystemService +import io.timelimit.android.R import io.timelimit.android.databinding.NotifyPermissionCardBinding object NotifyPermissionCard { @@ -29,6 +48,49 @@ object NotifyPermissionCard { Granted } + @Composable + fun View( + status: Status, + listener: Listener, + modifier: Modifier = Modifier, + enabled: Boolean = true + ) { + Card { Column( + modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + stringResource(R.string.notify_permission_title), + style = MaterialTheme.typography.h5 + ) + + Text( + stringResource(R.string.notify_permission_text) + ) + + if (status == Status.Granted) Text( + stringResource(R.string.notify_permission_text_granted), + color = Color(ContextCompat.getColor(LocalContext.current, R.color.text_green)).copy(alpha = 1f), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) else if (status == Status.WaitingForInteraction) TextButton( + onClick = { listener.onSkipClicked() }, + modifier = Modifier.align(Alignment.End), + enabled = enabled + ) { + Text(stringResource(R.string.notify_permission_btn_later)) + } + + if (status != Status.Granted) Button( + onClick = { listener.onGrantClicked() }, + modifier = Modifier.align(Alignment.End), + enabled = enabled + ) { + Text(stringResource(R.string.notify_permission_btn_grant)) + } + } } + } + fun bind(status: Status, view: NotifyPermissionCardBinding) { view.showGrantButton = status != Status.Granted view.showGrantedMessage = status == Status.Granted diff --git a/app/src/main/java/io/timelimit/android/ui/view/SetPassword.kt b/app/src/main/java/io/timelimit/android/ui/view/SetPassword.kt new file mode 100644 index 0000000..25d07e4 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/view/SetPassword.kt @@ -0,0 +1,100 @@ +/* + * TimeLimit Copyright 2019 - 2023 Jonas Lochmann + * + * This program 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 version 3 of the License. + * + * This program 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 this program. If not, see . + */ +package io.timelimit.android.ui.view + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import io.timelimit.android.R + +@Composable +fun SetPassword( + password1: String, + password2: String, + updatePassword1: (String) -> Unit, + updatePassword2: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean +) { + Column( + modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextField( + value = password1, + onValueChange = updatePassword1, + enabled = enabled, + label = { + Text(stringResource(R.string.set_password_view_label_password)) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next + ), + visualTransformation = PasswordVisualTransformation() + ) + + AnimatedVisibility(visible = password1.isEmpty()) { + Text( + stringResource(R.string.password_validator_empty), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colors.secondary + ) + } + + TextField( + value = password2, + onValueChange = updatePassword2, + enabled = enabled, + label = { + Text(stringResource(R.string.set_password_view_label_password_repeat)) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next + ), + visualTransformation = PasswordVisualTransformation() + ) + + AnimatedVisibility(visible = password1 != password2) { + Text( + stringResource(R.string.set_password_view_not_identical), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colors.secondary + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/view/SwitchRow.kt b/app/src/main/java/io/timelimit/android/ui/view/SwitchRow.kt index 4edfd1d..e72050b 100644 --- a/app/src/main/java/io/timelimit/android/ui/view/SwitchRow.kt +++ b/app/src/main/java/io/timelimit/android/ui/view/SwitchRow.kt @@ -17,6 +17,7 @@ package io.timelimit.android.ui.view import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -36,7 +37,8 @@ fun SwitchRow( checked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, - enabled: Boolean = true + enabled: Boolean = true, + reverse: Boolean = false ) { Row ( modifier @@ -51,14 +53,21 @@ fun SwitchRow( ), verticalAlignment = Alignment.CenterVertically ) { + if (reverse) { + Text(label) + Spacer(Modifier.width(8.dp)) + Spacer(Modifier.weight(1f)) + } + Switch( checked = checked, onCheckedChange = onCheckedChange, enabled = enabled ) - Spacer(Modifier.width(8.dp)) - - Text(label) + if (!reverse) { + Spacer(Modifier.width(8.dp)) + Text(label) + } } } \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_setup_parent_mode.xml b/app/src/main/res/layout/fragment_setup_parent_mode.xml deleted file mode 100644 index 2c39b71..0000000 --- a/app/src/main/res/layout/fragment_setup_parent_mode.xml +++ /dev/null @@ -1,173 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -