diff --git a/cmp-ios/iosApp.xcodeproj/xcshareddata/xcschemes/cmp-ios.xcscheme b/cmp-ios/iosApp.xcodeproj/xcshareddata/xcschemes/cmp-ios.xcscheme index ecd5e7fae7..040bf8b823 100644 --- a/cmp-ios/iosApp.xcodeproj/xcshareddata/xcschemes/cmp-ios.xcscheme +++ b/cmp-ios/iosApp.xcodeproj/xcshareddata/xcschemes/cmp-ios.xcscheme @@ -67,4 +67,4 @@ buildConfiguration = "Release" revealArchiveInOrganizer = "YES"> - + \ No newline at end of file diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesDataSource.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesDataSource.kt index 5732a2491b..900fda11ea 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesDataSource.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesDataSource.kt @@ -32,6 +32,7 @@ import org.mifos.mobile.core.model.MifosThemeConfig private const val USER_DATA = "userData" private const val APP_SETTINGS = "appSettings" +@Suppress("TooManyFunctions") class UserPreferencesDataSource( private val settings: Settings, private val dispatcher: CoroutineDispatcher, @@ -160,6 +161,7 @@ class UserPreferencesDataSource( suspend fun clearInfo() { withContext(dispatcher) { settings.putUserPreference(UserData.DEFAULT) + _userInfo.value = UserData.DEFAULT val cleared = settings.getSettingsPreference().copy( isAuthenticated = false, ) @@ -234,8 +236,37 @@ class UserPreferencesDataSource( _settingsInfo.value = newPreference } + suspend fun setSelectedServices(selectedServices: Set?) = + withContext(dispatcher) { + val newPreference = settings.getSettingsPreference().copy(selectedServices = selectedServices ?: emptySet()) + settings.putSettingsPreference(newPreference) + _settingsInfo.value = newPreference + } + + fun saveSelectedServicesDirectly(services: Set?) { + if (services == null) { + settings.remove(SELECTED_SERVICES_KEY) + } else { + settings.putString(SELECTED_SERVICES_KEY, services.joinToString(",")) + } + val newPreference = settings.getSettingsPreference().copy(selectedServices = services ?: emptySet()) + _settingsInfo.value = newPreference + } + + fun getSelectedServicesDirectly(): Set? { + val directString = settings.getStringOrNull(SELECTED_SERVICES_KEY) + return if (directString == null) { + null + } else if (directString.isBlank()) { + emptySet() + } else { + directString.split(",").filter { it.isNotBlank() }.toSet() + } + } + companion object { private const val PROFILE_IMAGE = "preferences_profile_image" + private const val SELECTED_SERVICES_KEY = "selected_services_list" } } diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepository.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepository.kt index dd65b26111..613d5f98d4 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepository.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepository.kt @@ -75,5 +75,10 @@ interface UserPreferencesRepository { suspend fun setLanguage(language: LanguageConfig) + suspend fun setSelectedServices(selectedServices: Set?) + + val selectedServices: Set? + fun saveSelectedServices(services: Set?) + suspend fun logOut(): Unit } diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepositoryImpl.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepositoryImpl.kt index 2434b008b6..a63364b59f 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepositoryImpl.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/UserPreferencesRepositoryImpl.kt @@ -187,6 +187,17 @@ class UserPreferencesRepositoryImpl( preferenceManager.setPasscode(passcode) } + override suspend fun setSelectedServices(selectedServices: Set?) { + preferenceManager.setSelectedServices(selectedServices) + } + + override val selectedServices: Set? + get() = preferenceManager.getSelectedServicesDirectly() + + override fun saveSelectedServices(services: Set?) { + preferenceManager.saveSelectedServicesDirectly(services) + } + override suspend fun logOut() { preferenceManager.clearInfo() } diff --git a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/model/AppSettings.kt b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/model/AppSettings.kt index 12658b192d..bbd2137632 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/model/AppSettings.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifos/mobile/core/datastore/model/AppSettings.kt @@ -29,6 +29,7 @@ data class AppSettings( val showOnboarding: Boolean, val firstTimeState: Boolean, val timeBasedTheme: TimeBasedTheme, + val selectedServices: Set = emptySet(), ) { companion object { val DEFAULT = AppSettings( diff --git a/core/designsystem/src/commonMain/kotlin/org/mifos/mobile/core/designsystem/icon/MifosIcons.kt b/core/designsystem/src/commonMain/kotlin/org/mifos/mobile/core/designsystem/icon/MifosIcons.kt index 916b485076..27b4d5d123 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifos/mobile/core/designsystem/icon/MifosIcons.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifos/mobile/core/designsystem/icon/MifosIcons.kt @@ -69,6 +69,7 @@ import fluent.ui.system.icons.filled.CaretUp import fluent.ui.system.icons.filled.ChatBubblesQuestion import fluent.ui.system.icons.filled.ChatHistory import fluent.ui.system.icons.filled.ChatMultiple +import fluent.ui.system.icons.filled.CheckmarkCircle import fluent.ui.system.icons.filled.ChevronRight import fluent.ui.system.icons.filled.CoinMultiple import fluent.ui.system.icons.filled.ContactCardRibbon @@ -265,4 +266,8 @@ object MifosIcons { val Attach = FluentIcons.Regular.Attach val AddColor = FluentIcons.Colored.AddCircle + + val GridApps = FluentIcons.Filled.Grid + val CheckCircle1 = FluentIcons.Filled.CheckmarkCircle + val Pencil = Icons.Filled.Edit } diff --git a/feature/home/src/commonMain/composeResources/values/strings.xml b/feature/home/src/commonMain/composeResources/values/strings.xml index a828d5567c..53cbac474d 100644 --- a/feature/home/src/commonMain/composeResources/values/strings.xml +++ b/feature/home/src/commonMain/composeResources/values/strings.xml @@ -45,6 +45,8 @@ Hello %1$s, Services + Edit services + Selected Savings Accounts Loan Accounts Share Accounts @@ -65,6 +67,7 @@ Something went wrong An unexpected error occurred on our server. Please try again later. + Tap the grid icon to add services \ No newline at end of file diff --git a/feature/home/src/commonMain/kotlin/org/mifos/mobile/feature/home/HomeScreen.kt b/feature/home/src/commonMain/kotlin/org/mifos/mobile/feature/home/HomeScreen.kt index 4d5a90a444..845a655996 100644 --- a/feature/home/src/commonMain/kotlin/org/mifos/mobile/feature/home/HomeScreen.kt +++ b/feature/home/src/commonMain/kotlin/org/mifos/mobile/feature/home/HomeScreen.kt @@ -11,6 +11,7 @@ package org.mifos.mobile.feature.home import androidx.compose.foundation.Image import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,11 +20,14 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState @@ -35,12 +39,16 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.collections.immutable.ImmutableList import mifos_mobile.core.ui.generated.resources.ic_icon_logo_1 import mifos_mobile.feature.home.generated.resources.Res +import mifos_mobile.feature.home.generated.resources.feature_home_edit_services import mifos_mobile.feature.home.generated.resources.feature_home_greet +import mifos_mobile.feature.home.generated.resources.feature_home_no_services_hint +import mifos_mobile.feature.home.generated.resources.feature_home_selected import mifos_mobile.feature.home.generated.resources.feature_home_services import mifos_mobile.feature.home.generated.resources.feature_home_total_available_loan import mifos_mobile.feature.home.generated.resources.feature_home_total_available_savings @@ -208,17 +216,38 @@ internal fun HomeContent( Spacer(modifier = Modifier.height(DesignToken.spacing.extraLarge)) - Text( - text = stringResource(Res.string.feature_home_services), - style = MifosTypography.titleMediumEmphasized, - color = KptTheme.colorScheme.onSurface, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Text( + text = stringResource(Res.string.feature_home_services), + style = MifosTypography.titleMediumEmphasized, + color = KptTheme.colorScheme.onSurface, + ) + IconButton(onClick = { onAction(HomeAction.ToggleEditMode) }) { + Icon( + imageVector = if (state.isEditMode) MifosIcons.Edit else MifosIcons.GridApps, + contentDescription = stringResource(Res.string.feature_home_edit_services), + tint = KptTheme.colorScheme.primary, + modifier = Modifier.size(DesignToken.sizes.iconSmall), + ) + } + } - Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + Spacer(modifier = Modifier.height(KptTheme.spacing.sm)) ServiceBox( - items = state.items, - onAction = onAction, + visibleItems = state.visibleItems, + isEditMode = state.isEditMode, + selectedServices = state.selectedServices, + onServiceClick = { route -> + if (state.isEditMode) { + onAction(HomeAction.ToggleServiceSelection(route)) + } else { + onAction(HomeAction.OnNavigate(route)) + } + }, ) } } @@ -230,24 +259,38 @@ internal fun HomeContent( @Composable internal fun ServiceBox( - items: ImmutableList, - onAction: (HomeAction) -> Unit, + visibleItems: ImmutableList, + isEditMode: Boolean, + selectedServices: Set, + onServiceClick: (String) -> Unit, modifier: Modifier = Modifier, ) { val columnCount = 4 val spacing = DesignToken.spacing.medium - val rows = items.chunked(columnCount) + val rows = visibleItems.chunked(columnCount) Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(spacing), ) { + if (visibleItems.isEmpty() && !isEditMode) { + Text( + text = stringResource(Res.string.feature_home_no_services_hint), + style = MifosTypography.bodyMedium, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(DesignToken.padding.large), + ) + } rows.forEach { rowItems -> Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(spacing), ) { rowItems.forEach { item -> + val isSelected = selectedServices.contains(item.route) Box( modifier = Modifier.weight(1f), contentAlignment = Alignment.TopCenter, @@ -255,7 +298,9 @@ internal fun ServiceBox( ServiceItemCard( title = item.title, icon = item.icon, - onClick = { onAction(HomeAction.OnNavigate(item.route)) }, + isSelected = isSelected, + isEditMode = isEditMode, + onClick = { onServiceClick(item.route) }, ) } } @@ -273,30 +318,50 @@ internal fun ServiceItemCard( icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier, + isSelected: Boolean = false, + isEditMode: Boolean = false, ) { Column( modifier = modifier .padding(vertical = KptTheme.spacing.sm) - .clippedClickable( - onClick = onClick, - ), + .clickable(role = Role.Button, onClickLabel = stringResource(title)) { onClick() }, verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.sm), horizontalAlignment = Alignment.CenterHorizontally, ) { - Box( - modifier = Modifier - .border( - DesignToken.strokes.thin, - KptTheme.colorScheme.secondaryContainer, - KptTheme.shapes.medium, - ) - .padding(DesignToken.padding.dp14), - ) { + Box { Image( + modifier = Modifier + .border( + DesignToken.strokes.thin, + if (isEditMode && isSelected) { + KptTheme.colorScheme.primary + } else { + KptTheme.colorScheme.outlineVariant + }, + KptTheme.shapes.medium, + ) + .padding(DesignToken.padding.dp14), imageVector = icon, contentDescription = null, - colorFilter = ColorFilter.tint(KptTheme.colorScheme.tertiary), + colorFilter = ColorFilter.tint( + if (isEditMode && isSelected) { + KptTheme.colorScheme.primary + } else { + KptTheme.colorScheme.tertiary + }, + ), ) + if (isEditMode && isSelected) { + Icon( + imageVector = MifosIcons.CheckCircle1, + contentDescription = stringResource(Res.string.feature_home_selected), + tint = KptTheme.colorScheme.primary, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(DesignToken.spacing.extraSmall) + .size(DesignToken.spacing.medium), + ) + } } Text( diff --git a/feature/home/src/commonMain/kotlin/org/mifos/mobile/feature/home/HomeViewModel.kt b/feature/home/src/commonMain/kotlin/org/mifos/mobile/feature/home/HomeViewModel.kt index 379b560905..ead2b54d97 100644 --- a/feature/home/src/commonMain/kotlin/org/mifos/mobile/feature/home/HomeViewModel.kt +++ b/feature/home/src/commonMain/kotlin/org/mifos/mobile/feature/home/HomeViewModel.kt @@ -11,7 +11,10 @@ package org.mifos.mobile.feature.home import androidx.compose.runtime.Immutable import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update @@ -45,7 +48,7 @@ import org.mifos.mobile.core.ui.utils.BaseViewModel internal class HomeViewModel( private val homeRepositoryImpl: HomeRepository, private val networkMonitor: NetworkMonitor, - userPreferencesRepositoryImpl: UserPreferencesRepository, + private val userPreferencesRepositoryImpl: UserPreferencesRepository, ) : BaseViewModel( initialState = HomeState( clientId = requireNotNull(userPreferencesRepositoryImpl.clientId.value), @@ -57,8 +60,13 @@ internal class HomeViewModel( private var isHandlingNetworkChange = false + companion object { + private val logger = Logger.withTag("HomeViewModel") + } + init { observeNetworkStatus() + loadSavedServices() } /** @@ -113,6 +121,10 @@ internal class HomeViewModel( } is HomeAction.Internal.ReceiveClientDetails -> handleClientDetails(action.dataState) + + is HomeAction.ToggleEditMode -> handleToggleEditMode() + + is HomeAction.ToggleServiceSelection -> handleToggleServiceSelection(action.route) } } @@ -212,12 +224,111 @@ internal class HomeViewModel( mutableStateFlow.update(update) } + /** + * Computes the visible items based on the current edit mode and selected services. + * In edit mode, all items are shown. Otherwise, only selected services are shown. + */ + private fun computeVisibleItems( + items: ImmutableList, + isEditMode: Boolean, + selectedServices: Set, + ): ImmutableList { + return if (isEditMode) { + items + } else { + items.filter { selectedServices.contains(it.route) }.toImmutableList() + } + } + /** * Toggles the visibility of the amount on the screen. */ private fun handleAmountVisible() { updateState { - it.copy(isAmountVisible = !state.isAmountVisible) + it.copy(isAmountVisible = !it.isAmountVisible) + } + } + + /** + * Loads saved services from the preferences repository. + * If no saved preference exists (null), defaults to all services. + */ + private fun loadSavedServices() { + viewModelScope.launch { + val allRoutes = serviceCards.map { it.route }.toSet() + val savedServices = userPreferencesRepositoryImpl.selectedServices ?: allRoutes + updateState { + it.copy( + selectedServices = savedServices, + visibleItems = computeVisibleItems(it.items, false, savedServices), + ) + } + } + } + + private fun handleToggleEditMode() { + if (state.isEditMode) { + viewModelScope.launch { + try { + userPreferencesRepositoryImpl.saveSelectedServices(state.selectedServices) + updateState { + it.copy( + isEditMode = false, + visibleItems = computeVisibleItems( + it.items, + false, + it.selectedServices, + ), + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e { "Error saving selected services: ${e.message}" } + val lastSaved = userPreferencesRepositoryImpl.selectedServices + val rollbackServices = lastSaved ?: serviceCards.map { it.route }.toSet() + updateState { + it.copy( + isEditMode = false, + selectedServices = rollbackServices, + visibleItems = computeVisibleItems( + it.items, + false, + rollbackServices, + ), + ) + } + } + } + } else { + updateState { + it.copy( + isEditMode = true, + visibleItems = computeVisibleItems( + it.items, + true, + it.selectedServices, + ), + ) + } + } + } + + private fun handleToggleServiceSelection(route: String) { + updateState { current -> + val newSelection = if (current.selectedServices.contains(route)) { + current.selectedServices - route + } else { + current.selectedServices + route + } + current.copy( + selectedServices = newSelection, + visibleItems = computeVisibleItems( + current.items, + current.isEditMode, + newSelection, + ), + ) } } @@ -438,8 +549,11 @@ internal data class HomeState( val isAmountVisible: Boolean = false, val dialogState: DialogState? = null, val items: ImmutableList, + val visibleItems: ImmutableList = items, val networkStatus: Boolean = true, val uiState: HomeScreenState?, + val selectedServices: Set = emptySet(), + val isEditMode: Boolean = false, ) { /** @@ -528,6 +642,12 @@ sealed interface HomeAction { /** Action to trigger that display Bottom bar for applying to an account */ data object BottomBarPicker : HomeAction + /** Action to toggle edit mode for service selection */ + data object ToggleEditMode : HomeAction + + /** Action to toggle a service's selection state */ + data class ToggleServiceSelection(val route: String) : HomeAction + /** * A sealed interface for internal actions, which are not triggered directly by the UI. */