diff --git a/core/designsystem/src/commonMain/kotlin/org/mifos/mobile/core/designsystem/theme/DesignToken.kt b/core/designsystem/src/commonMain/kotlin/org/mifos/mobile/core/designsystem/theme/DesignToken.kt index aa981e8a0a..396ee9e6bb 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifos/mobile/core/designsystem/theme/DesignToken.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifos/mobile/core/designsystem/theme/DesignToken.kt @@ -130,6 +130,7 @@ data class AppSpacing( @Immutable data class AppPadding( val none: Dp = 0.dp, + val tiny:Dp=2.dp, val extraSmall: Dp = 4.dp, val small: Dp = 8.dp, val medium: Dp = 12.dp, diff --git a/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/entity/client/Client.kt b/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/entity/client/Client.kt index a4240d29fc..39a5c9b1e3 100644 --- a/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/entity/client/Client.kt +++ b/core/model/src/commonMain/kotlin/org/mifos/mobile/core/model/entity/client/Client.kt @@ -64,4 +64,6 @@ data class Client( val gender: Gender? = null, val groups: List = emptyList(), + + val emailAddress: String? = null, ) : Parcelable diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 9f0c24c237..59e344681c 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -44,6 +44,7 @@ kotlin{ implementation(libs.jb.composeNavigation) implementation(libs.filekit.compose) implementation(libs.filekit.core) + implementation(libs.filekit.coil) } } } diff --git a/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/ImageLoaderUtil.kt b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/ImageLoaderUtil.kt new file mode 100644 index 0000000000..7ebf6c04ee --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/mifos/mobile/core/ui/utils/ImageLoaderUtil.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2025 Mobile Byte Sensei + * + * 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/mobilebytesensei/financiera-bienestar/blob/dev/LICENSE + */ +package org.mifos.mobile.core.ui.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import coil3.ImageLoader +import coil3.PlatformContext +import coil3.memory.MemoryCache +import coil3.request.ImageRequest +import coil3.util.DebugLogger +import io.github.vinceglb.filekit.coil.addPlatformFileSupport + +internal val LocalAppImageLoader = compositionLocalOf { null } + +@Composable +fun rememberImageLoader(context: PlatformContext): ImageLoader { + return LocalAppImageLoader.current ?: rememberDefaultImageLoader(context) +} + +@Composable +internal fun rememberDefaultImageLoader(context: PlatformContext): ImageLoader { + return remember(context) { + ImageLoader.Builder(context) + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, 0.25) + .build() + } + .components { + addPlatformFileSupport() + } + .logger(DebugLogger()) + .build() + } +} diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 08d27c39af..f5ce75a8b8 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -26,6 +26,14 @@ kotlin { implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) implementation(projects.core.datastore) + + implementation(libs.coil.kt) + implementation(libs.coil.kt.compose) + implementation(libs.coil.network.ktor) + + implementation(libs.filekit.core) + implementation(libs.filekit.compose) + implementation(libs.filekit.dialog.compose) } } } diff --git a/feature/settings/src/commonMain/composeResources/values/strings.xml b/feature/settings/src/commonMain/composeResources/values/strings.xml index f4c2c024d8..38895e5023 100644 --- a/feature/settings/src/commonMain/composeResources/values/strings.xml +++ b/feature/settings/src/commonMain/composeResources/values/strings.xml @@ -201,4 +201,71 @@ Language Change Language + + + + Profile + + + Name cannot be empty + Name must be at least 2 characters long + Name cannot exceed 50 characters + Name can only contain letters, spaces, hyphens, and apostrophes + + Email address cannot be empty + Please enter a valid email address + + Phone number cannot be empty + Phone number is too short (minimum 9 digits) + Phone number is too long (maximum 13 characters) + Please enter a valid Spanish phone number + + + Failed to load profile information. Please try again. + Profile updated successfully! + Failed to update profile. Please try again. + Too many failed attempts. Please try again later. + + + Profile picture updated successfully! + Failed to update profile picture. Please try again. + Failed to delete profile picture. Please try again. + + + Your profile has been updated with the latest information. + Continue + Updating your profile... + + + Update Failed + Retry + Cancel + + + Unsaved Changes + You have unsaved changes. Do you want to discard them and leave? + Discard + Keep Editing + + + Loading profile... + + + Name cannot be empty + Please enter a valid email address + Phone number cannot be empty + + + Update Photo + Delete Photo + Profile picture + + + Save Changes + + + Full Name + Email Address + Customer Account + Phone Number \ No newline at end of file diff --git a/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/componenets/SettingsItems.kt b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/componenets/SettingsItems.kt index 1972f76dbb..2488e5d253 100644 --- a/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/componenets/SettingsItems.kt +++ b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/componenets/SettingsItems.kt @@ -80,13 +80,14 @@ sealed class SettingsItems( route = Constants.LANGUAGE, ) - @Serializable - data object Theme : SettingsItems( - title = Res.string.feature_settings_action_theme, - subTitle = Res.string.feature_settings_action_theme_tip, - icon = MifosIcons.DarkTheme, - route = Constants.THEME, - ) + //TODO: Uncomment once we get valid dark theme colours by ui/ux theme +// @Serializable +// data object Theme : SettingsItems( +// title = Res.string.feature_settings_action_theme, +// subTitle = Res.string.feature_settings_action_theme_tip, +// icon = MifosIcons.DarkTheme, +// route = Constants.THEME, +// ) @Serializable data object Endpoint : SettingsItems( @@ -142,7 +143,7 @@ internal val settingsItems: ImmutableList = persistentListOf( SettingsItems.Password, SettingsItems.AuthPasscode, SettingsItems.Language, - SettingsItems.Theme, +// SettingsItems.Theme, SettingsItems.Endpoint, SettingsItems.AboutUs, SettingsItems.FAQ, diff --git a/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/di/SettingsModule.kt b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/di/SettingsModule.kt index 9ad1dc4492..29c594c20b 100644 --- a/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/di/SettingsModule.kt +++ b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/di/SettingsModule.kt @@ -15,6 +15,7 @@ import org.mifos.mobile.feature.settings.faq.FaqViewModel import org.mifos.mobile.feature.settings.language.LanguageViewModel import org.mifos.mobile.feature.settings.passcode.UpdatePasscodeViewModel import org.mifos.mobile.feature.settings.password.ChangePasswordViewModel +import org.mifos.mobile.feature.settings.profile.UpdateProfileViewModel import org.mifos.mobile.feature.settings.settings.SettingsViewModel val SettingsModule = module { @@ -24,4 +25,5 @@ val SettingsModule = module { viewModelOf(::UpdatePasscodeViewModel) viewModelOf(::FaqViewModel) viewModelOf(::LanguageViewModel) + viewModelOf(::UpdateProfileViewModel) } diff --git a/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/navigation/SettingsNavGraphRoute.kt b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/navigation/SettingsNavGraphRoute.kt index e6cd351099..63e7c6121a 100644 --- a/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/navigation/SettingsNavGraphRoute.kt +++ b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/navigation/SettingsNavGraphRoute.kt @@ -22,6 +22,7 @@ import org.mifos.mobile.feature.settings.help.helpDestination import org.mifos.mobile.feature.settings.language.languageDestination import org.mifos.mobile.feature.settings.passcode.updatePasscodeDestination import org.mifos.mobile.feature.settings.password.changePasswordDestination +import org.mifos.mobile.feature.settings.profile.profileDestination import org.mifos.mobile.feature.settings.settings.SettingsRoute import org.mifos.mobile.feature.settings.settings.settingsDestination @@ -66,6 +67,9 @@ fun NavGraphBuilder.settingsGraph( changePasswordDestination( onBackClick = navController::popBackStack, ) + profileDestination( + onBackClick = navController::popBackStack + ) } } diff --git a/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/profile/UpdateProfileNavigation.kt b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/profile/UpdateProfileNavigation.kt new file mode 100644 index 0000000000..f29ba1644f --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/profile/UpdateProfileNavigation.kt @@ -0,0 +1,20 @@ +package org.mifos.mobile.feature.settings.profile + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import org.mifos.mobile.core.ui.composableWithPushTransitions +import org.mifos.mobile.feature.settings.componenets.SettingsItems + +internal fun NavController.navigateToProfile(navOptions: NavOptions? = null) = + navigate(SettingsItems.Profile, navOptions) + +internal fun NavGraphBuilder.profileDestination( + onBackClick: () -> Unit, +) { + composableWithPushTransitions { + UpdateProfileScreen( + navigateBack = onBackClick, + ) + } +} \ No newline at end of file diff --git a/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/profile/UpdateProfileScreen.kt b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/profile/UpdateProfileScreen.kt new file mode 100644 index 0000000000..eac2676929 --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/profile/UpdateProfileScreen.kt @@ -0,0 +1,393 @@ +package org.mifos.mobile.feature.settings.profile + +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 +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +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.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import mifos_mobile.feature.settings.generated.resources.Res +import mifos_mobile.feature.settings.generated.resources.feature_settings_profile_content_description_profile_icon +import mifos_mobile.feature.settings.generated.resources.feature_settings_profile_delete_photo +import mifos_mobile.feature.settings.generated.resources.feature_settings_profile_label_customer_account +import mifos_mobile.feature.settings.generated.resources.feature_settings_profile_label_email +import mifos_mobile.feature.settings.generated.resources.feature_settings_profile_label_full_name +import mifos_mobile.feature.settings.generated.resources.feature_settings_profile_label_phone_number +import mifos_mobile.feature.settings.generated.resources.feature_settings_profile_submit_changes +import mifos_mobile.feature.settings.generated.resources.feature_settings_profile_topbar_title +import mifos_mobile.feature.settings.generated.resources.feature_settings_profile_update_photo +import mifos_mobile.feature.settings.generated.resources.mifos_icon +import mifos_mobile.feature.settings.generated.resources.profile_error_dialog_title +import mifos_mobile.feature.settings.generated.resources.profile_unsaved_changes_discard +import mifos_mobile.feature.settings.generated.resources.profile_unsaved_changes_stay +import mifos_mobile.feature.settings.generated.resources.profile_unsaved_changes_title +import mifos_mobile.feature.settings.generated.resources.profile_update_dialog_button +import mifos_mobile.feature.settings.generated.resources.profile_update_success_message +import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.painterResource +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.MifosButton +import org.mifos.mobile.core.designsystem.component.MifosElevatedScaffold +import org.mifos.mobile.core.designsystem.component.MifosOutlinedTextField +import org.mifos.mobile.core.designsystem.component.MifosTextField +import org.mifos.mobile.core.designsystem.component.MifosTextFieldConfig +import org.mifos.mobile.core.designsystem.icon.MifosIcons +import org.mifos.mobile.core.designsystem.theme.AppSizes +import org.mifos.mobile.core.designsystem.theme.DesignToken +import org.mifos.mobile.core.designsystem.theme.MifosTypography +import org.mifos.mobile.core.ui.component.MifosProgressIndicator +import org.mifos.mobile.core.ui.component.MifosSuccessDialog +import org.mifos.mobile.core.ui.component.SuccessDialogState +import org.mifos.mobile.core.ui.utils.EventsEffect +import org.mifos.mobile.core.ui.utils.rememberImageLoader + +@Composable +internal fun UpdateProfileScreen( + navigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: UpdateProfileViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(eventFlow = viewModel.eventFlow) { event -> + when (event) { + ProfileEvent.OnNavigateBack -> navigateBack.invoke() + } + } + + UpdateProfileScreen( + modifier = modifier, + state = state, + onAction = remember(viewModel) { + { viewModel.trySendAction(it) } + }, + ) + + ProfileDialog( + dialogState = state.dialogState, + onConfirm = remember(viewModel) { + { viewModel.trySendAction(ProfileAction.DismissDialog) } + }, + onDismiss = remember(viewModel) { + { viewModel.trySendAction(ProfileAction.DismissDialog) } + }, + discardChanges = remember(viewModel) { + { viewModel.trySendAction(ProfileAction.DiscardChanges) } + }, + ) +} + +@Composable +internal fun UpdateProfileScreen( + state: ProfileState, + modifier: Modifier = Modifier, + onAction: (action: ProfileAction) -> Unit, +) { + MifosElevatedScaffold( + modifier = modifier.fillMaxSize(), + onNavigateBack = remember(state) { + { onAction(ProfileAction.NavigateBack) } + }, + topBarTitle = stringResource(Res.string.feature_settings_profile_topbar_title), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = AppSizes().headerToContentHeight), + ) { + if (state.isLoading) { + MifosProgressIndicator() + } else { + ProfileScreenContent( + state = state, + onAction = onAction, + ) + } + } + } +} + +@Composable +internal fun ProfileScreenContent( + state: ProfileState, + modifier: Modifier = Modifier, + onAction: (action: ProfileAction) -> Unit, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 24.dp) + .statusBarsPadding(), + verticalArrangement = Arrangement.spacedBy(DesignToken.padding.medium), + ) { + ProfileImageSection( + image = state.image, + onUpdate = onAction, + onDelete = onAction, + ) + + MifosOutlinedTextField( + value = state.firstName, + label =stringResource( Res.string.feature_settings_profile_label_full_name), + onValueChange = { onAction(ProfileAction.OnFirstNameChanged(it)) }, + config = MifosTextFieldConfig( + isError = state.nameError != null, + errorText = if(state.nameError!=null){ + stringResource(state.nameError) + }else{ + null + }, + ) + ) + + MifosOutlinedTextField( + value = state.middleName, + label =stringResource( Res.string.feature_settings_profile_label_full_name), + onValueChange = { onAction(ProfileAction.OnMiddleNameChanged(it)) }, + ) + + MifosOutlinedTextField( + value = state.lastName, + label =stringResource( Res.string.feature_settings_profile_label_full_name), + onValueChange = { onAction(ProfileAction.OnLastNameChanged(it)) }, + ) + + MifosOutlinedTextField( + value = state.email, + label =stringResource( Res.string.feature_settings_profile_label_email), + onValueChange = { onAction(ProfileAction.OnEmailChanged(it)) }, + config = MifosTextFieldConfig( + isError = state.emailError != null, + errorText = if(state.emailError!=null){ + stringResource(state.emailError) + }else{ + null + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email + ), + ) + ) + + MifosOutlinedTextField( + value = state.mobile, + label =stringResource( Res.string.feature_settings_profile_label_phone_number), + onValueChange = { onAction(ProfileAction.OnMobileChanged(it)) }, + config = MifosTextFieldConfig( + isError =state.mobileError != null, + errorText = if(state.mobileError!=null){ + stringResource(state.mobileError) + }else{ + null + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone + ), + ) + ) + + MifosButton( + text = { + Text(stringResource(Res.string.feature_settings_profile_submit_changes)) + }, + onClick = { onAction(ProfileAction.OnSubmit) }, + enabled = state.hasChanges && !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Composable +private fun ProfileImageSection( + image: Any?, + onUpdate: (ProfileAction.PickImage) -> Unit, + onDelete: (ProfileAction.DeleteImage) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(DesignToken.padding.medium), + modifier = modifier.padding(vertical = DesignToken.padding.medium), + ) { + Box( + modifier = Modifier + .size(DesignToken.sizes.profile) + .clip(CircleShape) + .border( + width = DesignToken.padding.tiny, + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + val context = LocalPlatformContext.current + val newImage = remember(image) { image ?: Res.drawable.mifos_icon } + AsyncImage( + model = ImageRequest.Builder(context) + .data(newImage) + .crossfade(true) + .build(), + imageLoader = rememberImageLoader(context), + error = painterResource(Res.drawable.mifos_icon), + onLoading = { + @Composable { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = MaterialTheme.colorScheme.primary, + ) + } + }, + contentDescription = stringResource( + Res.string.feature_settings_profile_content_description_profile_icon, + ), + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + + Column( + modifier = Modifier.height(DesignToken.sizes.profile), + verticalArrangement = Arrangement.SpaceEvenly, + ) { + ProfileImageAction( + icon = MifosIcons.Edit, + text = stringResource(Res.string.feature_settings_profile_update_photo), + onClick = { onUpdate(ProfileAction.PickImage) }, + enabled = true, + ) + + ProfileImageAction( + icon = MifosIcons.Delete, + text = stringResource(Res.string.feature_settings_profile_delete_photo), + onClick = { onDelete(ProfileAction.DeleteImage) }, + enabled = image != null, + ) + } + } +} + +@Composable +private fun ProfileImageAction( + icon: Any, + text: String, + onClick: () -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .clickable(enabled = enabled, onClick = onClick) + .alpha(if (enabled) 1f else 0.6f), + ) { + when (icon) { + is DrawableResource -> { + Icon( + painter = painterResource(icon), + contentDescription = text, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(DesignToken.sizes.iconMiny), + ) + } + + is ImageVector -> { + Icon( + imageVector = icon, + contentDescription = text, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(DesignToken.sizes.iconMiny), + ) + } + } + + Text( + text = text, + color = MaterialTheme.colorScheme.primary, + style = MifosTypography.bodyMedium, + ) + } +} + +@Composable +private fun ProfileDialog( + dialogState: ProfileState.DialogState?, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + discardChanges: () -> Unit, +) { + when (dialogState) { + is ProfileState.DialogState.Success -> { + MifosSuccessDialog( + visibilityState = SuccessDialogState.Shown( + title = dialogState.message, + message = Res.string.profile_update_success_message, + buttonText = Res.string.profile_update_dialog_button, + onBtnClick = onConfirm, + ), + ) + } + + is ProfileState.DialogState.Error -> { + MifosBasicDialog( + visibilityState = BasicDialogState.Shown( + title = stringResource(Res.string.profile_error_dialog_title), + message = stringResource(dialogState.message), + ), + onDismissRequest = onDismiss, + ) + } + + is ProfileState.DialogState.UnsavedChanges -> { + MifosBasicDialog( + visibilityState = BasicDialogState.Shown( + title = stringResource(Res.string.profile_unsaved_changes_title), + message = stringResource(dialogState.message), + ), + onConfirm = onDismiss, + onDismissRequest = discardChanges, + confirmText = stringResource(Res.string.profile_unsaved_changes_stay), + cancelText = stringResource(Res.string.profile_unsaved_changes_discard), + ) + } + + is ProfileState.DialogState.Loading -> { + MifosProgressIndicator() + } + + null -> Unit + } +} \ No newline at end of file diff --git a/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/profile/UpdateProfileViewModel.kt b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/profile/UpdateProfileViewModel.kt new file mode 100644 index 0000000000..348e6dfd1f --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/profile/UpdateProfileViewModel.kt @@ -0,0 +1,616 @@ +package org.mifos.mobile.feature.settings.profile + +import androidx.lifecycle.viewModelScope +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.dialogs.FileKitType +import io.github.vinceglb.filekit.dialogs.openFilePicker +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import mifos_mobile.feature.settings.generated.resources.Res +import mifos_mobile.feature.settings.generated.resources.feature_settings_error_fetching_client +import mifos_mobile.feature.settings.generated.resources.profile_image_delete_failed +import mifos_mobile.feature.settings.generated.resources.profile_image_update_failed +import mifos_mobile.feature.settings.generated.resources.profile_image_update_success +import mifos_mobile.feature.settings.generated.resources.profile_load_failed +import mifos_mobile.feature.settings.generated.resources.profile_name_empty_error +import mifos_mobile.feature.settings.generated.resources.profile_name_invalid_format_error +import mifos_mobile.feature.settings.generated.resources.profile_name_too_long_error +import mifos_mobile.feature.settings.generated.resources.profile_name_too_short_error +import mifos_mobile.feature.settings.generated.resources.profile_too_many_attempts +import mifos_mobile.feature.settings.generated.resources.profile_unsaved_changes_message +import mifos_mobile.feature.settings.generated.resources.profile_update_failed +import mifos_mobile.feature.settings.generated.resources.profile_update_success +import org.jetbrains.compose.resources.StringResource +import org.mifos.mobile.core.common.DataState +import org.mifos.mobile.core.data.repository.HomeRepository +import org.mifos.mobile.core.datastore.UserPreferencesRepository +import org.mifos.mobile.core.model.entity.client.Client +import org.mifos.mobile.core.ui.utils.BaseViewModel +import org.mifos.mobile.core.ui.utils.EmailValidationResult +import org.mifos.mobile.core.ui.utils.ImageUtil +import org.mifos.mobile.core.ui.utils.PhoneValidationResult +import org.mifos.mobile.core.ui.utils.ValidationHelper +import org.mifos.mobile.feature.settings.password.ValidationResult +import org.mifos.mobile.feature.settings.settings.SettingsAction +import org.mifos.mobile.feature.settings.settings.SettingsState +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +internal class UpdateProfileViewModel( + private val homeRepositoryImpl: HomeRepository, + private val userPreferencesRepositoryImpl: UserPreferencesRepository, +) : BaseViewModel( + initialState = run { + ProfileState( + clientId = requireNotNull(userPreferencesRepositoryImpl.clientId.value), + ) + } +) { + private var validationJob: Job? = null + private var submitAttempts = 0 + private val maxSubmitAttempts = 5 + + init { + loadUserData() + } + + override fun handleAction(action: ProfileAction) { + when (action) { + is ProfileAction.OnFirstNameChanged -> onFirstNameChange(action.name) + + is ProfileAction.OnMiddleNameChanged -> onMiddleNameChange(action.name) + + is ProfileAction.OnLastNameChanged -> onLastNameChange(action.name) + + is ProfileAction.OnEmailChanged -> onEmailChange(action.email) + + is ProfileAction.OnMobileChanged -> onMobileChange(action.mobile) + + is ProfileAction.Internal.LoadProfileResult -> handleLoadProfileResult(action) + + is ProfileAction.Internal.UpdateProfileResult -> handleUpdateProfileResult(action) + + is ProfileAction.Internal.HandleImageUpdateResult -> handleImageUpdateResult(action) + + ProfileAction.OnSubmit -> validateAndSubmit() + ProfileAction.RetrySubmit -> resetSubmitAttempts() + + ProfileAction.NavigateBack -> navigateBack() + ProfileAction.DismissDialog -> dismissDialog() + ProfileAction.DiscardChanges -> handleDiscardChangesAndNavigateBack() + + ProfileAction.PickImage -> updateProfileImage() + ProfileAction.DeleteImage -> deleteProfileImage() + is ProfileAction.Internal.ReceiveClientImage -> handleClientImageResponse(action.dataState) + is ProfileAction.Internal.ReceiveClientInfo -> handleClientResponse(action.dataState) + } + } + + private fun loadUserData() { + viewModelScope.launch { + homeRepositoryImpl.currentClient(state.clientId ?: -1L) + .catch { + mutableStateFlow.update { + it.copy( + dialogState = ProfileState.DialogState.Error( + Res.string.feature_settings_error_fetching_client, + ), + ) + } + } + .collect { sendAction(ProfileAction.Internal.ReceiveClientInfo(it)) } + } + + viewModelScope.launch { + homeRepositoryImpl.clientImage(state.clientId ?: -1L) + .catch { + // Do nothing on image fetch error, as it's not critical. + } + .collect { sendAction(ProfileAction.Internal.ReceiveClientImage(it)) } + } + } + + private fun loadProfile() { + mutableStateFlow.update { + it.copy(isLoading = true) + } + + viewModelScope.launch { + try { + // TODO: Load actual profile from repository + sendAction( + ProfileAction.Internal.LoadProfileResult(ProfileLoadResult.Success), + ) + } catch (_: Exception) { + trySendAction( + ProfileAction.Internal.LoadProfileResult( + ProfileLoadResult.Failure(Res.string.profile_load_failed), + ), + ) + } + } + } + + private fun handleClientResponse(state: DataState) { + when (state) { + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = ProfileState.DialogState.Error( + Res.string.feature_settings_error_fetching_client, + ), + ) + } + } + DataState.Loading -> { + mutableStateFlow.update { + it.copy( + dialogState = ProfileState.DialogState.Loading + ) + } + } + is DataState.Success -> { + dismissDialog() + val client=state.data + mutableStateFlow.update { + it.copy( + client = client, + firstName = client.firstname?:"", + middleName = client.middlename?:"", + lastName = client.lastname?:"", + mobile = client.mobileNo?:"", + email = client.emailAddress?:"" + ) + } + } + } + } + + private fun handleClientImageResponse(state: DataState) { + when (state) { + is DataState.Error -> { + // No need to show user that client image getting failed + dismissDialog() + } + DataState.Loading -> { + mutableStateFlow.update { + it.copy( + dialogState = ProfileState.DialogState.Loading + ) + } + } + is DataState.Success -> { + dismissDialog() + setUserProfile(state.data) + } + } + } + + @OptIn(ExperimentalEncodingApi::class) + private fun setUserProfile(image: String?) { + if (image.isNullOrBlank()) return + + // Extract the base64 part, removing any data URI prefix + val base64String = image.substringAfter(",", image) + + // Basic validation for a base64 string + if (!base64String.matches(Regex("^[A-Za-z0-9+/=]+$"))) return + + try { + val decodedBytes = Base64.decode(base64String) + val bitmap = ImageUtil.compressImage(decodedBytes) + mutableStateFlow.update { + it.copy(image = bitmap) + } + } catch (e: Exception) { + // Log the error but fail silently in the UI + println(e.message) + } + } + + private fun validateName(name: String): ValidationResult = when { + name.isEmpty() -> ValidationResult.Error(Res.string.profile_name_empty_error) + name.length < 2 -> ValidationResult.Error(Res.string.profile_name_too_short_error) + name.length > 50 -> ValidationResult.Error(Res.string.profile_name_too_long_error) + !ValidationHelper.isValidName(name) -> ValidationResult.Error(Res.string.profile_name_invalid_format_error) + else -> ValidationResult.Success + } + + private fun validateEmail(email: String): ValidationResult { + return when (val result = ValidationHelper.validateEmailWithDetails(email)) { + is EmailValidationResult.Valid -> ValidationResult.Success + is EmailValidationResult.Invalid -> ValidationResult.Error(result.errorResource) + } + } + + private fun validateMobile(mobile: String): ValidationResult { + val result = ValidationHelper.validatePhoneNumberWithDetails(mobile) + when (result) { + is PhoneValidationResult.Valid -> { + // Update the state with formatted number + mutableStateFlow.update { + it.copy(mobile = result.formattedNumber) + } + return ValidationResult.Success + } + + is PhoneValidationResult.Invalid -> { + return ValidationResult.Error(result.errorResource) + } + } + } + + private fun onFirstNameChange(newValue: String) { + mutableStateFlow.update { + it.copy( + firstName = newValue, + nameError = null, + hasChanges = true, + ) + } + + debounceValidation { + val result = validateName(newValue) + mutableStateFlow.update { + it.copy( + nameError = if (result is ValidationResult.Error) result.message else null, + ) + } + } + } + + private fun onMiddleNameChange(newValue: String) { + mutableStateFlow.update { + it.copy( + middleName = newValue, + hasChanges = true, + ) + } + } + + private fun onLastNameChange(newValue: String) { + mutableStateFlow.update { + it.copy( + lastName = newValue, + ) + } + } + + private fun onEmailChange(newValue: String) { + mutableStateFlow.update { + it.copy( + email = newValue, + emailError = null, + hasChanges = true, + ) + } + + debounceValidation { + val result = validateEmail(newValue) + mutableStateFlow.update { + it.copy( + emailError = if (result is ValidationResult.Error) result.message else null, + ) + } + } + } + + private fun onMobileChange(newValue: String) { + mutableStateFlow.update { + it.copy( + mobile = newValue, + mobileError = null, + hasChanges = true, + ) + } + + debounceValidation { + val result = validateMobile(newValue) + mutableStateFlow.update { + it.copy( + mobileError = if (result is ValidationResult.Error) result.message else null, + ) + } + } + } + + private fun validateAndSubmit() { + if (submitAttempts >= maxSubmitAttempts) { + mutableStateFlow.update { + it.copy( + dialogState = ProfileState.DialogState.Error( + Res.string.profile_too_many_attempts, + ), + ) + } + return + } + + val firstNameResult = validateName(state.firstName) + val emailResult = validateEmail(state.email) + val mobileResult = validateMobile(state.mobile) + + mutableStateFlow.update { + it.copy( + nameError = if (firstNameResult is ValidationResult.Error) firstNameResult.message else null, + emailError = if (emailResult is ValidationResult.Error) emailResult.message else null, + mobileError = if (mobileResult is ValidationResult.Error) mobileResult.message else null, + ) + } + + val isValid = + listOf(firstNameResult, emailResult, mobileResult).all { it is ValidationResult.Success } + + if (isValid) { + handleSubmit() + } else { + submitAttempts++ + } + } + + private fun handleSubmit() { + mutableStateFlow.update { + it.copy(dialogState = ProfileState.DialogState.Loading) + } + + viewModelScope.launch { + try { + // TODO: Update profile in repository + // val result = profileRepository.updateProfile(state.profile) + + delay(1000) + + trySendAction( + ProfileAction.Internal.UpdateProfileResult( + ProfileUpdateResult.Success(Res.string.profile_update_success), + ), + ) + } catch (_: Exception) { + trySendAction( + ProfileAction.Internal.UpdateProfileResult( + ProfileUpdateResult.Failure(Res.string.profile_update_failed), + ), + ) + } + } + } + + private fun updateProfileImage() { + viewModelScope.launch { + try { + val image = FileKit.openFilePicker(type = FileKitType.Image) + image?.let { file -> + mutableStateFlow.update { + it.copy( + hasChanges = true, + image = file, + ) + } + } + + // TODO:: Upload Image to Server + + trySendAction( + ProfileAction.Internal.HandleImageUpdateResult( + ImageUpdateResult.Success(Res.string.profile_image_update_success), + ), + ) + } catch (e: Exception) { + e.printStackTrace() + trySendAction( + ProfileAction.Internal.HandleImageUpdateResult( + ImageUpdateResult.Failure(Res.string.profile_image_update_failed), + ), + ) + } + } + } + + private fun deleteProfileImage() { + viewModelScope.launch { + try { + // TODO: Delete profile image from repository + mutableStateFlow.update { + it.copy( + image = null, + hasChanges = true, + ) + } + } catch (_: Exception) { + mutableStateFlow.update { + it.copy( + dialogState = ProfileState.DialogState.Error( + Res.string.profile_image_delete_failed, + ), + ) + } + } + } + } + + private fun handleLoadProfileResult(action: ProfileAction.Internal.LoadProfileResult) { + when (val result = action.result) { + is ProfileLoadResult.Success -> { + mutableStateFlow.update { + it.copy( + email = "test@gmail.com", + mobile = "+34908890098", + account = "129028932093", + isLoading = false, + ) + } + } + + is ProfileLoadResult.Failure -> { + mutableStateFlow.update { + it.copy( + isLoading = false, + dialogState = ProfileState.DialogState.Error(result.message), + ) + } + } + } + } + + private fun handleUpdateProfileResult(action: ProfileAction.Internal.UpdateProfileResult) { + when (val result = action.result) { + is ProfileUpdateResult.Success -> { + mutableStateFlow.update { + it.copy( + hasChanges = false, + dialogState = ProfileState.DialogState.Success(result.message), + ) + } + submitAttempts = 0 + } + + is ProfileUpdateResult.Failure -> { + submitAttempts++ + mutableStateFlow.update { + it.copy( + dialogState = ProfileState.DialogState.Error(result.message), + ) + } + } + } + } + + private fun handleImageUpdateResult(action: ProfileAction.Internal.HandleImageUpdateResult) { + when (val result = action.result) { + is ImageUpdateResult.Success -> { + mutableStateFlow.update { + it.copy(hasChanges = true) + } + } + + is ImageUpdateResult.Failure -> { + mutableStateFlow.update { + it.copy( + dialogState = ProfileState.DialogState.Error(result.message), + ) + } + } + } + } + + private fun debounceValidation(validation: suspend () -> Unit) { + validationJob?.cancel() + validationJob = viewModelScope.launch { + delay(300) + validation() + } + } + + private fun dismissDialog() { + mutableStateFlow.update { + it.copy(dialogState = null) + } + } + + private fun navigateBack() { + if (state.hasChanges) { + mutableStateFlow.update { + it.copy( + dialogState = ProfileState.DialogState.UnsavedChanges( + Res.string.profile_unsaved_changes_message, + ), + ) + } + } else { + sendEvent(ProfileEvent.OnNavigateBack) + } + } + + private fun handleDiscardChangesAndNavigateBack() { + mutableStateFlow.update { + it.copy( + hasChanges = false, + dialogState = null, + ) + } + + sendEvent(ProfileEvent.OnNavigateBack) + } + + private fun resetSubmitAttempts() { + submitAttempts = 0 + } + + override fun onCleared() { + super.onCleared() + validationJob?.cancel() + } +} + +internal data class ProfileState( + val clientId: Long? = null, + val image: Any? = null, + val firstName: String = "", + val middleName: String = "", + val lastName: String = "", + val email: String = "", + val account: String = "", + val mobile: String = "", + val client: Client? = null, + val nameError: StringResource? = null, + val emailError: StringResource? = null, + val mobileError: StringResource? = null, + val isLoading: Boolean = false, + val hasChanges: Boolean = false, + val dialogState: DialogState? = null, +) { + internal sealed interface DialogState { + data object Loading : DialogState + data class Success(val message: StringResource) : DialogState + data class Error(val message: StringResource) : DialogState + data class UnsavedChanges(val message: StringResource) : DialogState + } +} + +internal sealed interface ProfileEvent { + data object OnNavigateBack : ProfileEvent +} + +internal sealed interface ProfileAction { + data class OnFirstNameChanged(val name: String) : ProfileAction + data class OnMiddleNameChanged(val name: String) : ProfileAction + data class OnLastNameChanged(val name: String) : ProfileAction + data class OnEmailChanged(val email: String) : ProfileAction + data class OnMobileChanged(val mobile: String) : ProfileAction + + data object PickImage : ProfileAction + data object DeleteImage : ProfileAction + + data object OnSubmit : ProfileAction + data object RetrySubmit : ProfileAction + + data object NavigateBack : ProfileAction + data object DismissDialog : ProfileAction + data object DiscardChanges : ProfileAction + + sealed interface Internal : ProfileAction { + data class LoadProfileResult(val result: ProfileLoadResult) : Internal + data class UpdateProfileResult(val result: ProfileUpdateResult) : Internal + data class HandleImageUpdateResult(val result: ImageUpdateResult) : Internal + data class ReceiveClientImage(val dataState: DataState) : Internal + data class ReceiveClientInfo(val dataState: DataState) : Internal + } +} + +sealed class ProfileLoadResult { + data object Success : ProfileLoadResult() + data class Failure(val message: StringResource) : ProfileLoadResult() +} + +sealed class ProfileUpdateResult { + data class Success(val message: StringResource) : ProfileUpdateResult() + data class Failure(val message: StringResource) : ProfileUpdateResult() +} + +sealed class ImageUpdateResult { + data class Success(val message: StringResource) : ImageUpdateResult() + data class Failure(val message: StringResource) : ImageUpdateResult() +} \ No newline at end of file diff --git a/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/settings/SettingsScreen.kt b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/settings/SettingsScreen.kt index 5ef3cda12a..4508ccbfbd 100644 --- a/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/settings/SettingsScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/mifos/mobile/feature/settings/settings/SettingsScreen.kt @@ -66,7 +66,6 @@ internal fun SettingsScreen( when (events) { SettingsEvents.NavigateBack -> navigateBack.invoke() is SettingsEvents.NavigateTo -> { - // Using inside of if condition to resolve crash for other screens when (events.item) { SettingsItems.Help, SettingsItems.AboutUs, SettingsItems.AppInfo, @@ -74,6 +73,7 @@ internal fun SettingsScreen( SettingsItems.Language, SettingsItems.FAQ, SettingsItems.Password, + SettingsItems.Profile -> navigateToScreen.invoke(events.item) else -> {} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1719cebb06..dc53703988 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -248,6 +248,7 @@ back-handler = { group = "com.arkivanov.essenty", name = "back-handler", version filekit-core = { group = "io.github.vinceglb", name = "filekit-core", version.ref = "fileKit" } filekit-compose = { group = "io.github.vinceglb", name = "filekit-compose", version.ref = "fileKit" } filekit-dialog-compose = { group = "io.github.vinceglb", name = "filekit-dialogs-compose", version.ref = "fileKitDialog" } +filekit-coil = { module = "io.github.vinceglb:filekit-coil", version.ref = "fileKitDialog" } qrose = { group = "io.github.alexzhirkevich", name="qrose", version.ref = "qroseVersion" } kermit-logging = { group = "co.touchlab", name = "kermit", version.ref = "kermit" }