diff --git a/core/designsystem/src/main/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt b/core/designsystem/src/main/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt index fd0bb40c..71c7ac20 100644 --- a/core/designsystem/src/main/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt +++ b/core/designsystem/src/main/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt @@ -14,12 +14,16 @@ import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Wallet +import androidx.compose.material.icons.rounded.AccountBalance import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Contacts import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.QrCode import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.SwapHoriz import androidx.compose.material.icons.rounded.Wallet import androidx.compose.ui.graphics.vector.ImageVector @@ -51,4 +55,8 @@ object MifosIcons { val PhotoLibrary = Icons.Filled.PhotoLibrary val Delete = Icons.Filled.Delete val RoundedInfo = Icons.Rounded.Info + val Contact = Icons.Rounded.Contacts + val Settings = Icons.Rounded.Settings + val QR = Icons.Rounded.QrCode + val Bank = Icons.Rounded.AccountBalance } diff --git a/feature/profile/.gitignore b/feature/profile/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/profile/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts new file mode 100644 index 00000000..2d35a373 --- /dev/null +++ b/feature/profile/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + alias(libs.plugins.mifospay.android.feature) + alias(libs.plugins.mifospay.android.library.compose) +} + +android { + namespace = "org.mifospay.feature.profile" +} + +dependencies { + implementation(projects.core.data) + implementation(libs.squareup.okhttp) + implementation(libs.compose.country.code.picker) + implementation(libs.compose.material) + implementation(libs.coil.kt.compose) + implementation(projects.mifospay) +} \ No newline at end of file diff --git a/feature/profile/consumer-rules.pro b/feature/profile/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/profile/proguard-rules.pro b/feature/profile/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/profile/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/profile/src/androidTest/java/org/mifospay/profile/ExampleInstrumentedTest.kt b/feature/profile/src/androidTest/java/org/mifospay/profile/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..5a659e98 --- /dev/null +++ b/feature/profile/src/androidTest/java/org/mifospay/profile/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package org.mifospay.profile + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("org.mifospay.profile.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature/profile/src/main/AndroidManifest.xml b/feature/profile/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/profile/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/profile/src/main/kotlin/org/mifospay/feature/profile/ProfileItemCard.kt b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/ProfileItemCard.kt new file mode 100644 index 00000000..56f1b450 --- /dev/null +++ b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/ProfileItemCard.kt @@ -0,0 +1,91 @@ +package org.mifospay.feature.profile + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +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.MaterialTheme +import androidx.compose.material3.Text +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.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.designsystem.theme.MifosTheme + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ProfileItemCard( + modifier: Modifier = Modifier, + icon: ImageVector, + text: Int, + onClick: () -> Unit, +) { + val combinedModifier = modifier + .border(width = 1.dp, color = Color.Gray, shape = RoundedCornerShape(8.dp)) + .padding(16.dp) + .clickable { onClick.invoke() } + FlowRow(modifier = combinedModifier) { + Icon( + painter = rememberVectorPainter(icon), + modifier = Modifier.size(32.dp), + contentDescription = null, + tint = Color.Black + ) + Text( + modifier = if (text == R.string.edit_profile || text == R.string.settings) Modifier + .padding( + start = 18.dp + ) + .align(Alignment.CenterVertically) else Modifier, + text = stringResource(id = text), + style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Medium) + ) + if (text == R.string.edit_profile || text == R.string.settings) { + Spacer(modifier = Modifier.fillMaxWidth()) + } + } +} + +@Composable +fun DetailItem(label: String, value: String) { + Text( + text = "$label: $value", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(bottom = 12.dp) + ) +} + +@Preview(showBackground = true) +@Composable +fun PreviewProfileItemCard() { + MifosTheme { + ProfileItemCard( + icon = MifosIcons.Profile, + text = R.string.edit_profile, + onClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun PreviewDetailItem() { + MifosTheme { + DetailItem(label = "Email", value = "john.doe@example.com") + } +} \ No newline at end of file diff --git a/feature/profile/src/main/kotlin/org/mifospay/feature/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/ProfileScreen.kt new file mode 100644 index 00000000..ce78de19 --- /dev/null +++ b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/ProfileScreen.kt @@ -0,0 +1,189 @@ +package org.mifospay.feature.profile + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.ProfileImage + + +@Composable +fun ProfileRoute( + viewModel: ProfileViewModel = hiltViewModel(), + onEditProfile: () -> Unit, + onSettings: () -> Unit, +) { + val profileState by viewModel.profileState.collectAsStateWithLifecycle() + ProfileScreenContent( + profileState = profileState, + onEditProfile = onEditProfile, + onSettings = onSettings + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ProfileScreenContent( + profileState: ProfileUiState, + onEditProfile: () -> Unit, + onSettings: () -> Unit, +) { + var showDetails by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + when (profileState) { + ProfileUiState.Loading -> {} + is ProfileUiState.Success -> { + ProfileImage(bitmap = profileState.bitmapImage) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = profileState.name.toString(), + style = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Medium), + ) + IconButton(onClick = { showDetails = !showDetails }) { + Icon( + imageVector = Icons.Default.ArrowDropDown, + tint = Color.Black, + contentDescription = null + ) + } + } + + if (showDetails) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + DetailItem( + label = stringResource(id = R.string.email), + value = profileState.email.toString() + ) + DetailItem( + label = stringResource(id = R.string.vpa), + value = profileState.vpa.toString() + ) + DetailItem( + label = stringResource(id = R.string.mobile), + value = profileState.mobile.toString() + ) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(start = 24.dp, end = 24.dp) + ) { + FlowRow( + modifier = Modifier + .fillMaxWidth(), + maxItemsInEachRow = 2 + ) { + ProfileItemCard( + modifier = Modifier + .padding(end = 8.dp, bottom = 8.dp) + .weight(1f), + icon = MifosIcons.QR, + text = R.string.personal_qr_code, + onClick = {} + ) + + ProfileItemCard( + modifier = Modifier + .padding(start = 8.dp, bottom = 8.dp) + .weight(1f), + icon = MifosIcons.Bank, + text = R.string.link_bank_account, + onClick = {} + ) + + ProfileItemCard( + modifier = Modifier + .padding(top = 8.dp, bottom = 8.dp), + icon = MifosIcons.Contact, + text = R.string.edit_profile, + onClick = { onEditProfile.invoke() } + ) + + ProfileItemCard( + modifier = Modifier + .padding(top = 8.dp), + icon = MifosIcons.Settings, + text = R.string.settings, + onClick = { onSettings.invoke() } + ) + } + } + } + + else -> {} + } + } +} + +class ProfilePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + ProfileUiState.Loading, + ProfileUiState.Success( + name = "John Doe", + email = "john.doe@example.com", + vpa = "john@vpa", + mobile = "+1234567890", + bitmapImage = null + ) + ) +} + +@Preview(showSystemUi = true, showBackground = true) +@Composable +fun ProfileScreenPreview( + @PreviewParameter(ProfilePreviewProvider::class) profileState: ProfileUiState +) { + ProfileScreenContent( + profileState = profileState, + onEditProfile = {}, + onSettings = {} + ) +} \ No newline at end of file diff --git a/feature/profile/src/main/kotlin/org/mifospay/feature/profile/ProfileViewModel.kt b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/ProfileViewModel.kt new file mode 100644 index 00000000..a182b558 --- /dev/null +++ b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/ProfileViewModel.kt @@ -0,0 +1,90 @@ +package org.mifospay.feature.profile + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import okhttp3.ResponseBody +import org.mifospay.core.data.base.UseCase +import org.mifospay.core.data.base.UseCaseHandler +import org.mifospay.core.data.domain.usecase.client.FetchClientImage +import org.mifospay.core.datastore.PreferencesHelper +import org.mifospay.common.DebugUtil +import org.mifospay.core.data.repository.local.LocalRepository + +import javax.inject.Inject + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val mUsecaseHandler: UseCaseHandler, + private val fetchClientImageUseCase: FetchClientImage, + private val localRepository: LocalRepository, + private val mPreferencesHelper: PreferencesHelper +) : ViewModel() { + + private val _profileState = MutableStateFlow(ProfileUiState.Loading) + val profileState: StateFlow get() = _profileState + + init { + fetchClientImage() + fetchProfileDetails() + } + + private fun fetchClientImage() { + viewModelScope.launch { + mUsecaseHandler.execute(fetchClientImageUseCase, + FetchClientImage.RequestValues(localRepository.clientDetails.clientId), + object : UseCase.UseCaseCallback { + override fun onSuccess(response: FetchClientImage.ResponseValue) { + val bitmap = convertResponseToBitmap(response.responseBody) + val currentState = _profileState.value as ProfileUiState.Success + _profileState.value = currentState.copy(bitmapImage = bitmap) + } + + override fun onError(message: String) { + DebugUtil.log("image", message) + } + }) + } + } + + private fun fetchProfileDetails() { + val name = mPreferencesHelper.fullName ?: "-" + val email = mPreferencesHelper.email ?: "-" + val vpa = mPreferencesHelper.clientVpa ?: "-" + val mobile = mPreferencesHelper.mobile ?: "-" + + _profileState.value = ProfileUiState.Success( + name = name, + email = email, + vpa = vpa, + mobile = mobile + ) + } + + private fun convertResponseToBitmap(responseBody: ResponseBody?): Bitmap? { + return try { + responseBody?.byteStream()?.use { inputStream -> + BitmapFactory.decodeStream(inputStream) + } + } catch (e: Exception) { + null + } + } +} + +sealed class ProfileUiState { + data object Loading : ProfileUiState() + data class Success( + val bitmapImage: Bitmap? = null, + val name: String?, + val email: String?, + val vpa: String?, + val mobile: String? + ) : ProfileUiState() +} + diff --git a/feature/profile/src/main/kotlin/org/mifospay/feature/profile/edit/EditProfileActivity.kt b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/edit/EditProfileActivity.kt new file mode 100644 index 00000000..00894319 --- /dev/null +++ b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/edit/EditProfileActivity.kt @@ -0,0 +1,23 @@ +package org.mifospay.feature.profile.edit + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import org.mifospay.core.designsystem.theme.MifosTheme + + +@AndroidEntryPoint +class EditProfileActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MifosTheme { + EditProfileScreenRoute( + onBackClick = { finish() } + ) + } + } + } +} \ No newline at end of file diff --git a/feature/profile/src/main/kotlin/org/mifospay/feature/profile/edit/EditProfileScreen.kt b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/edit/EditProfileScreen.kt new file mode 100644 index 00000000..e2988c99 --- /dev/null +++ b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/edit/EditProfileScreen.kt @@ -0,0 +1,443 @@ +package org.mifospay.feature.profile.edit + +import android.Manifest +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.togitech.ccp.component.TogiCountryCodePicker +import org.mifospay.BuildConfig +import org.mifospay.core.designsystem.component.MfLoadingWheel +import org.mifospay.core.designsystem.component.MfOutlinedTextField +import org.mifospay.core.designsystem.component.MifosBottomSheet +import org.mifospay.core.designsystem.component.MifosDialogBox +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.PermissionBox +import org.mifospay.core.designsystem.icon.MifosIcons.Camera +import org.mifospay.core.designsystem.icon.MifosIcons.Delete +import org.mifospay.core.designsystem.icon.MifosIcons.PhotoLibrary +import org.mifospay.core.designsystem.theme.MifosTheme +import org.mifospay.core.designsystem.theme.historyItemTextStyle +import org.mifospay.core.designsystem.theme.styleMedium16sp +import org.mifospay.feature.profile.R +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.Objects + +@Composable +fun EditProfileScreenRoute( + viewModel: EditProfileViewModel = hiltViewModel(), + onBackClick: () -> Unit, +) { + val editProfileUiState by viewModel.editProfileUiState.collectAsStateWithLifecycle() + val updateSuccess by viewModel.updateSuccess.collectAsStateWithLifecycle() + + val context = LocalContext.current + val file = createImageFile(context) + val uri = FileProvider.getUriForFile( + Objects.requireNonNull(context), + BuildConfig.APPLICATION_ID + ".provider", file + ) + + LaunchedEffect(key1 = true) { + viewModel.fetchProfileDetails() + } + + EditProfileScreen( + editProfileUiState = editProfileUiState, + onBackClick = onBackClick, + updateEmail = { email -> + viewModel.updateEmail(email) + }, + updateMobile = { mobile -> + viewModel.updateMobile(mobile) + }, + updateSuccess = updateSuccess, + uri + ) +} + +@Composable +fun EditProfileScreen( + editProfileUiState: EditProfileUiState, + onBackClick: () -> Unit, + updateEmail: (String) -> Unit, + updateMobile: (String) -> Unit, + updateSuccess: Boolean, + uri: Uri? +) { + var showDiscardChangesDialog by rememberSaveable { mutableStateOf(false) } + Box( + modifier = Modifier + .fillMaxSize() + ) { + MifosScaffold( + topBarTitle = R.string.edit_profile, + backPress = { showDiscardChangesDialog = true }, + scaffoldContent = { + when (editProfileUiState) { + EditProfileUiState.Loading -> { + MfLoadingWheel( + contentDesc = stringResource(R.string.loading), + backgroundColor = Color.White + ) + } + + is EditProfileUiState.Success -> { + val initialUsername = editProfileUiState.username + val initialMobile = editProfileUiState.mobile + val initialVpa = editProfileUiState.vpa + val initialEmail = editProfileUiState.email + + EditProfileScreenContent( + initialUsername, + initialMobile, + initialVpa, + initialEmail, + uri, + updateEmail = updateEmail, + updateMobile = updateMobile, + contentPadding = it, + onBackClick = onBackClick, + updateSuccess = updateSuccess + ) + } + } + }) + + MifosDialogBox( + showDialogState = showDiscardChangesDialog, + onDismiss = { showDiscardChangesDialog = false }, + title = R.string.discard_changes, + confirmButtonText = R.string.confirm_text, + onConfirm = { + showDiscardChangesDialog = false + onBackClick.invoke() + }, + dismissButtonText = R.string.dismiss_text + ) + } +} + +@Composable +fun EditProfileScreenContent( + initialUsername: String, + initialMobile: String, + initialVpa: String, + initialEmail: String, + uri: Uri?, + contentPadding: PaddingValues, + updateEmail: (String) -> Unit, + updateMobile: (String) -> Unit, + updateSuccess: Boolean, + onBackClick: () -> Unit +) { + var username by rememberSaveable { mutableStateOf(initialUsername) } + var mobile by rememberSaveable { mutableStateOf(initialMobile) } + var vpa by rememberSaveable { mutableStateOf(initialVpa) } + var email by rememberSaveable { mutableStateOf(initialEmail) } + var imageUri by rememberSaveable { mutableStateOf(null) } + var showBottomSheet by rememberSaveable { mutableStateOf(false) } + val context = LocalContext.current + + PermissionBox( + requiredPermissions = if (Build.VERSION.SDK_INT >= 33) { + listOf( + Manifest.permission.CAMERA + ) + } else { + listOf( + Manifest.permission.CAMERA, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + }, + title = R.string.permission_required, + description = R.string.approve_permission_description_camera, + confirmButtonText = R.string.proceed, + dismissButtonText = R.string.dismiss, + onGranted = { + val cameraLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { + imageUri = uri + } + + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri?.let { + imageUri = uri + } + } + + if (showBottomSheet) { + MifosBottomSheet( + content = { + EditProfileBottomSheetContent( + { + cameraLauncher.launch(uri) + showBottomSheet = false + }, + { + galleryLauncher.launch("image/*") + showBottomSheet = false + }, + { + imageUri = null + showBottomSheet = false + }) + }, + onDismiss = { showBottomSheet = false } + ) + } + } + ) + Box( + modifier = Modifier + .padding(contentPadding) + .fillMaxSize() + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .verticalScroll(rememberScrollState()) + ) { + EditProfileScreenImage( + imageUri = imageUri, + onCameraIconClick = { showBottomSheet = true }) + MfOutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp), + value = username, + label = stringResource(id = R.string.username), + onValueChange = { username = it } + ) + MfOutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp), + value = email, + label = stringResource(id = R.string.email), + onValueChange = { email = it } + ) + MfOutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp), + value = vpa, + label = stringResource(id = R.string.vpa), + onValueChange = { vpa = it } + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp) + ) { + val keyboardController = LocalSoftwareKeyboardController.current + if (LocalInspectionMode.current) { + Text("Placeholder for TogiCountryCodePicker") + } else { + TogiCountryCodePicker( + modifier = Modifier, + initialPhoneNumber = mobile, + autoDetectCode = true, + shape = RoundedCornerShape(3.dp), + colors = TextFieldDefaults.outlinedTextFieldColors( + focusedBorderColor = Color.Black + ), + onValueChange = { (code, phone), isValid -> + if (isValid) { + mobile = code + phone + } + }, + label = { Text(stringResource(id = R.string.phone_number)) }, + keyboardActions = KeyboardActions { keyboardController?.hide() } + ) + } + } + EditProfileSaveButton( + onClick = { + if (isDataSaveNecessary(email, initialEmail)) { + updateEmail(email) + } + if (isDataSaveNecessary(mobile, initialMobile)) { + updateMobile(mobile) + } + if (updateSuccess) { + // if user details is successfully saved then go back to Profile Activity + // same behaviour as onBackPress, hence reused the callback + onBackClick.invoke() + } else { + Toast.makeText(context, R.string.failed_to_save_changes, Toast.LENGTH_SHORT) + .show() + } + }, + buttonText = R.string.save + ) + } + } +} + +private fun isDataSaveNecessary(input: String, initialInput: String): Boolean { + return input == initialInput +} + +@Composable +fun EditProfileBottomSheetContent( + onClickProfilePicture: () -> Unit, + onChangeProfilePicture: () -> Unit, + onRemoveProfilePicture: () -> Unit +) { + Column( + modifier = Modifier + .background(Color.White) + .padding(top = 8.dp, bottom = 12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .clickable { onClickProfilePicture.invoke() }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = Camera, contentDescription = null) + Text( + text = stringResource(id = R.string.click_profile_picture), + style = historyItemTextStyle + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .clickable { onChangeProfilePicture.invoke() }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = PhotoLibrary, contentDescription = null) + Text( + text = stringResource(id = R.string.change_profile_picture), + style = historyItemTextStyle + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + .clickable { onRemoveProfilePicture.invoke() }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(imageVector = Delete, contentDescription = null) + Text( + text = stringResource(id = R.string.remove_profile_picture), + style = historyItemTextStyle + ) + } + } +} + +@Composable +fun EditProfileSaveButton(onClick: () -> Unit, buttonText: Int) { + Button( + onClick = { onClick.invoke() }, + colors = ButtonDefaults.buttonColors(Color.Black), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(10.dp), + contentPadding = PaddingValues(12.dp) + ) { + Text(text = stringResource(id = buttonText), style = styleMedium16sp.copy(Color.White)) + } +} + +fun createImageFile(context: Context): File { + val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val storageDir: File? = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) + return File.createTempFile( + "JPEG_${timeStamp}_", + ".jpg", + storageDir + ) +} + +class EditProfilePreviewProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + EditProfileUiState.Loading, + EditProfileUiState.Success(), + EditProfileUiState.Success( + name = "John Doe", + username = "John", + email = "john@mifos.org", + vpa = "vpa", + mobile = "+1 55557772901" + ) + ) + +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun EditProfileScreenPreview( + @PreviewParameter(EditProfilePreviewProvider::class) editProfileUiState: EditProfileUiState +) { + MifosTheme { + EditProfileScreen( + editProfileUiState = editProfileUiState, + onBackClick = {}, + updateEmail = {}, + updateMobile = {}, + updateSuccess = false, + uri = null + ) + } +} \ No newline at end of file diff --git a/feature/profile/src/main/kotlin/org/mifospay/feature/profile/edit/EditProfileScreenImage.kt b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/edit/EditProfileScreenImage.kt new file mode 100644 index 00000000..c3eb7f3c --- /dev/null +++ b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/edit/EditProfileScreenImage.kt @@ -0,0 +1,69 @@ +package org.mifospay.feature.profile.edit + +import android.net.Uri +import androidx.compose.foundation.border +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.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import org.mifospay.core.designsystem.icon.MifosIcons + + +@Composable +fun EditProfileScreenImage(imageUri: Uri?, onCameraIconClick: () -> Unit) { + Column(Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 32.dp) + ) { + Box( + modifier = Modifier + .padding(top = 32.dp) + .size(200.dp) + .clip(CircleShape) + .border(2.dp, Color.Black, CircleShape), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = imageUri, + modifier = Modifier + .size(200.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + contentDescription = null + ) + } + IconButton( + onClick = { onCameraIconClick.invoke() }, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .align(Alignment.BottomEnd), + colors = IconButtonDefaults.iconButtonColors(Color.Black) + ) { + Icon( + painter = rememberVectorPainter(MifosIcons.Camera), + contentDescription = null, + modifier = Modifier + .size(24.dp), + tint = Color.White + ) + } + } + } +} \ No newline at end of file diff --git a/feature/profile/src/main/kotlin/org/mifospay/feature/profile/edit/EditProfileViewModel.kt b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/edit/EditProfileViewModel.kt new file mode 100644 index 00000000..82373266 --- /dev/null +++ b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/edit/EditProfileViewModel.kt @@ -0,0 +1,101 @@ +package org.mifospay.feature.profile.edit + +import android.graphics.Bitmap +import androidx.lifecycle.ViewModel +import com.mifospay.core.model.domain.user.UpdateUserEntityEmail +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.mifospay.core.data.base.UseCase +import org.mifospay.core.data.base.UseCaseHandler +import org.mifospay.core.data.domain.usecase.client.UpdateClient +import org.mifospay.core.data.domain.usecase.user.UpdateUser +import org.mifospay.core.datastore.PreferencesHelper +import javax.inject.Inject + +@HiltViewModel +class EditProfileViewModel @Inject constructor( + private val mUseCaseHandler: UseCaseHandler, + private val mPreferencesHelper: PreferencesHelper, + private val updateUserUseCase: UpdateUser, + private val updateClientUseCase: UpdateClient, +) : ViewModel() { + + private val _editProfileUiState = + MutableStateFlow(EditProfileUiState.Loading) + val editProfileUiState: StateFlow = _editProfileUiState + + private val _updateSuccess = MutableStateFlow(false) + val updateSuccess: StateFlow = _updateSuccess + + fun fetchProfileDetails() { + val name = mPreferencesHelper.fullName ?: "-" + val username = mPreferencesHelper.username + val email = mPreferencesHelper.email ?: "-" + val vpa = mPreferencesHelper.clientVpa ?: "-" + val mobile = mPreferencesHelper.mobile ?: "-" + + _editProfileUiState.value = EditProfileUiState.Success( + name = name, + username = username, + email = email, + vpa = vpa, + mobile = mobile + ) + } + + fun updateEmail(email: String?) { + mUseCaseHandler.execute(updateUserUseCase, + UpdateUser.RequestValues( + UpdateUserEntityEmail( + email + ), + mPreferencesHelper.userId.toInt() + ), + object : UseCase.UseCaseCallback { + override fun onSuccess(response: UpdateUser.ResponseValue?) { + mPreferencesHelper.saveEmail(email) + _editProfileUiState.value = EditProfileUiState.Success(email = email!!) + _updateSuccess.value = true + } + + override fun onError(message: String) { + _updateSuccess.value = false + } + }) + } + + fun updateMobile(fullNumber: String?) { + mUseCaseHandler.execute(updateClientUseCase, + UpdateClient.RequestValues( + com.mifospay.core.model.domain.client.UpdateClientEntityMobile( + fullNumber!! + ), + mPreferencesHelper.clientId.toInt().toLong() + ), + object : UseCase.UseCaseCallback { + override fun onSuccess(response: UpdateClient.ResponseValue) { + mPreferencesHelper.saveMobile(fullNumber) + _editProfileUiState.value = EditProfileUiState.Success(mobile = fullNumber) + _updateSuccess.value = true + } + + override fun onError(message: String) { + _updateSuccess.value = false + } + }) + } + +} + +sealed interface EditProfileUiState { + data object Loading : EditProfileUiState + data class Success( + val bitmapImage: Bitmap? = null, + val name: String = "", + var username: String = "", + val email: String = "", + val vpa: String = "", + val mobile: String = "" + ) : EditProfileUiState +} \ No newline at end of file diff --git a/feature/profile/src/main/kotlin/org/mifospay/feature/profile/navigation/ProfileNavigation.kt b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/navigation/ProfileNavigation.kt new file mode 100644 index 00000000..1ec4fc25 --- /dev/null +++ b/feature/profile/src/main/kotlin/org/mifospay/feature/profile/navigation/ProfileNavigation.kt @@ -0,0 +1,23 @@ +package org.mifospay.feature.profile.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import org.mifospay.feature.profile.ProfileRoute + +const val PROFILE_ROUTE = "profile_route" + +fun NavController.navigateToProfile(navOptions: NavOptions) = navigate(PROFILE_ROUTE, navOptions) + +fun NavGraphBuilder.profileScreen( + onEditProfile: () -> Unit, + onSettings: () -> Unit, +) { + composable(route = PROFILE_ROUTE) { + ProfileRoute( + onEditProfile = onEditProfile, + onSettings = onSettings + ) + } +} diff --git a/feature/profile/src/main/res/values/strings.xml b/feature/profile/src/main/res/values/strings.xml new file mode 100644 index 00000000..87dd0fd2 --- /dev/null +++ b/feature/profile/src/main/res/values/strings.xml @@ -0,0 +1,25 @@ + + + E-mail + VPA + Mobile number + Personal QR Code + Link Bank Account + Edit Profile + Settings + Loading + Do you want to discard changes? + Discard + Cancel + Permissions Required + You need to approve these permissions in order to access the camera. + Proceed + Dismiss + Username + Phone Number + Failed To Save Changes + Save + Click profile picture + Pick profile picture from device + Remove profile picture + \ No newline at end of file diff --git a/feature/profile/src/test/java/org/mifospay/profile/ExampleUnitTest.kt b/feature/profile/src/test/java/org/mifospay/profile/ExampleUnitTest.kt new file mode 100644 index 00000000..ab70f76c --- /dev/null +++ b/feature/profile/src/test/java/org/mifospay/profile/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package org.mifospay.profile + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index da057630..eaaa064b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -45,4 +45,11 @@ include(":feature:kyc") include(":feature:savedcards") + + + + + include(":feature:invoices") +include(":feature:profile") +