diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/navigation/NavDestination.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/navigation/NavDestination.kt index ee321515..e22de401 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/navigation/NavDestination.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/navigation/NavDestination.kt @@ -24,6 +24,8 @@ import kotlinx.serialization.Serializable @Immutable @Serializable sealed interface NavDestination : NavKey { + //region Onboarding + sealed interface Onboarding : NavDestination { @Serializable data object Migration : Onboarding @@ -43,16 +45,24 @@ sealed interface NavDestination : NavKey { @Serializable data object SecuringAccount : NavDestination - sealed interface Root : NavDestination { + //endregion + + //region Home + + @Serializable + data object Home : NavDestination + + sealed interface HomeSubDestination : NavDestination { @Serializable - data object Home : Root + data object AccountList : HomeSubDestination @Serializable - data object Settings : Root + data object Settings : HomeSubDestination } - @Serializable - data class LoginInApp(val legacyAccountId: Long, val isOnboarding: Boolean) : NavDestination + //endregion + + //region Settings @Serializable data object Theme : NavDestination @@ -66,6 +76,11 @@ sealed interface NavDestination : NavKey { @Serializable data object PrivacyManagementSentry : NavDestination + //endregion + + @Serializable + data class LoginInApp(val legacyAccountId: Long, val isOnboarding: Boolean) : NavDestination + @Serializable data class AccountDetails(val accountId: Long) : NavDestination } diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/navigation/NavigationEntryProvider.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/navigation/NavigationEntryProvider.kt index c3545195..3165ad70 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/navigation/NavigationEntryProvider.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/navigation/NavigationEntryProvider.kt @@ -17,12 +17,12 @@ */ package com.infomaniak.auth.ui.navigation -import androidx.compose.material3.SnackbarHostState import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import com.infomaniak.auth.ui.screen.accountdetails.AccountDetailsScreen +import com.infomaniak.auth.ui.screen.accountlist.AccountListScreen import com.infomaniak.auth.ui.screen.home.HomeScreen import com.infomaniak.auth.ui.screen.login.LoginScreen import com.infomaniak.auth.ui.screen.onboarding.complete.OnboardingCompleteScreen @@ -39,24 +39,9 @@ import com.infomaniak.core.privacymanagement.tracker.Tracker fun baseEntryProvider( backStack: NavBackStack, - snackbarHostState: SnackbarHostState, ): (NavKey) -> NavEntry = entryProvider { - entry { - HomeScreen( - onAccountClicked = { account -> - backStack.add(NavDestination.AccountDetails(account.id)) - }, - ) - } - entry { - SettingsScreen( - onThemeClicked = { - backStack.add(NavDestination.Theme) - }, - onPrivacyManagementClicked = { - backStack.add(NavDestination.PrivacyManagement) - } - ) + entry { + HomeScreen(rootBackStack = backStack) } entry { ThemeSettingsScreen(onBackPressed = backStack::tryPopLast) @@ -95,7 +80,7 @@ fun baseEntryProvider( MigrationScreen() } entry { - OnboardingStartScreen(snackbarHostState = snackbarHostState) + OnboardingStartScreen() } entry { SecuringAccountScreen() @@ -106,7 +91,7 @@ fun baseEntryProvider( entry { NotificationPermissionScreen( navigateToHome = { - backStack.replaceAllWith(NavDestination.Root.Home) + backStack.replaceAllWith(NavDestination.Home) }, ) } @@ -119,6 +104,26 @@ fun baseEntryProvider( } } +fun homeEntryProvider(rootBackStack: NavBackStack): (NavKey) -> NavEntry = entryProvider { + entry { + AccountListScreen( + onAccountClicked = { account -> + rootBackStack.add(NavDestination.AccountDetails(account.id)) + }, + ) + } + entry { + SettingsScreen( + onThemeClicked = { + rootBackStack.add(NavDestination.Theme) + }, + onPrivacyManagementClicked = { + rootBackStack.add(NavDestination.PrivacyManagement) + } + ) + } +} + fun NavBackStack.tryPopLast() { if (lastIndex == 0) return removeAt(lastIndex) diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListScreen.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListScreen.kt new file mode 100644 index 00000000..2aabf829 --- /dev/null +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListScreen.kt @@ -0,0 +1,253 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 com.infomaniak.auth.ui.screen.accountlist + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.infomaniak.auth.R +import com.infomaniak.auth.lib.Account +import com.infomaniak.auth.ui.components.Avatar +import com.infomaniak.auth.ui.components.StatusCard +import com.infomaniak.auth.ui.components.StatusCardVariant +import com.infomaniak.auth.ui.previewparameter.fakeAccountPairs +import com.infomaniak.auth.ui.screen.accountlist.AccountSecurityLevel.Companion.toAccountSecurityLevel +import com.infomaniak.auth.ui.theme.AppDimens.DefaultCornerRadius +import com.infomaniak.auth.ui.theme.AuthenticatorTheme +import com.infomaniak.core.auth.models.user.User +import com.infomaniak.core.ui.compose.basics.Dimens +import com.infomaniak.core.ui.compose.margin.Margin +import com.infomaniak.core.ui.compose.preview.PreviewSmallWindow +import kotlinx.coroutines.delay + +@Composable +fun AccountListScreen( + onAccountClicked: (Account) -> Unit, + viewModel: AccountListViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + when (val state = uiState) { + is AccountListUiState.Success -> { + AccountListScreen( + uiState = { state }, + onAccountClicked = onAccountClicked, + onChallengesRefreshRequested = viewModel::refreshChallenges, + ) + } + is AccountListUiState.Loading -> Unit + } +} + +@Composable +fun AccountListScreen( + uiState: () -> AccountListUiState.Success, + onAccountClicked: (Account) -> Unit, + onChallengesRefreshRequested: () -> Unit, + modifier: Modifier = Modifier +) { + val state = uiState() + val hasUnsecuredAccounts: Boolean by remember(state.accountPairs) { + derivedStateOf { state.accountPairs.any { it.first.status.toAccountSecurityLevel() != AccountSecurityLevel.Secured } } + } + + Column(modifier = modifier) { + if (hasUnsecuredAccounts) ActionRequired() + + val pullToRefreshState = rememberPullToRefreshState() + var isRefreshing by remember { mutableStateOf(false) } + LaunchedEffect(isRefreshing) { + if (isRefreshing) { + delay(2_000) + isRefreshing = false + } + } + + PullToRefreshBox( + isRefreshing = isRefreshing, + state = pullToRefreshState, + onRefresh = { + isRefreshing = true + onChallengesRefreshRequested() + }, + ) { + Column( + modifier = Modifier + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Margin.Small) + ) { + state.accountPairs.forEach { (account, user) -> + key(account.email) { + AccountItem(account, user, onClick = { account -> onAccountClicked(account) }) + } + } + } + } + } +} + +@Composable +private fun ActionRequired() { + StatusCard( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Margin.Medium, vertical = Margin.Large), + shape = RoundedCornerShape(DefaultCornerRadius), + variant = StatusCardVariant.Warning, + ) { + Row(modifier = Modifier.padding(Margin.Small), verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.alert), + contentDescription = null, + tint = AuthenticatorTheme.customColors.iconTintWarning + ) + Text( + modifier = Modifier.padding(start = Margin.Small), + text = stringResource(R.string.actionRequiredDescription) + ) + } + } +} + +@Composable +private fun AccountItem( + account: Account, + user: User?, + onClick: (Account) -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Margin.Medium) + .clip(RoundedCornerShape(DefaultCornerRadius)) + .clickable(onClick = { onClick(account) }), + colors = CardDefaults.cardColors(containerColor = AuthenticatorTheme.customColors.sectionBackground), + ) { + Row( + modifier = Modifier.padding(vertical = Margin.Small, horizontal = Margin.Medium), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Margin.Micro) + ) { + Avatar( + account = account, + user = user, + modifier = Modifier + .size(Dimens.bigAvatarSize) + .clip(CircleShape) + .background(AuthenticatorTheme.materialColors.surfaceContainerHighest) + ) + Column( + modifier = Modifier + .padding(start = Margin.Medium) + .weight(1f), + ) { + Text( + text = account.fullName, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = account.email, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(modifier = Modifier) + Icon( + modifier = Modifier.padding(end = Margin.Mini), + painter = painterResource(id = account.status.toAccountSecurityLevel().iconResId), + contentDescription = stringResource(R.string.accountSecurityLevelContentDescription), + tint = account.status.toAccountSecurityLevel().iconTint(), + ) + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(R.drawable.chevron_right), + contentDescription = null + ) + } + } +} + +private enum class AccountSecurityLevel(val iconResId: Int, val iconTint: @Composable () -> Color) { + Secured(iconResId = R.drawable.shield_check, iconTint = { AuthenticatorTheme.customColors.iconTintSuccess }), + Warning(iconResId = R.drawable.shield_check, iconTint = { AuthenticatorTheme.customColors.iconTintWarning }), + Danger(iconResId = R.drawable.shield_exclamation_mark, iconTint = { AuthenticatorTheme.customColors.iconTintWarning }); + + companion object { + fun Account.Status.toAccountSecurityLevel() = when (this) { + Account.Status.LoggedIn -> Secured + is Account.Status.NotConnected -> Danger //TODO: Shouldn't this show the exclamation mark too? + else -> Warning // TODO: Use secure level to determine the status more precisely + } + } +} + +@PreviewSmallWindow +@Composable +private fun AccountListScreenPreview() { + AuthenticatorTheme { + Scaffold { paddingValues -> + AccountListScreen( + modifier = Modifier.padding(paddingValues), + uiState = { AccountListUiState.Success(fakeAccountPairs) }, + onAccountClicked = {}, + onChallengesRefreshRequested = {}, + ) + } + } +} diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListViewModel.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListViewModel.kt new file mode 100644 index 00000000..642989c7 --- /dev/null +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/accountlist/AccountListViewModel.kt @@ -0,0 +1,79 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 com.infomaniak.auth.ui.screen.accountlist + +import androidx.compose.runtime.Immutable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.infomaniak.auth.lib.Account +import com.infomaniak.auth.lib.AuthenticatorFacade +import com.infomaniak.auth.utils.AccountUtils +import com.infomaniak.core.auth.models.user.User +import com.infomaniak.core.twofactorauth.back.TwoFactorAuthManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AccountListViewModel @Inject constructor( + accountUtils: AccountUtils, + private val authenticatorFacade: AuthenticatorFacade, + private val twoFactorAuthManager: TwoFactorAuthManager +) : ViewModel() { + val uiState: StateFlow = authenticatorFacade.accounts + .combine(accountUtils.users) { accounts, users -> + val usersMap = users.associateBy { it.id.toLong() } + accounts.map { account -> + account to usersMap[account.id] + } + } + .map { accountPairs -> + if (accountPairs.isEmpty()) AccountListUiState.Loading else AccountListUiState.Success(accountPairs.toPersistentList()) + } + .stateIn( + viewModelScope, + SharingStarted.Eagerly, + AccountListUiState.Loading + ) + + fun refreshChallenges() { + viewModelScope.launch { + authenticatorFacade.accounts.first() + .filter { account -> + account.status == Account.Status.LoggedIn + } + .forEach { account -> + twoFactorAuthManager.refreshChallengeNow(account.id) + } + } + } +} + +@Immutable +sealed interface AccountListUiState { + data object Loading : AccountListUiState + data class Success(val accountPairs: ImmutableList>) : AccountListUiState +} diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/home/HomeScreen.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/home/HomeScreen.kt index ebb7c69a..9367a57c 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/home/HomeScreen.kt @@ -17,256 +17,115 @@ */ package com.infomaniak.auth.ui.screen.home -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Text -import androidx.compose.material3.pulltorefresh.PullToRefreshBox -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -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.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.ui.NavDisplay import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.infomaniak.auth.R -import com.infomaniak.auth.lib.Account import com.infomaniak.auth.ui.components.AuthenticatorFab -import com.infomaniak.auth.ui.components.Avatar import com.infomaniak.auth.ui.components.InfomaniakAuthenticatorTopAppBar -import com.infomaniak.auth.ui.components.StatusCard -import com.infomaniak.auth.ui.components.StatusCardVariant -import com.infomaniak.auth.ui.previewparameter.fakeAccountPairs -import com.infomaniak.auth.ui.screen.home.AccountSecurityLevel.Companion.toAccountSecurityLevel -import com.infomaniak.auth.ui.theme.AppDimens.DefaultCornerRadius +import com.infomaniak.auth.ui.navigation.NavDestination +import com.infomaniak.auth.ui.navigation.homeEntryProvider +import com.infomaniak.auth.ui.navigation.tryPopLast import com.infomaniak.auth.ui.theme.AuthenticatorTheme -import com.infomaniak.core.auth.models.user.User -import com.infomaniak.core.ui.compose.basics.Dimens +import com.infomaniak.auth.ui.theme.defaultEnterAnimation +import com.infomaniak.auth.ui.theme.defaultExitAnimation import com.infomaniak.core.ui.compose.bottomstickybuttonscaffolds.SinglePaneScaffold import com.infomaniak.core.ui.compose.margin.Margin import com.infomaniak.core.ui.compose.preview.PreviewSmallWindow -import kotlinx.coroutines.delay + @OptIn(ExperimentalPermissionsApi::class) @Composable fun HomeScreen( - onAccountClicked: (Account) -> Unit, + rootBackStack: NavBackStack, viewModel: HomeScreenViewModel = hiltViewModel(), ) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - HomeScreen( - uiState = { uiState }, - onAccountClicked = onAccountClicked, + rootBackStack = rootBackStack, onAddAccountClicked = viewModel::onAddAccountClicked, - onChallengesRefreshRequested = viewModel::refreshChallenges, ) } @Composable fun HomeScreen( - uiState: () -> HomeScreenUiState, - onAccountClicked: (Account) -> Unit, onAddAccountClicked: () -> Unit, - onChallengesRefreshRequested: () -> Unit, + rootBackStack: NavBackStack, modifier: Modifier = Modifier ) { + val homeBackStack = rememberNavBackStack(NavDestination.HomeSubDestination.AccountList) + SinglePaneScaffold( modifier = modifier, topBar = { InfomaniakAuthenticatorTopAppBar(isCentered = false, isBackgroundTransparent = true) }, + bottomBar = { + AuthenticatorBottomBar( + backStack = homeBackStack, + onMyAccountsClicked = { homeBackStack.tryPopLast() }, + onSettingsClicked = { homeBackStack.add(NavDestination.HomeSubDestination.Settings) } + ) + }, floatingActionButton = { AuthenticatorFab(onClick = onAddAccountClicked) } ) { paddingValues -> - when (val uiState = uiState()) { - is HomeScreenUiState.Success -> { - HomeScreenContent( - paddingValues = paddingValues, - uiState = uiState, - onAccountClicked = onAccountClicked, - onChallengesRefreshRequested = onChallengesRefreshRequested - ) - } - is HomeScreenUiState.Loading -> Unit - } - } -} - -@Composable -private fun HomeScreenContent( - paddingValues: PaddingValues, - uiState: HomeScreenUiState.Success, - onAccountClicked: (Account) -> Unit, - onChallengesRefreshRequested: () -> Unit, -) { - val hasUnsecuredAccounts: Boolean by remember(uiState.accountPairs) { - derivedStateOf { uiState.accountPairs.any { it.first.status.toAccountSecurityLevel() != AccountSecurityLevel.Secured } } - } - - Column( - modifier = Modifier - .padding(paddingValues) - ) { - if (hasUnsecuredAccounts) ActionRequired() - - val state = rememberPullToRefreshState() - var isRefreshing by remember { mutableStateOf(false) } - LaunchedEffect(isRefreshing) { - if (isRefreshing) { - delay(2_000) - isRefreshing = false - } - } - - PullToRefreshBox( - isRefreshing = isRefreshing, - state = state, - onRefresh = { - isRefreshing = true - onChallengesRefreshRequested() - }, - ) { - Column( - modifier = Modifier - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(Margin.Small) - ) { - uiState.accountPairs.forEach { (account, user) -> - key(account.email) { - AccountItem(account, user, onClick = { account -> onAccountClicked(account) }) - } - } - } - } - } -} - -@Composable -private fun ActionRequired() { - StatusCard( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Margin.Medium, vertical = Margin.Large), - shape = RoundedCornerShape(DefaultCornerRadius), - variant = StatusCardVariant.Warning, - ) { - Row(modifier = Modifier.padding(Margin.Small), verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(R.drawable.alert), - contentDescription = null, - tint = AuthenticatorTheme.customColors.iconTintWarning - ) - Text( - modifier = Modifier.padding(start = Margin.Small), - text = stringResource(R.string.actionRequiredDescription) - ) - } + NavDisplay( + modifier = Modifier.padding(paddingValues), + backStack = homeBackStack, + entryProvider = homeEntryProvider(rootBackStack), + transitionSpec = { defaultEnterAnimation }, + popTransitionSpec = { defaultExitAnimation }, + predictivePopTransitionSpec = { defaultExitAnimation }, + ) } } @Composable -private fun AccountItem( - account: Account, - user: User?, - onClick: (Account) -> Unit +private fun AuthenticatorBottomBar( + backStack: NavBackStack, + onMyAccountsClicked: () -> Unit, + onSettingsClicked: () -> Unit ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Margin.Medium) - .clip(RoundedCornerShape(DefaultCornerRadius)) - .clickable(onClick = { onClick(account) }), - colors = CardDefaults.cardColors(containerColor = AuthenticatorTheme.customColors.sectionBackground), - ) { + NavigationBar { Row( - modifier = Modifier.padding(vertical = Margin.Small, horizontal = Margin.Medium), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Margin.Micro) + .heightIn(min = 80.dp), + horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Margin.Micro) ) { - Avatar( - account = account, - user = user, - modifier = Modifier - .size(Dimens.bigAvatarSize) - .clip(CircleShape) - .background(AuthenticatorTheme.materialColors.surfaceContainerHighest) + NavigationBarItem( + selected = backStack.last() is NavDestination.HomeSubDestination.AccountList, + onClick = onMyAccountsClicked, + icon = { Icon(painterResource(R.drawable.accounts), null) }, + label = { Text(stringResource(R.string.accountsTitle)) }, ) - Column( - modifier = Modifier - .padding(start = Margin.Medium) - .weight(1f), - ) { - Text( - text = account.fullName, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = account.email, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Spacer(modifier = Modifier) - Icon( - modifier = Modifier.padding(end = Margin.Mini), - painter = painterResource(id = account.status.toAccountSecurityLevel().iconResId), - contentDescription = stringResource(R.string.accountSecurityLevelContentDescription), - tint = account.status.toAccountSecurityLevel().iconTint(), + NavigationBarItem( + selected = backStack.last() is NavDestination.HomeSubDestination.Settings, + onClick = onSettingsClicked, + icon = { Icon(painterResource(R.drawable.settings), null) }, + label = { Text(stringResource(R.string.settingsTitle)) }, ) - Icon( - modifier = Modifier.size(20.dp), - painter = painterResource(R.drawable.chevron_right), - contentDescription = null - ) - } - } -} - -private enum class AccountSecurityLevel(val iconResId: Int, val iconTint: @Composable () -> Color) { - Secured(iconResId = R.drawable.shield_check, iconTint = { AuthenticatorTheme.customColors.iconTintSuccess }), - Warning(iconResId = R.drawable.shield_check, iconTint = { AuthenticatorTheme.customColors.iconTintWarning }), - Danger(iconResId = R.drawable.shield_exclamation_mark, iconTint = { AuthenticatorTheme.customColors.iconTintWarning }); - - companion object { - fun Account.Status.toAccountSecurityLevel() = when (this) { - Account.Status.LoggedIn -> Secured - is Account.Status.NotConnected -> Danger //TODO: Shouldn't this show the exclamation mark too? - else -> Warning // TODO: Use secure level to determine the status more precisely } } } @@ -276,10 +135,8 @@ private enum class AccountSecurityLevel(val iconResId: Int, val iconTint: @Compo private fun HomeScreenPreview() { AuthenticatorTheme { HomeScreen( - uiState = { HomeScreenUiState.Success(fakeAccountPairs) }, - onAccountClicked = {}, + rootBackStack = rememberNavBackStack(NavDestination.Home), onAddAccountClicked = {}, - onChallengesRefreshRequested = {} ) } } diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/home/HomeScreenViewModel.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/home/HomeScreenViewModel.kt index fded3742..70e2926e 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/home/HomeScreenViewModel.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/home/HomeScreenViewModel.kt @@ -17,69 +17,18 @@ */ package com.infomaniak.auth.ui.screen.home -import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.infomaniak.auth.lib.Account import com.infomaniak.auth.lib.AppStatus import com.infomaniak.auth.lib.AuthenticatorFacade -import com.infomaniak.auth.utils.AccountUtils -import com.infomaniak.core.auth.models.user.User -import com.infomaniak.core.twofactorauth.back.TwoFactorAuthManager import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toPersistentList -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class HomeScreenViewModel @Inject constructor( - accountUtils: AccountUtils, - private val authenticatorFacade: AuthenticatorFacade, - private val twoFactorAuthManager: TwoFactorAuthManager + private val authenticatorFacade: AuthenticatorFacade ) : ViewModel() { - val uiState: StateFlow = authenticatorFacade.accounts - .combine(accountUtils.users) { accounts, users -> - val usersMap = users.associateBy { it.id.toLong() } - accounts.map { account -> - account to usersMap[account.id] - } - } - .map { accountPairs -> - if (accountPairs.isEmpty()) HomeScreenUiState.Loading else HomeScreenUiState.Success(accountPairs.toPersistentList()) - } - .stateIn( - viewModelScope, - SharingStarted.Eagerly, - HomeScreenUiState.Loading - ) - - fun refreshChallenges() { - viewModelScope.launch { - authenticatorFacade.accounts.first() - .filter { account -> - account.status == Account.Status.LoggedIn - } - .forEach { account -> - twoFactorAuthManager.refreshChallengeNow(account.id) - } - } - } - fun onAddAccountClicked() { val appStatus = authenticatorFacade.appStatusOrNull() ?: return appStatus.addAnAccount() } } - -@Immutable -sealed interface HomeScreenUiState { - data object Loading : HomeScreenUiState - data class Success(val accountPairs: ImmutableList>) : HomeScreenUiState -} diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainActivity.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainActivity.kt index 8084c8b5..71a1ab4b 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainActivity.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainActivity.kt @@ -81,7 +81,7 @@ class MainActivity : FragmentActivity() { MainScreen( viewModel = viewModel, startDestination = if (appStatus is AppStatus.SetupComplete) { - NavDestination.Root.Home + NavDestination.Home } else { NavDestination.Onboarding.Start } diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt index ceb3e61e..1dc76079 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/main/MainScreen.kt @@ -19,31 +19,15 @@ package com.infomaniak.auth.ui.screen.main import android.Manifest import android.os.Build.VERSION.SDK_INT -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntryDecorator @@ -58,15 +42,13 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberPermissionState -import com.infomaniak.auth.R import com.infomaniak.auth.lib.AppStatus import com.infomaniak.auth.ui.navigation.NavDestination import com.infomaniak.auth.ui.navigation.baseEntryProvider import com.infomaniak.auth.ui.navigation.replaceAllWith -import com.infomaniak.auth.ui.navigation.tryPopLast import com.infomaniak.auth.ui.theme.AuthenticatorTheme -import com.infomaniak.core.ui.compose.bottomstickybuttonscaffolds.SinglePaneScaffold -import com.infomaniak.core.ui.compose.margin.Margin +import com.infomaniak.auth.ui.theme.defaultEnterAnimation +import com.infomaniak.auth.ui.theme.defaultExitAnimation import com.infomaniak.core.ui.compose.preview.PreviewSmallWindow import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -87,21 +69,41 @@ fun MainScreen( rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS) } else null - LaunchedEffect(Unit) { - viewModel.appStatus.collect { handleAppStatus(it, backStack, notificationPermissionState?.status) } + var notificationPermissionScreenShown by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(viewModel.appStatus) { + viewModel.appStatus.collect { + val permissionStatus = notificationPermissionState?.status + val isPermanentlyDenied = (permissionStatus as? PermissionStatus.Denied)?.shouldShowRationale == false + val isPermissionNotRequired = with(permissionStatus) { + (this == null || this == PermissionStatus.Granted || !isPermanentlyDenied) + } + val skipPermission = notificationPermissionScreenShown && isPermissionNotRequired + + handleAppStatus( + appStatus = it, + currentDestination = currentDestination, + backStack = backStack, + skipPermission = skipPermission, + onPermissionAsked = { + notificationPermissionScreenShown = true + } + ) + } } - MainScreen(backStack, currentDestination, entryDecorators) + MainScreen(backStack, entryDecorators) } @OptIn(ExperimentalPermissionsApi::class) private fun handleAppStatus( appStatus: AppStatus, + currentDestination: NavKey, backStack: NavBackStack, - status: PermissionStatus?, + skipPermission: Boolean, + onPermissionAsked: () -> Unit, ) { - val currentDestination = backStack.lastOrNull() val targetDestination = when (appStatus) { is AppStatus.LoginRequired.NotMigrating -> NavDestination.Onboarding.Start is AppStatus.LoginRequired.MigratingFromLegacyKAuth -> NavDestination.Onboarding.Migration @@ -109,10 +111,10 @@ private fun handleAppStatus( is AppStatus.LoggingIn -> NavDestination.SecuringAccount is AppStatus.EverythingReady -> NavDestination.Onboarding.Complete is AppStatus.SetupComplete -> { - val isPermanentlyDenied = (status as? PermissionStatus.Denied)?.shouldShowRationale == false - if (status == null || status == PermissionStatus.Granted || isPermanentlyDenied) { - NavDestination.Root.Home + if (skipPermission) { + NavDestination.Home } else { + onPermissionAsked() NavDestination.Permission.Notification } } @@ -127,66 +129,16 @@ private fun handleAppStatus( @Composable fun MainScreen( backStack: NavBackStack, - currentDestination: NavKey, entryDecorators: ImmutableList>, ) { - val snackbarHostState = remember { SnackbarHostState() } - - SinglePaneScaffold( - bottomBar = { - if (currentDestination is NavDestination.Root) AuthenticatorBottomBar( - backStack = backStack, - onMyAccountsClicked = { backStack.tryPopLast() }, - onSettingsClicked = { backStack.add(NavDestination.Root.Settings) } - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) } - ) { contentPadding -> - val enterAnimation = slideInHorizontally(initialOffsetX = { it }) togetherWith - slideOutHorizontally(targetOffsetX = { -it }) - val exitAnimation = slideInHorizontally(initialOffsetX = { -it }) togetherWith - slideOutHorizontally(targetOffsetX = { it }) - NavDisplay( - modifier = Modifier.padding(contentPadding), - backStack = backStack, - entryDecorators = entryDecorators, - entryProvider = baseEntryProvider(backStack, snackbarHostState), - transitionSpec = { enterAnimation }, - popTransitionSpec = { exitAnimation }, - predictivePopTransitionSpec = { exitAnimation }, - ) - } -} - -@Composable -private fun AuthenticatorBottomBar( - backStack: NavBackStack, - onMyAccountsClicked: () -> Unit, - onSettingsClicked: () -> Unit -) { - NavigationBar { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Margin.Micro) - .heightIn(min = 80.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, - ) { - NavigationBarItem( - selected = backStack.last() is NavDestination.Root.Home, - onClick = onMyAccountsClicked, - icon = { Icon(painterResource(R.drawable.accounts), null) }, - label = { Text(stringResource(R.string.accountsTitle)) }, - ) - NavigationBarItem( - selected = backStack.last() is NavDestination.Root.Settings, - onClick = onSettingsClicked, - icon = { Icon(painterResource(R.drawable.settings), null) }, - label = { Text(stringResource(R.string.settingsTitle)) }, - ) - } - } + NavDisplay( + backStack = backStack, + entryDecorators = entryDecorators, + entryProvider = baseEntryProvider(backStack), + transitionSpec = { defaultEnterAnimation }, + popTransitionSpec = { defaultExitAnimation }, + predictivePopTransitionSpec = { defaultExitAnimation }, + ) } @PreviewSmallWindow @@ -197,10 +149,9 @@ private fun MainScreenPreview() { override val navigationEventDispatcher: NavigationEventDispatcher = NavigationEventDispatcher() } CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides owner) { - val backStack = rememberNavBackStack(NavDestination.Root.Home) - val currentDestination by remember(backStack) { derivedStateOf { backStack.last() } } + val backStack = rememberNavBackStack(NavDestination.Home) val entryDecorators = persistentListOf>() - MainScreen(backStack, currentDestination, entryDecorators) + MainScreen(backStack, entryDecorators) } } } diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/onboarding/start/OnboardingStartScreen.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/onboarding/start/OnboardingStartScreen.kt index 92c8c75f..f4246ce4 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/onboarding/start/OnboardingStartScreen.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/onboarding/start/OnboardingStartScreen.kt @@ -23,6 +23,7 @@ import androidx.activity.compose.LocalActivity import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -63,9 +64,9 @@ import kotlinx.coroutines.launch @Composable fun OnboardingStartScreen( - snackbarHostState: SnackbarHostState, onboardingStartViewModel: OnboardingStartViewModel = hiltViewModel(), ) { + val snackbarHostState = remember { SnackbarHostState() } val crossAppLoginFacade = onboardingStartViewModel.crossAppLoginFacade val accountsCheckingState by crossAppLoginFacade.accountsCheckingState.collectAsStateWithLifecycle() @@ -100,6 +101,7 @@ fun OnboardingStartScreen( } OnboardingStartScreen( + snackbarHostState = snackbarHostState, accountsCheckingState = { accountsCheckingState }, skippedIds = { skippedIds }, isLoginButtonLoading = { isButtonLoading }, @@ -119,6 +121,7 @@ fun OnboardingStartScreen( @Composable private fun OnboardingStartScreen( + snackbarHostState: SnackbarHostState, accountsCheckingState: () -> AccountsCheckingState, skippedIds: () -> Set, isLoginButtonLoading: () -> Boolean, @@ -136,6 +139,9 @@ private fun OnboardingStartScreen( OnboardingScaffold( pagerState = pagerState, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, onboardingPages = Page.entries.mapIndexed { index, page -> page.toOnboardingPage(pagerState, index) }, @@ -198,6 +204,7 @@ private fun OnboardingStartScreenPreview( ) { AuthenticatorTheme { OnboardingStartScreen( + snackbarHostState = SnackbarHostState(), accountsCheckingState = { AccountsCheckingState(AccountsCheckingStatus.Checking, checkedAccounts = accounts) }, diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/permission/NotificationPermissionScreen.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/permission/NotificationPermissionScreen.kt index a095c409..3c8f15a6 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/permission/NotificationPermissionScreen.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/permission/NotificationPermissionScreen.kt @@ -57,7 +57,7 @@ import com.infomaniak.core.ui.compose.preview.PreviewSmallWindow @SuppressLint("ComposeModifierMissing") @Composable fun NotificationPermissionScreen( - navigateToHome: () -> Unit, + navigateToHome: () -> Unit ) { var permissionAsked by remember { mutableStateOf(false) } val notificationPermissionState: PermissionState? = if (SDK_INT >= 33) { diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/settings/SettingsScreen.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/settings/SettingsScreen.kt index dd3438aa..9b812ac4 100644 --- a/app/src/main/kotlin/com/infomaniak/auth/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/screen/settings/SettingsScreen.kt @@ -20,8 +20,8 @@ package com.infomaniak.auth.ui.screen.settings import android.content.Intent import android.provider.Settings import androidx.activity.compose.LocalActivity -import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -32,7 +32,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.infomaniak.auth.MatomoAuthenticator.trackSettingsEvent import com.infomaniak.auth.R import com.infomaniak.auth.lib.matomo.MatomoName -import com.infomaniak.auth.ui.components.InfomaniakAuthenticatorTopAppBar import com.infomaniak.auth.ui.components.OptionItemType import com.infomaniak.auth.ui.components.OptionsSection import com.infomaniak.auth.ui.screen.settings.theme.AppSettingsViewModel @@ -43,7 +42,6 @@ import com.infomaniak.core.applock.AppLockHelper.requestCredentials import com.infomaniak.core.applock.AppLockManager import com.infomaniak.core.common.extensions.openUrl import com.infomaniak.core.network.SUPPORT_URL -import com.infomaniak.core.ui.compose.bottomstickybuttonscaffolds.SinglePaneScaffold import com.infomaniak.core.ui.compose.preview.PreviewSmallWindow import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -70,6 +68,7 @@ private fun SettingsScreen( onThemeClicked: () -> Unit, onPrivacyManagementClicked: () -> Unit, hasBiometrics: Boolean, + modifier: Modifier = Modifier, ) { val fragmentActivity = LocalActivity.current as? FragmentActivity @@ -135,28 +134,24 @@ private fun SettingsScreen( }, ), ) - SinglePaneScaffold( - modifier = Modifier.background(AuthenticatorTheme.materialColors.inverseOnSurface), - topBar = { - InfomaniakAuthenticatorTopAppBar(isCentered = false, isBackgroundTransparent = true) - }, - ) { paddingValues -> - OptionsSection( - sections = persistentListOf(firstSectionItems, secondSectionItems), - modifier = Modifier.padding(paddingValues), - ) - } + OptionsSection( + modifier = modifier, + sections = persistentListOf(firstSectionItems, secondSectionItems), + ) } @PreviewSmallWindow @Composable private fun SettingsScreenPreview() { AuthenticatorTheme { - SettingsScreen( - appLocked = GetSetCallbacks(get = { true }, set = {}), - onThemeClicked = {}, - onPrivacyManagementClicked = {}, - hasBiometrics = true, - ) + Scaffold { paddingValues -> + SettingsScreen( + modifier = Modifier.padding(paddingValues), + appLocked = GetSetCallbacks(get = { true }, set = {}), + onThemeClicked = {}, + onPrivacyManagementClicked = {}, + hasBiometrics = true, + ) + } } } diff --git a/app/src/main/kotlin/com/infomaniak/auth/ui/theme/NavigationAnimation.kt b/app/src/main/kotlin/com/infomaniak/auth/ui/theme/NavigationAnimation.kt new file mode 100644 index 00000000..4acb08cd --- /dev/null +++ b/app/src/main/kotlin/com/infomaniak/auth/ui/theme/NavigationAnimation.kt @@ -0,0 +1,27 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * 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, either version 3 of the License, or + * (at your option) any later version. + * + * 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 com.infomaniak.auth.ui.theme + +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith + +val defaultEnterAnimation = slideInHorizontally(initialOffsetX = { it }) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) +val defaultExitAnimation = slideInHorizontally(initialOffsetX = { -it }) togetherWith + slideOutHorizontally(targetOffsetX = { it })