refactor: enhance navigation and screens (#2858)

This commit is contained in:
Nagarjuna 2025-07-13 22:02:57 +05:30 committed by GitHub
parent ca5bb5902b
commit d3ccfb3678
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
109 changed files with 2754 additions and 1202 deletions

View File

@ -0,0 +1,85 @@
/*
* Copyright 2025 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 cmp.android.app
import android.content.res.Configuration
import android.graphics.Color
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.util.Consumer
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.mifos.mobile.core.model.DarkThemeConfig
@ColorInt
private val SCRIM_COLOR: Int = Color.TRANSPARENT
/**
* Helper method to handle edge-to-edge logic for dark mode.
*
* This logic is from the Now-In-Android app found
* [here](https://github.com/android/nowinandroid/blob/689ef92e41427ab70f82e2c9fe59755441deae92/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt#L94).
*/
@Suppress("MaxLineLength")
fun ComponentActivity.setupEdgeToEdge(
appThemeFlow: Flow<DarkThemeConfig>,
) {
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) {
combine(
isSystemInDarkModeFlow(),
appThemeFlow,
) { isSystemDarkMode, appTheme ->
AppCompatDelegate.setDefaultNightMode(appTheme.osValue)
}
.distinctUntilChanged()
.collect { isDarkMode ->
// This handles all the settings to go edge-to-edge. We are using a transparent
// scrim for system bars and switching between "light" and "dark" based on the
// system and internal app theme settings.
val style = SystemBarStyle.auto(
darkScrim = SCRIM_COLOR,
lightScrim = SCRIM_COLOR,
// Disabling Dark Mode for this app
detectDarkMode = { false },
)
enableEdgeToEdge(statusBarStyle = style, navigationBarStyle = style)
}
}
}
}
/**
* Adds a configuration change listener to retrieve whether system is in
* dark theme or not. This will emit current status immediately and then
* will emit changes as needed.
*/
private fun ComponentActivity.isSystemInDarkModeFlow(): Flow<Boolean> =
callbackFlow {
channel.trySend(element = resources.configuration.isSystemInDarkMode)
val listener = Consumer<Configuration> {
channel.trySend(element = it.isSystemInDarkMode)
}
addOnConfigurationChangedListener(listener = listener)
awaitClose { removeOnConfigurationChangedListener(listener = listener) }
}
.distinctUntilChanged()
.conflate()

View File

@ -0,0 +1,15 @@
/*
* Copyright 2025 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 cmp.android.app
import android.content.res.Configuration
val Configuration.isSystemInDarkMode
get() = (uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES

View File

@ -12,13 +12,18 @@ package cmp.android.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import cmp.shared.SharedApp
import io.github.vinceglb.filekit.FileKit
import io.github.vinceglb.filekit.dialogs.init
import org.koin.android.ext.android.inject
import org.mifos.mobile.core.datastore.UserPreferencesRepository
import org.mifos.mobile.core.ui.utils.ShareUtils
import java.util.Locale
import kotlin.getValue
/**
* Main activity class.
@ -32,13 +37,18 @@ class MainActivity : ComponentActivity() {
* Called when the activity is starting.
* This is where most initialization should go: calling [setContentView(int)] to inflate the activity's UI,
*/
private val userPreferencesRepository: UserPreferencesRepository by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
installSplashScreen()
val darkThemeConfigFlow = userPreferencesRepository.observeDarkThemeConfig
WindowCompat.setDecorFitsSystemWindows(window, false)
enableEdgeToEdge()
setupEdgeToEdge(darkThemeConfigFlow)
ShareUtils.setActivityProvider { return@setActivityProvider this }
FileKit.init(this)
/**
@ -46,7 +56,22 @@ class MainActivity : ComponentActivity() {
* @see setContent
*/
setContent {
SharedApp()
SharedApp(
handleThemeMode = {
AppCompatDelegate.setDefaultNightMode(it)
},
handleAppLocale = {
it?.let {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(it),
)
Locale.setDefault(Locale(it))
}
},
onSplashScreenRemoved = {
shouldShowSplashScreen = false
},
)
}
}
}

View File

@ -46,7 +46,11 @@ fun main() {
title = stringResource(Res.string.application_title),
) {
// Sets the content of the window.
SharedApp()
SharedApp(
handleThemeMode = {},
handleAppLocale = {},
onSplashScreenRemoved = {},
)
}
}
}

View File

@ -40,6 +40,7 @@ kotlin {
implementation(projects.feature.notification)
implementation(projects.feature.userProfile)
implementation(projects.feature.location)
implementation(projects.feature.onboardingLanguage)
// Core Modules
implementation(projects.core.data)
implementation(projects.core.common)
@ -55,6 +56,7 @@ kotlin {
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.kotlinx.serialization.json)
}
androidMain.dependencies {
implementation(libs.androidx.core.ktx)

View File

@ -9,9 +9,9 @@
See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
-->
<resources>
<string name="app_name">CMP App</string>
<string name="app_name">Mifos Mobile</string>
<string name="home">Home</string>
<string name="transfer">Transfer</string>
<string name="profile">Profile</string>
<string name="settings">Settings</string>
<string name="not_connected">⚠️ You arent connected to the internet</string>
</resources>

View File

@ -9,65 +9,41 @@
*/
package cmp.navigation
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.rememberNavController
import cmp.navigation.navigation.NavGraphRoute.AUTH_GRAPH
import cmp.navigation.navigation.NavGraphRoute.PASSCODE_GRAPH
import cmp.navigation.navigation.RootNavGraph
import org.koin.compose.koinInject
import cmp.navigation.rootnav.RootNavScreen
import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.data.util.NetworkMonitor
import org.mifos.mobile.core.datastore.model.AppTheme
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
import org.mifos.mobile.core.ui.utils.EventsEffect
@Composable
fun ComposeApp(
handleThemeMode: (osValue: Int) -> Unit,
handleAppLocale: (locale: String?) -> Unit,
onSplashScreenRemoved: () -> Unit,
modifier: Modifier = Modifier,
networkMonitor: NetworkMonitor = koinInject(),
viewModel: ComposeAppViewModel = koinViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val navController = rememberNavController()
val uiState by viewModel.stateFlow.collectAsStateWithLifecycle()
val navDestination = when (uiState) {
is MainUiState.Loading -> AUTH_GRAPH
is MainUiState.Success -> if ((uiState as MainUiState.Success).userData.isAuthenticated) {
PASSCODE_GRAPH
} else {
AUTH_GRAPH
EventsEffect(eventFlow = viewModel.eventFlow) { event ->
when (event) {
is AppEvent.ShowToast -> {}
is AppEvent.UpdateAppLocale -> handleAppLocale(event.localeName)
is AppEvent.UpdateAppTheme -> handleThemeMode(event.osValue)
}
else -> AUTH_GRAPH
}
val isDarkMode = when (uiState) {
is MainUiState.Success -> when ((uiState as MainUiState.Success).appTheme) {
AppTheme.SYSTEM -> isSystemInDarkTheme()
AppTheme.LIGHT -> false
AppTheme.DARK -> true
}
else -> true
}
MifosMobileTheme(isDarkMode) {
RootNavGraph(
modifier = modifier.fillMaxSize(),
networkMonitor = networkMonitor,
navHostController = navController,
startDestination = navDestination,
onClickLogout = {
viewModel.logOut()
navController.navigate(AUTH_GRAPH) {
popUpTo(navController.graph.id) {
inclusive = true
}
}
},
MifosMobileTheme(
darkTheme = uiState.darkTheme,
androidTheme = uiState.isAndroidTheme,
shouldDisplayDynamicTheming = uiState.isDynamicColorsEnabled,
) {
RootNavScreen(
modifier = modifier,
onSplashScreenRemoved = onSplashScreenRemoved,
)
}
}

View File

@ -9,51 +9,113 @@
*/
package cmp.navigation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.mifos.library.passcode.data.PasscodeManager
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.data.repository.UserDataRepository
import org.mifos.mobile.core.data.util.NetworkMonitor
import org.mifos.mobile.core.datastore.UserPreferencesRepository
import org.mifos.mobile.core.datastore.model.AppTheme
import org.mifos.mobile.core.model.UserData
import org.mifos.mobile.core.model.DarkThemeConfig
import org.mifos.mobile.core.model.LanguageConfig
import org.mifos.mobile.core.ui.utils.BaseViewModel
class ComposeAppViewModel(
private val userDataRepository: UserDataRepository,
private val passcodeManager: PasscodeManager,
private val userPreferencesRepository: UserPreferencesRepository,
) : ViewModel() {
private val networkMonitor: NetworkMonitor,
) : BaseViewModel<AppState, AppEvent, AppAction>(
initialState = AppState(
darkTheme = false,
isAndroidTheme = false,
isDynamicColorsEnabled = false,
),
) {
val networkStatus = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false,
)
private val userDataFlow = userDataRepository.userData
private val appThemeFlow = userPreferencesRepository.appTheme
init {
userPreferencesRepository
.observeDarkThemeConfig
.onEach { trySendAction(AppAction.Internal.ThemeUpdate(it)) }
.launchIn(viewModelScope)
val uiState: StateFlow<MainUiState> = combine(userDataFlow, appThemeFlow) { dataState, appTheme ->
when (dataState) {
is DataState.Success -> MainUiState.Success(dataState.data, appTheme)
is DataState.Error -> MainUiState.Error(dataState.exception.message ?: "Unknown error")
DataState.Loading -> MainUiState.Loading
userPreferencesRepository
.observeDynamicColorPreference
.onEach { trySendAction(AppAction.Internal.DynamicColorsUpdate(it)) }
.launchIn(viewModelScope)
userPreferencesRepository
.observeLanguage
.map { AppEvent.UpdateAppLocale(it.localName) }
.onEach(::sendEvent)
.launchIn(viewModelScope)
}
override fun handleAction(action: AppAction) {
when (action) {
is AppAction.AppSpecificLanguageUpdate -> handleAppSpecificLanguageUpdate(action)
is AppAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is AppAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
}
}.stateIn(
scope = viewModelScope,
initialValue = MainUiState.Loading,
started = SharingStarted.WhileSubscribed(5_000),
)
}
fun logOut() {
private fun handleAppSpecificLanguageUpdate(action: AppAction.AppSpecificLanguageUpdate) {
viewModelScope.launch {
userDataRepository.logOut()
passcodeManager.clearPasscode()
userPreferencesRepository.setLanguage(action.appLanguage)
}
}
private fun handleAppThemeUpdated(action: AppAction.Internal.ThemeUpdate) {
mutableStateFlow.update {
it.copy(darkTheme = action.theme == DarkThemeConfig.DARK)
}
sendEvent(AppEvent.UpdateAppTheme(osValue = action.theme.osValue))
}
private fun handleDynamicColorsUpdate(action: AppAction.Internal.DynamicColorsUpdate) {
mutableStateFlow.update { it.copy(isDynamicColorsEnabled = action.isDynamicColorsEnabled) }
}
}
sealed interface MainUiState {
data object Loading : MainUiState
data class Error(val error: String) : MainUiState
data class Success(val userData: UserData, val appTheme: AppTheme) : MainUiState
data class AppState(
val darkTheme: Boolean,
val isAndroidTheme: Boolean,
val isDynamicColorsEnabled: Boolean,
)
sealed interface AppEvent {
data class ShowToast(val message: String) : AppEvent
data class UpdateAppLocale(
val localeName: String?,
) : AppEvent
data class UpdateAppTheme(
val osValue: Int,
) : AppEvent
}
sealed interface AppAction {
data class AppSpecificLanguageUpdate(val appLanguage: LanguageConfig) : AppAction
sealed class Internal : AppAction {
data class ThemeUpdate(
val theme: DarkThemeConfig,
) : Internal()
data class DynamicColorsUpdate(
val isDynamicColorsEnabled: Boolean,
) : Internal()
}
}

View File

@ -0,0 +1,45 @@
/*
* Copyright 2025 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
*/
@file:Suppress("MatchingDeclarationName")
package cmp.navigation.authenticated
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.navigation
import cmp.navigation.authenticatednavbar.AuthenticatedNavbarRoute
import cmp.navigation.authenticatednavbar.authenticatedNavbarGraph
import kotlinx.serialization.Serializable
@Serializable
internal data object AuthenticatedGraphRoute
internal fun NavController.navigateToAuthenticatedGraph(navOptions: NavOptions? = null) {
navigate(route = AuthenticatedGraphRoute, navOptions = navOptions)
}
@Suppress("UnusedParameter")
internal fun NavGraphBuilder.authenticatedGraph(
navController: NavController,
) {
navigation<AuthenticatedGraphRoute>(
startDestination = AuthenticatedNavbarRoute,
) {
authenticatedNavbarGraph()
}
}
@Suppress("UnusedPrivateMember")
private fun NavController.navigateUpToAuthenticatedNavbarRoot() {
this.popBackStack<AuthenticatedNavbarRoute>(inclusive = false)
}
// User shouldn't navigate back to intermediate screens when reached to this destination

View File

@ -0,0 +1,77 @@
/*
* Copyright 2025 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 cmp.navigation.authenticatednavbar
import androidx.compose.ui.graphics.vector.ImageVector
import cmp.navigation.utils.toObjectNavigationRoute
import org.jetbrains.compose.resources.StringResource
import org.mifos.mobile.core.designsystem.icon.MifosIcons
import org.mifos.mobile.core.ui.navigation.NavigationItem
import org.mifos.mobile.feature.home.navigation.HomeRoute
import org.mifos.mobile.feature.user.profile.navigation.ProfileGraphRoute
import org.mifos.mobile.navigation.generated.resources.Res
import org.mifos.mobile.navigation.generated.resources.home
import org.mifos.mobile.navigation.generated.resources.profile
sealed class AuthenticatedNavBarTabItem : NavigationItem {
data object HomeTab : AuthenticatedNavBarTabItem() {
override val iconResSelected: ImageVector
get() = MifosIcons.HomeTabFilled
override val iconRes: ImageVector
get() = MifosIcons.HomeTabFilled
override val labelRes: StringResource
get() = Res.string.home
override val contentDescriptionRes: StringResource
get() = Res.string.home
override val graphRoute: String
get() = HomeRoute.toObjectNavigationRoute()
override val startDestinationRoute: String
get() = HomeRoute.toObjectNavigationRoute()
override val testTag: String
get() = "HomeTab"
}
// TODO Add Top level destinations here
// data object TransferTab : AuthenticatedNavBarTabItem() {
// override val iconResSelected: ImageVector
// get() = MifosIcons.TransferTab
// override val iconRes: ImageVector
// get() = MifosIcons.TransferTab
// override val labelRes: StringResource
// get() = Res.string.transfer
// override val contentDescriptionRes: StringResource
// get() = Res.string.transfer
// override val graphRoute: String
// get() = transferNavRoute.toObjectNavigationRoute()
// override val startDestinationRoute: String
// get() = transferNavRoute.toObjectNavigationRoute()
// override val testTag: String
// get() = "TransferTab"
// }
data object ProfileTab : AuthenticatedNavBarTabItem() {
override val iconResSelected: ImageVector
get() = MifosIcons.PersonTabFilled
override val iconRes: ImageVector
get() = MifosIcons.PersonTabFilled
override val labelRes: StringResource
get() = Res.string.profile
override val contentDescriptionRes: StringResource
get() = Res.string.profile
override val graphRoute: String
get() = ProfileGraphRoute.toObjectNavigationRoute()
override val startDestinationRoute: String
get() = ProfileGraphRoute.toObjectNavigationRoute()
override val testTag: String
get() = "ProfileTab"
}
}

View File

@ -0,0 +1,31 @@
/*
* Copyright 2025 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
*/
@file:Suppress("MatchingDeclarationName")
package cmp.navigation.authenticatednavbar
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import kotlinx.serialization.Serializable
import org.mifos.mobile.core.ui.composableWithStayTransitions
@Serializable
data object AuthenticatedNavbarRoute
internal fun NavController.navigateToAuthenticatedNavBar(navOptions: NavOptions? = null) {
navigate(route = AuthenticatedNavbarRoute, navOptions = navOptions)
}
internal fun NavGraphBuilder.authenticatedNavbarGraph() {
composableWithStayTransitions<AuthenticatedNavbarRoute> {
AuthenticatedNavbarNavigationScreen()
}
}

View File

@ -0,0 +1,198 @@
/*
* Copyright 2025 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 cmp.navigation.authenticatednavbar
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.NavOptions
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.navOptions
import cmp.navigation.ui.MifosScaffold
import cmp.navigation.ui.ScaffoldNavigationData
import cmp.navigation.ui.rememberMifosNavController
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.ui.RootTransitionProviders
import org.mifos.mobile.core.ui.navigation.NavigationItem
import org.mifos.mobile.core.ui.utils.EventsEffect
import org.mifos.mobile.feature.home.navigation.HomeRoute
import org.mifos.mobile.feature.home.navigation.homeDestination
import org.mifos.mobile.feature.home.navigation.navigateToHomeScreen
import org.mifos.mobile.feature.user.profile.navigation.navigateToUserProfileGraph
import org.mifos.mobile.feature.user.profile.navigation.userprofileNavGraph
import org.mifos.mobile.navigation.generated.resources.Res
import org.mifos.mobile.navigation.generated.resources.not_connected
@Composable
internal fun AuthenticatedNavbarNavigationScreen(
modifier: Modifier = Modifier,
navController: NavHostController = rememberMifosNavController(
name = "AuthenticatedNavbarScreen",
),
viewModel: AuthenticatedNavbarNavigationViewModel = koinViewModel(),
) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val isOffline by viewModel.isOffline.collectAsStateWithLifecycle()
EventsEffect(eventFlow = viewModel.eventFlow) { event ->
navController.apply {
when (event) {
// TODO Add navigation to respective screens
AuthenticatedNavBarEvent.NavigateToHomeScreen -> {
navigateToTabOrRoot(tabToNavigateTo = event.tab) {
navigateToHomeScreen(navOptions = it)
}
}
AuthenticatedNavBarEvent.NavigateToUserProfileScreen -> {
navigateToTabOrRoot(tabToNavigateTo = event.tab) {
navigateToUserProfileGraph(navOptions = it)
}
}
}
}
}
val message = stringResource(Res.string.not_connected)
LaunchedEffect(isOffline) {
if (isOffline) {
scope.launch {
snackbarHostState.showSnackbar(
message = message,
duration = Indefinite,
)
}
}
}
AuthenticatedNavbarNavigationScreenContent(
navController = navController,
snackbarHostState = snackbarHostState,
modifier = modifier,
onAction = remember(viewModel) {
{ viewModel.trySendAction(it) }
},
)
}
@Composable
internal fun AuthenticatedNavbarNavigationScreenContent(
navController: NavHostController,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
onAction: (AuthenticatedNavBarAction) -> Unit,
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val navigationItems = persistentListOf<NavigationItem>(
AuthenticatedNavBarTabItem.HomeTab,
AuthenticatedNavBarTabItem.ProfileTab,
)
MifosScaffold(
contentWindowInsets = WindowInsets(0.dp),
navigationData = ScaffoldNavigationData(
navigationItems = navigationItems,
selectedNavigationItem = navigationItems.find {
navBackStackEntry.isCurrentRoute(route = it.graphRoute)
},
onNavigationClick = { navigationItem ->
// TODO navigate to respective screens
when (navigationItem) {
is AuthenticatedNavBarTabItem.HomeTab -> {
onAction(AuthenticatedNavBarAction.HomeTabClick)
}
is AuthenticatedNavBarTabItem.ProfileTab -> {
onAction(AuthenticatedNavBarAction.ProfileTabClick)
}
}
},
shouldShowNavigation = navigationItems.any {
navBackStackEntry.isCurrentRoute(route = it.startDestinationRoute)
},
),
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
modifier = modifier,
) {
// Because this Scaffold has a bottom navigation bar, the NavHost will:
// - consume the vertical navigation bar insets.
// - consume the IME insets.
NavHost(
navController = navController,
startDestination = HomeRoute,
enterTransition = RootTransitionProviders.Enter.fadeIn,
exitTransition = RootTransitionProviders.Exit.fadeOut,
popEnterTransition = RootTransitionProviders.Enter.fadeIn,
popExitTransition = RootTransitionProviders.Exit.fadeOut,
) {
// TODO Add top level destination screens
homeDestination(
onNavigate = {},
callHelpline = {},
mailHelpline = {},
)
userprofileNavGraph(navController, {})
}
}
}
private fun NavController.navigateToTabOrRoot(
tabToNavigateTo: AuthenticatedNavBarTabItem,
navigate: (NavOptions) -> Unit,
) {
if (tabToNavigateTo.startDestinationRoute == currentDestination?.route) {
// We are at the start destination already, so nothing to do.
return
} else if (currentDestination?.parent?.route == tabToNavigateTo.graphRoute) {
// We are not at the start destination but we are in the correct graph,
// so lets pop up to the start destination.
popBackStack(route = tabToNavigateTo.startDestinationRoute, inclusive = false)
} else {
// We are not in correct graph at all, so navigate there.
navigate(
navOptions {
popUpTo(graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
},
)
}
}
private fun NavBackStackEntry?.isCurrentRoute(route: String): Boolean =
this
?.destination
?.hierarchy
?.any { it.route == route } == true

View File

@ -0,0 +1,84 @@
/*
* Copyright 2025 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 cmp.navigation.authenticatednavbar
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.mifos.mobile.core.data.util.NetworkMonitor
import org.mifos.mobile.core.datastore.model.AppSettings
import org.mifos.mobile.core.ui.utils.BaseViewModel
internal class AuthenticatedNavbarNavigationViewModel(
networkMonitor: NetworkMonitor,
) : BaseViewModel<Unit, AuthenticatedNavBarEvent, AuthenticatedNavBarAction>(
initialState = Unit,
) {
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false,
)
override fun handleAction(action: AuthenticatedNavBarAction) {
when (action) {
AuthenticatedNavBarAction.HomeTabClick -> handleVaultTabClicked()
is AuthenticatedNavBarAction.Internal -> handleInternalAction(action)
AuthenticatedNavBarAction.ProfileTabClick -> handleProfileTabClicked()
}
}
private fun handleVaultTabClicked() {
sendEvent(AuthenticatedNavBarEvent.NavigateToHomeScreen)
}
private fun handleProfileTabClicked() {
sendEvent(AuthenticatedNavBarEvent.NavigateToUserProfileScreen)
}
private fun handleInternalAction(action: AuthenticatedNavBarAction.Internal) {
when (action) {
is AuthenticatedNavBarAction.Internal.UserStateUpdateReceive -> {
}
}
}
}
internal sealed class AuthenticatedNavBarAction {
// TODO: Add top level destinations here
data object HomeTabClick : AuthenticatedNavBarAction()
data object ProfileTabClick : AuthenticatedNavBarAction()
sealed class Internal : AuthenticatedNavBarAction() {
data class UserStateUpdateReceive(val appSettings: AppSettings?) : Internal()
}
}
internal sealed class AuthenticatedNavBarEvent {
abstract val tab: AuthenticatedNavBarTabItem
data object NavigateToHomeScreen : AuthenticatedNavBarEvent() {
override val tab: AuthenticatedNavBarTabItem = AuthenticatedNavBarTabItem.HomeTab
}
data object NavigateToUserProfileScreen : AuthenticatedNavBarEvent() {
override val tab: AuthenticatedNavBarTabItem = AuthenticatedNavBarTabItem.ProfileTab
}
}

View File

@ -10,6 +10,8 @@
package cmp.navigation.di
import cmp.navigation.ComposeAppViewModel
import cmp.navigation.authenticatednavbar.AuthenticatedNavbarNavigationViewModel
import cmp.navigation.rootnav.RootNavViewModel
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
import org.mifos.library.passcode.di.PasscodeModule
@ -27,6 +29,7 @@ import org.mifos.mobile.feature.home.di.HomeModule
import org.mifos.mobile.feature.loan.di.LoanModule
import org.mifos.mobile.feature.loanaccount.di.loanAccountModule
import org.mifos.mobile.feature.notification.di.NotificationModule
import org.mifos.mobile.feature.onboarding.language.di.SetOnboardingLanguageModule
import org.mifos.mobile.feature.qr.di.QrModule
import org.mifos.mobile.feature.recent.transaction.di.recentTransactionModule
import org.mifos.mobile.feature.savings.di.SavingsModule
@ -53,6 +56,8 @@ object KoinModules {
}
private val sharedModule = module {
viewModelOf(::ComposeAppViewModel)
viewModelOf(::AuthenticatedNavbarNavigationViewModel)
viewModelOf(::RootNavViewModel)
}
private val featureModules = module {
includes(
@ -76,6 +81,7 @@ object KoinModules {
GuarantorModule,
NotificationModule,
ProfileModule,
SetOnboardingLanguageModule,
)
}
private val LibraryModule = module {

View File

@ -1,256 +0,0 @@
/*
* Copyright 2024 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 cmp.navigation.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import cmp.navigation.ui.AppState
import org.mifos.mobile.core.model.entity.TransferSuccessDestination
import org.mifos.mobile.core.model.enums.AccountType
import org.mifos.mobile.core.model.enums.ChargeType
import org.mifos.mobile.core.ui.utils.ShareUtils.callHelpline
import org.mifos.mobile.core.ui.utils.ShareUtils.mailHelpline
import org.mifos.mobile.core.ui.utils.ShareUtils.openAppInfo
import org.mifos.mobile.core.ui.utils.ShareUtils.ossLicensesMenuActivity
import org.mifos.mobile.core.ui.utils.ShareUtils.shareApp
import org.mifos.mobile.feature.about.navigation.aboutUsNavGraph
import org.mifos.mobile.feature.about.navigation.navigateToAboutUsScreen
import org.mifos.mobile.feature.accounts.navigation.AccountsNavigation
import org.mifos.mobile.feature.accounts.navigation.accountsNavGraph
import org.mifos.mobile.feature.accounts.navigation.navigateToAccountsScreen
import org.mifos.mobile.feature.auth.navigation.navigateToAuthGraph
import org.mifos.mobile.feature.beneficiary.navigation.beneficiaryNavGraph
import org.mifos.mobile.feature.beneficiary.navigation.navigateToBeneficiaryApplicationScreen
import org.mifos.mobile.feature.beneficiary.navigation.navigateToBeneficiaryListScreen
import org.mifos.mobile.feature.charge.navigation.clientChargeNavGraph
import org.mifos.mobile.feature.charge.navigation.navigateToClientChargeScreen
import org.mifos.mobile.feature.guarantor.navigation.guarantorNavGraph
import org.mifos.mobile.feature.guarantor.navigation.navigateToGuarantorListScreen
import org.mifos.mobile.feature.help.navigation.helpNavGraph
import org.mifos.mobile.feature.help.navigation.navigateToHelpScreen
import org.mifos.mobile.feature.home.navigation.HomeDestinations
import org.mifos.mobile.feature.home.navigation.HomeNavigation
import org.mifos.mobile.feature.home.navigation.homeNavGraph
import org.mifos.mobile.feature.home.navigation.navigateToHomeScreen
import org.mifos.mobile.feature.loan.navigation.loanNavGraph
import org.mifos.mobile.feature.loan.navigation.navigateToLoanApplication
import org.mifos.mobile.feature.loan.navigation.navigateToLoanDetailScreen
import org.mifos.mobile.feature.location.navigation.locationsNavGraph
import org.mifos.mobile.feature.location.navigation.navigateToLocationsScreen
import org.mifos.mobile.feature.notification.navigation.navigateToNotificationScreen
import org.mifos.mobile.feature.notification.navigation.notificationNavGraph
import org.mifos.mobile.feature.qr.navigation.navigateToQrDisplayScreen
import org.mifos.mobile.feature.qr.navigation.navigateToQrImportScreen
import org.mifos.mobile.feature.qr.navigation.navigateToQrReaderScreen
import org.mifos.mobile.feature.qr.navigation.qrNavGraph
import org.mifos.mobile.feature.recent.transaction.navigation.navigateToRecentTransactionScreen
import org.mifos.mobile.feature.recent.transaction.navigation.recentTransactionNavGraph
import org.mifos.mobile.feature.savings.navigation.navigateToSavingsApplicationScreen
import org.mifos.mobile.feature.savings.navigation.navigateToSavingsDetailScreen
import org.mifos.mobile.feature.savings.navigation.navigateToSavingsMakeTransfer
import org.mifos.mobile.feature.savings.navigation.savingsNavGraph
import org.mifos.mobile.feature.settings.navigation.navigateToSettings
import org.mifos.mobile.feature.settings.navigation.settingsNavGraph
import org.mifos.mobile.feature.third.party.transfer.navigation.navigateToThirdPartyTransfer
import org.mifos.mobile.feature.third.party.transfer.navigation.thirdPartyTransferNavGraph
import org.mifos.mobile.feature.transfer.process.navigation.navigateToTransferProcessScreen
import org.mifos.mobile.feature.transfer.process.navigation.transferProcessNavGraph
import org.mifos.mobile.feature.update.password.navigation.navigateToUpdatePassword
import org.mifos.mobile.feature.update.password.navigation.updatePasswordNavGraph
import org.mifos.mobile.feature.user.profile.navigation.navigateToUserProfile
import org.mifos.mobile.feature.user.profile.navigation.userProfileNavGraph
@Composable
internal fun FeatureNavHost(
appState: AppState,
onClickLogout: () -> Unit,
modifier: Modifier = Modifier,
) {
NavHost(
route = NavGraphRoute.MAIN_GRAPH,
startDestination = HomeNavigation.HomeBase.route,
navController = appState.navController,
modifier = modifier,
) {
helpNavGraph(
findLocations = appState.navController::navigateToLocationsScreen,
navigateBack = appState.navController::popBackStack,
callHelpline = { callHelpline() },
mailHelpline = { mailHelpline() },
)
homeNavGraph(
onNavigate = { handleHomeNavigation(appState.navController, it, onClickLogout) },
callHelpline = { callHelpline() },
mailHelpline = { mailHelpline() },
)
accountsNavGraph(
navController = appState.navController,
navigateToLoanApplicationScreen = appState.navController::navigateToLoanApplication,
navigateToSavingsApplicationScreen = { appState.navController::navigateToSavingsApplicationScreen },
navigateToAccountDetail = { accountType, id ->
when (accountType) {
AccountType.SAVINGS -> {
appState.navController.navigateToSavingsDetailScreen(savingsId = id)
}
AccountType.LOAN -> {
appState.navController.navigateToLoanDetailScreen(loanId = id)
}
AccountType.SHARE -> { }
}
},
)
savingsNavGraph(
navController = appState.navController,
viewCharges = { chargeType, chargeTypeId ->
appState.navController.navigateToClientChargeScreen(chargeType, chargeTypeId)
},
viewQrCode = { appState.navController.navigateToQrDisplayScreen(it) },
callHelpline = { callHelpline() },
reviewTransfer = { transferPayload, transferType, transferDestination ->
appState.navController.navigateToTransferProcessScreen(
transferPayload,
transferType,
transferDestination,
)
},
)
aboutUsNavGraph(
navController = appState.navController,
navigateToOssLicense = { ossLicensesMenuActivity() },
)
recentTransactionNavGraph(appState.navController)
loanNavGraph(
navController = appState.navController,
viewQr = { appState.navController.navigateToQrDisplayScreen(it) },
viewGuarantor = { appState.navController.navigateToGuarantorListScreen(it) },
viewCharges = { chargeType, chargeTypeId ->
appState.navController.navigateToClientChargeScreen(chargeType, chargeTypeId)
},
makePayment = { args ->
appState.navController.navigateToSavingsMakeTransfer(
args,
)
},
)
clientChargeNavGraph(
navigateBack = { appState.navController.popBackStack() },
)
thirdPartyTransferNavGraph(
navigateBack = { appState.navController.popBackStack() },
addBeneficiary = { },
reviewTransfer = { transferPayload, transferType, transferDestination ->
appState.navController.navigateToTransferProcessScreen(
transferPayload,
transferType,
transferDestination,
)
},
)
updatePasswordNavGraph {
appState.navController.popBackStack()
}
transferProcessNavGraph(
navigateBack = { appState.navController.popBackStack() },
onTransferSuccessNavigate = { destination ->
when (destination) {
TransferSuccessDestination.HOME -> appState.navController.navigateToHomeScreen()
TransferSuccessDestination.LOAN_ACCOUNT ->
appState.navController.navigateToAccountsScreen(
AccountType.LOAN,
AccountsNavigation.AccountsBase.route,
)
TransferSuccessDestination.SAVINGS_ACCOUNT -> appState.navController.navigateToAccountsScreen(
AccountType.SAVINGS,
AccountsNavigation.AccountsBase.route,
)
}
},
)
beneficiaryNavGraph(
navController = appState.navController,
openQrImportScreen = { appState.navController.navigateToQrImportScreen() },
openQrReaderScreen = { appState.navController.navigateToQrReaderScreen() },
)
settingsNavGraph(
navigateBack = { appState.navController.popBackStack() },
navigateToLoginScreen = { appState.navController::navigateToAuthGraph },
changePasscode = { },
changePassword = { appState.navController::navigateToUpdatePassword.invoke() },
languageChanged = { },
)
locationsNavGraph()
guarantorNavGraph(
navController = appState.navController,
)
qrNavGraph(
navController = appState.navController,
openBeneficiaryApplication = { beneficiary, beneficiaryState ->
appState.navController
.navigateToBeneficiaryApplicationScreen(beneficiary, beneficiaryState)
},
)
notificationNavGraph(navigateBack = appState.navController::popBackStack)
userProfileNavGraph(
navigateBack = { appState.navController.popBackStack() },
navigateToChangePassword = { appState.navController::navigateToUpdatePassword.invoke() },
)
}
}
fun handleHomeNavigation(
navController: NavHostController,
homeDestinations: HomeDestinations,
onClickLogout: () -> Unit,
) {
when (homeDestinations) {
HomeDestinations.LOGOUT -> onClickLogout.invoke()
HomeDestinations.HOME -> Unit
HomeDestinations.ACCOUNTS -> navController.navigateToAccountsScreen()
HomeDestinations.LOAN_ACCOUNT -> navController.navigateToAccountsScreen(accountType = AccountType.LOAN)
HomeDestinations.SAVINGS_ACCOUNT -> navController.navigateToAccountsScreen(accountType = AccountType.SAVINGS)
HomeDestinations.RECENT_TRANSACTIONS -> navController.navigateToRecentTransactionScreen()
HomeDestinations.CHARGES -> navController.navigateToClientChargeScreen(ChargeType.CLIENT, -1L)
HomeDestinations.THIRD_PARTY_TRANSFER -> navController.navigateToThirdPartyTransfer()
HomeDestinations.SETTINGS -> {
navController.navigateToSettings()
}
HomeDestinations.ABOUT_US -> navController.navigateToAboutUsScreen()
HomeDestinations.HELP -> navController.navigateToHelpScreen()
HomeDestinations.SHARE -> shareApp()
HomeDestinations.APP_INFO -> openAppInfo()
HomeDestinations.TRANSFER -> {
navController.navigateToSavingsMakeTransfer(
null,
)
}
HomeDestinations.BENEFICIARIES -> navController.navigateToBeneficiaryListScreen()
HomeDestinations.SURVEY -> {}
HomeDestinations.NOTIFICATIONS -> navController.navigateToNotificationScreen()
HomeDestinations.PROFILE -> navController.navigateToUserProfile()
}
}

View File

@ -12,6 +12,7 @@ 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
@ -23,19 +24,19 @@ internal fun NavGraphBuilder.passcodeNavGraph(navController: NavHostController)
passcodeRoute(
onForgotButton = {
navController.popBackStack()
navController.navigate(NavGraphRoute.MAIN_GRAPH)
navController.navigate(AuthenticatedGraphRoute)
},
onSkipButton = {
navController.popBackStack()
navController.navigate(NavGraphRoute.MAIN_GRAPH)
navController.navigate(AuthenticatedGraphRoute)
},
onPasscodeConfirm = {
navController.popBackStack()
navController.navigate(NavGraphRoute.MAIN_GRAPH)
navController.navigate(AuthenticatedGraphRoute)
},
onPasscodeRejected = {
navController.popBackStack()
navController.navigate(NavGraphRoute.MAIN_GRAPH)
navController.navigate(AuthenticatedGraphRoute)
},
)
}

View File

@ -1,52 +0,0 @@
/*
* Copyright 2024 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 cmp.navigation.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import cmp.navigation.navigation.NavGraphRoute.MAIN_GRAPH
import cmp.navigation.ui.App
import org.mifos.library.passcode.navigateToPasscodeScreen
import org.mifos.mobile.core.data.util.NetworkMonitor
import org.mifos.mobile.feature.auth.navigation.authenticationNavGraph
@Composable
fun RootNavGraph(
networkMonitor: NetworkMonitor,
navHostController: NavHostController,
startDestination: String,
modifier: Modifier = Modifier,
onClickLogout: () -> Unit,
) {
NavHost(
navController = navHostController,
startDestination = startDestination,
route = NavGraphRoute.ROOT_GRAPH,
) {
authenticationNavGraph(
navController = navHostController,
navigateToPasscodeScreen = navHostController::navigateToPasscodeScreen,
)
passcodeNavGraph(navHostController)
composable(MAIN_GRAPH) {
App(
modifier = modifier,
networkMonitor = networkMonitor,
onClickLogout = onClickLogout,
)
}
}
}

View File

@ -0,0 +1,29 @@
/*
* Copyright 2025 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 cmp.navigation.rootnav
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
/**
* The route for the root navigation screen.
*/
const val ROOT_ROUTE: String = "root"
/**
* Add the root navigation screen to the nav graph.
*/
fun NavGraphBuilder.rootNavDestination(
onSplashScreenRemoved: () -> Unit,
) {
composable(route = ROOT_ROUTE) {
RootNavScreen(onSplashScreenRemoved = onSplashScreenRemoved)
}
}

View File

@ -0,0 +1,149 @@
/*
* Copyright 2025 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 cmp.navigation.rootnav
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
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.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.viewmodel.koinViewModel
import org.mifos.library.passcode.navigateToPasscodeScreen
import org.mifos.mobile.core.ui.NonNullEnterTransitionProvider
import org.mifos.mobile.core.ui.NonNullExitTransitionProvider
import org.mifos.mobile.core.ui.RootTransitionProviders
import org.mifos.mobile.feature.auth.navigation.AuthGraphRoute
import org.mifos.mobile.feature.auth.navigation.authenticationNavGraph
import org.mifos.mobile.feature.auth.navigation.navigateToAuthGraph
import org.mifos.mobile.feature.onboarding.language.navigation.OnboardingLanguageRoute
import org.mifos.mobile.feature.onboarding.language.navigation.navigateToOnboardingLanguage
import org.mifos.mobile.feature.onboarding.language.navigation.onBoardingLanguageDestination
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun RootNavScreen(
modifier: Modifier = Modifier,
viewModel: RootNavViewModel = koinViewModel(),
navController: NavHostController = rememberMifosNavController(name = "RootNavScreen"),
onSplashScreenRemoved: () -> Unit = {},
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val previousStateReference = remember { mutableStateOf(state) }
val isNotSplashScreen = state != RootNavState.Splash
LaunchedEffect(isNotSplashScreen) {
if (isNotSplashScreen) onSplashScreenRemoved()
}
NavHost(
navController = navController,
startDestination = SplashRoute,
modifier = modifier,
enterTransition = { toEnterTransition()(this) },
exitTransition = { toExitTransition()(this) },
popEnterTransition = { toEnterTransition()(this) },
popExitTransition = { toExitTransition()(this) },
) {
splashDestination()
onBoardingLanguageDestination()
authenticationNavGraph(navController, navController::navigateToPasscodeScreen)
authenticatedGraph(navController)
// userUnlockDestination()
passcodeNavGraph(navController)
}
val targetRoute = when (state) {
RootNavState.SetLanguage -> OnboardingLanguageRoute
RootNavState.Auth -> AuthGraphRoute
RootNavState.Splash -> SplashRoute
// RootNavState.UserLocked -> UserUnlockRoute.Standard
RootNavState.UserLocked -> { }
is RootNavState.UserUnlocked -> AuthenticatedGraphRoute
}
val currentRoute = navController.currentDestination?.rootLevelRoute()
// Don't navigate if we are already at the correct root. This notably happens during process
// death. In this case, the NavHost already restores state, so we don't have to navigate.
// However, if the route is correct but the underlying state is different, we should still
// proceed in order to get a fresh version of that route.
if (currentRoute == targetRoute.toObjectNavigationRoute() &&
previousStateReference.value == state
) {
previousStateReference.value == state
return
}
previousStateReference.value = state
// When state changes, navigate to different root navigation state
val rootNavOptions = navOptions {
// When changing root navigation state, pop everything else off the back stack:
popUpTo(navController.graph.id) {
inclusive = false
saveState = false
}
launchSingleTop = true
restoreState = false
}
// Use a LaunchedEffect to ensure we don't navigate too soon when the app first opens. This
// avoids a bug that first appeared in Compose Material3 1.2.0-rc01 that causes the initial
// transition to appear corrupted.
LaunchedEffect(state) {
when (state) {
RootNavState.Splash -> navController.navigateToSplash(rootNavOptions)
RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions)
RootNavState.SetLanguage -> navController.navigateToOnboardingLanguage(rootNavOptions)
// RootNavState.UserLocked -> navController.navigateToUserUnlock(rootNavOptions)
RootNavState.UserLocked -> { }
is RootNavState.UserUnlocked -> navController.navigateToAuthenticatedGraph(
navOptions = rootNavOptions,
)
}
}
}
private fun NavDestination?.rootLevelRoute(): String? = when {
this == null -> null
parent?.route == null -> route
else -> parent.rootLevelRoute()
}
@Suppress("MaxLineLength")
private fun AnimatedContentTransitionScope<NavBackStackEntry>.toEnterTransition(): NonNullEnterTransitionProvider =
when (targetState.destination.rootLevelRoute()) {
SplashRoute.toObjectNavigationRoute() -> RootTransitionProviders.Enter.none
else -> RootTransitionProviders.Enter.fadeIn
}
@Suppress("MaxLineLength")
private fun AnimatedContentTransitionScope<NavBackStackEntry>.toExitTransition(): NonNullExitTransitionProvider {
return when (initialState.destination.rootLevelRoute()) {
// Disable transitions when coming from the splash screen
SplashRoute.toObjectNavigationRoute() -> RootTransitionProviders.Exit.none
else -> RootTransitionProviders.Exit.fadeOut
}
}

View File

@ -0,0 +1,97 @@
/*
* Copyright 2025 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 cmp.navigation.rootnav
import androidx.lifecycle.viewModelScope
import cmp.navigation.rootnav.RootNavAction.Internal.UserStateUpdateReceive
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import org.mifos.mobile.core.data.repository.UserDataRepository
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,
) : BaseViewModel<RootNavState, Unit, RootNavAction>(
initialState = RootNavState.Splash,
) {
init {
combine(
userDataRepository.authState,
userDataRepository.settingsState,
) { authState, settingsData ->
UserStateUpdateReceive(
authState = authState,
settingsData = settingsData,
)
}.onEach(::handleAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: RootNavAction) {
when (action) {
is UserStateUpdateReceive -> handleUserStateUpdateReceive(action)
}
}
private fun handleUserStateUpdateReceive(
action: UserStateUpdateReceive,
) {
val settingsData = action.settingsData
println("User data in view model $settingsData")
val updatedRootNavState = when {
settingsData.firstTimeState -> RootNavState.SetLanguage
!settingsData.isAuthenticated -> RootNavState.Auth
// settingsData.passcode.isEmpty() -> RootNavState.UserLocked
// TODO: Passcode library needs to update so that it updates passcode in datastore
settingsData.passcode.isEmpty() -> {
RootNavState.UserUnlocked(settingsData.userId)
}
settingsData.isUnlocked -> {
RootNavState.UserUnlocked(settingsData.userId)
}
else -> RootNavState.UserLocked
}
mutableStateFlow.update { updatedRootNavState }
}
}
sealed class RootNavState {
data object Auth : RootNavState()
data object SetLanguage : RootNavState()
data object Splash : RootNavState()
data object UserLocked : RootNavState()
data class UserUnlocked(
val activeUserId: String,
) : RootNavState()
}
sealed class RootNavAction {
sealed class Internal {
data class UserStateUpdateReceive(
val authState: AuthState,
val settingsData: AppSettings,
) : RootNavAction()
}
}

View File

@ -0,0 +1,31 @@
/*
* Copyright 2025 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
*/
@file:Suppress("MatchingDeclarationName")
package cmp.navigation.splash
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import kotlinx.serialization.Serializable
@Serializable
data object SplashRoute
fun NavGraphBuilder.splashDestination() {
composable<SplashRoute> { SplashScreen() }
}
fun NavController.navigateToSplash(
navOptions: NavOptions? = null,
) {
navigate(SplashRoute, navOptions)
}

View File

@ -0,0 +1,28 @@
/*
* Copyright 2025 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 cmp.navigation.splash
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@Composable
fun SplashScreen(
modifier: Modifier = Modifier,
) {
Surface(
color = Color.White,
) {
Box(modifier = modifier.fillMaxSize())
}
}

View File

@ -1,89 +0,0 @@
/*
* Copyright 2024 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 cmp.navigation.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration.Indefinite
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cmp.navigation.navigation.FeatureNavHost
import org.mifos.mobile.core.data.util.NetworkMonitor
import org.mifos.mobile.core.designsystem.theme.MifosBackground
@Composable
fun App(
networkMonitor: NetworkMonitor,
modifier: Modifier = Modifier,
onClickLogout: () -> Unit,
) {
MifosBackground(modifier) {
val snackbarHostState = remember { SnackbarHostState() }
val appState = rememberAppState(
networkMonitor = networkMonitor,
)
val isOffline by appState.isOffline.collectAsStateWithLifecycle()
// If user is not connected stringResource the internet show a snack bar to inform them.
// val notConnectedMessage = (Res.string.not_connected)
val notConnectedMessage = "you have lost network connection"
LaunchedEffect(isOffline) {
if (isOffline) {
snackbarHostState.showSnackbar(
message = notConnectedMessage,
duration = Indefinite,
)
}
}
Scaffold(
modifier = Modifier,
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.windowInsetsPadding(
WindowInsets.safeDrawing.only(
WindowInsetsSides.Horizontal,
),
),
) {
FeatureNavHost(
appState = appState,
onClickLogout = onClickLogout,
modifier = Modifier,
)
}
}
}
}

View File

@ -1,77 +0,0 @@
/*
* Copyright 2025 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 cmp.navigation.ui
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.mifos.mobile.core.data.util.NetworkMonitor
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
internal fun rememberAppState(
networkMonitor: NetworkMonitor,
windowSizeClass: WindowSizeClass = calculateWindowSizeClass(),
coroutineScope: CoroutineScope = rememberCoroutineScope(),
navController: NavHostController = rememberNavController(),
): AppState {
return remember(
navController,
coroutineScope,
windowSizeClass,
networkMonitor,
) {
AppState(
navController = navController,
coroutineScope = coroutineScope,
windowSizeClass = windowSizeClass,
networkMonitor = networkMonitor,
)
}
}
@Stable
internal class AppState(
val navController: NavHostController,
coroutineScope: CoroutineScope,
val windowSizeClass: WindowSizeClass,
networkMonitor: NetworkMonitor,
) {
val currentDestination: NavDestination?
@Composable get() = navController
.currentBackStackEntryAsState().value?.destination
val shouldShowBottomBar: Boolean
get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact
val shouldShowNavRail: Boolean
get() = !shouldShowBottomBar
val isOffline = networkMonitor.isOnline
.map(Boolean::not)
.stateIn(
scope = coroutineScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = false,
)
}

View File

@ -0,0 +1,200 @@
/*
* Copyright 2025 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 cmp.navigation.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.pullToRefresh
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
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.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import org.mifos.mobile.core.designsystem.component.MifosPullToRefreshState
import org.mifos.mobile.core.designsystem.component.rememberMifosPullToRefreshState
import org.mifos.mobile.core.designsystem.theme.AppColors
import org.mifos.mobile.core.ui.component.MifosPoweredCard
import org.mifos.mobile.core.ui.navigation.MifosBottomBar
import org.mifos.mobile.core.ui.navigation.MifosNavigationRail
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3WindowSizeClassApi::class)
@Suppress("LongMethod")
@Composable
fun MifosScaffold(
modifier: Modifier = Modifier,
topBar: @Composable () -> Unit = { },
utilityBar: @Composable () -> Unit = { },
overlay: @Composable () -> Unit = { },
snackbarHost: @Composable () -> Unit = { },
floatingActionButton: @Composable () -> Unit = { },
floatingActionButtonPosition: FabPosition = FabPosition.End,
navigationData: ScaffoldNavigationData? = null,
pullToRefreshState: MifosPullToRefreshState = rememberMifosPullToRefreshState(),
containerColor: Color = AppColors.customWhite,
contentColor: Color = AppColors.customBlack,
contentWindowInsets: WindowInsets = ScaffoldDefaults
.contentWindowInsets
// .union(WindowInsets.displayCutout)
.only(WindowInsetsSides.Horizontal),
content: @Composable () -> Unit,
) {
val windowSize = calculateWindowSizeClass()
val isCompact = windowSize.widthSizeClass == WindowWidthSizeClass.Compact
val hasNavigationItems = navigationData?.shouldShowNavigation == true
val isNavigationRailVisible = !isCompact && hasNavigationItems
val isNavigationBarVisible = isCompact && hasNavigationItems
Scaffold(
modifier = modifier,
topBar = topBar,
bottomBar = {
AnimatedVisibility(
visible = isNavigationBarVisible,
enter = fadeIn() + slideInVertically(initialOffsetY = { it / 2 }),
exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 2 }),
) {
ScaffoldBottomAppBar(navigationData = requireNotNull(navigationData))
}
},
snackbarHost = {
Box(modifier = Modifier.imePadding()) {
snackbarHost()
}
},
floatingActionButton = floatingActionButton,
floatingActionButtonPosition = floatingActionButtonPosition,
containerColor = containerColor,
contentColor = contentColor,
contentWindowInsets = WindowInsets(0.dp),
content = { paddingValues ->
Row(
modifier = Modifier
.padding(paddingValues = paddingValues)
.consumeWindowInsets(paddingValues = paddingValues)
.imePadding(),
) {
if (isNavigationRailVisible) {
ScaffoldNavigationRail(navigationData = navigationData)
}
Box(
modifier = Modifier.run {
if (isNavigationBarVisible) {
consumeWindowInsets(
insets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
)
} else {
this
}
},
) {
Column {
utilityBar()
val internalPullToRefreshState = rememberPullToRefreshState()
Box(
modifier = Modifier
.windowInsetsPadding(insets = contentWindowInsets)
.pullToRefresh(
state = internalPullToRefreshState,
isRefreshing = pullToRefreshState.isRefreshing,
onRefresh = pullToRefreshState.onRefresh,
enabled = pullToRefreshState.isEnabled,
),
) {
content()
PullToRefreshDefaults.Indicator(
modifier = Modifier.align(Alignment.TopCenter),
isRefreshing = pullToRefreshState.isRefreshing,
state = internalPullToRefreshState,
containerColor = MaterialTheme.colorScheme.tertiary,
color = MaterialTheme.colorScheme.primary,
)
}
}
overlay()
}
}
},
)
}
@Composable
private fun ScaffoldBottomAppBar(
navigationData: ScaffoldNavigationData,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxWidth()) {
Column {
MifosPoweredCard(
modifier = Modifier.fillMaxWidth(),
)
MifosBottomBar(
navigationItems = navigationData.navigationItems,
selectedItem = navigationData.selectedNavigationItem,
onClick = navigationData.onNavigationClick,
modifier = Modifier
.fillMaxWidth()
.testTag(tag = "NavigationBarContainer"),
)
}
}
}
@Composable
private fun ScaffoldNavigationRail(
navigationData: ScaffoldNavigationData,
modifier: Modifier = Modifier,
) {
// We set the z-index to 1f in order to make sure the content transitions
// animate in under the navigation rail.
Box(
modifier = modifier
.fillMaxHeight()
.zIndex(zIndex = 1f),
) {
MifosNavigationRail(
navigationItems = navigationData.navigationItems,
selectedItem = navigationData.selectedNavigationItem,
onClick = navigationData.onNavigationClick,
modifier = Modifier
.fillMaxHeight()
.wrapContentWidth()
.testTag(tag = "NavigationBarContainer"),
)
}
}

View File

@ -0,0 +1,29 @@
/*
* Copyright 2025 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 cmp.navigation.ui
import androidx.compose.runtime.Composable
import androidx.navigation.NavDestination
import androidx.navigation.NavHostController
import androidx.navigation.Navigator
import androidx.navigation.compose.rememberNavController
import co.touchlab.kermit.Logger
@Composable
fun rememberMifosNavController(
name: String,
vararg navigators: Navigator<out NavDestination>,
): NavHostController =
rememberNavController(navigators = navigators).apply {
this.addOnDestinationChangedListener { _, destination, _ ->
val graph = destination.parent?.route?.let { " in $it" }.orEmpty()
Logger.d("$name destination changed: ${destination.route}$graph")
}
}

View File

@ -0,0 +1,20 @@
/*
* Copyright 2025 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 cmp.navigation.ui
import kotlinx.collections.immutable.ImmutableList
import org.mifos.mobile.core.ui.navigation.NavigationItem
data class ScaffoldNavigationData(
val onNavigationClick: (NavigationItem) -> Unit,
val navigationItems: ImmutableList<NavigationItem>,
val selectedNavigationItem: NavigationItem?,
val shouldShowNavigation: Boolean,
)

View File

@ -0,0 +1,28 @@
/*
* Copyright 2025 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 cmp.navigation.utils
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.serializer
import kotlin.reflect.KClass
/**
* Gets the route string for an object.
*/
@OptIn(InternalSerializationApi::class)
fun <T : Any> T.toObjectNavigationRoute(): String = this::class.toObjectKClassNavigationRoute()
/**
* Gets the route string for a [KClass] of an object.
*/
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
fun <T : Any> KClass<T>.toObjectKClassNavigationRoute(): String =
this.serializer().descriptor.serialName

View File

@ -15,7 +15,15 @@ import cmp.navigation.ComposeApp
@Composable
fun SharedApp(
handleThemeMode: (osValue: Int) -> Unit,
handleAppLocale: (locale: String?) -> Unit,
onSplashScreenRemoved: () -> Unit,
modifier: Modifier = Modifier,
) {
ComposeApp(modifier = modifier)
ComposeApp(
handleThemeMode = handleThemeMode,
handleAppLocale = handleAppLocale,
onSplashScreenRemoved = onSplashScreenRemoved,
modifier = modifier,
)
}

View File

@ -20,7 +20,11 @@ fun main() {
onWasmReady {
ComposeViewport(document.body!!) {
SharedApp() // Render the root composable of the application.
SharedApp(
handleThemeMode = {},
handleAppLocale = {},
onSplashScreenRemoved = {},
)
}
}
}

View File

@ -44,6 +44,10 @@ fun main() {
* Invokes the root composable of the application.
* This function is responsible for setting up the entire UI structure of the app.
*/
SharedApp()
SharedApp(
handleThemeMode = {},
handleAppLocale = {},
onSplashScreenRemoved = {},
)
}
}

View File

@ -48,13 +48,14 @@ import org.mifos.mobile.core.data.repositoryImpl.UserDetailRepositoryImp
import org.mifos.mobile.core.data.util.NetworkMonitor
private val ioDispatcher = named(MifosDispatchers.IO.name)
private val unconfinedDispatcher = named(MifosDispatchers.Unconfined.name)
val RepositoryModule = module {
single<Json> { Json { ignoreUnknownKeys = true } }
single<AccountsRepository> { AccountsRepositoryImp(get(), get(ioDispatcher)) }
single<UserDataRepository> { AuthenticationUserRepository(get(), get(ioDispatcher)) }
single<UserDataRepository> { AuthenticationUserRepository(get(), get(ioDispatcher), get(unconfinedDispatcher)) }
single<BeneficiaryRepository> { BeneficiaryRepositoryImp(get(), get(ioDispatcher)) }
single<ClientChargeRepository> { ClientChargeRepositoryImp(get(), get(ioDispatcher)) } // TODO
single<ClientRepository> { ClientRepositoryImp(get(), get(ioDispatcher)) }

View File

@ -10,14 +10,25 @@
package org.mifos.mobile.core.data.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.datastore.model.AppSettings
import org.mifos.mobile.core.model.AuthState
import org.mifos.mobile.core.model.UserData
interface UserDataRepository {
val activeUserId: Long?
// TODO
/**
* Stream of [UserData]
*/
val userData: Flow<DataState<UserData>>
val authState: StateFlow<AuthState>
val settingsState: StateFlow<AppSettings>
suspend fun logOut(): DataState<String>
}

View File

@ -10,21 +10,37 @@
package org.mifos.mobile.core.data.repositoryImpl
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.zip
import kotlinx.coroutines.withContext
import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.common.Dispatcher
import org.mifos.mobile.core.common.MifosDispatchers
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.model.UserData
class AuthenticationUserRepository(
private val preferencesHelper: UserPreferencesRepository,
private val ioDispatcher: CoroutineDispatcher,
@Dispatcher(MifosDispatchers.Unconfined)
private val unconfinedDispatcher: CoroutineDispatcher,
) : UserDataRepository {
private val unconfinedScope = CoroutineScope(unconfinedDispatcher)
override val activeUserId: Long?
get() = preferencesHelper.userInfo.value.userId
override val userData: Flow<DataState<UserData>> = flow {
try {
val userData = UserData(
@ -48,4 +64,24 @@ class AuthenticationUserRepository(
DataState.Error(e, null)
}
}
override val authState: StateFlow<AuthState>
get() = preferencesHelper.userInfo.zip(preferencesHelper.settingsInfo) { account, settings ->
when {
account.isAuthenticated && settings.isAuthenticated &&
account.base64EncodedAuthenticationKey != null ->
account.base64EncodedAuthenticationKey
else -> null
}
}.map {
if (it != null) AuthState.Authenticated(it) else AuthState.Unauthenticated
}.stateIn(
scope = unconfinedScope,
started = kotlinx.coroutines.flow.SharingStarted.Eagerly,
initialValue = AuthState.Unauthenticated,
)
override val settingsState: StateFlow<AppSettings>
get() = preferencesHelper.settingsInfo
}

View File

@ -26,6 +26,7 @@ import okio.ByteString.Companion.encodeUtf8
import org.mifos.mobile.core.datastore.model.AppSettings
import org.mifos.mobile.core.datastore.model.AppTheme
import org.mifos.mobile.core.datastore.model.UserData
import org.mifos.mobile.core.model.DarkThemeConfig
import org.mifos.mobile.core.model.LanguageConfig
private const val USER_DATA = "userData"
@ -75,6 +76,12 @@ class UserPreferencesDataSource(
val observeLanguage: Flow<LanguageConfig>
get() = _settingsInfo.map { it.language }
val observeDynamicColorPreference: Flow<Boolean>
get() = _settingsInfo.map { it.useDynamicColor }
val observeDarkThemeConfig: Flow<DarkThemeConfig>
get() = _settingsInfo.map { it.darkThemeConfig }
suspend fun updateSettingsInfo(appSettings: AppSettings) {
withContext(dispatcher) {
settings.putSettingsPreference(appSettings)
@ -193,6 +200,20 @@ class UserPreferencesDataSource(
_settingsInfo.value = newPreference
}
suspend fun setIsAuthenticated(isAuthenticated: Boolean) =
withContext(dispatcher) {
val newPreference = settings.getSettingsPreference().copy(isAuthenticated = isAuthenticated)
settings.putSettingsPreference(newPreference)
_settingsInfo.value = newPreference
}
suspend fun setIsUnlocked(isUnlocked: Boolean) =
withContext(dispatcher) {
val newPreference = settings.getSettingsPreference().copy(isUnlocked = isUnlocked)
settings.putSettingsPreference(newPreference)
_settingsInfo.value = newPreference
}
companion object {
private const val PROFILE_IMAGE = "preferences_profile_image"
}

View File

@ -15,12 +15,13 @@ import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.datastore.model.AppSettings
import org.mifos.mobile.core.datastore.model.AppTheme
import org.mifos.mobile.core.datastore.model.UserData
import org.mifos.mobile.core.model.DarkThemeConfig
import org.mifos.mobile.core.model.LanguageConfig
interface UserPreferencesRepository {
val userInfo: Flow<UserData>
val userInfo: StateFlow<UserData>
val settingsInfo: Flow<AppSettings>
val settingsInfo: StateFlow<AppSettings>
val token: StateFlow<String?>
@ -36,6 +37,10 @@ interface UserPreferencesRepository {
val observeLanguage: Flow<LanguageConfig>
val observeDarkThemeConfig: Flow<DarkThemeConfig>
val observeDynamicColorPreference: Flow<Boolean>
suspend fun updateToken(password: String): DataState<Unit>
suspend fun updateTheme(theme: AppTheme): DataState<Unit>
@ -52,6 +57,10 @@ interface UserPreferencesRepository {
suspend fun saveGcmToken(token: String?): DataState<Unit>
suspend fun setIsAuthenticated(isAuthenticated: Boolean)
suspend fun setIsUnlocked(isUnlocked: Boolean)
suspend fun setShowOnboarding(showOnboarding: Boolean)
suspend fun setFirstTimeState(firstTimeState: Boolean)

View File

@ -20,6 +20,7 @@ import org.mifos.mobile.core.common.DataState
import org.mifos.mobile.core.datastore.model.AppSettings
import org.mifos.mobile.core.datastore.model.AppTheme
import org.mifos.mobile.core.datastore.model.UserData
import org.mifos.mobile.core.model.DarkThemeConfig
import org.mifos.mobile.core.model.LanguageConfig
class UserPreferencesRepositoryImpl(
@ -29,10 +30,10 @@ class UserPreferencesRepositoryImpl(
) : UserPreferencesRepository {
private val unconfinedScope = CoroutineScope(unconfinedDispatcher)
override val userInfo: Flow<UserData>
override val userInfo: StateFlow<UserData>
get() = preferenceManager.userInfo
override val settingsInfo: Flow<AppSettings>
override val settingsInfo: StateFlow<AppSettings>
get() = preferenceManager.settingsInfo
override val appTheme: StateFlow<AppTheme>
@ -69,6 +70,12 @@ class UserPreferencesRepositoryImpl(
override val observeLanguage: Flow<LanguageConfig>
get() = preferenceManager.observeLanguage
override val observeDarkThemeConfig: Flow<DarkThemeConfig>
get() = preferenceManager.observeDarkThemeConfig
override val observeDynamicColorPreference: Flow<Boolean>
get() = preferenceManager.observeDynamicColorPreference
override suspend fun updateToken(password: String): DataState<Unit> {
return try {
val result = preferenceManager.updateToken(password)
@ -153,6 +160,14 @@ class UserPreferencesRepositoryImpl(
preferenceManager.setLanguage(language)
}
override suspend fun setIsAuthenticated(isAuthenticated: Boolean) {
preferenceManager.setIsAuthenticated(isAuthenticated)
}
override suspend fun setIsUnlocked(isUnlocked: Boolean) {
preferenceManager.setIsUnlocked(isUnlocked)
}
override suspend fun logOut() {
preferenceManager.clearInfo()
}

View File

@ -10,23 +10,29 @@
package org.mifos.mobile.core.datastore.model
import kotlinx.serialization.Serializable
import org.mifos.mobile.core.model.DarkThemeConfig
import org.mifos.mobile.core.model.LanguageConfig
@Serializable
data class AppSettings(
val userId: String,
val tenant: String,
val baseUrl: String,
val passcode: String? = null,
val appTheme: AppTheme = AppTheme.SYSTEM,
val passcode: String,
val appTheme: AppTheme,
val sentTokenToServer: Boolean = false,
val gcmToken: String? = null,
val useDynamicColor: Boolean,
val darkThemeConfig: DarkThemeConfig,
val isAuthenticated: Boolean,
val isUnlocked: Boolean,
val language: LanguageConfig,
val showOnboarding: Boolean,
val firstTimeState: Boolean,
) {
companion object {
val DEFAULT = AppSettings(
userId = "",
tenant = "default",
baseUrl = "https://tt.mifos.community/",
appTheme = AppTheme.SYSTEM,
@ -35,6 +41,11 @@ data class AppSettings(
language = LanguageConfig.DEFAULT,
showOnboarding = true,
firstTimeState = true,
useDynamicColor = false,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
isAuthenticated = false,
passcode = "",
isUnlocked = false,
)
}
}

View File

@ -16,8 +16,8 @@ data class UserData(
val userId: Long,
val userName: String,
val clientId: Long,
val isAuthenticated: Boolean,
val base64EncodedAuthenticationKey: String,
val isAuthenticated: Boolean = false,
val base64EncodedAuthenticationKey: String? = null,
val officeName: String,
) {
companion object {

View File

@ -10,16 +10,21 @@
package org.mifos.mobile.core.designsystem.component
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
@ -33,25 +38,34 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.mifos.mobile.core.designsystem.theme.AppColors
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MifosScaffold(
backPress: () -> Unit,
onNavigationIconClick: () -> Unit,
modifier: Modifier = Modifier,
topBarTitle: String? = null,
containerColor: Color = Color.White,
floatingActionButtonContent: FloatingActionButtonContent? = null,
pullToRefreshState: MifosPullToRefreshState = rememberMifosPullToRefreshState(),
contentWindowInsets: WindowInsets = ScaffoldDefaults
.contentWindowInsets
.only(WindowInsetsSides.Horizontal),
snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable (PaddingValues) -> Unit = {},
content: @Composable () -> Unit = {},
) {
Scaffold(
topBar = {
if (topBarTitle != null) {
MifosTopAppBar(
MifosTopBar(
topBarTitle = topBarTitle,
backPress = backPress,
onNavigationIconClick = onNavigationIconClick,
actions = actions,
)
}
@ -60,38 +74,205 @@ fun MifosScaffold(
floatingActionButtonContent?.let { content ->
FloatingActionButton(
onClick = content.onClick,
// contentColor = content.contentColor,
contentColor = content.contentColor,
content = content.content,
)
}
},
snackbarHost = snackbarHost,
// containerColor = MaterialTheme.colorScheme.background,
containerColor = containerColor,
contentWindowInsets = WindowInsets(0.dp),
content = { paddingValues ->
val internalPullToRefreshState = rememberPullToRefreshState()
Box(
modifier = Modifier.pullToRefresh(
state = internalPullToRefreshState,
isRefreshing = pullToRefreshState.isRefreshing,
onRefresh = pullToRefreshState.onRefresh,
enabled = pullToRefreshState.isEnabled,
),
modifier = Modifier
.padding(paddingValues = paddingValues)
.consumeWindowInsets(paddingValues = paddingValues)
.imePadding()
.navigationBarsPadding(),
) {
content(paddingValues)
PullToRefreshDefaults.Indicator(
Box(
modifier = Modifier
.padding(paddingValues)
.align(Alignment.TopCenter),
isRefreshing = pullToRefreshState.isRefreshing,
state = internalPullToRefreshState,
.windowInsetsPadding(insets = contentWindowInsets)
.pullToRefresh(
state = internalPullToRefreshState,
isRefreshing = pullToRefreshState.isRefreshing,
onRefresh = pullToRefreshState.onRefresh,
enabled = pullToRefreshState.isEnabled,
),
) {
Column(modifier = Modifier) {
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = AppColors.borderColor,
)
content()
}
PullToRefreshDefaults.Indicator(
modifier = Modifier
.align(Alignment.TopCenter),
isRefreshing = pullToRefreshState.isRefreshing,
state = internalPullToRefreshState,
)
}
}
},
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MifosScaffold(
showNavigationIcon: Boolean,
modifier: Modifier = Modifier,
onNavigationIconClick: () -> Unit = {},
topBarTitle: String? = null,
containerColor: Color = Color.White,
floatingActionButtonContent: FloatingActionButtonContent? = null,
pullToRefreshState: MifosPullToRefreshState = rememberMifosPullToRefreshState(),
contentWindowInsets: WindowInsets = ScaffoldDefaults
.contentWindowInsets
.only(WindowInsetsSides.Horizontal),
snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable () -> Unit = {},
) {
Scaffold(
topBar = {
if (topBarTitle != null) {
MifosTopBar(
topBarTitle = topBarTitle,
showNavigationIcon = showNavigationIcon,
onNavigationIconClick = onNavigationIconClick,
actions = actions,
)
}
},
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
floatingActionButton = {
floatingActionButtonContent?.let { content ->
FloatingActionButton(
onClick = content.onClick,
contentColor = content.contentColor,
content = content.content,
)
}
},
snackbarHost = snackbarHost,
containerColor = containerColor,
contentWindowInsets = WindowInsets(0.dp),
content = { paddingValues ->
val internalPullToRefreshState = rememberPullToRefreshState()
Box(
modifier = Modifier
.padding(paddingValues = paddingValues)
.consumeWindowInsets(paddingValues = paddingValues)
.imePadding()
.navigationBarsPadding(),
) {
Box(
modifier = Modifier
.windowInsetsPadding(insets = contentWindowInsets)
.pullToRefresh(
state = internalPullToRefreshState,
isRefreshing = pullToRefreshState.isRefreshing,
onRefresh = pullToRefreshState.onRefresh,
enabled = pullToRefreshState.isEnabled,
),
) {
Column(modifier = Modifier) {
HorizontalDivider(
modifier = Modifier.fillMaxWidth(),
color = AppColors.borderColor,
)
content()
}
PullToRefreshDefaults.Indicator(
modifier = Modifier
.align(Alignment.TopCenter),
isRefreshing = pullToRefreshState.isRefreshing,
state = internalPullToRefreshState,
)
}
}
},
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MifosElevatedScaffold(
onNavigateBack: () -> Unit,
topBarTitle: String,
modifier: Modifier = Modifier,
brandIcon: DrawableResource? = null,
bottomBar: @Composable () -> Unit = {},
floatingActionButtonContent: FloatingActionButtonContent? = null,
pullToRefreshState: MifosPullToRefreshState = rememberMifosPullToRefreshState(),
contentWindowInsets: WindowInsets = ScaffoldDefaults
.contentWindowInsets
.only(WindowInsetsSides.Horizontal),
snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable () -> Unit = {},
) {
Scaffold(
topBar = {
MifosRoundedTopAppBar(
brandIcon = brandIcon,
title = topBarTitle,
onNavigateBack = onNavigateBack,
actions = actions,
)
},
floatingActionButton = {
floatingActionButtonContent?.let { content ->
FloatingActionButton(
onClick = content.onClick,
contentColor = content.contentColor,
content = content.content,
)
}
},
bottomBar = bottomBar,
snackbarHost = snackbarHost,
contentWindowInsets = WindowInsets(0.dp),
containerColor = MaterialTheme.colorScheme.background,
content = { paddingValues ->
Box(
modifier = Modifier
.padding(paddingValues = paddingValues)
.consumeWindowInsets(paddingValues = paddingValues)
.imePadding()
.navigationBarsPadding(),
) {
val internalPullToRefreshState = rememberPullToRefreshState()
Box(
modifier = Modifier
.windowInsetsPadding(insets = contentWindowInsets)
.pullToRefresh(
state = internalPullToRefreshState,
isRefreshing = pullToRefreshState.isRefreshing,
onRefresh = pullToRefreshState.onRefresh,
enabled = pullToRefreshState.isEnabled,
),
) {
content()
PullToRefreshDefaults.Indicator(
modifier = Modifier.align(Alignment.TopCenter),
isRefreshing = pullToRefreshState.isRefreshing,
state = internalPullToRefreshState,
containerColor = MaterialTheme.colorScheme.tertiary,
color = MaterialTheme.colorScheme.primary,
)
}
}
},
modifier = modifier,
)
}
@ -105,16 +286,15 @@ fun MifosScaffold(
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
pullToRefreshState: MifosPullToRefreshState = rememberMifosPullToRefreshState(),
floatingActionButtonPosition: FabPosition = FabPosition.End,
containerColor: Color = MaterialTheme.colorScheme.background,
containerColor: Color = Color.White,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
content: @Composable (PaddingValues) -> Unit,
contentWindowInsets: WindowInsets = ScaffoldDefaults
.contentWindowInsets
.only(WindowInsetsSides.Horizontal),
content: @Composable () -> Unit,
) {
Scaffold(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding()
.imePadding(),
modifier = modifier,
topBar = topBar,
bottomBar = bottomBar,
snackbarHost = { SnackbarHost(snackbarHostState) },
@ -128,24 +308,34 @@ fun MifosScaffold(
contentColor = contentColor,
contentWindowInsets = contentWindowInsets,
content = { paddingValues ->
val internalPullToRefreshState = rememberPullToRefreshState()
Box(
modifier = Modifier.pullToRefresh(
state = internalPullToRefreshState,
isRefreshing = pullToRefreshState.isRefreshing,
onRefresh = pullToRefreshState.onRefresh,
enabled = pullToRefreshState.isEnabled,
),
modifier = Modifier
.padding(paddingValues = paddingValues)
.consumeWindowInsets(paddingValues = paddingValues)
.imePadding()
.navigationBarsPadding(),
) {
content(paddingValues)
PullToRefreshDefaults.Indicator(
val internalPullToRefreshState = rememberPullToRefreshState()
Box(
modifier = Modifier
.padding(paddingValues)
.align(Alignment.TopCenter),
isRefreshing = pullToRefreshState.isRefreshing,
state = internalPullToRefreshState,
)
.windowInsetsPadding(insets = contentWindowInsets)
.pullToRefresh(
state = internalPullToRefreshState,
isRefreshing = pullToRefreshState.isRefreshing,
onRefresh = pullToRefreshState.onRefresh,
enabled = pullToRefreshState.isEnabled,
),
) {
content()
PullToRefreshDefaults.Indicator(
modifier = Modifier.align(Alignment.TopCenter),
isRefreshing = pullToRefreshState.isRefreshing,
state = internalPullToRefreshState,
containerColor = MaterialTheme.colorScheme.tertiary,
color = MaterialTheme.colorScheme.primary,
)
}
}
},
)
@ -175,3 +365,14 @@ fun rememberMifosPullToRefreshState(
onRefresh = onRefresh,
)
}
@Preview
@Composable
private fun MifosElevated_Preview() {
MifosMobileTheme {
MifosElevatedScaffold(
onNavigateBack = { },
topBarTitle = "Mifos Mobile",
)
}
}

View File

@ -9,7 +9,13 @@
*/
package org.mifos.mobile.core.designsystem.component
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -19,20 +25,24 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.mifos.mobile.core.designsystem.icon.MifosIcons
import org.mifos.mobile.core.designsystem.theme.AppColors
import org.mifos.mobile.core.designsystem.theme.DesignToken
import org.mifos.mobile.core.designsystem.theme.MifosTypography
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MifosTopAppBar(
fun MifosTopBar(
topBarTitle: String,
backPress: () -> Unit,
onNavigationIconClick: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector = MifosIcons.ArrowBack,
actions: @Composable RowScope.() -> Unit = {},
) {
TopAppBar(
@ -40,85 +50,133 @@ fun MifosTopAppBar(
Text(
text = topBarTitle,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(
onClick = backPress,
onClick = onNavigationIconClick,
) {
Icon(
imageVector = icon,
imageVector = MifosIcons.Chevron,
contentDescription = "Back",
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
containerColor = AppColors.customWhite,
),
actions = actions,
modifier = modifier,
)
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun MifosTopAppBar(
navigateBack: () -> Unit,
title: @Composable () -> Unit,
@Composable
fun MifosTopBar(
topBarTitle: String,
showNavigationIcon: Boolean,
modifier: Modifier = Modifier,
icon: ImageVector = MifosIcons.ArrowBack,
onNavigationIconClick: () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
) {
TopAppBar(
modifier = modifier,
title = title,
navigationIcon = {
IconButton(
onClick = navigateBack,
) {
Icon(
imageVector = icon,
contentDescription = "Back Arrow",
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
),
actions = actions,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MifosTopAppBar(
topBarTitleResId: StringResource,
navigateBack: () -> Unit,
modifier: Modifier = Modifier,
actions: @Composable RowScope.() -> Unit = {},
) {
TopAppBar(
modifier = modifier,
title = {
Text(
stringResource(topBarTitleResId),
text = topBarTitle,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(
onClick = navigateBack,
) {
Icon(
imageVector = MifosIcons.ArrowBack,
contentDescription = "Back Arrow",
)
if (showNavigationIcon) {
IconButton(
onClick = onNavigationIconClick,
) {
Icon(
imageVector = MifosIcons.Chevron,
contentDescription = "Back",
)
}
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = AppColors.customWhite,
),
actions = actions,
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MifosRoundedTopAppBar(
title: String,
onNavigateBack: () -> Unit,
modifier: Modifier = Modifier,
brandIcon: DrawableResource? = null,
actions: @Composable RowScope.() -> Unit = {},
) {
TopAppBar(
title = {
Text(
text = title,
style = MifosTypography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
},
actions = actions,
navigationIcon = {
if (brandIcon != null) {
Box(
modifier = Modifier.padding(DesignToken.padding.medium),
) {
Image(
painter = painterResource(brandIcon),
contentDescription = "Brand Icon",
modifier = Modifier
.size(344.dp, 40.dp),
)
}
} else {
IconButton(
onClick = onNavigateBack,
) {
Icon(
imageVector = MifosIcons.Chevron,
contentDescription = null,
)
}
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = AppColors.customWhite,
),
modifier = modifier
.fillMaxWidth()
.shadow(
elevation = DesignToken.elevation.elevation,
shape = DesignToken.shapes.topBar,
spotColor = Color(0xFF5D5D5D),
ambientColor = AppColors.customBlack,
)
.clip(DesignToken.shapes.topBar)
.background(AppColors.customWhite),
)
}
@Preview
@Composable
fun PreviewMbsRoundedTopAppBar() {
MifosRoundedTopAppBar(
title = "TopAppBar",
onNavigateBack = {},
)
}
@Preview
@Composable
fun MbsTopBarPreview() {
MifosTopBar(
topBarTitle = "Title",
onNavigationIconClick = {},
)
}

View File

@ -57,9 +57,13 @@ import fluent.ui.system.icons.FluentIcons
import fluent.ui.system.icons.filled.Document
import fluent.ui.system.icons.filled.ErrorCircle
import fluent.ui.system.icons.filled.Eye
import fluent.ui.system.icons.filled.Grid
import fluent.ui.system.icons.filled.MoneyHand
import fluent.ui.system.icons.filled.Person
import fluent.ui.system.icons.regular.Calendar
import fluent.ui.system.icons.regular.CardUi
import fluent.ui.system.icons.regular.CheckmarkCircle
import fluent.ui.system.icons.regular.ChevronLeft
import fluent.ui.system.icons.regular.Eye
import fluent.ui.system.icons.regular.EyeOff
import fluent.ui.system.icons.regular.Image
@ -123,4 +127,10 @@ object MifosIcons {
val DocumentFilled = FluentIcons.Filled.Document
val Calendar = FluentIcons.Regular.Calendar
val HomeTabFilled = FluentIcons.Filled.Grid
val TransferTabFilled = FluentIcons.Filled.MoneyHand
val PersonTabFilled = FluentIcons.Filled.Person
val Chevron = FluentIcons.Regular.ChevronLeft
}

View File

@ -0,0 +1,31 @@
/*
* Copyright 2025 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.model
/**
* Models high level auth state for the application.
*/
sealed class AuthState {
/**
* Auth state is unknown.
*/
data object Uninitialized : AuthState()
/**
* User is unauthenticated. Said another way, the app has no access token.
*/
data object Unauthenticated : AuthState()
/**
* User is authenticated with the given access token.
*/
data class Authenticated(val accessToken: String) : AuthState()
}

View File

@ -0,0 +1,23 @@
/*
* Copyright 2025 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.model
enum class DarkThemeConfig(val configName: String, val osValue: Int) {
FOLLOW_SYSTEM("Follow System", -1),
LIGHT("Light", 1),
DARK("Dark", 2),
;
companion object {
fun fromString(value: String): DarkThemeConfig {
return entries.find { it.configName.equals(value, ignoreCase = true) } ?: FOLLOW_SYSTEM
}
}
}

View File

@ -47,6 +47,7 @@ fun MifosPoweredCard(
modifier = modifier
.shadow(
elevation = DesignToken.elevation.elevation,
shape = DesignToken.shapes.bottomSheet,
ambientColor = AppColors.customBlack,
spotColor = AppColors.customBlack,
clip = false,

View File

@ -0,0 +1,58 @@
/*
* Copyright 2025 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.navigation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import org.mifos.mobile.core.designsystem.theme.AppColors
@Composable
fun MifosBottomBar(
navigationItems: List<NavigationItem>,
selectedItem: NavigationItem?,
onClick: (NavigationItem) -> Unit,
modifier: Modifier = Modifier,
windowInsets: WindowInsets = BottomAppBarDefaults.windowInsets,
) {
BottomAppBar(
containerColor = AppColors.customWhite,
contentColor = Color.Unspecified,
windowInsets = windowInsets,
modifier = modifier
.fillMaxWidth()
// .shadow(
// elevation = DesignToken.elevation.elevation,
// spotColor = Color(0xFF5D5D5D),
// ambientColor = Color.Black,
// )
.background(AppColors.customWhite),
tonalElevation = 0.dp,
) {
navigationItems.forEach { navigationItem ->
MifosNavigationBarItem(
contentDescriptionRes = navigationItem.contentDescriptionRes,
selectedIconRes = navigationItem.iconResSelected,
unselectedIconRes = navigationItem.iconRes,
label = navigationItem.labelRes,
isSelected = selectedItem == navigationItem,
onClick = { onClick(navigationItem) },
modifier = Modifier.testTag(tag = navigationItem.testTag),
)
}
}
}

View File

@ -0,0 +1,63 @@
/*
* Copyright 2025 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.navigation
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.mifos.mobile.core.designsystem.theme.DesignToken
import org.mifos.mobile.core.designsystem.theme.MifosTypography
@Composable
fun RowScope.MifosNavigationBarItem(
contentDescriptionRes: StringResource,
selectedIconRes: ImageVector,
label: StringResource,
unselectedIconRes: ImageVector,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
NavigationBarItem(
icon = {
Icon(
imageVector = if (isSelected) selectedIconRes else unselectedIconRes,
contentDescription = stringResource(contentDescriptionRes),
)
},
label = {
Text(
modifier = Modifier.padding(DesignToken.padding.extraSmall),
text = stringResource(label),
style = MifosTypography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
)
},
selected = isSelected,
alwaysShowLabel = true,
onClick = onClick,
modifier = modifier.padding(vertical = 6.dp),
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
),
)
}

View File

@ -0,0 +1,72 @@
/*
* Copyright 2025 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.navigation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.NavigationRailDefaults
import androidx.compose.material3.Surface
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.testTag
import androidx.compose.ui.unit.dp
import org.mifos.mobile.core.designsystem.theme.DesignToken
@Composable
fun MifosNavigationRail(
navigationItems: List<NavigationItem>,
selectedItem: NavigationItem?,
onClick: (NavigationItem) -> Unit,
modifier: Modifier = Modifier,
windowInsets: WindowInsets = NavigationRailDefaults.windowInsets,
) {
Surface(
color = Color.White,
contentColor = Color.Unspecified,
modifier = modifier,
) {
Column(
modifier = Modifier
.fillMaxHeight()
.windowInsetsPadding(insets = windowInsets)
.widthIn(min = 80.dp)
.padding(vertical = DesignToken.padding.extraSmall)
.selectableGroup()
.verticalScroll(state = rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(
space = DesignToken.spacing.large,
alignment = Alignment.CenterVertically,
),
) {
navigationItems.forEach { navigationItem ->
MifosNavigationRailItem(
contentDescriptionRes = navigationItem.contentDescriptionRes,
selectedIconRes = navigationItem.iconResSelected,
unselectedIconRes = navigationItem.iconRes,
isSelected = navigationItem == selectedItem,
label = navigationItem.labelRes,
onClick = { onClick(navigationItem) },
modifier = Modifier.testTag(tag = navigationItem.testTag),
)
}
}
}
}

View File

@ -0,0 +1,63 @@
/*
* Copyright 2025 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.navigation
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.mifos.mobile.core.designsystem.theme.DesignToken
import org.mifos.mobile.core.designsystem.theme.MifosTypography
@Composable
fun ColumnScope.MifosNavigationRailItem(
contentDescriptionRes: StringResource,
selectedIconRes: ImageVector,
label: StringResource,
unselectedIconRes: ImageVector,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
NavigationRailItem(
icon = {
Icon(
imageVector = if (isSelected) selectedIconRes else unselectedIconRes,
contentDescription = stringResource(contentDescriptionRes),
tint = Color.Unspecified,
)
},
label = {
Text(
modifier = Modifier.padding(DesignToken.padding.extraSmall),
text = stringResource(label),
style = MifosTypography.labelMedium,
color = MaterialTheme.colorScheme.secondary,
)
},
selected = isSelected,
alwaysShowLabel = true,
onClick = onClick,
colors = NavigationRailItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.primary,
),
modifier = modifier,
)
}

View File

@ -0,0 +1,53 @@
/*
* Copyright 2025 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.navigation
import androidx.compose.ui.graphics.vector.ImageVector
import org.jetbrains.compose.resources.StringResource
/**
* Represents a user-interactable item to navigate a user via the bottom app bar or navigation rail.
*/
interface NavigationItem {
/**
* The resource ID for the icon representing the tab when it is selected.
*/
val iconResSelected: ImageVector
/**
* Resource id for the icon representing the tab.
*/
val iconRes: ImageVector
/**
* Resource id for the label describing the tab.
*/
val labelRes: StringResource
/**
* Resource id for the content description describing the tab.
*/
val contentDescriptionRes: StringResource
/**
* Route of the tab's graph.
*/
val graphRoute: String
/**
* Route of the tab's start destination.
*/
val startDestinationRoute: String
/**
* The test tag of the tab.
*/
val testTag: String
}

View File

@ -51,7 +51,7 @@ internal fun AboutUsScreen(
val aboutUsItems = remember { getAboutUsItems() }
MifosScaffold(
backPress = navigateBack,
onNavigationIconClick = navigateBack,
topBarTitle = stringResource(Res.string.feature_about_about_us),
content = {
LazyColumn(

View File

@ -11,7 +11,6 @@ package org.mifos.mobile.feature.about.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -35,14 +34,14 @@ internal fun PrivacyPolicyScreen(
MifosScaffold(
topBarTitle = stringResource(Res.string.feature_about_privacy_policy),
backPress = navigateBack,
onNavigationIconClick = navigateBack,
modifier = modifier,
) { paddingValues ->
) {
WebView(
url = stringResource(Res.string.feature_about_policy_url),
isLoading = isLoading,
onLoadingChange = { isLoading = it },
modifier = Modifier.padding(paddingValues),
modifier = Modifier,
)
}
}

View File

@ -9,7 +9,6 @@
*/
package org.mifos.mobile.feature.accounts.screen
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -27,16 +26,13 @@ import mifos_mobile.feature.accounts.generated.resources.Res
import mifos_mobile.feature.accounts.generated.resources.feature_account_loan_account
import mifos_mobile.feature.accounts.generated.resources.feature_account_savings_account
import mifos_mobile.feature.accounts.generated.resources.feature_account_share_account
import mifos_mobile.feature.accounts.generated.resources.feature_account_title
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.designsystem.component.MifosScaffold
import org.mifos.mobile.core.designsystem.component.MifosTabPager
import org.mifos.mobile.core.designsystem.component.MifosTopAppBar
import org.mifos.mobile.core.designsystem.icon.MifosIcons
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
import org.mifos.mobile.core.model.enums.AccountType
import org.mifos.mobile.core.ui.component.MifosTitleSearchCard
import org.mifos.mobile.core.ui.utils.DevicePreview
import org.mifos.mobile.feature.accounts.component.AccountFilterDialog
import org.mifos.mobile.feature.accounts.model.CheckboxStatus
@ -102,6 +98,7 @@ fun AccountsScreen(
)
}
@Suppress("UnusedParameter")
@Composable
private fun AccountsScreenContent(
currentPage: Int,
@ -138,23 +135,23 @@ private fun AccountsScreenContent(
}
MifosScaffold(
topBar = {
MifosTopAppBar(
navigateBack = navigateBack,
title = {
MifosTitleSearchCard(
actions = {
IconButton(onClick = openFilterDialog) {
Icon(imageVector = MifosIcons.FilterList, contentDescription = "Filter")
}
},
titleResourceId = Res.string.feature_account_title,
searchQuery = onSearchQueryChange,
onSearchDismiss = closeSearch,
)
},
)
},
// topBar = {
// MifosTopAppBar(
// navigateBack = navigateBack,
// title = {
// MifosTitleSearchCard(
// actions = {
// IconButton(onClick = openFilterDialog) {
// Icon(imageVector = MifosIcons.FilterList, contentDescription = "Filter")
// }
// },
// titleResourceId = Res.string.feature_account_title,
// searchQuery = onSearchQueryChange,
// onSearchDismiss = closeSearch,
// )
// },
// )
// },
floatingActionButton = {
IconButton(
onClick = {
@ -171,7 +168,7 @@ private fun AccountsScreenContent(
}
},
modifier = modifier,
) { paddingValues ->
) {
ClientAccountsTabRow(
tabs = tabs,
checkboxOptions = checkboxOptions,
@ -179,7 +176,7 @@ private fun AccountsScreenContent(
searchQuery = searchQuery,
onPageChange = onPageChange,
onAccountClicked = onAccountClicked,
modifier = Modifier.padding(paddingValues),
modifier = Modifier,
)
}
}

View File

@ -103,8 +103,10 @@
<string name="feature_signup_error_name_contains_digits">Name should not contain digits</string>
<string name="feature_signup_error_name_contains_symbols">Name should not contain symbols and spaces</string>
<string name="feature_signup_error_name_contains_emoji">Text must not contain emoji or invalid characters</string>
<string name="feature_signup_user_registered_successfully">User Successfully Registered</string>
<string name="feature_signup_user_registered_successfully_tip">You can now log in with your username (phone number) and password.</string>
<string name="feature_signup_user_registered_failed">Failed To Register The User</string>
<string name="feature_signup_user_registered_failed_tip">There is an error to register the user. Please try again.</string>
<string name="feature_upload_id_title">Upload ID</string>
@ -132,6 +134,7 @@
<string name="feature_upload_id_error_dob_required">Date of birth is required</string>
<string name="feature_upload_id_upload_success">File uploaded successfully</string>
<string name="feature_upload_id_upload_failed">Failed to upload file. Please try again.</string>
<string name="feature_upload_id_error_invalid_dob">Date of Birth cannot be today or a future date</string>
<string name="feature_upload_id_generic_error">Something went wrong. Please try again later.</string>
<string name="feature_otp_title">OTP Authentication</string>
@ -177,4 +180,10 @@
<string name="feature_recover_now_remember_your_password">Remember your password?</string>
<string name="feature_recover_now_log_in">Log in!</string>
<string name="feature_recover_now_recovered_successfully">Password Recovery Successful</string>
<string name="feature_recover_now_recovered_successfully_tip">You can now log in with your username(phone number) and password.</string>
<string name="feature_recover_now_recovered_failed">Password Recovery Failed</string>
<string name="feature_recover_now_registered_failed_tip">There is an error to recover the password of the user. Please try again.</string>
</resources>

View File

@ -13,17 +13,21 @@ package org.mifos.mobile.feature.auth.login
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.mifos.mobile.core.ui.composableWithStayTransitions
import org.mifos.mobile.feature.auth.navigation.AuthGraphRoute
@Serializable
@SerialName("login")
data object LoginRoute
fun NavController.navigateToLoginScreen(navOptions: NavOptions? = null) {
this.navigate(route = LoginRoute, navOptions = navOptions)
fun NavController.navigateToLoginScreen() {
this.navigate(LoginRoute) {
popUpTo(AuthGraphRoute) {
inclusive = true
}
}
}
fun NavGraphBuilder.loginDestination(

View File

@ -13,13 +13,13 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
@ -28,6 +28,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -122,9 +123,17 @@ private fun LoginScreen(
modifier: Modifier = Modifier,
onAction: (LoginAction) -> Unit,
) {
MifosScaffold { paddingValues ->
MifosScaffold(
bottomBar = {
Surface {
MifosPoweredCard(
modifier = Modifier.fillMaxWidth().navigationBarsPadding(),
)
}
},
) {
LoginScreenContent(
modifier = modifier.padding(paddingValues),
modifier = modifier,
state = state,
onAction = onAction,
)
@ -160,35 +169,25 @@ private fun LoginScreenContent(
) {
val keyboardController = LocalSoftwareKeyboardController.current
Box(
Column(
modifier = modifier
.fillMaxSize()
.padding(top = 75.dp)
.padding(top = 100.dp)
.padding(DesignToken.padding.large)
.pointerInput(Unit) {
detectTapGestures(
onTap = {
keyboardController?.hide()
},
)
},
}
.verticalScroll(rememberScrollState()),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(DesignToken.padding.large)
.verticalScroll(rememberScrollState()),
) {
LogoBox()
Spacer(modifier = Modifier.height(DesignToken.spacing.medium))
InputBox(
state = state,
onAction = onAction,
)
}
MifosPoweredCard(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
LogoBox()
Spacer(modifier = Modifier.height(DesignToken.spacing.medium))
InputBox(
state = state,
onAction = onAction,
)
}
}

View File

@ -121,6 +121,8 @@ class LoginViewModel(
)
viewModelScope.launch {
userPreferencesRepositoryImpl.updateUser(userData)
userPreferencesRepositoryImpl.setIsAuthenticated(true)
userPreferencesRepositoryImpl.setIsUnlocked(true)
}
sendEvent(LoginEvent.NavigateToPasscode)
}

View File

@ -66,13 +66,18 @@ fun NavGraphBuilder.authenticationNavGraph(
)
otpAuthenticationDestination(
navigateBack = navController::popBackStack,
navigateToStatusScreen = navController::navigateToStatusScreen,
navigateToSetPasswordScreen = navController::navigateToSetPasswordScreen,
)
statusDestination(
navigateToDestination = {
navController.navigate(it)
if (it == "login") {
navController.navigateToLoginScreen()
} else {
navController.navigate(it)
}
},
)

View File

@ -34,11 +34,13 @@ fun NavController.navigateToOtpAuthScreen(
}
fun NavGraphBuilder.otpAuthenticationDestination(
navigateToStatusScreen: (EventType, String) -> Unit,
navigateBack: () -> Unit,
navigateToStatusScreen: (EventType, String, String, String, String) -> Unit,
navigateToSetPasswordScreen: () -> Unit,
) {
composableWithStayTransitions<OtpAuthenticationRoute> {
OtpAuthenticationScreen(
navigateBack = navigateBack,
navigateToStatusScreen = navigateToStatusScreen,
navigateToSetPasswordScreen = navigateToSetPasswordScreen,
)

View File

@ -17,7 +17,9 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ButtonDefaults
@ -66,7 +68,8 @@ import org.mifos.mobile.core.ui.utils.EventsEffect
@Composable
internal fun OtpAuthenticationScreen(
navigateToStatusScreen: (EventType, String) -> Unit,
navigateBack: () -> Unit,
navigateToStatusScreen: (EventType, String, String, String, String) -> Unit,
navigateToSetPasswordScreen: () -> Unit,
modifier: Modifier = Modifier,
viewModel: OtpAuthenticationViewModel = koinViewModel(),
@ -79,6 +82,9 @@ internal fun OtpAuthenticationScreen(
navigateToStatusScreen(
event.eventType,
event.eventDestination,
event.title,
event.subtitle,
event.buttonText,
)
}
@ -87,6 +93,8 @@ internal fun OtpAuthenticationScreen(
navigateToSetPasswordScreen.invoke()
}
}
is OtpAuthEvent.NavigateBack -> navigateBack.invoke()
}
}
@ -137,16 +145,16 @@ internal fun OptAuthScreenContent(
bottomBar = {
Surface {
MifosPoweredCard(
modifier = modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth().navigationBarsPadding(),
)
}
},
) { paddingValues ->
) {
Column(
modifier = Modifier.fillMaxSize()
.padding(paddingValues)
.padding(DesignToken.padding.large)
.padding(top = DesignToken.padding.large)
.padding(DesignToken.padding.large),
.statusBarsPadding(),
) {
Text(

View File

@ -20,9 +20,13 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.serializer
import mifos_mobile.feature.auth.generated.resources.Res
import mifos_mobile.feature.auth.generated.resources.feature_common_next
import mifos_mobile.feature.auth.generated.resources.feature_otp_invalid_error
import mifos_mobile.feature.auth.generated.resources.feature_otp_required_error
import mifos_mobile.feature.auth.generated.resources.feature_signup_user_registered_successfully
import mifos_mobile.feature.auth.generated.resources.feature_signup_user_registered_successfully_tip
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.mifos.mobile.core.ui.utils.BaseViewModel
import org.mifos.mobile.feature.auth.login.LoginRoute
@ -47,7 +51,7 @@ internal class OtpAuthenticationViewModel(
override fun handleAction(action: OtpAuthAction) {
when (action) {
is OtpAuthAction.OnCancelClick -> { }
is OtpAuthAction.OnCancelClick -> sendEvent(OtpAuthEvent.NavigateBack)
is OtpAuthAction.OnNextClick -> handleNextClick()
@ -127,8 +131,11 @@ internal class OtpAuthenticationViewModel(
dismissDialog()
sendEvent(
OtpAuthEvent.NavigateToStatus(
EventType.SUCCESS,
LoginRoute::class.serializer().descriptor.serialName,
eventType = EventType.SUCCESS,
eventDestination = LoginRoute::class.serializer().descriptor.serialName,
title = getString(Res.string.feature_signup_user_registered_successfully),
subtitle = getString(Res.string.feature_signup_user_registered_successfully_tip),
buttonText = getString(Res.string.feature_common_next),
),
)
}
@ -188,8 +195,13 @@ internal sealed interface OtpAuthAction {
internal sealed interface OtpAuthEvent {
data object NavigateNext : OtpAuthEvent
data object NavigateBack : OtpAuthEvent
data class NavigateToStatus(
val eventType: EventType,
val eventDestination: String,
val title: String,
val subtitle: String,
val buttonText: String,
) : OtpAuthEvent
}

View File

@ -25,7 +25,7 @@ fun NavController.navigateToRecoverPasswordScreen(navOptions: NavOptions? = null
}
fun NavGraphBuilder.recoverPasswordDestination(
navigateToOtpAuthenticationScreen: () -> Unit,
navigateToOtpAuthenticationScreen: (String) -> Unit,
navigateToLoginScreen: () -> Unit,
) {
composableWithSlideTransitions<RecoverPasswordRoute> {

View File

@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
@ -61,7 +62,7 @@ import org.mifos.mobile.core.ui.utils.EventsEffect
@Composable
internal fun RecoverPasswordScreen(
navigateToOtpAuthenticationScreen: () -> Unit,
navigateToOtpAuthenticationScreen: (String) -> Unit,
navigateToLoginScreen: () -> Unit,
modifier: Modifier = Modifier,
viewModel: RecoverPasswordViewModel = koinViewModel(),
@ -70,9 +71,13 @@ internal fun RecoverPasswordScreen(
EventsEffect(viewModel.eventFlow) { event ->
when (event) {
RecoverPasswordEvent.NavigateToLogin -> navigateToLoginScreen.invoke()
is RecoverPasswordEvent.NavigateToLogin -> navigateToLoginScreen.invoke()
RecoverPasswordEvent.NavigateToOtpAuth -> navigateToOtpAuthenticationScreen.invoke()
is RecoverPasswordEvent.NavigateToOtpAuth -> {
navigateToOtpAuthenticationScreen(
event.nextRoute,
)
}
}
}
@ -104,7 +109,8 @@ internal fun RecoverPasswordScreen(
Surface {
MifosPoweredCard(
modifier = Modifier
.fillMaxWidth(),
.fillMaxWidth()
.navigationBarsPadding(),
)
}
},
@ -164,7 +170,7 @@ internal fun ForgotPasswordInputBox(
),
config = MifosTextFieldConfig(
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
keyboardType = KeyboardType.Phone,
),
isError = state.phoneNumberError != null,
errorText = state.phoneNumberError?.let { stringResource(it) },

View File

@ -14,6 +14,9 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.serializer
import mifos_mobile.feature.auth.generated.resources.Res
import mifos_mobile.feature.auth.generated.resources.feature_recover_now_email_format_error
import mifos_mobile.feature.auth.generated.resources.feature_recover_now_email_required
@ -23,6 +26,7 @@ import mifos_mobile.feature.auth.generated.resources.feature_recover_now_phone_n
import org.jetbrains.compose.resources.StringResource
import org.mifos.mobile.core.ui.utils.BaseViewModel
import org.mifos.mobile.core.ui.utils.ValidationHelper
import org.mifos.mobile.feature.auth.setNewPassword.SetPasswordRoute
const val PHONE_NUMBER_LENGTH = 10
@ -131,12 +135,17 @@ internal class RecoverPasswordViewModel :
}
}
@OptIn(InternalSerializationApi::class, ExperimentalSerializationApi::class)
private fun requestRecoveryCode() {
viewModelScope.launch {
mutableStateFlow.update { it.copy(dialogState = RecoverPasswordState.DialogState.Loading) }
delay(3000)
dismissDialog()
sendEvent(RecoverPasswordEvent.NavigateToOtpAuth)
sendEvent(
RecoverPasswordEvent.NavigateToOtpAuth(
nextRoute = SetPasswordRoute::class.serializer().descriptor.serialName,
),
)
}
}
@ -169,7 +178,9 @@ data class RecoverPasswordState(
}
internal sealed interface RecoverPasswordEvent {
data object NavigateToOtpAuth : RecoverPasswordEvent
data class NavigateToOtpAuth(
val nextRoute: String,
) : RecoverPasswordEvent
data object NavigateToLogin : RecoverPasswordEvent
}

View File

@ -19,7 +19,9 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
@ -158,15 +160,15 @@ private fun RegistrationScreen(
bottomBar = {
Surface {
MifosPoweredCard(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().navigationBarsPadding(),
)
}
},
) { paddingValues ->
) {
RegistrationScreenContent(
state = state,
onAction = onAction,
modifier = modifier.padding(paddingValues),
modifier = modifier,
)
}
}
@ -193,7 +195,9 @@ private fun RegistrationScreenContent(
keyboardController?.hide()
}
}
.padding(DesignToken.padding.large),
.padding(DesignToken.padding.large)
.padding(top = DesignToken.padding.large)
.statusBarsPadding(),
verticalArrangement = Arrangement.spacedBy(DesignToken.spacing.medium),
contentPadding = PaddingValues(
bottom = DesignToken.spacing.extraLarge,
@ -385,10 +389,16 @@ fun MifosInputField(
trailingIcon = trailingIcon,
visualTransformation = visualTransformation,
keyboardOptions = KeyboardOptions(
keyboardType = if (config.fieldType == InputFieldType.PASSWORD) {
KeyboardType.Password
} else {
KeyboardType.Text
keyboardType = when (config.fieldType) {
InputFieldType.PASSWORD -> {
KeyboardType.Password
}
InputFieldType.NUMBER -> {
KeyboardType.Number
}
else -> {
KeyboardType.Text
}
},
imeAction = ImeAction.Next,
),
@ -400,6 +410,7 @@ fun MifosInputField(
enum class InputFieldType {
TEXT,
PASSWORD,
NUMBER,
}
data class InputFieldConfig(
@ -454,6 +465,7 @@ fun getInputConfigs(
errorText = state.customerAccountError,
labelRes = Res.string.feature_signup_customer_account_label,
onValueChange = { onAction(SignUpAction.OnCustomerAccountChange(it)) },
fieldType = InputFieldType.NUMBER,
),
InputFieldConfig(
value = state.password,

View File

@ -28,7 +28,7 @@ fun NavController.navigateToSetPasswordScreen(navOptions: NavOptions? = null) {
}
fun NavGraphBuilder.setPasswordDestination(
navigateToStatusScreen: (EventType, String) -> Unit,
navigateToStatusScreen: (EventType, String, String, String, String) -> Unit,
navigateToLoginScreen: () -> Unit,
) {
composableWithSlideTransitions<SetPasswordRoute> {

View File

@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
@ -62,7 +63,7 @@ import org.mifos.mobile.feature.auth.otpAuthentication.EventType
@Composable
internal fun SetPasswordScreen(
navigateToStatusScreen: (EventType, String) -> Unit,
navigateToStatusScreen: (EventType, String, String, String, String) -> Unit,
navigateToLoginScreen: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SetPasswordViewModel = koinViewModel(),
@ -76,6 +77,9 @@ internal fun SetPasswordScreen(
is SetPasswordEvent.NavigateToStatus -> navigateToStatusScreen(
event.eventType,
event.eventDestination,
event.title,
event.subtitle,
event.buttonText,
)
}
}
@ -128,7 +132,7 @@ internal fun SetPasswordScreen(
bottomBar = {
Surface {
MifosPoweredCard(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().navigationBarsPadding(),
)
}
},
@ -252,7 +256,6 @@ internal fun SetPasswordInputBox(
Text(
text = stringResource(Res.string.feature_set_new_password_submit),
style = MifosTypography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}

View File

@ -18,9 +18,13 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.serializer
import mifos_mobile.feature.auth.generated.resources.Res
import mifos_mobile.feature.auth.generated.resources.feature_common_next
import mifos_mobile.feature.auth.generated.resources.feature_recover_now_recovered_successfully
import mifos_mobile.feature.auth.generated.resources.feature_recover_now_recovered_successfully_tip
import mifos_mobile.feature.auth.generated.resources.feature_signup_error_password_mismatch
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.ui.PasswordStrengthState
import org.mifos.mobile.core.ui.utils.BaseViewModel
import org.mifos.mobile.core.ui.utils.PasswordChecker
@ -182,8 +186,11 @@ internal class SetPasswordViewModel : BaseViewModel<SetPasswordState, SetPasswor
dismissDialog()
sendEvent(
SetPasswordEvent.NavigateToStatus(
EventType.SUCCESS,
LoginRoute::class.serializer().descriptor.serialName,
eventType = EventType.SUCCESS,
eventDestination = LoginRoute::class.serializer().descriptor.serialName,
title = getString(Res.string.feature_recover_now_recovered_successfully),
subtitle = getString(Res.string.feature_recover_now_recovered_successfully_tip),
buttonText = getString(Res.string.feature_common_next),
),
)
}
@ -234,7 +241,7 @@ internal data class SetPasswordState(
}
val isSubmitButtonEnabled: Boolean
get() = password.isNotBlank() && password.isNotBlank()
get() = password.isNotBlank() && confirmPassword.isNotBlank()
}
internal sealed interface SetPasswordEvent {
@ -244,6 +251,9 @@ internal sealed interface SetPasswordEvent {
data class NavigateToStatus(
val eventType: EventType,
val eventDestination: String,
val buttonText: String,
val title: String,
val subtitle: String,
) : SetPasswordEvent
}

View File

@ -30,9 +30,9 @@ import org.mifos.mobile.feature.auth.otpAuthentication.EventType
internal fun StatusScreen(
eventType: EventType,
eventDestination: String,
buttonText: String,
title: String,
subtitle: String,
buttonText: String,
navigateToDestination: (String) -> Unit,
) {
MifosScaffold(
@ -47,7 +47,6 @@ internal fun StatusScreen(
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(DesignToken.padding.large),
verticalArrangement = Arrangement.Center,
) {

View File

@ -16,33 +16,38 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable
import org.mifos.mobile.core.ui.composableWithStayTransitions
import org.mifos.mobile.feature.auth.navigation.AuthGraphRoute
import org.mifos.mobile.feature.auth.otpAuthentication.EventType
@Serializable
data class StatusNavigationRoute(
val eventType: EventType,
val eventDestination: String,
val buttonText: String = "Continue",
val title: String = "Success",
val subtitle: String = "You have completed the action.",
val title: String,
val subtitle: String,
val buttonText: String,
)
fun NavController.navigateToStatusScreen(
eventType: EventType,
eventDestination: String,
buttonText: String = "Continue",
title: String = "Success",
subtitle: String = "You have completed the action.",
title: String,
subtitle: String,
buttonText: String,
) {
this.navigate(
StatusNavigationRoute(
eventType = eventType,
eventDestination = eventDestination,
buttonText = buttonText,
title = title,
subtitle = subtitle,
buttonText = buttonText,
),
)
) {
popUpTo(AuthGraphRoute) {
inclusive = false
}
}
}
fun NavGraphBuilder.statusDestination(
@ -53,9 +58,9 @@ fun NavGraphBuilder.statusDestination(
StatusScreen(
eventType = route.eventType,
eventDestination = route.eventDestination,
buttonText = route.buttonText,
title = route.title,
subtitle = route.subtitle,
buttonText = route.buttonText,
navigateToDestination = navigateToDestination,
)
}

View File

@ -17,8 +17,11 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DatePicker
@ -40,6 +43,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import mifos_mobile.feature.auth.generated.resources.Res
import mifos_mobile.feature.auth.generated.resources.feature_common_submit
@ -138,18 +143,19 @@ internal fun UploadIdScreenContent(
Surface {
MifosPoweredCard(
modifier = Modifier
.fillMaxWidth(),
.fillMaxWidth()
.navigationBarsPadding(),
)
}
},
) { paddingValues ->
) {
Column(
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(paddingValues)
.padding(DesignToken.padding.large),
.padding(top = DesignToken.padding.large)
.padding(DesignToken.padding.large)
.statusBarsPadding(),
) {
Text(
text = stringResource(Res.string.feature_upload_id_title),
@ -187,7 +193,7 @@ internal fun InputForm(
initialSelectedDateMillis = activateDate,
selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean {
return utcTimeMillis >= Clock.System.now().toEpochMilliseconds()
return utcTimeMillis < Clock.System.now().toEpochMilliseconds()
}
},
)
@ -208,6 +214,7 @@ internal fun InputForm(
isError = state.cellPhoneError != null,
errorText = state.cellPhoneError?.let { stringResource(it) },
onValueChange = { onAction(UploadIdAction.OnMobileChange(it)) },
inputFieldType = InputFieldType.PHONE,
)
MifosTextFieldWithError(
@ -216,6 +223,7 @@ internal fun InputForm(
isError = state.nationalIdError != null,
errorText = state.nationalIdError?.let { stringResource(it) },
onValueChange = { onAction(UploadIdAction.OnNationalIdChange(it)) },
inputFieldType = InputFieldType.PHONE,
)
MifosTextFieldWithError(
@ -307,6 +315,11 @@ internal fun InputForm(
}
}
enum class InputFieldType {
TEXT,
PHONE,
}
@Composable
private fun MifosTextFieldWithError(
value: String,
@ -316,6 +329,7 @@ private fun MifosTextFieldWithError(
errorText: String? = null,
trailingIcon: @Composable (() -> Unit)? = null,
showClearIcon: Boolean = true,
inputFieldType: InputFieldType = InputFieldType.TEXT,
) {
MifosOutlinedTextField(
value = value,
@ -333,6 +347,14 @@ private fun MifosTextFieldWithError(
errorText = errorText,
trailingIcon = trailingIcon,
showClearIcon = showClearIcon,
keyboardOptions = KeyboardOptions(
keyboardType = if (inputFieldType == InputFieldType.PHONE) {
KeyboardType.Phone
} else {
KeyboardType.Text
},
imeAction = ImeAction.Next,
),
),
)
}

View File

@ -21,6 +21,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import mifos_mobile.feature.auth.generated.resources.Res
import mifos_mobile.feature.auth.generated.resources.feature_recover_now_invalid_phone_number_error
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 mifos_mobile.feature.auth.generated.resources.feature_upload_id_error_dob_required
import mifos_mobile.feature.auth.generated.resources.feature_upload_id_error_field_empty
import mifos_mobile.feature.auth.generated.resources.feature_upload_id_error_id_required
@ -30,6 +33,8 @@ import mifos_mobile.feature.auth.generated.resources.feature_upload_id_upload_fa
import org.jetbrains.compose.resources.StringResource
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 :
BaseViewModel<UploadIdUiState, UploadIdEvent, UploadIdAction>(
@ -142,9 +147,18 @@ internal class UploadIdViewModel :
private fun validateCellPhone(cellPhone: String): StringResource? {
return when {
cellPhone.isBlank() -> Res.string.feature_upload_id_error_field_empty
// !ValidationHelper.isValidPhoneNumber(cellPhone) -> Res.string.feature_upload_id_error_mobile_not_valid
cellPhone.length > 10 -> Res.string.feature_upload_id_error_mobile_not_valid
cellPhone.isEmpty() -> Res.string.feature_recover_now_phone_number_required
cellPhone.any { it.isLetter() || !it.isDigit() } ->
Res.string.feature_recover_now_invalid_phone_number_error
cellPhone.length <= PHONE_NUMBER_LENGTH ->
Res.string.feature_recover_now_phone_number_error
!ValidationHelper.isValidPhoneNumber(cellPhone) ->
Res.string
.feature_upload_id_error_mobile_not_valid
else -> null
}
}
@ -192,13 +206,14 @@ internal class UploadIdViewModel :
private fun uploadDetails() {
// TODO call api
// viewModelScope.launch {
// mutableStateFlow.update {
// it.copy(dialogState = UploadIdUiState.DialogState.Loading)
// }
// delay(3000)
// sendEvent(UploadIdEvent.NavigateToOtp)
// }
viewModelScope.launch {
mutableStateFlow.update {
it.copy(dialogState = UploadIdUiState.DialogState.Loading)
}
delay(3000)
dismissDialog()
sendEvent(UploadIdEvent.NavigateToOtp)
}
}
private fun toggleDatePicker() {

View File

@ -10,7 +10,6 @@
package org.mifos.mobile.feature.beneficiary.beneficiaryApplication
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
@ -93,12 +92,11 @@ private fun BeneficiaryApplicationScreen(
MifosScaffold(
topBarTitle = state.topBarTitle,
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
backPress = { onAction(BeneficiaryApplicationAction.OnNavigate) },
onNavigationIconClick = { onAction(BeneficiaryApplicationAction.OnNavigate) },
modifier = modifier,
content = { paddingValues ->
content = {
Box(
modifier = Modifier
.padding(paddingValues = paddingValues),
modifier = Modifier,
) {
if (state.template != null && state.beneficiary != null) {
BeneficiaryApplicationContent(

View File

@ -10,13 +10,7 @@
package org.mifos.mobile.feature.beneficiary.beneficiaryDetail
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -29,19 +23,15 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import mifos_mobile.feature.beneficiary.generated.resources.Res
import mifos_mobile.feature.beneficiary.generated.resources.beneficiary_detail
import mifos_mobile.feature.beneficiary.generated.resources.cancel
import mifos_mobile.feature.beneficiary.generated.resources.delete
import mifos_mobile.feature.beneficiary.generated.resources.delete_beneficiary
import mifos_mobile.feature.beneficiary.generated.resources.update_beneficiary
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.designsystem.component.BasicDialogState
import org.mifos.mobile.core.designsystem.component.MifosBasicDialog
import org.mifos.mobile.core.designsystem.component.MifosScaffold
import org.mifos.mobile.core.designsystem.component.MifosTopAppBar
import org.mifos.mobile.core.designsystem.icon.MifosIcons
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
import org.mifos.mobile.core.model.entity.beneficiary.Beneficiary
import org.mifos.mobile.core.ui.component.MifosAlertDialog
@ -144,7 +134,7 @@ private fun BeneficiaryDetailScreen(
modifier = modifier,
) {
Box(
modifier = Modifier.padding(it),
modifier = Modifier,
) {
if (state.beneficiary != null) {
BeneficiaryDetailContent(
@ -160,6 +150,7 @@ private fun BeneficiaryDetailScreen(
)
}
@Suppress("UnusedParameter")
@Composable
private fun BeneficiaryDetailTopAppBar(
navigateBack: () -> Unit,
@ -172,42 +163,41 @@ private fun BeneficiaryDetailTopAppBar(
mutableStateOf(false)
}
MifosTopAppBar(
backPress = navigateBack,
topBarTitle = stringResource(Res.string.beneficiary_detail),
actions = {
IconButton(
onClick = { openDropdown = updateDropdownValue.invoke(!openDropdown) },
) {
Icon(
imageVector = MifosIcons.MoreVert,
contentDescription = "More",
)
}
DropdownMenu(
expanded = openDropdown,
onDismissRequest = {
openDropdown = updateDropdownValue.invoke(!openDropdown)
},
) {
DropdownMenuItem(
text = { Text(text = stringResource(Res.string.update_beneficiary)) },
onClick = {
openDropdown = updateDropdownValue.invoke(!openDropdown)
updateBeneficiaryClicked.invoke()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(Res.string.delete_beneficiary)) },
onClick = {
openDropdown = updateDropdownValue.invoke(!openDropdown)
showAlert.invoke()
},
)
}
},
modifier = modifier,
)
// MifosTopAppBar(
// onNavigationIconClick = navigateBack,
// title = stringResource(Res.string.beneficiary_detail),
// actions = {
// IconButton(
// onClick = { openDropdown = updateDropdownValue.invoke(!openDropdown) },
// ) {
// Icon(
// imageVector = MifosIcons.MoreVert,
// contentDescription = "More",
// )
// }
// DropdownMenu(
// expanded = openDropdown,
// onDismissRequest = {
// openDropdown = updateDropdownValue.invoke(!openDropdown)
// },
// ) {
// DropdownMenuItem(
// text = { Text(text = stringResource(Res.string.update_beneficiary)) },
// onClick = {
// openDropdown = updateDropdownValue.invoke(!openDropdown)
// updateBeneficiaryClicked.invoke()
// },
// )
// DropdownMenuItem(
// text = { Text(text = stringResource(Res.string.delete_beneficiary)) },
// onClick = {
// openDropdown = updateDropdownValue.invoke(!openDropdown)
// showAlert.invoke()
// },
// )
// }
// },
// )
}
@Composable

View File

@ -11,7 +11,6 @@ package org.mifos.mobile.feature.beneficiary.beneficiaryList
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
@ -115,7 +114,7 @@ private fun BeneficiaryListScreen(
) {
MifosScaffold(
topBarTitle = stringResource(Res.string.beneficiaries),
backPress = { onAction(BeneficiaryListAction.OnNavigate) },
onNavigationIconClick = { onAction(BeneficiaryListAction.OnNavigate) },
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
modifier = modifier,
floatingActionButtonContent = FloatingActionButtonContent(
@ -135,8 +134,7 @@ private fun BeneficiaryListScreen(
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(it),
.fillMaxSize(),
) {
if (state.dialogState == null) {
if (state.beneficiaries.isEmpty()) {

View File

@ -36,12 +36,11 @@ internal fun BeneficiaryScreen(
) {
MifosScaffold(
topBarTitle = stringResource(Res.string.add_beneficiary),
backPress = topAppbarNavigateBack,
onNavigationIconClick = topAppbarNavigateBack,
modifier = modifier,
) {
Column(
modifier = Modifier
.padding(it)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {

View File

@ -120,12 +120,11 @@ private fun ClientChargeScreen(
) {
MifosScaffold(
topBarTitle = stringResource(state.topBarTitleResId),
backPress = { onAction(ClientChargeAction.OnNavigate) },
onNavigationIconClick = { onAction(ClientChargeAction.OnNavigate) },
modifier = modifier,
content = { paddingValues ->
content = {
Box(
modifier = Modifier
.padding(paddingValues = paddingValues)
.fillMaxSize(),
) {
when {

View File

@ -107,10 +107,10 @@ private fun AddGuarantorScreen(
)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
backPress = { onAction(AddGuarantorAction.NavigateBack) },
onNavigationIconClick = { onAction(AddGuarantorAction.NavigateBack) },
modifier = modifier,
content = {
Box(modifier = Modifier.padding(it)) {
Box(modifier = Modifier) {
AddGuarantorContent(
state = state,
guarantorItem = state.guarantorItem,

View File

@ -10,7 +10,6 @@
package org.mifos.mobile.feature.guarantor.screens.guarantorDetails
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -23,16 +22,12 @@ import mifos_mobile.feature.guarantor.generated.resources.Res
import mifos_mobile.feature.guarantor.generated.resources.delete_guarantor
import mifos_mobile.feature.guarantor.generated.resources.dialog_are_you_sure_that_you_want_to_string
import mifos_mobile.feature.guarantor.generated.resources.dismiss
import mifos_mobile.feature.guarantor.generated.resources.guarantor_details
import mifos_mobile.feature.guarantor.generated.resources.update_guarantor
import mifos_mobile.feature.guarantor.generated.resources.yes
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.designsystem.component.BasicDialogState
import org.mifos.mobile.core.designsystem.component.MifosBasicDialog
import org.mifos.mobile.core.designsystem.component.MifosDropdownMenu
import org.mifos.mobile.core.designsystem.component.MifosScaffold
import org.mifos.mobile.core.designsystem.component.MifosTopAppBar
import org.mifos.mobile.core.ui.component.MifosAlertDialog
import org.mifos.mobile.core.ui.component.MifosProgressIndicatorOverlay
import org.mifos.mobile.core.ui.utils.EventsEffect
@ -83,25 +78,25 @@ private fun GuarantorDetailScreen(
) {
MifosScaffold(
modifier = modifier,
topBar = {
MifosTopAppBar(
topBarTitle = stringResource(Res.string.guarantor_details),
backPress = { onAction(GuarantorDetailAction.NavigateBack) },
actions = {
MifosDropdownMenu(
menuItems = listOf(
stringResource(Res.string.update_guarantor)
to { onAction(GuarantorDetailAction.UpdateGuarantor) },
stringResource(Res.string.delete_guarantor)
to { onAction(GuarantorDetailAction.UpdateMenuDialogValue) },
),
)
},
)
},
// topBar = {
// MifosTopAppBar(
// topBarTitle = stringResource(Res.string.guarantor_details),
// backPress = { onAction(GuarantorDetailAction.NavigateBack) },
// actions = {
// MifosDropdownMenu(
// menuItems = listOf(
// stringResource(Res.string.update_guarantor)
// to { onAction(GuarantorDetailAction.UpdateGuarantor) },
// stringResource(Res.string.delete_guarantor)
// to { onAction(GuarantorDetailAction.UpdateMenuDialogValue) },
// ),
// )
// },
// )
// },
snackbarHostState = snackbarHostState,
content = {
Box(modifier = Modifier.padding(it)) {
Box(modifier = Modifier) {
state.guarantor?.let { it1 -> GuarantorDetailContent(data = it1) }
}
},

View File

@ -90,7 +90,7 @@ private fun GuarantorListScreen(
) {
MifosScaffold(
topBarTitle = stringResource(Res.string.view_guarantor),
backPress = { (onAction(GuarantorListAction.OnNavigateBackClick)) },
onNavigationIconClick = { (onAction(GuarantorListAction.OnNavigateBackClick)) },
modifier = modifier,
floatingActionButtonContent = FloatingActionButtonContent(
onClick = { onAction(GuarantorListAction.OnAddGuarantor) },
@ -109,7 +109,7 @@ private fun GuarantorListScreen(
MifosErrorComponent(isEmptyData = true)
} else {
GuarantorList(
modifier = Modifier.padding(it),
modifier = Modifier,
guarantorList = state.guarantorList,
onAction = onAction,
)

View File

@ -35,13 +35,11 @@ import mifos_mobile.feature.help.generated.resources.no_questions_found
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.designsystem.component.MifosScaffold
import org.mifos.mobile.core.designsystem.component.MifosTopAppBar
import org.mifos.mobile.core.designsystem.icon.MifosIcons
import org.mifos.mobile.core.model.entity.FAQ
import org.mifos.mobile.core.ui.component.EmptyDataView
import org.mifos.mobile.core.ui.component.FaqItemHolder
import org.mifos.mobile.core.ui.component.MifosTextButtonWithTopDrawable
import org.mifos.mobile.core.ui.component.MifosTitleSearchCard
import org.mifos.mobile.core.ui.utils.EventsEffect
@Composable
@ -81,20 +79,20 @@ private fun HelpScreenContent(
modifier: Modifier = Modifier,
) {
MifosScaffold(
topBar = {
MifosTopAppBar(
navigateBack = { onAction(HelpAction.NavigateBack) },
title = {
MifosTitleSearchCard(
searchQuery = { query -> onAction(HelpAction.SearchFaq(query)) },
titleResourceId = Res.string.help,
onSearchDismiss = { onAction(HelpAction.DismissSearch) },
)
},
)
},
content = { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
// topBar = {
// MifosTopAppBar(
// navigateBack = { onAction(HelpAction.NavigateBack) },
// title = {
// MifosTitleSearchCard(
// searchQuery = { query -> onAction(HelpAction.SearchFaq(query)) },
// titleResourceId = Res.string.help,
// onSearchDismiss = { onAction(HelpAction.DismissSearch) },
// )
// },
// )
// },
content = {
Box(modifier = Modifier) {
if (uiState.faqList.isNotEmpty()) {
HelpContent(
faqArrayList = uiState.faqList,

View File

@ -1,54 +0,0 @@
/*
* Copyright 2024 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.feature.home.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import org.mifos.mobile.feature.home.screens.HomeScreen
fun NavGraphBuilder.homeNavGraph(
onNavigate: (HomeDestinations) -> Unit,
callHelpline: () -> Unit,
mailHelpline: () -> Unit,
) {
navigation(
startDestination = HomeNavigation.HomeScreen.route,
route = HomeNavigation.HomeBase.route,
) {
homeRoute(
onNavigate = onNavigate,
callHelpline = callHelpline,
mailHelpline = mailHelpline,
)
}
}
fun NavGraphBuilder.homeRoute(
onNavigate: (HomeDestinations) -> Unit,
callHelpline: () -> Unit,
mailHelpline: () -> Unit,
) {
composable(
route = HomeNavigation.HomeScreen.route,
) {
HomeScreen(
callHelpline = callHelpline,
mailHelpline = mailHelpline,
onNavigate = onNavigate,
)
}
}
fun NavController.navigateToHomeScreen(navOptions: NavOptions? = null) {
return this.navigate(HomeNavigation.HomeScreen.route, navOptions)
}

View File

@ -9,21 +9,15 @@
*/
package org.mifos.mobile.feature.home.navigation
import org.mifos.mobile.feature.home.navigation.HomeRoute.HOME_NAVIGATION_ROUTE_BASE
import org.mifos.mobile.feature.home.navigation.HomeRoute.HOME_SCREEN_ROUTE
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import kotlinx.serialization.Serializable
import org.mifos.mobile.core.ui.composableWithStayTransitions
import org.mifos.mobile.feature.home.screens.HomeScreen
import org.mifos.mobile.feature.home.viewmodel.HomeCardItem
import org.mifos.mobile.feature.home.viewmodel.HomeNavigationItems
sealed class HomeNavigation(val route: String) {
data object HomeBase : HomeNavigation(route = HOME_NAVIGATION_ROUTE_BASE)
data object HomeScreen : HomeNavigation(route = HOME_SCREEN_ROUTE)
}
object HomeRoute {
const val HOME_NAVIGATION_ROUTE_BASE = "home_base_route"
const val HOME_SCREEN_ROUTE = "home_screen_route"
}
enum class HomeDestinations {
HOME,
ACCOUNTS,
@ -72,3 +66,23 @@ fun HomeCardItem.toDestination(): HomeDestinations {
HomeCardItem.TransferCard -> HomeDestinations.TRANSFER
}
}
@Serializable
data object HomeRoute
fun NavController.navigateToHomeScreen(navOptions: NavOptions? = null) =
navigate(HomeRoute, navOptions)
fun NavGraphBuilder.homeDestination(
onNavigate: (HomeDestinations) -> Unit,
callHelpline: () -> Unit,
mailHelpline: () -> Unit,
) {
composableWithStayTransitions<HomeRoute> {
HomeScreen(
callHelpline = callHelpline,
mailHelpline = mailHelpline,
onNavigate = onNavigate,
)
}
}

View File

@ -12,7 +12,6 @@ package org.mifos.mobile.feature.home.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
@ -22,13 +21,11 @@ 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.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
@ -41,12 +38,10 @@ import androidx.compose.runtime.saveable.rememberSaveable
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.vector.ImageVector
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import mifos_mobile.feature.home.generated.resources.Res
import mifos_mobile.feature.home.generated.resources.accounts_overview
@ -66,8 +61,6 @@ import org.jetbrains.compose.ui.tooling.preview.Preview
import org.mifos.mobile.core.common.CurrencyFormatter
import org.mifos.mobile.core.designsystem.component.MifosCard
import org.mifos.mobile.core.designsystem.component.MifosScaffold
import org.mifos.mobile.core.designsystem.component.MifosTopAppBar
import org.mifos.mobile.core.designsystem.icon.MifosIcons
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
import org.mifos.mobile.core.ui.component.MifosHiddenTextRow
import org.mifos.mobile.core.ui.component.MifosLinkText
@ -104,49 +97,49 @@ internal fun HomeContent(
},
content = {
MifosScaffold(
topBar = {
MifosTopAppBar(
topBarTitle = stringResource(Res.string.home),
icon = MifosIcons.NavigationDrawer,
actions = {
IconButton(
onClick = {
onAction(HomeAction.OnNavigate(HomeDestinations.NOTIFICATIONS))
},
) {
Box(
modifier = Modifier,
contentAlignment = Alignment.TopEnd,
) {
Icon(
imageVector = MifosIcons.Notifications,
contentDescription = null,
)
if (state.notificationCount > 0) {
Box(
modifier = Modifier
.clip(CircleShape)
.padding(2.dp)
.size(8.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = state.notificationCount.toString(),
fontSize = 6.sp,
)
}
}
}
}
},
backPress = {
coroutineScope.launch { drawerState.open() }
},
)
},
// topBar = {
// MifosTopAppBar(
// topBarTitle = stringResource(Res.string.home),
// icon = MifosIcons.NavigationDrawer,
// actions = {
// IconButton(
// onClick = {
// onAction(HomeAction.OnNavigate(HomeDestinations.NOTIFICATIONS))
// },
// ) {
// Box(
// modifier = Modifier,
// contentAlignment = Alignment.TopEnd,
// ) {
// Icon(
// imageVector = MifosIcons.Notifications,
// contentDescription = null,
// )
// if (state.notificationCount > 0) {
// Box(
// modifier = Modifier
// .clip(CircleShape)
// .padding(2.dp)
// .size(8.dp),
// contentAlignment = Alignment.Center,
// ) {
// Text(
// text = state.notificationCount.toString(),
// fontSize = 6.sp,
// )
// }
// }
// }
// }
// },
// backPress = {
// coroutineScope.launch { drawerState.open() }
// },
// )
// },
) {
HomeScreenContent(
modifier = Modifier.padding(it),
modifier = Modifier,
onAction = onAction,
state = state,
)

View File

@ -11,7 +11,6 @@ package org.mifos.mobile.feature.loan.loanAccount
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -151,7 +150,7 @@ private fun LoanAccountDetailScreen(
)
},
content = {
Box(modifier = Modifier.padding(it)) {
Box(modifier = Modifier) {
if (state.loanAccountAssociations != null) {
LoanAccountDetailContent(
loanWithAssociations = state.loanAccountAssociations,

View File

@ -7,32 +7,20 @@
*
* See https://github.com/openMF/mobile-mobile/blob/master/LICENSE.md
*/
@file:Suppress("unusedPrivateProperty", "unusedParameter")
package org.mifos.mobile.feature.loan.loanAccount
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import mifos_mobile.feature.loan.generated.resources.Res
import mifos_mobile.feature.loan.generated.resources.loan_account_details
import mifos_mobile.feature.loan.generated.resources.update_loan
import mifos_mobile.feature.loan.generated.resources.view_guarantor
import mifos_mobile.feature.loan.generated.resources.withdraw_loan
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.mifos.mobile.core.designsystem.component.MifosTopAppBar
import org.mifos.mobile.core.designsystem.icon.MifosIcons
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
@Suppress("UnusedParameter", "UnusedPrivateProperty")
@Composable
internal fun LoanAccountDetailTopBar(
navigateBack: () -> Unit,
@ -43,43 +31,43 @@ internal fun LoanAccountDetailTopBar(
) {
var showMenu by remember { mutableStateOf(false) }
MifosTopAppBar(
modifier = modifier,
topBarTitle = stringResource(Res.string.loan_account_details),
backPress = navigateBack,
actions = {
IconButton(onClick = { showMenu = !showMenu }) {
Icon(
imageVector = MifosIcons.MoreVert,
contentDescription = "Menu",
)
}
DropdownMenu(
expanded = showMenu,
modifier = Modifier.padding(start = 16.dp, end = 32.dp),
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = {
Text(text = stringResource(Res.string.view_guarantor))
},
onClick = viewGuarantor,
)
DropdownMenuItem(
text = {
Text(text = stringResource(Res.string.update_loan))
},
onClick = updateLoan,
)
DropdownMenuItem(
text = {
Text(text = stringResource(Res.string.withdraw_loan))
},
onClick = withdrawLoan,
)
}
},
)
// MifosTopAppBar(
// modifier = modifier,
// topBarTitle = stringResource(Res.string.loan_account_details),
// backPress = navigateBack,
// actions = {
// IconButton(onClick = { showMenu = !showMenu }) {
// Icon(
// imageVector = MifosIcons.MoreVert,
// contentDescription = "Menu",
// )
// }
// DropdownMenu(
// expanded = showMenu,
// modifier = Modifier.padding(start = 16.dp, end = 32.dp),
// onDismissRequest = { showMenu = false },
// ) {
// DropdownMenuItem(
// text = {
// Text(text = stringResource(Res.string.view_guarantor))
// },
// onClick = viewGuarantor,
// )
// DropdownMenuItem(
// text = {
// Text(text = stringResource(Res.string.update_loan))
// },
// onClick = updateLoan,
// )
// DropdownMenuItem(
// text = {
// Text(text = stringResource(Res.string.withdraw_loan))
// },
// onClick = withdrawLoan,
// )
// }
// },
// )
}
@Preview

View File

@ -12,24 +12,16 @@ package org.mifos.mobile.feature.loan.loanAccountApplication
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import mifos_mobile.feature.loan.generated.resources.Res
import mifos_mobile.feature.loan.generated.resources.apply_for_loan
import mifos_mobile.feature.loan.generated.resources.update_loan
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.designsystem.component.MifosScaffold
import org.mifos.mobile.core.designsystem.component.MifosTopAppBar
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
import org.mifos.mobile.core.model.enums.LoanState
import org.mifos.mobile.core.ui.component.MifosErrorComponent
import org.mifos.mobile.core.ui.component.MifosProgressIndicator
import org.mifos.mobile.core.ui.utils.EventsEffect
@ -101,24 +93,23 @@ private fun LoanApplicationScreen(
) {
MifosScaffold(
modifier = modifier,
topBar = {
MifosTopAppBar(
modifier = Modifier.fillMaxWidth(),
backPress = { onAction(LoanApplicationAction.BackPress) },
topBarTitle =
stringResource(
if (state.loanState == LoanState.CREATE) {
Res.string.apply_for_loan
} else {
Res.string.update_loan
},
),
)
},
// topBar = {
// MifosTopAppBar(
// modifier = Modifier.fillMaxWidth(),
// backPress = { onAction(LoanApplicationAction.BackPress) },
// topBarTitle =
// stringResource(
// if (state.loanState == LoanState.CREATE) {
// Res.string.apply_for_loan
// } else {
// Res.string.update_loan
// },
// ),
// )
// },
content = {
Column(
modifier = Modifier
.padding(it)
.fillMaxSize(),
) {
state.loanWithAssociations?.let {

View File

@ -98,9 +98,9 @@ private fun LoanAccountSummaryScreen(
) {
MifosScaffold(
topBarTitle = stringResource(Res.string.loan_summary),
backPress = { (onAction(LoanAccountSummaryAction.BackPress)) },
onNavigationIconClick = { (onAction(LoanAccountSummaryAction.BackPress)) },
) {
Box(modifier = Modifier.padding(it)) {
Box(modifier = Modifier) {
state.loanAccountAssociations?.let {
LoanAccountSummaryContent(
state = state,

View File

@ -43,7 +43,6 @@ import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.common.CurrencyFormatter
import org.mifos.mobile.core.common.DateHelper
import org.mifos.mobile.core.common.Utils.formatTransactionType
import org.mifos.mobile.core.designsystem.component.MifosTopAppBar
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
import org.mifos.mobile.core.model.entity.Transaction
import org.mifos.mobile.core.model.entity.accounts.loan.LoanWithAssociations
@ -91,6 +90,7 @@ private fun LoanAccountTransactionDialog(
}
}
@Suppress("UnusedParameter")
@Composable
private fun LoanAccountTransactionScreen(
state: LoanAccountTransactionState,
@ -98,10 +98,10 @@ private fun LoanAccountTransactionScreen(
onAction: (LoanAccountTransactionAction) -> Unit,
) {
Column(modifier = modifier.fillMaxSize()) {
MifosTopAppBar(
backPress = { (onAction(LoanAccountTransactionAction.BackPress)) },
topBarTitle = stringResource(Res.string.transactions),
)
// MifosTopAppBar(
// backPress = { (onAction(LoanAccountTransactionAction.BackPress)) },
// topBarTitle = stringResource(Res.string.transactions),
// )
Box(modifier = Modifier.weight(1f)) {
state.loanWithAssociations?.let {

View File

@ -107,11 +107,11 @@ private fun LoanAccountWithdrawScreen(
modifier: Modifier = Modifier,
) {
MifosScaffold(
backPress = { onAction(LoanAccountWithdrawAction.BackPress) },
onNavigationIconClick = { onAction(LoanAccountWithdrawAction.BackPress) },
topBarTitle = stringResource(Res.string.withdraw_loan),
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
content = {
Box(modifier = Modifier.padding(it)) {
Box(modifier = Modifier) {
Column(modifier = modifier.padding(16.dp)) {
LoanAccountWithdrawContent(
loanWithAssociations = state.loanWithAssociations,

View File

@ -112,13 +112,12 @@ private fun LoanRepaymentScheduleScreen(
) {
MifosScaffold(
topBarTitle = stringResource(Res.string.loan_repayment_schedule),
backPress = { (onAction(LoanRepaymentScheduleAction.BackPress)) },
onNavigationIconClick = { (onAction(LoanRepaymentScheduleAction.BackPress)) },
modifier = modifier,
) { contentPadding ->
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
.fillMaxSize(),
) {
state.loanWithAssociations?.let {
LoanRepaymentScheduleCard(it)

View File

@ -27,12 +27,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import mifos_mobile.feature.loan.generated.resources.Res
import mifos_mobile.feature.loan.generated.resources.no_internet_connection
import mifos_mobile.feature.loan.generated.resources.update_loan
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.designsystem.component.MifosScaffold
import org.mifos.mobile.core.designsystem.component.MifosTopAppBar
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
import org.mifos.mobile.core.model.enums.LoanState
import org.mifos.mobile.core.ui.component.MifosErrorComponent
@ -105,11 +102,11 @@ private fun ReviewLoanApplicationScreen(
snackbarHostState = snackbarHostState,
content = {
Column(modifier = modifier.fillMaxSize()) {
MifosTopAppBar(
modifier = Modifier.fillMaxWidth(),
backPress = { onAction(ReviewLoanApplicationAction.NavigateBack(false)) },
topBarTitle = stringResource(Res.string.update_loan),
)
// MifosTopAppBar(
// modifier = Modifier.fillMaxWidth(),
// backPress = { onAction(ReviewLoanApplicationAction.NavigateBack(false)) },
// topBarTitle = stringResource(Res.string.update_loan),
// )
Box(modifier = Modifier.weight(1f)) {
ReviewLoanApplicationContent(
data = state.reviewLoanApplicationUiData,

View File

@ -85,10 +85,10 @@ private fun NotificationScreen(
) {
MifosScaffold(
topBarTitle = stringResource(Res.string.notification),
backPress = navigateBack,
onNavigationIconClick = navigateBack,
modifier = modifier,
content = {
Box(modifier = Modifier.padding(it)) {
Box(modifier = Modifier) {
when (uiState) {
is NotificationUiState.Loading -> MifosProgressIndicatorOverlay()

View File

@ -14,7 +14,7 @@ plugins {
}
android {
namespace = "org.mifos.mobile"
namespace = "org.mifos.mobile.feature.onboarding.language"
}
kotlin {

View File

@ -11,7 +11,6 @@ package org.mifos.mobile.feature.qr.qr
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@ -83,7 +82,7 @@ private fun QrCodeReaderContent(
) {
MifosScaffold(
topBarTitle = null,
backPress = { onAction(QrCodeReaderAction.OnNavigate) },
onNavigationIconClick = { onAction(QrCodeReaderAction.OnNavigate) },
modifier = modifier.fillMaxSize(),
) {
Box(
@ -92,7 +91,7 @@ private fun QrCodeReaderContent(
) {
QrScannerWithPermissions(
types = listOf(CodeType.QR),
modifier = Modifier.padding(it),
modifier = Modifier,
onScanned = {
onAction(QrCodeReaderAction.ScanQrCode(it))
true

View File

@ -18,8 +18,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -36,15 +34,12 @@ import io.github.alexzhirkevich.qrose.rememberQrCodePainter
import io.github.alexzhirkevich.qrose.toByteArray
import mifos_mobile.feature.qr.generated.resources.Res
import mifos_mobile.feature.qr.generated.resources.choose_option
import mifos_mobile.feature.qr.generated.resources.qr_code
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.ui.tooling.preview.Preview
import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.designsystem.component.BasicDialogState
import org.mifos.mobile.core.designsystem.component.MifosBasicDialog
import org.mifos.mobile.core.designsystem.component.MifosScaffold
import org.mifos.mobile.core.designsystem.component.MifosTopAppBar
import org.mifos.mobile.core.designsystem.icon.MifosIcons
import org.mifos.mobile.core.designsystem.theme.MifosMobileTheme
import org.mifos.mobile.core.ui.component.MifosProgressIndicatorOverlay
import org.mifos.mobile.core.ui.utils.EventsEffect
@ -92,6 +87,7 @@ private fun QrCodeDialog(
}
}
@Suppress("UnusedPrivateProperty")
@Composable
private fun QrCodeDisplayScreen(
state: QrCodeDisplayState,
@ -109,34 +105,33 @@ private fun QrCodeDisplayScreen(
val option = stringResource(Res.string.choose_option)
MifosScaffold(
modifier = modifier,
topBar = {
MifosTopAppBar(
backPress = { onAction(QrCodeDisplayAction.OnNavigate) },
topBarTitle = stringResource(Res.string.qr_code),
actions = {
IconButton(
onClick = {
onAction(
QrCodeDisplayAction.ShareQrCode(
bytes,
option,
),
)
},
content = {
Icon(
imageVector = MifosIcons.Share,
contentDescription = null,
)
},
)
},
)
},
content = { paddingValues ->
// topBar = {
// MifosTopAppBar(
// backPress = { onAction(QrCodeDisplayAction.OnNavigate) },
// topBarTitle = stringResource(Res.string.qr_code),
// actions = {
// IconButton(
// onClick = {
// onAction(
// QrCodeDisplayAction.ShareQrCode(
// bytes,
// option,
// ),
// )
// },
// content = {
// Icon(
// imageVector = MifosIcons.Share,
// contentDescription = null,
// )
// },
// )
// },
// )
// },
content = {
Box(
modifier = Modifier
.padding(paddingValues = paddingValues)
.fillMaxSize(),
) {
QrCodeDisplayContent(painter = painter)

View File

@ -12,7 +12,6 @@ package org.mifos.mobile.feature.qr.qrCodeImport
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
@ -104,14 +103,13 @@ private fun QrCodeImportScreen(
) {
MifosScaffold(
topBarTitle = stringResource(Res.string.import_qr),
backPress = { onAction(QrCodeImportAction.OnNavigate) },
onNavigationIconClick = { onAction(QrCodeImportAction.OnNavigate) },
modifier = modifier,
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it),
.fillMaxSize(),
) {
Box(modifier = Modifier.fillMaxSize()) {
QrCodeImportContent(proceedClicked = { imageBitmap ->

View File

@ -99,13 +99,12 @@ private fun RecentTransactionScreen(
MifosScaffold(
topBarTitle = stringResource(Res.string.recent_transactions),
backPress = navigateBack,
onNavigationIconClick = navigateBack,
modifier = modifier,
content = { paddingValues ->
content = {
Box(
Modifier
.fillMaxSize()
.padding(paddingValues),
.fillMaxSize(),
) {
PullToRefreshBox(
state = pullRefreshState,

View File

@ -11,7 +11,6 @@ package org.mifos.mobile.feature.savings.savingsAccount
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@ -19,14 +18,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import mifos_mobile.feature.savings.generated.resources.Res
import mifos_mobile.feature.savings.generated.resources.approval_pending
import mifos_mobile.feature.savings.generated.resources.ic_assignment_turned_in_black_24dp
import mifos_mobile.feature.savings.generated.resources.saving_account_details
import mifos_mobile.feature.savings.generated.resources.update_savings_account
import mifos_mobile.feature.savings.generated.resources.withdraw_savings_account
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.mifos.mobile.core.designsystem.component.MifosDropdownMenu
import org.mifos.mobile.core.designsystem.component.MifosScaffold
import org.mifos.mobile.core.designsystem.component.MifosTopAppBar
import org.mifos.mobile.core.model.enums.ChargeType
import org.mifos.mobile.core.ui.component.EmptyDataView
import org.mifos.mobile.core.ui.component.MifosErrorComponent
@ -69,6 +62,7 @@ internal fun SavingsAccountDetailScreen(
)
}
@Suppress("UnusedParameter")
@Composable
private fun SavingsAccountDetailScreen(
uiState: SavingsAccountDetailUiState,
@ -84,23 +78,23 @@ private fun SavingsAccountDetailScreen(
modifier: Modifier = Modifier,
) {
MifosScaffold(
topBar = {
MifosTopAppBar(
topBarTitle = stringResource(Res.string.saving_account_details),
backPress = navigateBack,
actions = {
MifosDropdownMenu(
menuItems = listOf(
stringResource(Res.string.update_savings_account) to updateSavingsAccount,
stringResource(Res.string.withdraw_savings_account) to withdrawSavingsAccount,
),
)
},
)
},
// topBar = {
// MifosTopAppBar(
// topBarTitle = stringResource(Res.string.saving_account_details),
// backPress = navigateBack,
// actions = {
// MifosDropdownMenu(
// menuItems = listOf(
// stringResource(Res.string.update_savings_account) to updateSavingsAccount,
// stringResource(Res.string.withdraw_savings_account) to withdrawSavingsAccount,
// ),
// )
// },
// )
// },
modifier = modifier,
) {
Box(modifier = Modifier.padding(it)) {
Box(modifier = Modifier) {
when (uiState) {
is SavingsAccountDetailUiState.Error -> MifosErrorComponent()

View File

@ -10,7 +10,6 @@
package org.mifos.mobile.feature.savings.savingsAccountApplication
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
@ -83,12 +82,12 @@ private fun SavingsAccountApplicationScreen(
val scope = rememberCoroutineScope()
MifosScaffold(
backPress = navigateBack,
onNavigationIconClick = navigateBack,
topBarTitle = topBarTitleText,
modifier = modifier,
snackbarHost = { SnackbarHost(snackbarHostState) },
content = {
Box(modifier = Modifier.padding(it)) {
Box(modifier = Modifier) {
when (uiState) {
is SavingsAccountApplicationUiState.Error -> {
MifosErrorComponent(

Some files were not shown because too many files have changed in this diff Show More