This commit is contained in:
Mina Rizkalla Azmy 2026-02-05 02:28:31 +00:00 committed by GitHub
commit b2370ce40b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 393 additions and 52 deletions

View File

@ -16,18 +16,29 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import cmp.navigation.rootnav.RootNavScreen
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.common.SessionManager
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
import org.mifos.mobile.core.model.MifosThemeConfig
import org.mifos.mobile.core.ui.utils.EventsEffect
import org.mifos.mobile.core.ui.utils.NetworkBanner
import org.mifos.mobile.core.ui.utils.SessionHandler
import template.core.base.designsystem.theme.KptTheme
@Composable
fun ComposeApp(
@ -35,10 +46,35 @@ fun ComposeApp(
handleAppLocale: (locale: String?) -> Unit,
onSplashScreenRemoved: () -> Unit,
modifier: Modifier = Modifier,
sessionManager: SessionManager = koinInject(),
viewModel: ComposeAppViewModel = koinViewModel(),
) {
val navController = rememberNavController()
val uiState by viewModel.stateFlow.collectAsStateWithLifecycle()
var wasBackgrounded by remember { mutableStateOf(false) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) {
wasBackgrounded = true
viewModel.trySendAction(AppAction.LockApp)
} else if (event == Lifecycle.Event.ON_START) {
if (wasBackgrounded) {
viewModel.trySendAction(AppAction.LockApp)
wasBackgrounded = false
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
val isSessionExpired by sessionManager.isExpired.collectAsStateWithLifecycle()
EventsEffect(eventFlow = viewModel.eventFlow) { event ->
when (event) {
is AppEvent.ShowToast -> {}
@ -59,25 +95,36 @@ fun ComposeApp(
androidTheme = uiState.isAndroidTheme,
shouldDisplayDynamicTheming = uiState.isDynamicColorsEnabled,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
) {
Column(
modifier = modifier
.fillMaxSize()
.statusBarsPadding(),
) {
NetworkBanner(
bannerState = uiState.networkBanner,
modifier = Modifier.fillMaxWidth(),
)
LaunchedEffect(isSessionExpired) {
if (isSessionExpired) {
viewModel.trySendAction(AppAction.SessionExpired)
}
}
RootNavScreen(
modifier = Modifier,
onSplashScreenRemoved = onSplashScreenRemoved,
)
SessionHandler(
sessionManager = sessionManager,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(KptTheme.colorScheme.surface),
) {
Column(
modifier = modifier
.fillMaxSize()
.statusBarsPadding(),
) {
NetworkBanner(
bannerState = uiState.networkBanner,
modifier = Modifier.fillMaxWidth(),
)
RootNavScreen(
navController = navController,
modifier = Modifier,
onSplashScreenRemoved = onSplashScreenRemoved,
)
}
}
}
}

View File

@ -146,6 +146,16 @@ class ComposeAppViewModel(
is AppAction.Internal.SystemThemeUpdate -> handleSystemThemeUpdate(action)
is AppAction.Internal.TimeBasedThemeUpdate -> handleTimeBasedThemeUpdate(action)
is AppAction.SessionExpired -> handleLockApp()
is AppAction.LockApp -> handleLockApp()
}
}
private fun handleLockApp() {
viewModelScope.launch {
userPreferencesRepository.setIsUnlocked(false)
}
}
@ -261,4 +271,7 @@ sealed interface AppAction {
val timeBasedTheme: TimeBasedTheme,
) : Internal()
}
data object LockApp : AppAction
data object SessionExpired : AppAction
}

View File

@ -10,33 +10,21 @@
package cmp.navigation.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.navigation
import cmp.navigation.authenticated.AuthenticatedGraphRoute
import org.mifos.library.passcode.PASSCODE_SCREEN
import org.mifos.library.passcode.passcodeRoute
import cmp.navigation.utils.toObjectNavigationRoute
import org.mifos.mobile.feature.passcode.navigation.PasscodeRoute
import org.mifos.mobile.feature.passcode.navigation.passcodeDestination
internal fun NavGraphBuilder.passcodeNavGraph(navController: NavHostController) {
internal fun NavGraphBuilder.passcodeNavGraph(
onPasscodeVerified: () -> Unit,
) {
navigation(
route = NavGraphRoute.PASSCODE_GRAPH,
startDestination = PASSCODE_SCREEN,
startDestination = PasscodeRoute.Standard.toObjectNavigationRoute(),
) {
passcodeRoute(
onForgotButton = {
navController.popBackStack()
navController.navigate(AuthenticatedGraphRoute)
},
onSkipButton = {
navController.popBackStack()
navController.navigate(AuthenticatedGraphRoute)
},
onPasscodeConfirm = {
navController.popBackStack()
navController.navigate(AuthenticatedGraphRoute)
},
onPasscodeRejected = {
navController.popBackStack()
navController.navigate(AuthenticatedGraphRoute)
passcodeDestination(
onPasscodeConfirm = { _, _, _, _, _ ->
onPasscodeVerified()
},
)
}

View File

@ -24,15 +24,16 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.navOptions
import cmp.navigation.authenticated.AuthenticatedGraphRoute
import cmp.navigation.authenticated.authenticatedGraph
import cmp.navigation.authenticated.navigateToAuthenticatedGraph
import cmp.navigation.authenticated.navigateToStatusScreenLoginFlow
import cmp.navigation.authenticated.navigateToStatusScreenPasscodeFlow
import cmp.navigation.navigation.passcodeNavGraph
import cmp.navigation.splash.SplashRoute
import cmp.navigation.splash.navigateToSplash
import cmp.navigation.splash.splashDestination
import cmp.navigation.ui.rememberMifosNavController
import cmp.navigation.utils.toObjectNavigationRoute
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.common.SessionManager
import org.mifos.mobile.core.ui.NonNullEnterTransitionProvider
import org.mifos.mobile.core.ui.NonNullExitTransitionProvider
import org.mifos.mobile.core.ui.RootTransitionProviders
@ -44,13 +45,13 @@ import org.mifos.mobile.feature.onboarding.language.navigation.navigateToOnboard
import org.mifos.mobile.feature.onboarding.language.navigation.onBoardingLanguageDestination
import org.mifos.mobile.feature.passcode.navigation.PasscodeRoute
import org.mifos.mobile.feature.passcode.navigation.navigateToPasscodeScreen
import org.mifos.mobile.feature.passcode.navigation.passcodeDestination
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun RootNavScreen(
modifier: Modifier = Modifier,
viewModel: RootNavViewModel = koinViewModel(),
sessionManager: SessionManager = koinInject(),
navController: NavHostController = rememberMifosNavController(name = "RootNavScreen"),
onSplashScreenRemoved: () -> Unit = {},
) {
@ -79,7 +80,14 @@ fun RootNavScreen(
navController::navigateToStatusScreenLoginFlow,
)
authenticatedGraph(navController)
passcodeDestination(navController::navigateToStatusScreenPasscodeFlow)
passcodeNavGraph(
onPasscodeVerified = {
sessionManager.startSession()
navController.navigate(AuthenticatedGraphRoute) {
popUpTo(0) { inclusive = true }
}
},
)
}
val targetRoute = when (state) {
@ -122,10 +130,20 @@ fun RootNavScreen(
RootNavState.Splash -> navController.navigateToSplash(rootNavOptions)
RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions)
RootNavState.SetLanguage -> navController.navigateToOnboardingLanguage(rootNavOptions)
RootNavState.UserLocked -> navController.navigateToPasscodeScreen(rootNavOptions)
is RootNavState.UserUnlocked -> navController.navigateToAuthenticatedGraph(
navOptions = rootNavOptions,
RootNavState.UserLocked -> navController.navigateToPasscodeScreen(
navOptions {
popUpTo(navController.graph.id) {
inclusive = true
}
launchSingleTop = true
},
)
is RootNavState.UserUnlocked -> {
navController.navigate(AuthenticatedGraphRoute) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
}
}
}
}
}

View File

@ -17,17 +17,24 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.mifos.mobile.core.data.repository.UserDataRepository
import org.mifos.mobile.core.datastore.UserPreferencesRepository
import org.mifos.mobile.core.datastore.model.AppSettings
import org.mifos.mobile.core.model.AuthState
import org.mifos.mobile.core.ui.utils.BaseViewModel
class RootNavViewModel(
userDataRepository: UserDataRepository,
private val userPreferencesRepository: UserPreferencesRepository,
) : BaseViewModel<RootNavState, Unit, RootNavAction>(
initialState = RootNavState.Splash,
) {
init {
viewModelScope.launch {
userPreferencesRepository.setIsUnlocked(false)
}
viewModelScope.launch {
userDataRepository.authState
.collect { authState ->

View File

@ -95,7 +95,9 @@ object Constants {
const val MAKE_PAYMENT = "make_payments"
const val LOAN_SUMMARY = "loan_summary"
// Settings constants
const val TIMEOUT_SESSION_MS = 5 * 60 * 1000L
// Settings constants
const val PROFILE = "profile"
const val PASSWORD = "password"
const val AUTH_PASSCODE = "auth_passcode"

View File

@ -0,0 +1,103 @@
/*
* Copyright 2026 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.common
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.AtomicLong
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
class SessionManager {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val checkLock = Mutex()
private var heartbeatJob: Job? = null
@OptIn(ExperimentalAtomicApi::class)
private val lastInteractionTime = AtomicLong(0L)
@OptIn(ExperimentalAtomicApi::class)
private val isMonitoring = AtomicBoolean(false)
private val timeoutMs = Constants.TIMEOUT_SESSION_MS
private val _isExpired = MutableStateFlow(false)
val isExpired = _isExpired.asStateFlow()
@OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class)
fun startSession() {
if (isMonitoring.compareAndSet(expectedValue = false, newValue = true)) {
_isExpired.value = false
scope.launch {
val now = Clock.System.now().toEpochMilliseconds()
lastInteractionTime.store(now)
if (!_isExpired.value) {
heartbeatJob = startHeartbeat()
}
}
}
}
@OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class)
fun userInteracted() {
if (_isExpired.value) return
if (isMonitoring.load()) {
val now = Clock.System.now().toEpochMilliseconds()
lastInteractionTime.store(now)
}
}
@OptIn(ExperimentalAtomicApi::class)
fun stopSession() {
isMonitoring.store(false)
_isExpired.value = false
heartbeatJob?.cancel()
heartbeatJob = null
lastInteractionTime.store(0L)
}
@OptIn(ExperimentalAtomicApi::class, ExperimentalTime::class)
private suspend fun checkExpirationInternal() {
checkLock.withLock {
if (!isMonitoring.load() || _isExpired.value) return
val ramTime = lastInteractionTime.load()
if (ramTime == 0L) return
val currentTime = Clock.System.now().toEpochMilliseconds()
if (currentTime - ramTime >= timeoutMs) {
_isExpired.value = true
isMonitoring.store(false)
}
}
}
@OptIn(ExperimentalTime::class, ExperimentalAtomicApi::class)
private fun startHeartbeat(): Job {
return scope.launch {
while (isMonitoring.load()) {
checkExpirationInternal()
delay(30_000)
}
}
}
}

View File

@ -17,6 +17,7 @@ import org.koin.core.module.Module
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.mifos.mobile.core.common.MifosDispatchers
import org.mifos.mobile.core.common.SessionManager
val DispatchersModule = module {
includes(ioDispatcherModule)
@ -25,6 +26,7 @@ val DispatchersModule = module {
single<CoroutineScope>(named("ApplicationScope")) {
CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
single { SessionManager() }
}
expect val ioDispatcherModule: Module

View File

@ -32,6 +32,7 @@ kotlin{
commonMain.dependencies {
api(projects.core.designsystem)
implementation(projects.core.model)
implementation(projects.core.common)
api(libs.kotlinx.datetime)
implementation(libs.jb.lifecycle.compose)
implementation(libs.jb.composeViewmodel)

View File

@ -0,0 +1,62 @@
/*
* Copyright 2026 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
package org.mifos.mobile.core.ui.utils
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.pointerInput
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.mifos.mobile.core.common.SessionManager
@Composable
fun SessionHandler(
sessionManager: SessionManager,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val isExpired by sessionManager.isExpired.collectAsStateWithLifecycle()
Box(
modifier = modifier
.fillMaxSize()
.pointerInput(isExpired) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
if (isExpired) {
event.changes.forEach { it.consume() }
} else if (event.changes.any { it.changedToDown() }) {
sessionManager.userInteracted()
}
}
}
}.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyUp) {
if (isExpired) {
return@onPreviewKeyEvent true
} else {
sessionManager.userInteracted()
}
}
false
},
) {
content()
}
}

View File

@ -20,6 +20,7 @@ import mifos_mobile.feature.auth.generated.resources.feature_sign_in_password_er
import mifos_mobile.feature.auth.generated.resources.feature_sign_in_username_error
import org.jetbrains.compose.resources.StringResource
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.common.SessionManager
import org.mifos.mobile.core.data.repository.UserAuthRepository
import org.mifos.mobile.core.datastore.UserPreferencesRepository
import org.mifos.mobile.core.datastore.model.UserData
@ -30,6 +31,7 @@ import org.mifos.mobile.core.ui.utils.ScreenUiState
class LoginViewModel(
private val userAuthRepositoryImpl: UserAuthRepository,
private val userPreferencesRepositoryImpl: UserPreferencesRepository,
sessionManager: SessionManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LoginState, LoginEvent, LoginAction>(
initialState = LoginState(uiState = ScreenUiState.Success),
@ -38,6 +40,7 @@ class LoginViewModel(
private var loginJob: Job? = null
init {
sessionManager.stopSession()
savedStateHandle.get<String>("username")?.let {
trySendAction(LoginAction.UsernameChanged(it))
}

View File

@ -31,6 +31,7 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.mifos.mobile.core.common.Constants
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.common.SessionManager
import org.mifos.mobile.core.data.repository.UserAuthRepository
import org.mifos.mobile.core.model.EventType
import org.mifos.mobile.core.ui.utils.BaseViewModel
@ -39,11 +40,13 @@ import org.mifos.mobile.feature.auth.login.LoginRoute
internal class OtpAuthenticationViewModel(
private val userAuthRepositoryImpl: UserAuthRepository,
sessionManager: SessionManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<OtpAuthState, OtpAuthEvent, OtpAuthAction>(
initialState = OtpAuthState(dialogState = null),
) {
init {
sessionManager.stopSession()
val nextRoute = savedStateHandle.toRoute<OtpAuthenticationRoute>()
mutableStateFlow.update {

View File

@ -24,6 +24,7 @@ import mifos_mobile.feature.auth.generated.resources.feature_recover_now_invalid
import mifos_mobile.feature.auth.generated.resources.feature_recover_now_phone_number_error
import mifos_mobile.feature.auth.generated.resources.feature_recover_now_phone_number_required
import org.jetbrains.compose.resources.StringResource
import org.mifos.mobile.core.common.SessionManager
import org.mifos.mobile.core.ui.utils.BaseViewModel
import org.mifos.mobile.core.ui.utils.ScreenUiState
import org.mifos.mobile.core.ui.utils.ValidationHelper
@ -31,11 +32,17 @@ import org.mifos.mobile.feature.auth.setNewPassword.SetPasswordRoute
const val PHONE_NUMBER_LENGTH = 10
internal class RecoverPasswordViewModel :
internal class RecoverPasswordViewModel(
sessionManager: SessionManager,
) :
BaseViewModel<RecoverPasswordState, RecoverPasswordEvent, RecoverPasswordAction>(
initialState = RecoverPasswordState(),
) {
init {
sessionManager.stopSession()
}
private var validationJob: Job? = null
override fun handleAction(action: RecoverPasswordAction) {

View File

@ -27,6 +27,7 @@ import mifos_mobile.feature.auth.generated.resources.feature_signup_error_passwo
import mifos_mobile.feature.auth.generated.resources.feature_signup_error_password_short
import org.jetbrains.compose.resources.StringResource
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.common.SessionManager
import org.mifos.mobile.core.data.repository.UserAuthRepository
import org.mifos.mobile.core.model.entity.register.RegisterPayload
import org.mifos.mobile.core.ui.PasswordStrengthState
@ -48,10 +49,15 @@ import org.mifos.mobile.core.ui.utils.ValidationHelper
@Suppress("TooManyFunctions")
class RegistrationViewModel(
private val userAuthRepositoryImpl: UserAuthRepository,
sessionManager: SessionManager,
) : BaseViewModel<SignUpState, SignUpEvent, SignUpAction>(
initialState = SignUpState(),
) {
init {
sessionManager.stopSession()
}
private var validationJob: Job? = null
private var passwordStrengthJob: Job = Job()

View File

@ -25,6 +25,7 @@ import mifos_mobile.feature.auth.generated.resources.feature_signup_error_passwo
import mifos_mobile.feature.auth.generated.resources.feature_signup_error_password_required_error
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.mifos.mobile.core.common.SessionManager
import org.mifos.mobile.core.model.EventType
import org.mifos.mobile.core.ui.PasswordStrengthState
import org.mifos.mobile.core.ui.utils.BaseViewModel
@ -34,10 +35,16 @@ import org.mifos.mobile.core.ui.utils.PasswordStrengthResult
import org.mifos.mobile.core.ui.utils.ScreenUiState
import org.mifos.mobile.feature.auth.login.LoginRoute
internal class SetPasswordViewModel : BaseViewModel<SetPasswordState, SetPasswordEvent, SetPasswordAction>(
internal class SetPasswordViewModel(
sessionManager: SessionManager,
) : BaseViewModel<SetPasswordState, SetPasswordEvent, SetPasswordAction>(
initialState = SetPasswordState(dialogState = null),
) {
init {
sessionManager.stopSession()
}
private var validationJob: Job? = null
private var passwordStrengthJob: Job = Job()

View File

@ -31,15 +31,22 @@ import mifos_mobile.feature.auth.generated.resources.feature_upload_id_error_mob
import mifos_mobile.feature.auth.generated.resources.feature_upload_id_error_photo_required
import mifos_mobile.feature.auth.generated.resources.feature_upload_id_upload_failed
import org.jetbrains.compose.resources.StringResource
import org.mifos.mobile.core.common.SessionManager
import org.mifos.mobile.core.common.toBase64DataUri
import org.mifos.mobile.core.ui.utils.BaseViewModel
import org.mifos.mobile.core.ui.utils.ValidationHelper
import org.mifos.mobile.feature.auth.recoverPassword.PHONE_NUMBER_LENGTH
internal class UploadIdViewModel :
internal class UploadIdViewModel(
sessionManager: SessionManager,
) :
BaseViewModel<UploadIdUiState, UploadIdEvent, UploadIdAction>(
initialState = UploadIdUiState(dialogState = null),
) {
init {
sessionManager.stopSession()
}
override fun handleAction(action: UploadIdAction) {
when (action) {
UploadIdAction.OnBackClick -> sendEvent(UploadIdEvent.BackClick)

View File

@ -21,6 +21,7 @@ import mifos_mobile.feature.home.generated.resources.feature_server_error
import org.jetbrains.compose.resources.StringResource
import org.mifos.mobile.core.common.CurrencyFormatter
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.common.SessionManager
import org.mifos.mobile.core.data.repository.HomeRepository
import org.mifos.mobile.core.data.util.NetworkMonitor
import org.mifos.mobile.core.datastore.UserPreferencesRepository
@ -45,6 +46,7 @@ import org.mifos.mobile.core.ui.utils.BaseViewModel
internal class HomeViewModel(
private val homeRepositoryImpl: HomeRepository,
private val networkMonitor: NetworkMonitor,
private val sessionManager: SessionManager,
userPreferencesRepositoryImpl: UserPreferencesRepository,
) : BaseViewModel<HomeState, HomeEvent, HomeAction>(
initialState = HomeState(
@ -58,6 +60,7 @@ internal class HomeViewModel(
private var isHandlingNetworkChange = false
init {
sessionManager.startSession()
observeNetworkStatus()
}

View File

@ -23,4 +23,6 @@
<string name="feature_passcode_authenticate">Ingresa el PIN de autenticación</string>
<string name="feature_passcode_authenticate_tip">Confirma que eres tú ingresando tu PIN de autenticación de 4 dígitos para completar tu acción</string>
<string name="feature_passcode_unlock_success">¡Desbloqueado!</string>
<string name="feature_passcode_unlock_success_msg">Has verificado tu identidad correctamente.</string>
</resources>

View File

@ -23,4 +23,7 @@
<string name="feature_passcode_authenticate">Enter Authentication Code</string>
<string name="feature_passcode_authenticate_tip">Confirm its you by entering your 4-digit authentication code in order to complete your action</string>
<string name="feature_passcode_unlock_success">Unlocked!</string>
<string name="feature_passcode_unlock_success_msg">You have successfully verified your identity.</string>
</resources>

View File

@ -36,6 +36,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import mifos_mobile.feature.passcode.generated.resources.Res
import mifos_mobile.feature.passcode.generated.resources.feature_passcode_authenticate
import mifos_mobile.feature.passcode.generated.resources.feature_passcode_confirm
import mifos_mobile.feature.passcode.generated.resources.feature_passcode_setup
import mifos_mobile.feature.passcode.generated.resources.feature_passcode_tip
@ -121,6 +122,7 @@ private fun PasscodeScreenContent(
text = when (state.mode) {
PasscodeMode.Set -> stringResource(Res.string.feature_passcode_setup)
PasscodeMode.Confirm -> stringResource(Res.string.feature_passcode_confirm)
PasscodeMode.Verify -> stringResource(Res.string.feature_passcode_authenticate)
},
style = MifosTypography.titleMedium,
color = KptTheme.colorScheme.onBackground,

View File

@ -10,24 +10,45 @@
package org.mifos.mobile.feature.passcode
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import mifos_mobile.feature.passcode.generated.resources.Res
import mifos_mobile.feature.passcode.generated.resources.feature_passcode_common_continue
import mifos_mobile.feature.passcode.generated.resources.feature_passcode_setup_successful
import mifos_mobile.feature.passcode.generated.resources.feature_passcode_setup_successful_msg
import mifos_mobile.feature.passcode.generated.resources.feature_passcode_unlock_success
import mifos_mobile.feature.passcode.generated.resources.feature_passcode_unlock_success_msg
import org.jetbrains.compose.resources.getString
import org.mifos.mobile.core.common.Constants
import org.mifos.mobile.core.common.SessionManager
import org.mifos.mobile.core.datastore.UserPreferencesRepository
import org.mifos.mobile.core.model.EventType
import org.mifos.mobile.core.ui.utils.BaseViewModel
internal class PasscodeViewModel(
private val userPreferencesRepository: UserPreferencesRepository,
sessionManager: SessionManager,
) : BaseViewModel<PasscodeState, PasscodeEvent, PasscodeAction>(
initialState = PasscodeState(),
) {
init {
sessionManager.stopSession()
viewModelScope.launch {
val storedPasscode = userPreferencesRepository.passcode.firstOrNull()
if (!storedPasscode.isNullOrEmpty()) {
mutableStateFlow.update {
it.copy(
mode = PasscodeMode.Verify,
storedPasscode = storedPasscode,
)
}
}
}
}
private var passcodeBuilder: StringBuilder = StringBuilder()
override fun handleAction(action: PasscodeAction) {
@ -77,6 +98,7 @@ internal class PasscodeViewModel(
if (confirm == state.firstPasscode) {
viewModelScope.launch {
userPreferencesRepository.setPasscode(confirm)
userPreferencesRepository.setIsUnlocked(true)
sendEvent(
PasscodeEvent.OnPasscodeConfirm(
eventType = EventType.SUCCESS.name,
@ -100,10 +122,42 @@ internal class PasscodeViewModel(
}
}
}
PasscodeMode.Verify -> viewModelScope.launch {
val storedPasscode = userPreferencesRepository.passcode.firstOrNull()
val entered = passcodeBuilder.toString()
if (entered == storedPasscode) {
userPreferencesRepository.setIsUnlocked(true)
sendEvent(
PasscodeEvent.OnPasscodeConfirm(
eventType = EventType.SUCCESS.name,
eventDestination = "UNLOCK_ACTION",
title = getString(Res.string.feature_passcode_unlock_success),
subtitle = getString(Res.string.feature_passcode_unlock_success_msg),
buttonText = getString(Res.string.feature_passcode_common_continue),
),
)
} else {
handleWrongPasscode()
}
}
}
}
}
private fun handleWrongPasscode() {
passcodeBuilder.clear()
mutableStateFlow.update {
it.copy(
firstPasscode = if (it.mode == PasscodeMode.Confirm) "" else it.firstPasscode,
mode = if (it.mode == PasscodeMode.Confirm) PasscodeMode.Set else it.mode,
passcode = "",
filledDots = 0,
passcodeError = true,
)
}
}
private fun updatePasscodeState() {
mutableStateFlow.update {
it.copy(
@ -144,4 +198,5 @@ internal sealed interface PasscodeAction {
enum class PasscodeMode {
Set,
Confirm,
Verify,
}