diff --git a/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt b/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt index f666a3614..26699621c 100644 --- a/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt +++ b/cmp-android/dependencies/prodReleaseRuntimeClasspath.tree.txt @@ -3012,6 +3012,39 @@ | | +--- org.jetbrains.compose.material3:material3:1.8.2 (*) | | +--- org.jetbrains.compose.components:components-resources:1.8.2 (*) | | \--- org.jetbrains.compose.components:components-ui-tooling-preview:1.8.2 (*) +| +--- project :feature:autopay +| | +--- androidx.lifecycle:lifecycle-runtime-compose:2.9.2 (*) +| | +--- androidx.lifecycle:lifecycle-viewmodel-compose:2.9.2 (*) +| | +--- androidx.tracing:tracing-ktx:1.3.0 (*) +| | +--- io.insert-koin:koin-bom:4.1.0 (*) +| | +--- io.insert-koin:koin-android:4.1.0 (*) +| | +--- io.insert-koin:koin-androidx-compose:4.1.0 (*) +| | +--- io.insert-koin:koin-androidx-navigation:4.1.0 (*) +| | +--- io.insert-koin:koin-core-viewmodel:4.1.0 (*) +| | +--- org.jetbrains.kotlin:kotlin-stdlib:2.1.20 -> 2.1.21 (*) +| | +--- io.insert-koin:koin-core:4.1.0 (*) +| | +--- io.insert-koin:koin-annotations:2.1.0 (*) +| | +--- project :core:ui (*) +| | +--- project :core:designsystem (*) +| | +--- project :core:data (*) +| | +--- io.insert-koin:koin-compose:4.1.0 (*) +| | +--- io.insert-koin:koin-compose-viewmodel:4.1.0 (*) +| | +--- org.jetbrains.compose.runtime:runtime:1.8.2 (*) +| | +--- org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.9.1 (*) +| | +--- org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.9.1 (*) +| | +--- org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.1 (*) +| | +--- org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-savedstate:2.9.1 (*) +| | +--- org.jetbrains.androidx.savedstate:savedstate:1.3.1 (*) +| | +--- org.jetbrains.androidx.core:core-bundle:1.0.1 (*) +| | +--- org.jetbrains.androidx.navigation:navigation-compose:2.9.0-beta03 (*) +| | +--- org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8 (*) +| | +--- org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3 -> 1.8.0 (*) +| | +--- org.jetbrains.compose.ui:ui:1.8.2 (*) +| | +--- org.jetbrains.compose.foundation:foundation:1.8.2 (*) +| | +--- org.jetbrains.compose.material3:material3:1.8.2 (*) +| | +--- org.jetbrains.compose.components:components-resources:1.8.2 (*) +| | +--- org.jetbrains.compose.components:components-ui-tooling-preview:1.8.2 (*) +| | \--- project :core:common (*) | \--- org.jetbrains.kotlin:kotlin-parcelize-runtime:2.1.20 (*) +--- project :core:data (*) +--- project :core:ui (*) diff --git a/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt b/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt index 89aaf0d21..7d2e8a654 100644 --- a/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt +++ b/cmp-android/dependencies/prodReleaseRuntimeClasspath.txt @@ -12,6 +12,7 @@ :core:ui :feature:accounts :feature:auth +:feature:autopay :feature:editpassword :feature:faq :feature:finance diff --git a/cmp-android/prodRelease-badging.txt b/cmp-android/prodRelease-badging.txt index 91ced69b5..522e01de8 100644 --- a/cmp-android/prodRelease-badging.txt +++ b/cmp-android/prodRelease-badging.txt @@ -1,4 +1,4 @@ -package: name='org.mifospay' versionCode='1' versionName='2025.8.2-beta.0.3' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' +package: name='org.mifospay' versionCode='1' versionName='2025.8.4-beta.0.9' platformBuildVersionName='15' platformBuildVersionCode='35' compileSdkVersion='35' compileSdkVersionCodename='15' minSdkVersion:'26' targetSdkVersion:'34' uses-permission: name='android.permission.INTERNET' diff --git a/cmp-shared/build.gradle.kts b/cmp-shared/build.gradle.kts index 0b8ef8a66..0c2ef4174 100644 --- a/cmp-shared/build.gradle.kts +++ b/cmp-shared/build.gradle.kts @@ -55,6 +55,7 @@ kotlin { implementation(projects.feature.qr) implementation(projects.feature.merchants) implementation(projects.feature.upiSetup) + implementation(projects.feature.autopay) } desktopMain.dependencies { diff --git a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt index b87dc3783..9d8cb8366 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/di/KoinModules.kt @@ -24,6 +24,7 @@ import org.mifospay.core.network.di.LocalModule import org.mifospay.core.network.di.NetworkModule import org.mifospay.feature.accounts.di.AccountsModule import org.mifospay.feature.auth.di.AuthModule +import org.mifospay.feature.autopay.di.AutoPayModule import org.mifospay.feature.editpassword.di.EditPasswordModule import org.mifospay.feature.faq.di.FaqModule import org.mifospay.feature.history.di.HistoryModule @@ -88,6 +89,7 @@ object KoinModules { QrModule, MerchantsModule, UpiSetupModule, + AutoPayModule, ) } private val LibraryModule = module { diff --git a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt index d6a94277b..9eb61bb91 100644 --- a/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt +++ b/cmp-shared/src/commonMain/kotlin/org/mifospay/shared/navigation/MifosNavHost.kt @@ -22,6 +22,17 @@ import org.mifospay.feature.accounts.savingsaccount.addEditSavingAccountScreen import org.mifospay.feature.accounts.savingsaccount.details.navigateToSavingAccountDetails import org.mifospay.feature.accounts.savingsaccount.details.savingAccountDetailRoute import org.mifospay.feature.accounts.savingsaccount.navigateToSavingAccountAddEdit +import org.mifospay.feature.autopay.AutoPayScreen +import org.mifospay.feature.autopay.autoPayGraph +import org.mifospay.feature.autopay.navigateToAddBill +import org.mifospay.feature.autopay.navigateToAddBiller +import org.mifospay.feature.autopay.navigateToAutoPay +import org.mifospay.feature.autopay.navigateToAutoPayHistory +import org.mifospay.feature.autopay.navigateToAutoPayPreferences +import org.mifospay.feature.autopay.navigateToAutoPayScheduleDetails +import org.mifospay.feature.autopay.navigateToBillList +import org.mifospay.feature.autopay.navigateToBillerList +import org.mifospay.feature.autopay.navigateToScheduleManagement import org.mifospay.feature.editpassword.navigation.editPasswordScreen import org.mifospay.feature.editpassword.navigation.navigateToEditPassword import org.mifospay.feature.faq.navigation.faqScreen @@ -72,7 +83,12 @@ import org.mifospay.feature.savedcards.details.cardDetailRoute import org.mifospay.feature.savedcards.details.navigateToCardDetails import org.mifospay.feature.send.money.SendMoneyScreen import org.mifospay.feature.send.money.navigation.SEND_MONEY_BASE_ROUTE +import org.mifospay.feature.send.money.navigation.SEND_MONEY_OPTIONS_ROUTE +import org.mifospay.feature.send.money.navigation.navigateToPayeeDetailsScreen +import org.mifospay.feature.send.money.navigation.navigateToSendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.navigateToSendMoneyScreen +import org.mifospay.feature.send.money.navigation.payeeDetailsScreen +import org.mifospay.feature.send.money.navigation.sendMoneyOptionsScreen import org.mifospay.feature.send.money.navigation.sendMoneyScreen import org.mifospay.feature.settings.navigation.settingsScreen import org.mifospay.feature.standing.instruction.StandingInstructionsScreen @@ -97,6 +113,7 @@ internal fun MifosNavHost( onBackClick = navController::navigateUp, navigateToTransferScreen = navController::navigateToTransferScreen, navigateToScanQrScreen = navController::navigateToScanQr, + navigateToPayeeDetails = navController::navigateToPayeeDetailsScreen, showTopBar = false, ) }, @@ -121,6 +138,35 @@ internal fun MifosNavHost( navigateToInvoiceDetailScreen = navController::navigateToInvoiceDetail, ) }, + TabContent(PaymentsScreenContents.AUTOPAY.name) { + AutoPayScreen( + onNavigateToScheduleManagement = { + navController.navigateToScheduleManagement() + }, + onNavigateToPreferences = { + navController.navigateToAutoPayPreferences() + }, + onNavigateToHistory = { + navController.navigateToAutoPayHistory() + }, + onNavigateToScheduleDetails = { scheduleId -> + navController.navigateToAutoPayScheduleDetails(scheduleId) + }, + onNavigateToAddBiller = { + navController.navigateToAddBiller() + }, + onNavigateToBillerList = { + navController.navigateToBillerList() + }, + onNavigateToAddBill = { + navController.navigateToAddBill() + }, + onNavigateToBillList = { + navController.navigateToBillList() + }, + showTopBar = false, + ) + }, ) val tabContents = listOf( @@ -160,7 +206,10 @@ internal fun MifosNavHost( onRequest = { navController.navigateToShowQrScreen() }, - onPay = navController::navigateToSendMoneyScreen, + onPay = navController::navigateToSendMoneyOptionsScreen, + onAutoPay = { + navController.navigateToAutoPay() + }, navigateToTransactionDetail = navController::navigateToSpecificTransaction, navigateToAccountDetail = navController::navigateToSavingAccountDetails, ) @@ -279,12 +328,55 @@ internal fun MifosNavHost( navigateBack = navController::navigateUp, ) + sendMoneyOptionsScreen( + onBackClick = navController::popBackStack, + onScanQrClick = { + // This is now handled by the ViewModel using ML Kit scanner + }, + onPayAnyoneClick = { + // TODO: Navigate to Pay Anyone screen + }, + onBankTransferClick = { + // TODO: Navigate to Bank Transfer screen + }, + onFineractPaymentsClick = { + navController.navigateToSendMoneyScreen() + }, + onAutoPayClick = { + navController.navigateToAutoPay() + }, + onQrCodeScanned = { qrData -> + navController.navigateToSendMoneyScreen( + requestData = qrData, + navOptions = navOptions { + popUpTo(SEND_MONEY_OPTIONS_ROUTE) { + inclusive = true + } + }, + ) + }, + onNavigateToPayeeDetails = { qrCodeData -> + navController.navigateToPayeeDetailsScreen(qrCodeData) + }, + ) + sendMoneyScreen( onBackClick = navController::popBackStack, navigateToTransferScreen = navController::navigateToTransferScreen, + navigateToPayeeDetailsScreen = navController::navigateToPayeeDetailsScreen, navigateToScanQrScreen = navController::navigateToScanQr, ) + payeeDetailsScreen( + onBackClick = navController::popBackStack, + onNavigateToUpiPayment = { state -> + // TODO: Handle UPI payment navigation + }, + onNavigateToFineractPayment = { state -> + // TODO: Handle Fineract payment navigation + }, + ) + transferScreen( navigateBack = navController::popBackStack, onTransferSuccess = { @@ -322,6 +414,16 @@ internal fun MifosNavHost( }, ) }, + navigateToPayeeDetailsScreen = { + navController.navigateToPayeeDetailsScreen( + qrCodeData = it, + navOptions = navOptions { + popUpTo(SCAN_QR_ROUTE) { + inclusive = true + } + }, + ) + }, ) merchantTransferScreen( @@ -332,5 +434,10 @@ internal fun MifosNavHost( setupUpiPinScreen( navigateBack = navController::navigateUp, ) + + autoPayGraph( + navController = navController, + onNavigateBack = navController::navigateUp, + ) } } diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/di/RepositoryModule.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/di/RepositoryModule.kt index 053c48b7b..4d8796877 100644 --- a/core/data/src/commonMain/kotlin/org/mifospay/core/data/di/RepositoryModule.kt +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/di/RepositoryModule.kt @@ -16,7 +16,10 @@ import org.mifospay.core.common.MifosDispatchers import org.mifospay.core.data.repository.AccountRepository import org.mifospay.core.data.repository.AssetRepository import org.mifospay.core.data.repository.AuthenticationRepository +import org.mifospay.core.data.repository.AutoPayHistoryRepository +import org.mifospay.core.data.repository.AutoPayRepository import org.mifospay.core.data.repository.BeneficiaryRepository +import org.mifospay.core.data.repository.BillerRepository import org.mifospay.core.data.repository.ClientRepository import org.mifospay.core.data.repository.DocumentRepository import org.mifospay.core.data.repository.InvoiceRepository @@ -36,7 +39,10 @@ import org.mifospay.core.data.repository.UserRepository import org.mifospay.core.data.repositoryImpl.AccountRepositoryImpl import org.mifospay.core.data.repositoryImpl.AssetRepositoryImpl import org.mifospay.core.data.repositoryImpl.AuthenticationRepositoryImpl +import org.mifospay.core.data.repositoryImpl.AutoPayHistoryRepositoryImpl +import org.mifospay.core.data.repositoryImpl.AutoPayRepositoryImpl import org.mifospay.core.data.repositoryImpl.BeneficiaryRepositoryImpl +import org.mifospay.core.data.repositoryImpl.BillerRepositoryImpl import org.mifospay.core.data.repositoryImpl.ClientRepositoryImpl import org.mifospay.core.data.repositoryImpl.DocumentRepositoryImpl import org.mifospay.core.data.repositoryImpl.InvoiceRepositoryImpl @@ -93,6 +99,15 @@ val RepositoryModule = module { } single { TwoFactorAuthRepositoryImpl(get(), get(ioDispatcher)) } single { UserRepositoryImpl(get(), get(ioDispatcher)) } + single { AutoPayRepositoryImpl(get(), get(ioDispatcher)) } + single { AutoPayHistoryRepositoryImpl(get(), get(ioDispatcher)) } + + // TODO: Switch to network-based implementation when APIs are finalized + // or use hybrid approach syncing local and remote data + // single { BillerRepositoryImpl(get(), get(ioDispatcher)) } + + // Current local storage implementation + single { BillerRepositoryImpl(get(), get(ioDispatcher)) } includes(platformModule) single { getPlatformDataModule } diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/mapper/BillMapper.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/mapper/BillMapper.kt new file mode 100644 index 000000000..5dd781201 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/mapper/BillMapper.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.mapper + +import kotlinx.datetime.Clock +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.model.autopay.BillFormData +import org.mifospay.core.model.autopay.BillStatus +import org.mifospay.core.model.autopay.RecurrencePattern + +/** + * Utility class for mapping between different bill data models + */ +object BillMapper { + + /** + * Converts BillFormData to Bill + */ + fun formDataToBill(formData: BillFormData): Bill { + return Bill( + id = null, + name = formData.name.trim(), + amount = formData.amount.toDoubleOrNull() ?: 0.0, + currency = formData.currency, + dueDate = formData.dueDate, + recurrencePattern = formData.recurrencePattern, + billerId = formData.billerId, + billerName = formData.billerName, + description = formData.description.trim().takeIf { it.isNotBlank() }, + isActive = true, + status = BillStatus.ACTIVE, + createdAt = Clock.System.now().toEpochMilliseconds(), + updatedAt = Clock.System.now().toEpochMilliseconds(), + ) + } + + /** + * Converts Bill to BillFormData + */ + fun billToFormData(bill: Bill): BillFormData { + return BillFormData( + name = bill.name, + amount = bill.amount.toString(), + currency = bill.currency, + dueDate = bill.dueDate, + recurrencePattern = bill.recurrencePattern, + billerId = bill.billerId, + billerName = bill.billerName, + description = bill.description ?: "", + ) + } + + /** + * Updates an existing Bill with BillFormData + */ + fun updateBillWithFormData(existingBill: Bill, formData: BillFormData): Bill { + return existingBill.copy( + name = formData.name.trim(), + amount = formData.amount.toDoubleOrNull() ?: existingBill.amount, + currency = formData.currency, + dueDate = formData.dueDate, + recurrencePattern = formData.recurrencePattern, + billerId = formData.billerId, + billerName = formData.billerName, + description = formData.description.trim().takeIf { it.isNotBlank() }, + updatedAt = Clock.System.now().toEpochMilliseconds(), + ) + } + + /** + * Creates a copy of Bill with updated status + */ + fun updateBillStatus(bill: Bill, newStatus: BillStatus): Bill { + return bill.copy( + status = newStatus, + isActive = newStatus == BillStatus.ACTIVE, + updatedAt = Clock.System.now().toEpochMilliseconds(), + ) + } + + /** + * Creates a copy of Bill with updated active state + */ + fun updateBillActiveState(bill: Bill, isActive: Boolean): Bill { + return bill.copy( + isActive = isActive, + status = if (isActive) BillStatus.ACTIVE else BillStatus.PAUSED, + updatedAt = Clock.System.now().toEpochMilliseconds(), + ) + } + + /** + * Creates a new Bill from existing Bill with next recurrence date + */ + fun createNextRecurrenceBill(bill: Bill): Bill? { + if (bill.recurrencePattern == RecurrencePattern.NONE) { + return null + } + + val nextDueDate = calculateNextDueDate(bill) + return bill.copy( + id = null, + dueDate = nextDueDate, + createdAt = Clock.System.now().toEpochMilliseconds(), + updatedAt = Clock.System.now().toEpochMilliseconds(), + ) + } + + /** + * Calculates the next due date based on recurrence pattern + */ + private fun calculateNextDueDate(bill: Bill): Long { + val daysToAdd = when (bill.recurrencePattern) { + RecurrencePattern.DAILY -> 1 + RecurrencePattern.WEEKLY -> 7 + RecurrencePattern.BIWEEKLY -> 14 + RecurrencePattern.MONTHLY -> 30 + RecurrencePattern.QUARTERLY -> 90 + RecurrencePattern.SEMI_ANNUALLY -> 180 + RecurrencePattern.ANNUALLY -> 365 + RecurrencePattern.NONE -> 0 + } + + return if (daysToAdd > 0) { + bill.dueDate + (daysToAdd * 24 * 60 * 60 * 1000L) + } else { + bill.dueDate + } + } + + /** + * Creates a summary string for a bill + */ + fun createBillSummary(bill: Bill): String { + return buildString { + append(bill.name) + if (bill.billerName != null) { + append(" - ${bill.billerName}") + } + append(" (${bill.currency} ${bill.amount})") + } + } + + /** + * Creates a display name for recurrence pattern + */ + fun getRecurrenceDisplayName(pattern: RecurrencePattern): String { + return when (pattern) { + RecurrencePattern.NONE -> "No Recurrence" + RecurrencePattern.DAILY -> "Daily" + RecurrencePattern.WEEKLY -> "Weekly" + RecurrencePattern.BIWEEKLY -> "Bi-weekly" + RecurrencePattern.MONTHLY -> "Monthly" + RecurrencePattern.QUARTERLY -> "Quarterly" + RecurrencePattern.SEMI_ANNUALLY -> "Semi-annually" + RecurrencePattern.ANNUALLY -> "Annually" + } + } + + /** + * Creates a display name for bill status + */ + fun getStatusDisplayName(status: BillStatus): String { + return when (status) { + BillStatus.ACTIVE -> "Active" + BillStatus.PAUSED -> "Paused" + BillStatus.CANCELLED -> "Cancelled" + BillStatus.COMPLETED -> "Completed" + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/AutoPayHistoryRepository.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/AutoPayHistoryRepository.kt new file mode 100644 index 000000000..605d41e0f --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/AutoPayHistoryRepository.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.repository + +import kotlinx.coroutines.flow.Flow +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.network.model.entity.Page + +// TODO: Align repository with final API response/request schema once confirmed by backend + +interface AutoPayHistoryRepository { + /** + * Get AutoPay history for a specific AutoPay schedule + */ + fun getAutoPayHistory(autoPayId: Long): Flow>> + + /** + * Get AutoPay history with pagination + */ + fun getAutoPayHistoryWithPagination( + autoPayId: Long, + limit: Int = 20, + offset: Int = 0, + ): Flow>> + + /** + * Get AutoPay history by ID + */ + suspend fun getHistoryById(id: Long): DataState + + /** + * Get AutoPay history by status + */ + fun getHistoryByStatus(status: String): Flow>> + + /** + * Get AutoPay history by date range + */ + fun getHistoryByDateRange( + fromDate: String, + toDate: String, + ): Flow>> + + /** + * Search AutoPay history + */ + fun searchHistory(query: String): Flow>> + + /** + * Get history statistics + */ + fun getHistoryStatistics(autoPayId: Long): Flow> +} + +data class AutoPayHistoryStatistics( + val totalTransactions: Int = 0, + val successfulTransactions: Int = 0, + val failedTransactions: Int = 0, + val pendingTransactions: Int = 0, + val totalAmount: Double = 0.0, + val successfulAmount: Double = 0.0, + val failedAmount: Double = 0.0, + val currency: String = "USD", +) diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/AutoPayRepository.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/AutoPayRepository.kt new file mode 100644 index 000000000..91806a986 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/AutoPayRepository.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.repository + +import kotlinx.coroutines.flow.Flow +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.AutoPay +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.AutoPayPayload +import org.mifospay.core.model.autopay.AutoPayTemplate +import org.mifospay.core.model.autopay.AutoPayUpdatePayload +import org.mifospay.core.model.autopay.UpcomingPayment +import org.mifospay.core.network.model.entity.Page + +interface AutoPayRepository { + /** + * Get AutoPay template for creating new AutoPay schedules + */ + fun getAutoPayTemplate( + clientId: Long, + sourceAccountId: Long, + ): Flow> + + /** + * Get all AutoPay schedules for a client + */ + fun getAllAutoPaySchedules( + clientId: Long, + ): Flow>> + + /** + * Get AutoPay schedule by ID + */ + fun getAutoPaySchedule(autoPayId: Long): Flow> + + /** + * Create a new AutoPay schedule + */ + suspend fun createAutoPaySchedule( + payload: AutoPayPayload, + ): DataState + + /** + * Update an existing AutoPay schedule + */ + suspend fun updateAutoPaySchedule( + autoPayId: Long, + payload: AutoPayUpdatePayload, + ): DataState + + /** + * Delete an AutoPay schedule + */ + suspend fun deleteAutoPaySchedule(autoPayId: Long): DataState + + /** + * Pause an AutoPay schedule + */ + suspend fun pauseAutoPaySchedule(autoPayId: Long): DataState + + /** + * Resume a paused AutoPay schedule + */ + suspend fun resumeAutoPaySchedule(autoPayId: Long): DataState + + /** + * Get AutoPay payment history + */ + fun getAutoPayHistory( + autoPayId: Long, + limit: Int = 20, + ): Flow>> + + /** + * Get upcoming payments for all AutoPay schedules + */ + fun getUpcomingPayments( + clientId: Long, + limit: Int = 10, + ): Flow>> + + /** + * Get AutoPay statistics for dashboard + */ + fun getAutoPayStatistics( + clientId: Long, + ): Flow> + + /** + * Validate AutoPay payload before submission + */ + suspend fun validateAutoPayPayload(payload: AutoPayPayload): DataState +} + +data class AutoPayStatistics( + val totalActiveSchedules: Int = 0, + val totalPausedSchedules: Int = 0, + val totalCompletedSchedules: Int = 0, + val totalUpcomingPayments: Int = 0, + val totalAmountThisMonth: Double = 0.0, + val currency: String = "USD", +) diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/BillRepository.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/BillRepository.kt new file mode 100644 index 000000000..86ab7ceaa --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/BillRepository.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.repository + +import kotlinx.coroutines.flow.Flow +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.model.autopay.BillStatus +import org.mifospay.core.model.autopay.RecurrencePattern + +interface BillRepository { + /** + * Get all bills for a client + */ + fun getAllBills(clientId: Long): Flow>> + + /** + * Get bill by ID + */ + suspend fun getBillById(id: String): DataState + + /** + * Create a new bill + */ + suspend fun createBill(bill: Bill): DataState + + /** + * Update an existing bill + */ + suspend fun updateBill(bill: Bill): DataState + + /** + * Delete a bill + */ + suspend fun deleteBill(id: String): DataState + + /** + * Get bills by status + */ + suspend fun getBillsByStatus(status: BillStatus): List + + /** + * Get bills by biller ID + */ + suspend fun getBillsByBillerId(billerId: String): List + + /** + * Get bills by recurrence pattern + */ + suspend fun getBillsByRecurrencePattern(pattern: RecurrencePattern): List + + /** + * Search bills by name + */ + suspend fun searchBillsByName(query: String): List + + /** + * Get overdue bills + */ + suspend fun getOverdueBills(): List + + /** + * Get upcoming bills within a date range + */ + suspend fun getUpcomingBills(fromDate: Long, toDate: Long): List + + /** + * Update bill status + */ + suspend fun updateBillStatus(billId: String, status: BillStatus): DataState + + /** + * Validate bill data before submission + */ + suspend fun validateBill(bill: Bill): DataState + + /** + * Get bill statistics for dashboard + */ + fun getBillStatistics(clientId: Long): Flow> + + /** + * Clear all bills + */ + suspend fun clearAllBills(): DataState +} + +data class BillStatistics( + val totalBills: Int = 0, + val activeBills: Int = 0, + val overdueBills: Int = 0, + val totalAmount: Double = 0.0, + val currency: String = "USD", + val upcomingPayments: Int = 0, + val totalAmountThisMonth: Double = 0.0, +) diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/BillerRepository.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/BillerRepository.kt new file mode 100644 index 000000000..8ab1c916d --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repository/BillerRepository.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.repository + +import kotlinx.coroutines.flow.Flow +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.Biller +import org.mifospay.core.model.autopay.BillerCategory + +interface BillerRepository { + /** + * Get all saved billers + */ + fun getAllBillers(): Flow> + + /** + * Get biller by ID + */ + suspend fun getBillerById(id: String): Biller? + + /** + * Save a new biller + */ + suspend fun saveBiller(biller: Biller): DataState + + /** + * Update an existing biller + */ + suspend fun updateBiller(biller: Biller): DataState + + /** + * Delete a biller + */ + suspend fun deleteBiller(id: String): DataState + + /** + * Get billers by category + */ + suspend fun getBillersByCategory(category: BillerCategory): List + + /** + * Search billers by name + */ + suspend fun searchBillersByName(query: String): List + + /** + * Check if biller exists by name and account number + */ + suspend fun isBillerExists(name: String, accountNumber: String): Boolean + + /** + * Clear all billers + */ + suspend fun clearAllBillers(): DataState +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/AutoPayHistoryRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/AutoPayHistoryRepositoryImpl.kt new file mode 100644 index 000000000..71327b642 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/AutoPayHistoryRepositoryImpl.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.repositoryImpl + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import org.mifospay.core.common.DataState +import org.mifospay.core.common.asDataStateFlow +import org.mifospay.core.data.repository.AutoPayHistoryRepository +import org.mifospay.core.data.repository.AutoPayHistoryStatistics +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.PaymentStatus +import org.mifospay.core.network.FineractApiManager +import org.mifospay.core.network.model.entity.Page + +// TODO: Align repository with final API response/request schema once confirmed by backend + +class AutoPayHistoryRepositoryImpl( + private val apiManager: FineractApiManager, + private val ioDispatcher: CoroutineDispatcher, +) : AutoPayHistoryRepository { + + override fun getAutoPayHistory(autoPayId: Long): Flow>> { + return apiManager.autoPayApi.getAutoPayHistory(autoPayId, 100) + .map { page -> page.pageItems } + .catch { DataState.Error(it, null) } + .asDataStateFlow() + } + + override fun getAutoPayHistoryWithPagination( + autoPayId: Long, + limit: Int, + offset: Int, + ): Flow>> { + return apiManager.autoPayApi.getAutoPayHistory(autoPayId, limit) + .map { page -> page } + .catch { DataState.Error(it, null) } + .asDataStateFlow() + } + + override suspend fun getHistoryById(id: Long): DataState { + return DataState.Error(Exception("Individual history lookup not supported in read-only mode"), null) + } + + override fun getHistoryByStatus(status: String): Flow>> { + return apiManager.autoPayApi.getAutoPayHistory(0, 100) // Get all and filter client-side + .map { page -> + val filtered = page.pageItems.filter { it.status?.name == status } + filtered + } + .catch { DataState.Error(it, null) } + .asDataStateFlow() + } + + override fun getHistoryByDateRange( + fromDate: String, + toDate: String, + ): Flow>> { + return apiManager.autoPayApi.getAutoPayHistory(0, 100) // Get all and filter client-side + .map { page -> + val filtered = page.pageItems.filter { history -> + val transactionDate = history.transactionDate + transactionDate != null && transactionDate >= fromDate && transactionDate <= toDate + } + filtered + } + .catch { DataState.Error(it, null) } + .asDataStateFlow() + } + + override fun searchHistory(query: String): Flow>> { + return apiManager.autoPayApi.getAutoPayHistory(0, 100) // Get all and filter client-side + .map { page -> + val filtered = page.pageItems.filter { history -> + history.recipientName?.contains(query, ignoreCase = true) == true || + history.referenceNumber?.contains(query, ignoreCase = true) == true + } + filtered + } + .catch { DataState.Error(it, null) } + .asDataStateFlow() + } + + override fun getHistoryStatistics(autoPayId: Long): Flow> { + return apiManager.autoPayApi.getAutoPayHistory(autoPayId, 100) + .map { page -> + val historyList = page.pageItems + + val totalCount = historyList.size + val successfulCount = historyList.count { it.status == PaymentStatus.COMPLETED } + val failedCount = historyList.count { it.status == PaymentStatus.FAILED } + val pendingCount = historyList.count { it.status == PaymentStatus.PENDING } + + val statistics = AutoPayHistoryStatistics( + totalTransactions = totalCount, + successfulTransactions = successfulCount, + failedTransactions = failedCount, + pendingTransactions = pendingCount, + ) + statistics + } + .catch { DataState.Error(it, null) } + .asDataStateFlow() + } +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/AutoPayRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/AutoPayRepositoryImpl.kt new file mode 100644 index 000000000..2196bbe57 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/AutoPayRepositoryImpl.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.repositoryImpl + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.mifospay.core.common.DataState +import org.mifospay.core.common.asDataStateFlow +import org.mifospay.core.data.repository.AutoPayRepository +import org.mifospay.core.data.repository.AutoPayStatistics +import org.mifospay.core.data.util.AutoPayValidator +import org.mifospay.core.model.autopay.AutoPay +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.AutoPayPayload +import org.mifospay.core.model.autopay.AutoPayTemplate +import org.mifospay.core.model.autopay.AutoPayUpdatePayload +import org.mifospay.core.model.autopay.UpcomingPayment +import org.mifospay.core.network.FineractApiManager + +class AutoPayRepositoryImpl( + private val apiManager: FineractApiManager, + private val ioDispatcher: CoroutineDispatcher, +) : AutoPayRepository { + + override fun getAutoPayTemplate( + clientId: Long, + sourceAccountId: Long, + ): Flow> { + return apiManager.autoPayApi + .getAutoPayTemplate(clientId, sourceAccountId) + .catch { DataState.Error(it, null) } + .asDataStateFlow().flowOn(ioDispatcher) + } + + override fun getAllAutoPaySchedules( + clientId: Long, + ): Flow>> { + return apiManager.autoPayApi + .getAllAutoPaySchedules(clientId) + .catch { DataState.Error(it, null) } + .asDataStateFlow().flowOn(ioDispatcher) + } + + override fun getAutoPaySchedule( + autoPayId: Long, + ): Flow> { + return apiManager.autoPayApi + .getAutoPaySchedule(autoPayId) + .catch { DataState.Error(it, null) } + .asDataStateFlow().flowOn(ioDispatcher) + } + + override suspend fun createAutoPaySchedule( + payload: AutoPayPayload, + ): DataState { + return try { + withContext(ioDispatcher) { + apiManager.autoPayApi.createAutoPaySchedule(payload) + } + DataState.Success("AutoPay schedule created successfully") + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun updateAutoPaySchedule( + autoPayId: Long, + payload: AutoPayUpdatePayload, + ): DataState { + return try { + withContext(ioDispatcher) { + apiManager.autoPayApi.updateAutoPaySchedule( + autoPayId = autoPayId, + payload = payload, + ) + } + DataState.Success("AutoPay schedule updated successfully") + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun deleteAutoPaySchedule( + autoPayId: Long, + ): DataState { + return try { + withContext(ioDispatcher) { + apiManager.autoPayApi.deleteAutoPaySchedule(autoPayId) + } + DataState.Success("AutoPay schedule deleted successfully") + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun pauseAutoPaySchedule( + autoPayId: Long, + ): DataState { + return try { + withContext(ioDispatcher) { + apiManager.autoPayApi.pauseAutoPaySchedule(autoPayId) + } + DataState.Success("AutoPay schedule paused successfully") + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun resumeAutoPaySchedule( + autoPayId: Long, + ): DataState { + return try { + withContext(ioDispatcher) { + apiManager.autoPayApi.resumeAutoPaySchedule(autoPayId) + } + DataState.Success("AutoPay schedule resumed successfully") + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override fun getAutoPayHistory( + autoPayId: Long, + limit: Int, + ): Flow>> { + return apiManager.autoPayApi + .getAutoPayHistory(autoPayId, limit) + .catch { DataState.Error(it, null) } + .asDataStateFlow().flowOn(ioDispatcher) + } + + override fun getUpcomingPayments( + clientId: Long, + limit: Int, + ): Flow>> { + return apiManager.autoPayApi + .getUpcomingPayments(clientId, limit) + .catch { DataState.Error(it, null) } + .asDataStateFlow().flowOn(ioDispatcher) + } + + override fun getAutoPayStatistics( + clientId: Long, + ): Flow> { + return apiManager.autoPayApi + .getAutoPayStatistics(clientId) + .catch { DataState.Error(it, null) } + .map { response -> + AutoPayStatistics( + totalActiveSchedules = response.totalActiveSchedules, + totalPausedSchedules = response.totalPausedSchedules, + totalCompletedSchedules = response.totalCompletedSchedules, + totalUpcomingPayments = response.totalUpcomingPayments, + totalAmountThisMonth = response.totalAmountThisMonth, + currency = response.currency, + ) + } + .asDataStateFlow().flowOn(ioDispatcher) + } + + override suspend fun validateAutoPayPayload( + payload: AutoPayPayload, + ): DataState { + return try { + withContext(ioDispatcher) { + when (val validationResult = AutoPayValidator.validateAutoPayPayload(payload)) { + is AutoPayValidator.ValidationResult.Valid -> DataState.Success(true) + is AutoPayValidator.ValidationResult.Invalid -> { + DataState.Error(Exception(validationResult.errorMessage), null) + } + } + } + } catch (e: Exception) { + DataState.Error(e, null) + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/BillRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/BillRepositoryImpl.kt new file mode 100644 index 000000000..f44cb94f3 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/BillRepositoryImpl.kt @@ -0,0 +1,225 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.repositoryImpl + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import org.mifospay.core.common.DataState +import org.mifospay.core.common.asDataStateFlow +import org.mifospay.core.data.repository.BillRepository +import org.mifospay.core.data.repository.BillStatistics +import org.mifospay.core.data.util.BillErrorHandler +import org.mifospay.core.data.util.BillValidator +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.model.autopay.BillStatus +import org.mifospay.core.model.autopay.RecurrencePattern +import org.mifospay.core.network.FineractApiManager +import org.mifospay.core.network.services.BillStatisticsResponse + +/** + * Network-based implementation of BillRepository. + * + * TODO: This implementation uses placeholder API endpoints. When the backend APIs + * for bill management are finalized, update the endpoints and request/response + * models according to the actual API contract. + */ +class BillRepositoryImpl( + private val apiManager: FineractApiManager, + private val ioDispatcher: CoroutineDispatcher, +) : BillRepository { + + override fun getAllBills(clientId: Long): Flow>> { + return apiManager.billApi + .getAllBills(clientId) + .asDataStateFlow() + .flowOn(ioDispatcher) + } + + override suspend fun getBillById(id: String): DataState { + return try { + val result = withContext(ioDispatcher) { + apiManager.billApi.getBillById(id).first() + } + DataState.Success(result) + } catch (e: Exception) { + DataState.Error(e) + } + } + + override suspend fun createBill(bill: Bill): DataState { + return try { + val billWithId = bill.copy( + id = bill.id ?: generateBillId(), + createdAt = Clock.System.now().toEpochMilliseconds(), + updatedAt = Clock.System.now().toEpochMilliseconds(), + ) + val result = withContext(ioDispatcher) { + apiManager.billApi.createBill(billWithId).first() + } + DataState.Success(result) + } catch (e: Exception) { + DataState.Error(e) + } + } + + override suspend fun updateBill(bill: Bill): DataState { + return try { + val billId = + bill.id ?: return DataState.Error(Exception("Bill ID is required for update")) + val updatedBill = bill.copy( + updatedAt = Clock.System.now().toEpochMilliseconds(), + ) + val result = withContext(ioDispatcher) { + apiManager.billApi.updateBill(billId, updatedBill).first() + } + DataState.Success(result) + } catch (e: Exception) { + DataState.Error(e) + } + } + + override suspend fun deleteBill(id: String): DataState { + return try { + withContext(ioDispatcher) { + apiManager.billApi.deleteBill(id).first() + } + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e) + } + } + + override suspend fun getBillsByStatus(status: BillStatus): List { + return try { + withContext(ioDispatcher) { + apiManager.billApi.getBillsByStatus(status).first() + } + } catch (e: Exception) { + emptyList() + } + } + + override suspend fun getBillsByBillerId(billerId: String): List { + return try { + withContext(ioDispatcher) { + apiManager.billApi.getBillsByBillerId(billerId).first() + } + } catch (e: Exception) { + emptyList() + } + } + + override suspend fun getBillsByRecurrencePattern(pattern: RecurrencePattern): List { + return try { + withContext(ioDispatcher) { + apiManager.billApi.getBillsByRecurrencePattern(pattern).first() + } + } catch (e: Exception) { + emptyList() + } + } + + override suspend fun searchBillsByName(query: String): List { + return try { + withContext(ioDispatcher) { + apiManager.billApi.searchBillsByName(query).first() + } + } catch (e: Exception) { + emptyList() + } + } + + override suspend fun getOverdueBills(): List { + return try { + withContext(ioDispatcher) { + apiManager.billApi.getOverdueBills().first() + } + } catch (e: Exception) { + emptyList() + } + } + + override suspend fun getUpcomingBills(fromDate: Long, toDate: Long): List { + return try { + withContext(ioDispatcher) { + apiManager.billApi.getUpcomingBills(fromDate, toDate).first() + } + } catch (e: Exception) { + emptyList() + } + } + + override suspend fun updateBillStatus(billId: String, status: BillStatus): DataState { + return try { + val result = withContext(ioDispatcher) { + apiManager.billApi.updateBillStatus(billId, status).first() + } + DataState.Success(result) + } catch (e: Exception) { + DataState.Error(e) + } + } + + override suspend fun validateBill(bill: Bill): DataState { + return try { + val validationResult = BillValidator.validateBill(bill) + if (validationResult.isValid) { + DataState.Success(true) + } else { + val errorMessage = BillErrorHandler.handleValidationError(validationResult) + DataState.Error(Exception(errorMessage)) + } + } catch (e: Exception) { + val errorMessage = BillErrorHandler.handleNetworkError(e) + DataState.Error(Exception(errorMessage)) + } + } + + // TODO catch and emit DataState error + + override fun getBillStatistics(clientId: Long): Flow> { + return apiManager.billApi + .getBillStatistics(clientId) + .map { response: BillStatisticsResponse -> + DataState.Success( + BillStatistics( + totalBills = response.totalBills, + activeBills = response.activeBills, + overdueBills = response.overdueBills, + totalAmount = response.totalAmount, + currency = response.currency, + upcomingPayments = response.upcomingPayments, + totalAmountThisMonth = response.totalAmountThisMonth, + ), + ) + } + .flowOn(ioDispatcher) + } + + override suspend fun clearAllBills(): DataState { + return try { + withContext(ioDispatcher) { + apiManager.billApi.clearAllBills().first() + } + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e) + } + } + + private fun generateBillId(): String { + return "bill_${Clock.System.now().toEpochMilliseconds()}_${(0..999).random()}" + } +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/BillerRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/BillerRepositoryImpl.kt new file mode 100644 index 000000000..a6e8a8c07 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/repositoryImpl/BillerRepositoryImpl.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.repositoryImpl + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import org.mifospay.core.common.DataState +import org.mifospay.core.data.repository.BillerRepository +import org.mifospay.core.model.autopay.Biller +import org.mifospay.core.model.autopay.BillerCategory +import org.mifospay.core.network.FineractApiManager + +/** + * Network-based implementation of BillerRepository. + * + * TODO: This implementation uses placeholder API endpoints. When the backend APIs + * for biller management are finalized, update the endpoints and request/response + * models according to the actual API contract. + */ +class BillerRepositoryImpl( + private val apiManager: FineractApiManager, + private val ioDispatcher: CoroutineDispatcher, +) : BillerRepository { + + override fun getAllBillers(): Flow> { + return apiManager.billerApi + .getAllBillers() + .catch { + // Return empty list on error for now + emit(emptyList()) + } + .flowOn(ioDispatcher) + } + + override suspend fun getBillerById(id: String): Biller? { + return try { + withContext(ioDispatcher) { + apiManager.billerApi.getBillerById(id).first() + } + } catch (e: Exception) { + null + } + } + + override suspend fun saveBiller(biller: Biller): DataState { + return try { + val result = withContext(ioDispatcher) { + apiManager.billerApi.createBiller(biller).first() + } + DataState.Success(result) + } catch (e: Exception) { + DataState.Error(e) + } + } + + override suspend fun updateBiller(biller: Biller): DataState { + return try { + val billerId = biller.id ?: return DataState.Error(Exception("Biller ID is required for update")) + val result = withContext(ioDispatcher) { + apiManager.billerApi.updateBiller(billerId, biller).first() + } + DataState.Success(result) + } catch (e: Exception) { + DataState.Error(e) + } + } + + override suspend fun deleteBiller(id: String): DataState { + return try { + withContext(ioDispatcher) { + apiManager.billerApi.deleteBiller(id).first() + } + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e) + } + } + + override suspend fun getBillersByCategory(category: BillerCategory): List { + return try { + withContext(ioDispatcher) { + apiManager.billerApi.getBillersByCategory(category).first() + } + } catch (e: Exception) { + emptyList() + } + } + + override suspend fun searchBillersByName(query: String): List { + return try { + withContext(ioDispatcher) { + apiManager.billerApi.searchBillersByName(query).first() + } + } catch (e: Exception) { + emptyList() + } + } + + override suspend fun isBillerExists(name: String, accountNumber: String): Boolean { + return try { + withContext(ioDispatcher) { + apiManager.billerApi.isBillerExists(name, accountNumber).first() + } + } catch (e: Exception) { + false + } + } + + override suspend fun clearAllBillers(): DataState { + return try { + withContext(ioDispatcher) { + apiManager.billerApi.clearAllBillers().first() + } + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e) + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayErrorHandler.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayErrorHandler.kt new file mode 100644 index 000000000..cc2ada913 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayErrorHandler.kt @@ -0,0 +1,171 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.util + +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.ServerResponseException +import io.ktor.http.HttpStatusCode +import org.mifospay.core.common.DataState + +object AutoPayErrorHandler { + + sealed class AutoPayError( + open val message: String, + open val code: String? = null, + ) { + data class NetworkError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class ValidationError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class ServerError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class AuthenticationError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class AuthorizationError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class NotFoundError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class ConflictError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class RateLimitError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + + data class UnknownError( + override val message: String, + override val code: String? = null, + ) : AutoPayError(message, code) + } + + fun handleException(exception: Exception): AutoPayError { + return when (exception) { + is ClientRequestException -> handleClientRequestException(exception) + is ServerResponseException -> handleServerResponseException(exception) + is IllegalArgumentException -> AutoPayError.ValidationError( + message = exception.message ?: "Invalid input provided", + code = "VALIDATION_ERROR", + ) + is IllegalStateException -> AutoPayError.ValidationError( + message = exception.message ?: "Invalid state", + code = "STATE_ERROR", + ) + else -> AutoPayError.UnknownError( + message = exception.message ?: "An unexpected error occurred", + code = "UNKNOWN_ERROR", + ) + } + } + + private fun handleClientRequestException(exception: ClientRequestException): AutoPayError { + return when (exception.response.status) { + HttpStatusCode.Unauthorized -> AutoPayError.AuthenticationError( + message = "Authentication required. Please log in again.", + code = "UNAUTHORIZED", + ) + HttpStatusCode.Forbidden -> AutoPayError.AuthorizationError( + message = "You don't have permission to perform this action.", + code = "FORBIDDEN", + ) + HttpStatusCode.NotFound -> AutoPayError.NotFoundError( + message = "The requested AutoPay schedule was not found.", + code = "NOT_FOUND", + ) + HttpStatusCode.Conflict -> AutoPayError.ConflictError( + message = "The AutoPay schedule already exists or conflicts with existing data.", + code = "CONFLICT", + ) + HttpStatusCode.TooManyRequests -> AutoPayError.RateLimitError( + message = "Too many requests. Please try again later.", + code = "RATE_LIMIT", + ) + HttpStatusCode.BadRequest -> AutoPayError.ValidationError( + message = "Invalid request data. Please check your input.", + code = "BAD_REQUEST", + ) + else -> AutoPayError.NetworkError( + message = "Network error occurred. Please check your connection.", + code = "NETWORK_ERROR", + ) + } + } + + private fun handleServerResponseException(exception: ServerResponseException): AutoPayError { + return when (exception.response.status) { + HttpStatusCode.InternalServerError -> AutoPayError.ServerError( + message = "Server error occurred. Please try again later.", + code = "INTERNAL_SERVER_ERROR", + ) + HttpStatusCode.ServiceUnavailable -> AutoPayError.ServerError( + message = "Service temporarily unavailable. Please try again later.", + code = "SERVICE_UNAVAILABLE", + ) + HttpStatusCode.GatewayTimeout -> AutoPayError.NetworkError( + message = "Request timeout. Please try again.", + code = "TIMEOUT", + ) + else -> AutoPayError.ServerError( + message = "Server error occurred. Please try again later.", + code = "SERVER_ERROR", + ) + } + } + + fun createErrorDataState(error: AutoPayError): DataState { + return DataState.Error( + exception = Exception(error.message), + data = null, + ) + } + + fun getErrorMessage(error: AutoPayError): String { + return when (error) { + is AutoPayError.NetworkError -> "Network Error: ${error.message}" + is AutoPayError.ValidationError -> "Validation Error: ${error.message}" + is AutoPayError.ServerError -> "Server Error: ${error.message}" + is AutoPayError.AuthenticationError -> "Authentication Error: ${error.message}" + is AutoPayError.AuthorizationError -> "Authorization Error: ${error.message}" + is AutoPayError.NotFoundError -> "Not Found: ${error.message}" + is AutoPayError.ConflictError -> "Conflict: ${error.message}" + is AutoPayError.RateLimitError -> "Rate Limit: ${error.message}" + is AutoPayError.UnknownError -> "Error: ${error.message}" + } + } + + fun isRetryableError(error: AutoPayError): Boolean { + return when (error) { + is AutoPayError.NetworkError -> true + is AutoPayError.ServerError -> true + is AutoPayError.RateLimitError -> true + else -> false + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayValidator.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayValidator.kt new file mode 100644 index 000000000..b9d01e8e0 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/AutoPayValidator.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.util + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.byUnicodePattern +import org.mifospay.core.model.autopay.AutoPayPayload +import org.mifospay.core.model.autopay.AutoPayUpdatePayload + +@OptIn(FormatStringsInDatetimeFormats::class) +object AutoPayValidator { + + private val dateFormat = LocalDateTime.Format { + byUnicodePattern("dd MMMM yyyy") + } + + sealed class ValidationResult { + data object Valid : ValidationResult() + data class Invalid(val errorMessage: String) : ValidationResult() + } + + fun validateAutoPayPayload(payload: AutoPayPayload): ValidationResult { + return when { + payload.name.isBlank() -> ValidationResult.Invalid("Schedule name is required") + payload.name.length < 3 -> ValidationResult.Invalid("Schedule name must be at least 3 characters") + payload.name.length > 100 -> ValidationResult.Invalid("Schedule name must be less than 100 characters") + + payload.amount.isBlank() -> ValidationResult.Invalid("Amount is required") + !isValidAmount(payload.amount) -> ValidationResult.Invalid("Invalid amount format") + payload.amount.toDoubleOrNull()?.let { it <= 0 } == true -> ValidationResult.Invalid("Amount must be greater than 0") + + payload.currency.isBlank() -> ValidationResult.Invalid("Currency is required") + !isValidCurrency(payload.currency) -> ValidationResult.Invalid("Invalid currency code") + + payload.frequency.isBlank() -> ValidationResult.Invalid("Frequency is required") + !isValidFrequency(payload.frequency) -> ValidationResult.Invalid("Invalid frequency") + + payload.recipientName.isBlank() -> ValidationResult.Invalid("Recipient name is required") + payload.recipientName.length < 2 -> ValidationResult.Invalid("Recipient name must be at least 2 characters") + payload.recipientName.length > 100 -> ValidationResult.Invalid("Recipient name must be less than 100 characters") + + payload.recipientAccountNumber.isBlank() -> ValidationResult.Invalid("Recipient account number is required") + !isValidAccountNumber(payload.recipientAccountNumber) -> ValidationResult.Invalid("Invalid account number format") + + payload.sourceAccountId <= 0 -> ValidationResult.Invalid("Invalid source account") + payload.clientId <= 0 -> ValidationResult.Invalid("Invalid client ID") + + payload.validFrom.isNotBlank() && !isValidDate(payload.validFrom) -> ValidationResult.Invalid("Invalid start date format") + payload.validTill.isNotBlank() && !isValidDate(payload.validTill) -> ValidationResult.Invalid("Invalid end date format") + + payload.validFrom.isNotBlank() && payload.validTill.isNotBlank() -> { + val startDate = parseDate(payload.validFrom) + val endDate = parseDate(payload.validTill) + if (startDate != null && endDate != null && startDate >= endDate) { + ValidationResult.Invalid("End date must be after start date") + } else { + ValidationResult.Valid + } + } + + else -> ValidationResult.Valid + } + } + + fun validateAutoPayUpdatePayload(payload: AutoPayUpdatePayload): ValidationResult { + return when { + payload.name?.let { it.isBlank() } == true -> ValidationResult.Invalid("Schedule name cannot be empty") + payload.name?.let { it.length < 3 } == true -> ValidationResult.Invalid("Schedule name must be at least 3 characters") + payload.name?.let { it.length > 100 } == true -> ValidationResult.Invalid("Schedule name must be less than 100 characters") + + payload.amount?.let { it.isBlank() } == true -> ValidationResult.Invalid("Amount cannot be empty") + payload.amount?.let { !isValidAmount(it) } == true -> ValidationResult.Invalid("Invalid amount format") + payload.amount?.toDoubleOrNull()?.let { it <= 0 } == true -> ValidationResult.Invalid("Amount must be greater than 0") + + payload.currency?.let { it.isBlank() } == true -> ValidationResult.Invalid("Currency cannot be empty") + payload.currency?.let { !isValidCurrency(it) } == true -> ValidationResult.Invalid("Invalid currency code") + + payload.frequency?.let { it.isBlank() } == true -> ValidationResult.Invalid("Frequency cannot be empty") + payload.frequency?.let { !isValidFrequency(it) } == true -> ValidationResult.Invalid("Invalid frequency") + + payload.recipientName?.let { it.isBlank() } == true -> ValidationResult.Invalid("Recipient name cannot be empty") + payload.recipientName?.let { it.length < 2 } == true -> ValidationResult.Invalid("Recipient name must be at least 2 characters") + payload.recipientName?.let { it.length > 100 } == true -> ValidationResult.Invalid("Recipient name must be less than 100 characters") + + payload.recipientAccountNumber?.let { it.isBlank() } == true -> ValidationResult.Invalid("Recipient account number cannot be empty") + payload.recipientAccountNumber?.let { !isValidAccountNumber(it) } == true -> ValidationResult.Invalid("Invalid account number format") + + payload.validFrom?.let { it.isNotBlank() && !isValidDate(it) } == true -> ValidationResult.Invalid("Invalid start date format") + payload.validTill?.let { it.isNotBlank() && !isValidDate(it) } == true -> ValidationResult.Invalid("Invalid end date format") + + payload.validFrom?.let { it.isNotBlank() } == true && payload.validTill?.let { it.isNotBlank() } == true -> { + val startDate = parseDate(payload.validFrom!!) + val endDate = parseDate(payload.validTill!!) + if (startDate != null && endDate != null && startDate >= endDate) { + ValidationResult.Invalid("End date must be after start date") + } else { + ValidationResult.Valid + } + } + + else -> ValidationResult.Valid + } + } + + private fun isValidAmount(amount: String): Boolean { + return try { + amount.toDoubleOrNull() != null && amount.toDouble() > 0 + } catch (e: NumberFormatException) { + false + } + } + + private fun isValidCurrency(currency: String): Boolean { + return currency.length == 3 && currency.all { it.isLetter() } + } + + private fun isValidFrequency(frequency: String): Boolean { + val validFrequencies = listOf("DAILY", "WEEKLY", "MONTHLY", "QUARTERLY", "YEARLY") + return validFrequencies.contains(frequency.uppercase()) + } + + private fun isValidAccountNumber(accountNumber: String): Boolean { + return accountNumber.length >= 8 && accountNumber.length <= 20 && accountNumber.all { it.isLetterOrDigit() } + } + + private fun isValidDate(date: String): Boolean { + return try { + dateFormat.parse(date) + true + } catch (e: Exception) { + false + } + } + + private fun parseDate(date: String): LocalDate? { + return try { + dateFormat.parse(date).date + } catch (e: Exception) { + null + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/BillErrorHandler.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/BillErrorHandler.kt new file mode 100644 index 000000000..e1e4c4496 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/BillErrorHandler.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.util + +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.ServerResponseException +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.TimeoutCancellationException +import org.mifospay.core.common.DataState + +/** + * Utility class for handling bill-related errors + */ +object BillErrorHandler { + + /** + * Handles network errors and converts them to user-friendly messages + */ + fun handleNetworkError(throwable: Throwable): String { + return when (throwable) { + is TimeoutCancellationException -> "Request timed out. Please try again." + is ClientRequestException -> { + when (throwable.response.status) { + HttpStatusCode.BadRequest -> "Invalid request. Please check your input and try again." + HttpStatusCode.Unauthorized -> "Authentication failed. Please log in again." + HttpStatusCode.Forbidden -> "Access denied. You don't have permission to perform this action." + HttpStatusCode.NotFound -> "Bill not found. It may have been deleted or moved." + HttpStatusCode.Conflict -> "Bill already exists with the same name and biller." + HttpStatusCode.UnprocessableEntity -> "Invalid bill data. Please check your input and try again." + else -> "An error occurred. Please try again." + } + } + is ServerResponseException -> { + when (throwable.response.status) { + HttpStatusCode.InternalServerError -> "Server error. Please try again later." + HttpStatusCode.BadGateway -> "Bad gateway. Please try again later." + HttpStatusCode.ServiceUnavailable -> "Service temporarily unavailable. Please try again later." + else -> "Server error. Please try again later." + } + } + else -> "An unexpected error occurred. Please try again." + } + } + + /** + * Handles validation errors and converts them to user-friendly messages + */ + fun handleValidationError(validationResult: org.mifospay.core.model.autopay.BillValidationResult): String { + return validationResult.nameError + ?: validationResult.amountError + ?: validationResult.dueDateError + ?: validationResult.recurrencePatternError + ?: validationResult.billerError + ?: "Please check your input and try again." + } + + /** + * Handles DataState errors and converts them to user-friendly messages + */ + fun handleDataStateError(dataState: DataState.Error<*>): String { + val exception = dataState.exception + return when (exception) { + is IllegalArgumentException -> exception.message ?: "Invalid input provided." + is IllegalStateException -> exception.message ?: "Operation not allowed in current state." + is TimeoutCancellationException -> "Request timed out. Please try again." + is ClientRequestException -> handleClientRequestException(exception) + is ServerResponseException -> handleServerResponseException(exception) + else -> exception.message ?: "An unexpected error occurred. Please try again." + } + } + + /** + * Handles client request exceptions + */ + private fun handleClientRequestException(exception: ClientRequestException): String { + return when (exception.response.status) { + HttpStatusCode.BadRequest -> "Invalid request. Please check your input and try again." + HttpStatusCode.Unauthorized -> "Authentication failed. Please log in again." + HttpStatusCode.Forbidden -> "Access denied. You don't have permission to perform this action." + HttpStatusCode.NotFound -> "Bill not found. It may have been deleted or moved." + HttpStatusCode.Conflict -> "Bill already exists with the same name and biller." + HttpStatusCode.UnprocessableEntity -> "Invalid bill data. Please check your input and try again." + else -> "An error occurred. Please try again." + } + } + + /** + * Handles server response exceptions + */ + private fun handleServerResponseException(exception: ServerResponseException): String { + return when (exception.response.status) { + HttpStatusCode.InternalServerError -> "Server error. Please try again later." + HttpStatusCode.BadGateway -> "Bad gateway. Please try again later." + HttpStatusCode.ServiceUnavailable -> "Service temporarily unavailable. Please try again later." + else -> "Server error. Please try again later." + } + } + + /** + * Creates a user-friendly error message for specific bill operations + */ + fun createOperationErrorMessage(operation: String, error: String): String { + return when (operation.lowercase()) { + "create" -> "Failed to create bill: $error" + "update" -> "Failed to update bill: $error" + "delete" -> "Failed to delete bill: $error" + "fetch" -> "Failed to load bills: $error" + "validate" -> "Bill validation failed: $error" + else -> "Operation failed: $error" + } + } + + /** + * Checks if an error is retryable + */ + fun isRetryableError(throwable: Throwable): Boolean { + return when (throwable) { + is TimeoutCancellationException -> true + is ClientRequestException -> false + is ServerResponseException -> { + when (throwable.response.status) { + HttpStatusCode.InternalServerError, + HttpStatusCode.BadGateway, + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.GatewayTimeout, + -> true + else -> false + } + } + else -> false + } + } + + /** + * Gets retry delay in milliseconds based on error type + */ + fun getRetryDelay(throwable: Throwable): Long { + return when (throwable) { + is TimeoutCancellationException -> 2000L + is ServerResponseException -> { + when (throwable.response.status) { + HttpStatusCode.InternalServerError, + HttpStatusCode.BadGateway, + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.GatewayTimeout, + -> 3000L + else -> 1000L + } + } + else -> 1000L + } + } +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/BillValidator.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/BillValidator.kt new file mode 100644 index 000000000..e9d6b0347 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/BillValidator.kt @@ -0,0 +1,222 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.util + +import kotlinx.datetime.Clock +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.model.autopay.BillFormData +import org.mifospay.core.model.autopay.BillValidationResult +import org.mifospay.core.model.autopay.RecurrencePattern + +/** + * Utility class for validating bill data before submission + */ +object BillValidator { + + /** + * Validates a Bill object + */ + fun validateBill(bill: Bill): BillValidationResult { + val nameError = validateName(bill.name) + val amountError = validateAmount(bill.amount) + val dueDateError = validateDueDate(bill.dueDate) + val recurrencePatternError = validateRecurrencePattern(bill.recurrencePattern) + val billerError = validateBiller(bill.billerId, bill.billerName) + + val isValid = nameError == null && + amountError == null && + dueDateError == null && + recurrencePatternError == null && + billerError == null + + return BillValidationResult( + isValid = isValid, + nameError = nameError, + amountError = amountError, + dueDateError = dueDateError, + recurrencePatternError = recurrencePatternError, + billerError = billerError, + ) + } + + /** + * Validates BillFormData object + */ + fun validateBillFormData(formData: BillFormData): BillValidationResult { + val nameError = validateName(formData.name) + val amountError = validateAmountString(formData.amount) + val dueDateError = validateDueDate(formData.dueDate) + val recurrencePatternError = validateRecurrencePattern(formData.recurrencePattern) + val billerError = validateBiller(formData.billerId, formData.billerName) + + // AutoPay validation + val autoPayPaymentMethodError = validateAutoPayPaymentMethod(formData.enableAutoPay, formData.autoPayPaymentMethod) + val autoPaySourceAccountError = validateAutoPaySourceAccount(formData.enableAutoPay, formData.autoPaySourceAccount) + val autoPayMaxAmountError = validateAutoPayMaxAmount(formData.autoPayMaxAmount) + + val isValid = nameError == null && + amountError == null && + dueDateError == null && + recurrencePatternError == null && + billerError == null && + autoPayPaymentMethodError == null && + autoPaySourceAccountError == null && + autoPayMaxAmountError == null + + return BillValidationResult( + isValid = isValid, + nameError = nameError, + amountError = amountError, + dueDateError = dueDateError, + recurrencePatternError = recurrencePatternError, + billerError = billerError, + autoPayPaymentMethodError = autoPayPaymentMethodError, + autoPaySourceAccountError = autoPaySourceAccountError, + autoPayMaxAmountError = autoPayMaxAmountError, + ) + } + + private fun validateName(name: String): String? { + return when { + name.isBlank() -> "Bill name is required" + name.length < 2 -> "Bill name must be at least 2 characters long" + name.length > 100 -> "Bill name must be less than 100 characters" + !name.matches(Regex("^[a-zA-Z0-9\\s\\-_]+$")) -> "Bill name contains invalid characters" + else -> null + } + } + + private fun validateAmount(amount: Double): String? { + return when { + amount <= 0 -> "Bill amount must be greater than 0" + amount > 999999.99 -> "Bill amount cannot exceed 999,999.99" + else -> null + } + } + + private fun validateAmountString(amountString: String): String? { + return when { + amountString.isBlank() -> "Bill amount is required" + else -> { + try { + val amount = amountString.toDouble() + validateAmount(amount) + } catch (e: NumberFormatException) { + "Invalid amount format" + } + } + } + } + + private fun validateDueDate(dueDate: Long): String? { + val currentTime = Clock.System.now().toEpochMilliseconds() + return when { + dueDate <= 0 -> "Due date is required" + dueDate < currentTime -> "Due date cannot be in the past" + dueDate > currentTime + (365 * 24 * 60 * 60 * 1000L) -> "Due date cannot be more than 1 year in the future" + else -> null + } + } + + private fun validateRecurrencePattern(pattern: RecurrencePattern): String? { + return when (pattern) { + RecurrencePattern.NONE -> null + RecurrencePattern.DAILY -> null + RecurrencePattern.WEEKLY -> null + RecurrencePattern.BIWEEKLY -> null + RecurrencePattern.MONTHLY -> null + RecurrencePattern.QUARTERLY -> null + RecurrencePattern.SEMI_ANNUALLY -> null + RecurrencePattern.ANNUALLY -> null + } + } + + private fun validateBiller(billerId: String?, billerName: String?): String? { + return when { + billerId.isNullOrBlank() && billerName.isNullOrBlank() -> null + billerId.isNullOrBlank() && !billerName.isNullOrBlank() -> "Biller ID is required when biller name is provided" + !billerId.isNullOrBlank() && billerName.isNullOrBlank() -> "Biller name is required when biller ID is provided" + else -> null + } + } + + private fun validateAutoPayPaymentMethod(enableAutoPay: Boolean, paymentMethod: String): String? { + return when { + !enableAutoPay -> null + paymentMethod.isBlank() -> "Payment method is required when AutoPay is enabled" + !listOf("Bank Account", "Credit Card", "UPI").contains(paymentMethod) -> "Invalid payment method" + else -> null + } + } + + private fun validateAutoPaySourceAccount(enableAutoPay: Boolean, sourceAccount: String): String? { + return when { + !enableAutoPay -> null + sourceAccount.isBlank() -> "Source account is required when AutoPay is enabled" + sourceAccount.length < 8 -> "Source account must be at least 8 characters" + sourceAccount.length > 20 -> "Source account must be less than 20 characters" + else -> null + } + } + + private fun validateAutoPayMaxAmount(maxAmount: String): String? { + return when { + maxAmount.isBlank() -> null + else -> { + try { + val amount = maxAmount.toDouble() + when { + amount <= 0 -> "Maximum amount must be greater than 0" + amount > 999999.99 -> "Maximum amount cannot exceed 999,999.99" + else -> null + } + } catch (e: NumberFormatException) { + "Invalid maximum amount format" + } + } + } + } + + /** + * Validates if a bill is overdue + */ + fun isBillOverdue(bill: Bill): Boolean { + val currentTime = Clock.System.now().toEpochMilliseconds() + return bill.dueDate < currentTime && bill.status == org.mifospay.core.model.autopay.BillStatus.ACTIVE + } + + /** + * Calculates the next payment date based on recurrence pattern + */ + fun calculateNextPaymentDate(bill: Bill): Long { + val currentTime = Clock.System.now().toEpochMilliseconds() + return when (bill.recurrencePattern) { + RecurrencePattern.NONE -> bill.dueDate + RecurrencePattern.DAILY -> bill.dueDate + (1 * 24 * 60 * 60 * 1000L) + RecurrencePattern.WEEKLY -> bill.dueDate + (7 * 24 * 60 * 60 * 1000L) + RecurrencePattern.BIWEEKLY -> bill.dueDate + (14 * 24 * 60 * 60 * 1000L) + RecurrencePattern.MONTHLY -> bill.dueDate + (30 * 24 * 60 * 60 * 1000L) + RecurrencePattern.QUARTERLY -> bill.dueDate + (90 * 24 * 60 * 60 * 1000L) + RecurrencePattern.SEMI_ANNUALLY -> bill.dueDate + (180 * 24 * 60 * 60 * 1000L) + RecurrencePattern.ANNUALLY -> bill.dueDate + (365 * 24 * 60 * 60 * 1000L) + } + } + + /** + * Checks if a bill is due within the specified number of days + */ + fun isBillDueWithinDays(bill: Bill, days: Int): Boolean { + val currentTime = Clock.System.now().toEpochMilliseconds() + val daysInMillis = days * 24 * 60 * 60 * 1000L + return bill.dueDate <= currentTime + daysInMillis && + bill.dueDate > currentTime && + bill.status == org.mifospay.core.model.autopay.BillStatus.ACTIVE + } +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/BillerValidator.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/BillerValidator.kt new file mode 100644 index 000000000..1a2d3327b --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/BillerValidator.kt @@ -0,0 +1,209 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.util + +import co.touchlab.kermit.Logger +import org.mifospay.core.model.autopay.BillerFormData +import org.mifospay.core.model.autopay.BillerValidationResult + +/** + * Validator for biller form data with comprehensive validation rules. + * + * Provides validation for all biller fields including: + * - Name validation (required, length, format) + * - Account number validation (required, format, length) + * - Contact number validation (required, format, length) + * - Email validation (optional, format) + * - Category validation (required) + * - Address validation (optional, length) + */ +object BillerValidator { + + private val logger = Logger.withTag("BILLER_VALIDATOR") + + // Validation constants + private const val MIN_NAME_LENGTH = 2 + private const val MAX_NAME_LENGTH = 100 + private const val MIN_ACCOUNT_NUMBER_LENGTH = 8 + private const val MAX_ACCOUNT_NUMBER_LENGTH = 20 + private const val MIN_CONTACT_NUMBER_LENGTH = 10 + private const val MAX_CONTACT_NUMBER_LENGTH = 15 + private const val MAX_EMAIL_LENGTH = 254 + private const val MAX_ADDRESS_LENGTH = 500 + + // Regex patterns + private val NAME_PATTERN = Regex("^[a-zA-Z0-9\\s\\-_.]+$") + private val ACCOUNT_NUMBER_PATTERN = Regex("^[0-9]+$") + private val CONTACT_NUMBER_PATTERN = Regex("^[+]?[0-9\\s\\-()]+$") + private val EMAIL_PATTERN = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") + + /** + * Validates the complete biller form data and returns validation result. + * + * @param formData The biller form data to validate + * @return BillerValidationResult containing validation status and error messages + */ + fun validateBillerForm(formData: BillerFormData): BillerValidationResult { + logger.d("BILLER_VALIDATOR Starting validation for biller: ${formData.name}") + + val nameError = validateName(formData.name) + val accountNumberError = validateAccountNumber(formData.accountNumber) + val contactNumberError = validateContactNumber(formData.contactNumber) + val emailError = validateEmail(formData.email) + val categoryError = validateCategory(formData.category) + val addressError = validateAddress(formData.address) + + val isValid = nameError == null && + accountNumberError == null && + contactNumberError == null && + emailError == null && + categoryError == null && + addressError == null + + val validationResult = BillerValidationResult( + isValid = isValid, + nameError = nameError, + accountNumberError = accountNumberError, + contactNumberError = contactNumberError, + emailError = emailError, + categoryError = categoryError, + ) + + logger.d("BILLER_VALIDATOR Validation completed. Valid: $isValid") + return validationResult + } + + /** + * Validates biller name with length and format requirements. + * + * @param name The biller name to validate + * @return Error message if invalid, null if valid + */ + private fun validateName(name: String): String? { + return when { + name.isBlank() -> "Biller name is required" + name.length < MIN_NAME_LENGTH -> "Biller name must be at least $MIN_NAME_LENGTH characters" + name.length > MAX_NAME_LENGTH -> "Biller name must be less than $MAX_NAME_LENGTH characters" + !NAME_PATTERN.matches(name.trim()) -> "Biller name contains invalid characters" + name.trim().isEmpty() -> "Biller name cannot be only whitespace" + else -> null + } + } + + /** + * Validates account number with format and length requirements. + * + * @param accountNumber The account number to validate + * @return Error message if invalid, null if valid + */ + private fun validateAccountNumber(accountNumber: String): String? { + return when { + accountNumber.isBlank() -> "Account number is required" + accountNumber.length < MIN_ACCOUNT_NUMBER_LENGTH -> "Account number must be at least $MIN_ACCOUNT_NUMBER_LENGTH digits" + accountNumber.length > MAX_ACCOUNT_NUMBER_LENGTH -> "Account number must be less than $MAX_ACCOUNT_NUMBER_LENGTH digits" + !ACCOUNT_NUMBER_PATTERN.matches(accountNumber.trim()) -> "Account number must contain only digits" + accountNumber.trim().isEmpty() -> "Account number cannot be only whitespace" + else -> null + } + } + + /** + * Validates contact number with format and length requirements. + * + * @param contactNumber The contact number to validate + * @return Error message if invalid, null if valid + */ + private fun validateContactNumber(contactNumber: String): String? { + return when { + contactNumber.isBlank() -> "Contact number is required" + contactNumber.length < MIN_CONTACT_NUMBER_LENGTH -> "Contact number must be at least $MIN_CONTACT_NUMBER_LENGTH digits" + contactNumber.length > MAX_CONTACT_NUMBER_LENGTH -> "Contact number must be less than $MAX_CONTACT_NUMBER_LENGTH digits" + !CONTACT_NUMBER_PATTERN.matches(contactNumber.trim()) -> "Contact number contains invalid characters" + contactNumber.trim().isEmpty() -> "Contact number cannot be only whitespace" + else -> null + } + } + + /** + * Validates email address format (optional field). + * + * @param email The email address to validate + * @return Error message if invalid, null if valid + */ + private fun validateEmail(email: String): String? { + return when { + email.isBlank() -> null // Email is optional + email.length > MAX_EMAIL_LENGTH -> "Email address is too long (max $MAX_EMAIL_LENGTH characters)" + !EMAIL_PATTERN.matches(email.trim()) -> "Please enter a valid email address" + email.trim().isEmpty() -> "Email cannot be only whitespace" + else -> null + } + } + + /** + * Validates that a category has been selected. + * + * @param category The selected category to validate + * @return Error message if invalid, null if valid + */ + private fun validateCategory(category: org.mifospay.core.model.autopay.BillerCategory?): String? { + return when { + category == null -> "Please select a biller category" + else -> null + } + } + + /** + * Validates address length (optional field). + * + * @param address The address to validate + * @return Error message if invalid, null if valid + */ + private fun validateAddress(address: String): String? { + return when { + address.isBlank() -> null // Address is optional + address.length > MAX_ADDRESS_LENGTH -> "Address is too long (max $MAX_ADDRESS_LENGTH characters)" + address.trim().isEmpty() -> "Address cannot be only whitespace" + else -> null + } + } + + /** + * Validates individual name field for real-time validation. + * + * @param name The name to validate + * @return Error message if invalid, null if valid + */ + fun validateNameField(name: String): String? = validateName(name) + + /** + * Validates individual account number field for real-time validation. + * + * @param accountNumber The account number to validate + * @return Error message if invalid, null if valid + */ + fun validateAccountNumberField(accountNumber: String): String? = validateAccountNumber(accountNumber) + + /** + * Validates individual contact number field for real-time validation. + * + * @param contactNumber The contact number to validate + * @return Error message if invalid, null if valid + */ + fun validateContactNumberField(contactNumber: String): String? = validateContactNumber(contactNumber) + + /** + * Validates individual email field for real-time validation. + * + * @param email The email to validate + * @return Error message if invalid, null if valid + */ + fun validateEmailField(email: String): String? = validateEmail(email) +} diff --git a/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt new file mode 100644 index 000000000..545f7b574 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/mifospay/core/data/util/StandardUpiQrCodeProcessor.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.data.util + +import org.mifospay.core.model.utils.PaymentQrData +import org.mifospay.core.model.utils.StandardUpiQrData + +/** + * Standard UPI QR Code Processor + * Handles parsing of standard UPI QR codes according to UPI specification + */ +object StandardUpiQrCodeProcessor { + + /** + * Checks if the given string is a valid UPI QR code + * @param qrData The QR code data string + * @return true if it's a valid UPI QR code, false otherwise + */ + fun isValidUpiQrCode(qrData: String): Boolean { + return qrData.startsWith("upi://") || qrData.startsWith("UPI://") + } + + /** + * Parses a standard UPI QR code string + * @param qrData The QR code data string + * @return StandardUpiQrData object with parsed information + * @throws IllegalArgumentException if the QR code is invalid + */ + fun parseUpiQrCode(qrData: String): StandardUpiQrData { + if (!isValidUpiQrCode(qrData)) { + throw IllegalArgumentException("Invalid UPI QR code format") + } + + val paramsString = qrData.substringAfter("upi://").substringAfter("UPI://") + val parts = paramsString.split("?", limit = 2) + val params = if (parts.size > 1) parseParams(parts[1]) else emptyMap() + + val payeeVpa = params["pa"] ?: run { + throw IllegalArgumentException("Missing payee VPA (pa) in UPI QR code") + } + val payeeName = params["pn"] ?: "Unknown" + + val vpaParts = payeeVpa.split("@", limit = 2) + val actualVpa = if (vpaParts.size == 2) payeeVpa else payeeVpa + + return StandardUpiQrData( + payeeName = payeeName, + payeeVpa = actualVpa, + amount = params["am"] ?: "", + currency = params["cu"] ?: StandardUpiQrData.DEFAULT_CURRENCY, + transactionNote = params["tn"] ?: "", + merchantCode = params["mc"] ?: "", + transactionReference = params["tr"] ?: "", + url = params["url"] ?: "", + mode = params["mode"] ?: "02", + ) + } + + /** + * Parses URL parameters into a map + * @param paramsString The parameters string + * @return Map of parameter keys and values + */ + private fun parseParams(paramsString: String): Map { + return paramsString + .split("&") + .associate { param -> + val keyValue = param.split("=", limit = 2) + if (keyValue.size == 2) { + keyValue[0] to keyValue[1] + } else { + param to "" + } + } + } + + /** + * Converts StandardUpiQrData to PaymentQrData for compatibility with existing code + * @param standardData Standard UPI QR data + * @return PaymentQrData object + * Note: clientId and accountId not available in standard UPI + */ + fun toPaymentQrData(standardData: StandardUpiQrData): PaymentQrData { + return PaymentQrData( + clientId = 0, + clientName = standardData.payeeName, + accountNo = standardData.payeeVpa, + amount = standardData.amount, + accountId = 0, + currency = standardData.currency, + officeId = 1, + accountTypeId = 2, + ) + } +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesDataSource.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesDataSource.kt new file mode 100644 index 000000000..d550535ca --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesDataSource.kt @@ -0,0 +1,171 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +@file:OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class) + +package org.mifospay.core.datastore + +import com.russhwolf.settings.ExperimentalSettingsApi +import com.russhwolf.settings.Settings +import com.russhwolf.settings.serialization.decodeValue +import com.russhwolf.settings.serialization.encodeValue +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.builtins.ListSerializer +import org.mifospay.core.model.autopay.AutoPay +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.UpcomingPayment + +private const val IS_AUTO_PAY_ENABLED_KEY = "is_autopay_enabled" +private const val CACHED_AUTO_PAY_SCHEDULES_KEY = "cached_autopay_schedules" +private const val CACHED_UPCOMING_PAYMENTS_KEY = "cached_upcoming_payments" +private const val CACHED_AUTO_PAY_HISTORY_KEY = "cached_autopay_history" +private const val LAST_SYNC_TIMESTAMP_KEY = "last_sync_timestamp" + +@OptIn(ExperimentalSerializationApi::class) +class AutoPayPreferencesDataSource( + private val settings: Settings, + private val dispatcher: CoroutineDispatcher, +) { + + private val _isAutoPayEnabled = MutableStateFlow( + settings.getBoolean(IS_AUTO_PAY_ENABLED_KEY, false), + ) + + private val _cachedAutoPaySchedules = MutableStateFlow( + settings.decodeValue( + key = CACHED_AUTO_PAY_SCHEDULES_KEY, + serializer = ListSerializer(AutoPay.serializer()), + defaultValue = emptyList(), + ), + ) + + private val _cachedUpcomingPayments = MutableStateFlow( + settings.decodeValue( + key = CACHED_UPCOMING_PAYMENTS_KEY, + serializer = ListSerializer(UpcomingPayment.serializer()), + defaultValue = emptyList(), + ), + ) + + private val _cachedAutoPayHistory = MutableStateFlow( + settings.decodeValue( + key = CACHED_AUTO_PAY_HISTORY_KEY, + serializer = ListSerializer(AutoPayHistory.serializer()), + defaultValue = emptyList(), + ), + ) + + private val _lastSyncTimestamp = MutableStateFlow( + settings.getLong(LAST_SYNC_TIMESTAMP_KEY, 0L), + ) + + val isAutoPayEnabled: StateFlow = _isAutoPayEnabled + val cachedAutoPaySchedules: Flow> = _cachedAutoPaySchedules + val cachedUpcomingPayments: Flow> = _cachedUpcomingPayments + val cachedAutoPayHistory: Flow> = _cachedAutoPayHistory + val lastSyncTimestamp: StateFlow = _lastSyncTimestamp + + suspend fun updateAutoPayEnabled(enabled: Boolean) { + withContext(dispatcher) { + settings.putBoolean(IS_AUTO_PAY_ENABLED_KEY, enabled) + _isAutoPayEnabled.value = enabled + } + } + + suspend fun cacheAutoPaySchedules(schedules: List) { + withContext(dispatcher) { + settings.putAutoPaySchedules(schedules) + _cachedAutoPaySchedules.value = schedules + } + } + + suspend fun cacheUpcomingPayments(payments: List) { + withContext(dispatcher) { + settings.putUpcomingPayments(payments) + _cachedUpcomingPayments.value = payments + } + } + + suspend fun cacheAutoPayHistory(history: List) { + withContext(dispatcher) { + settings.putAutoPayHistory(history) + _cachedAutoPayHistory.value = history + } + } + + suspend fun updateLastSyncTimestamp(timestamp: Long) { + withContext(dispatcher) { + settings.putLong(LAST_SYNC_TIMESTAMP_KEY, timestamp) + _lastSyncTimestamp.value = timestamp + } + } + + suspend fun updateLastSyncTimestamp() { + withContext(dispatcher) { + val timestamp = Clock.System.now().toEpochMilliseconds() + settings.putLong(LAST_SYNC_TIMESTAMP_KEY, timestamp) + _lastSyncTimestamp.value = timestamp + } + } + + suspend fun clearCache() { + withContext(dispatcher) { + settings.remove(CACHED_AUTO_PAY_SCHEDULES_KEY) + settings.remove(CACHED_UPCOMING_PAYMENTS_KEY) + settings.remove(CACHED_AUTO_PAY_HISTORY_KEY) + settings.remove(LAST_SYNC_TIMESTAMP_KEY) + + _cachedAutoPaySchedules.value = emptyList() + _cachedUpcomingPayments.value = emptyList() + _cachedAutoPayHistory.value = emptyList() + _lastSyncTimestamp.value = 0L + } + } + + suspend fun getCachedAutoPaySchedule(autoPayId: Long): AutoPay? { + return _cachedAutoPaySchedules.value.find { autoPay -> autoPay.id == autoPayId } + } + + suspend fun isCacheStale(maxAgeMinutes: Long): Boolean { + val lastSync = _lastSyncTimestamp.value + val currentTime = Clock.System.now().toEpochMilliseconds() + val maxAgeMillis = maxAgeMinutes * 60 * 1000 + return (currentTime - lastSync) > maxAgeMillis + } +} + +private fun Settings.putAutoPaySchedules(schedules: List) { + encodeValue( + key = CACHED_AUTO_PAY_SCHEDULES_KEY, + serializer = ListSerializer(AutoPay.serializer()), + value = schedules, + ) +} + +private fun Settings.putUpcomingPayments(payments: List) { + encodeValue( + key = CACHED_UPCOMING_PAYMENTS_KEY, + serializer = ListSerializer(UpcomingPayment.serializer()), + value = payments, + ) +} + +private fun Settings.putAutoPayHistory(history: List) { + encodeValue( + key = CACHED_AUTO_PAY_HISTORY_KEY, + serializer = ListSerializer(AutoPayHistory.serializer()), + value = history, + ) +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepository.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepository.kt new file mode 100644 index 000000000..40d3f6c73 --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepository.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.datastore + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.AutoPay +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.UpcomingPayment + +interface AutoPayPreferencesRepository { + /** + * AutoPay enabled state + */ + val isAutoPayEnabled: StateFlow + + /** + * Cached AutoPay schedules + */ + val cachedAutoPaySchedules: Flow> + + /** + * Cached upcoming payments + */ + val cachedUpcomingPayments: Flow> + + /** + * Cached AutoPay history + */ + val cachedAutoPayHistory: Flow> + + /** + * Last sync timestamp + */ + val lastSyncTimestamp: StateFlow + + /** + * Update AutoPay enabled state + */ + suspend fun updateAutoPayEnabled(enabled: Boolean): DataState + + /** + * Cache AutoPay schedules + */ + suspend fun cacheAutoPaySchedules(schedules: List): DataState + + /** + * Cache upcoming payments + */ + suspend fun cacheUpcomingPayments(payments: List): DataState + + /** + * Cache AutoPay history + */ + suspend fun cacheAutoPayHistory(history: List): DataState + + /** + * Update last sync timestamp + */ + suspend fun updateLastSyncTimestamp(timestamp: Long): DataState + + /** + * Clear all cached data + */ + suspend fun clearCache(): DataState + + /** + * Get cached AutoPay schedule by ID + */ + suspend fun getCachedAutoPaySchedule(autoPayId: Long): AutoPay? + + /** + * Check if cache is stale (older than specified time) + */ + suspend fun isCacheStale(maxAgeMinutes: Long = 30): Boolean +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepositoryImpl.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepositoryImpl.kt new file mode 100644 index 000000000..63562dac3 --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/AutoPayPreferencesRepositoryImpl.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.datastore + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.AutoPay +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.UpcomingPayment + +class AutoPayPreferencesRepositoryImpl( + private val autoPayPreferencesDataSource: AutoPayPreferencesDataSource, + private val ioDispatcher: CoroutineDispatcher, + unconfinedDispatcher: CoroutineDispatcher, +) : AutoPayPreferencesRepository { + private val unconfinedScope = CoroutineScope(unconfinedDispatcher) + + override val isAutoPayEnabled: StateFlow = autoPayPreferencesDataSource.isAutoPayEnabled + + override val cachedAutoPaySchedules: Flow> = autoPayPreferencesDataSource.cachedAutoPaySchedules.flowOn(ioDispatcher) + + override val cachedUpcomingPayments: Flow> = autoPayPreferencesDataSource.cachedUpcomingPayments.flowOn(ioDispatcher) + + override val cachedAutoPayHistory: Flow> = autoPayPreferencesDataSource.cachedAutoPayHistory.flowOn(ioDispatcher) + + override val lastSyncTimestamp: StateFlow = autoPayPreferencesDataSource.lastSyncTimestamp + + override suspend fun updateAutoPayEnabled(enabled: Boolean): DataState { + return try { + autoPayPreferencesDataSource.updateAutoPayEnabled(enabled) + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun cacheAutoPaySchedules(schedules: List): DataState { + return try { + autoPayPreferencesDataSource.cacheAutoPaySchedules(schedules) + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun cacheUpcomingPayments(payments: List): DataState { + return try { + autoPayPreferencesDataSource.cacheUpcomingPayments(payments) + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun cacheAutoPayHistory(history: List): DataState { + return try { + autoPayPreferencesDataSource.cacheAutoPayHistory(history) + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun updateLastSyncTimestamp(timestamp: Long): DataState { + return try { + autoPayPreferencesDataSource.updateLastSyncTimestamp(timestamp) + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + suspend fun updateLastSyncTimestamp(): DataState { + return try { + autoPayPreferencesDataSource.updateLastSyncTimestamp() + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun clearCache(): DataState { + return try { + autoPayPreferencesDataSource.clearCache() + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(e, null) + } + } + + override suspend fun getCachedAutoPaySchedule(autoPayId: Long): AutoPay? { + return autoPayPreferencesDataSource.getCachedAutoPaySchedule(autoPayId) + } + + override suspend fun isCacheStale(maxAgeMinutes: Long): Boolean { + return autoPayPreferencesDataSource.isCacheStale(maxAgeMinutes) + } +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillDataSource.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillDataSource.kt new file mode 100644 index 000000000..fb48bf5ab --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillDataSource.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +@file:OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class) + +package org.mifospay.core.datastore + +import com.russhwolf.settings.ExperimentalSettingsApi +import com.russhwolf.settings.Settings +import com.russhwolf.settings.serialization.decodeValue +import com.russhwolf.settings.serialization.encodeValue +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.builtins.ListSerializer +import org.mifospay.core.model.autopay.Bill + +private const val BILLS_KEY = "bills" + +class BillDataSource( + private val settings: Settings, + private val dispatcher: CoroutineDispatcher, +) { + private val _bills = MutableStateFlow( + settings.decodeValue( + key = BILLS_KEY, + serializer = ListSerializer(Bill.serializer()), + defaultValue = emptyList(), + ), + ) + + val bills: Flow> = _bills + + suspend fun updateBills(bills: List) { + withContext(dispatcher) { + settings.putBills(bills) + _bills.value = bills + } + } + + suspend fun addBill(bill: Bill) { + withContext(dispatcher) { + val currentBills = _bills.value.toMutableList() + if (!currentBills.any { it.id == bill.id }) { + currentBills.add(bill) + settings.putBills(currentBills) + _bills.value = currentBills + } + } + } + + suspend fun removeBill(billId: String) { + withContext(dispatcher) { + val currentBills = _bills.value.toMutableList() + currentBills.removeAll { it.id == billId } + settings.putBills(currentBills) + _bills.value = currentBills + } + } + + suspend fun clearBills() { + withContext(dispatcher) { + settings.remove(BILLS_KEY) + _bills.value = emptyList() + } + } +} + +private fun Settings.putBills(bills: List) { + encodeValue( + key = BILLS_KEY, + serializer = ListSerializer(Bill.serializer()), + value = bills, + ) +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillRepository.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillRepository.kt new file mode 100644 index 000000000..94a3a31c9 --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillRepository.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.datastore + +import kotlinx.coroutines.flow.Flow +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.Bill + +interface BillRepository { + /** + * Get all saved bills + */ + fun getAllBills(): Flow> + + /** + * Get bill by ID + */ + suspend fun getBillById(id: String): Bill? + + /** + * Save a new bill + */ + suspend fun saveBill(bill: Bill): DataState + + /** + * Update an existing bill + */ + suspend fun updateBill(bill: Bill): DataState + + /** + * Delete a bill + */ + suspend fun deleteBill(id: String): DataState + + /** + * Search bills by name + */ + suspend fun searchBillsByName(query: String): List + + /** + * Clear all bills + */ + suspend fun clearAllBills(): DataState +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillRepositoryImpl.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillRepositoryImpl.kt new file mode 100644 index 000000000..d45d3b111 --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillRepositoryImpl.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.datastore + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.Bill + +/** + * TODO: This implementation currently uses local storage (Multiplatform Settings) for bill data. + * When the backend APIs for bill management are clarified and implemented, this should be + * refactored to use network-based repository pattern similar to other repositories in the codebase + * (e.g., UserRepositoryImpl, BeneficiaryRepositoryImpl) with proper API integration. + */ +class BillRepositoryImpl( + private val billDataSource: BillDataSource, +) : BillRepository { + + override fun getAllBills(): Flow> { + return billDataSource.bills + } + + override suspend fun getBillById(id: String): Bill? { + return try { + val bills = billDataSource.bills.first() + bills.find { it.id == id } + } catch (e: Exception) { + null + } + } + + override suspend fun saveBill(bill: Bill): DataState { + return try { + val existingBills = billDataSource.bills.first() + + // Check if bill already exists + val existingBill = existingBills.find { + it.name == bill.name && it.billerId == bill.billerId + } + + if (existingBill != null) { + DataState.Error(Exception("Bill with this name and biller already exists")) + } else { + billDataSource.addBill(bill) + DataState.Success(bill) + } + } catch (e: Exception) { + DataState.Error(Exception("Failed to save bill: ${e.message}")) + } + } + + override suspend fun updateBill(bill: Bill): DataState { + return try { + val existingBills = billDataSource.bills.first().toMutableList() + val index = existingBills.indexOfFirst { it.id == bill.id } + + if (index != -1) { + existingBills[index] = bill + billDataSource.updateBills(existingBills) + DataState.Success(bill) + } else { + DataState.Error(Exception("Bill not found")) + } + } catch (e: Exception) { + DataState.Error(Exception("Failed to update bill: ${e.message}")) + } + } + + override suspend fun deleteBill(id: String): DataState { + return try { + billDataSource.removeBill(id) + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(Exception("Failed to delete bill: ${e.message}")) + } + } + + override suspend fun searchBillsByName(query: String): List { + return try { + val bills = billDataSource.bills.first() + bills.filter { it.name.contains(query, ignoreCase = true) } + } catch (e: Exception) { + emptyList() + } + } + + override suspend fun clearAllBills(): DataState { + return try { + billDataSource.clearBills() + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(Exception("Failed to clear bills: ${e.message}")) + } + } +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillerDataSource.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillerDataSource.kt new file mode 100644 index 000000000..043f49ebf --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillerDataSource.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +@file:OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class) + +package org.mifospay.core.datastore + +import com.russhwolf.settings.ExperimentalSettingsApi +import com.russhwolf.settings.Settings +import com.russhwolf.settings.serialization.decodeValue +import com.russhwolf.settings.serialization.encodeValue +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.builtins.ListSerializer +import org.mifospay.core.model.autopay.Biller + +private const val BILLERS_KEY = "billers" + +class BillerDataSource( + private val settings: Settings, + private val dispatcher: CoroutineDispatcher, +) { + private val _billers = MutableStateFlow( + settings.decodeValue( + key = BILLERS_KEY, + serializer = ListSerializer(Biller.serializer()), + defaultValue = emptyList(), + ), + ) + + val billers: Flow> = _billers + + suspend fun updateBillers(billers: List) { + withContext(dispatcher) { + settings.putBillers(billers) + _billers.value = billers + } + } + + suspend fun addBiller(biller: Biller) { + withContext(dispatcher) { + val currentBillers = _billers.value.toMutableList() + if (!currentBillers.any { it.id == biller.id }) { + currentBillers.add(biller) + settings.putBillers(currentBillers) + _billers.value = currentBillers + } + } + } + + suspend fun removeBiller(billerId: String) { + withContext(dispatcher) { + val currentBillers = _billers.value.toMutableList() + currentBillers.removeAll { it.id == billerId } + settings.putBillers(currentBillers) + _billers.value = currentBillers + } + } + + suspend fun clearBillers() { + withContext(dispatcher) { + settings.remove(BILLERS_KEY) + _billers.value = emptyList() + } + } +} + +private fun Settings.putBillers(billers: List) { + encodeValue( + key = BILLERS_KEY, + serializer = ListSerializer(Biller.serializer()), + value = billers, + ) +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillerRepository.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillerRepository.kt new file mode 100644 index 000000000..31ee24aad --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillerRepository.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.datastore + +import kotlinx.coroutines.flow.Flow +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.Biller +import org.mifospay.core.model.autopay.BillerCategory + +interface BillerRepository { + /** + * Get all saved billers + */ + fun getAllBillers(): Flow> + + /** + * Get biller by ID + */ + suspend fun getBillerById(id: String): Biller? + + /** + * Save a new biller + */ + suspend fun saveBiller(biller: Biller): DataState + + /** + * Update an existing biller + */ + suspend fun updateBiller(biller: Biller): DataState + + /** + * Delete a biller + */ + suspend fun deleteBiller(id: String): DataState + + /** + * Get billers by category + */ + suspend fun getBillersByCategory(category: BillerCategory): List + + /** + * Search billers by name + */ + suspend fun searchBillersByName(query: String): List + + /** + * Check if biller exists by name and account number + */ + suspend fun isBillerExists(name: String, accountNumber: String): Boolean + + /** + * Clear all billers + */ + suspend fun clearAllBillers(): DataState +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillerRepositoryImpl.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillerRepositoryImpl.kt new file mode 100644 index 000000000..82292a482 --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/BillerRepositoryImpl.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.datastore + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import org.mifospay.core.common.DataState +import org.mifospay.core.model.autopay.Biller +import org.mifospay.core.model.autopay.BillerCategory + +/** + * TODO: This implementation currently uses local storage (Multiplatform Settings) for biller data. + * When the backend APIs for biller management are clarified and implemented, this should be + * refactored to use network-based repository pattern similar to other repositories in the codebase + * (e.g., UserRepositoryImpl, BeneficiaryRepositoryImpl) with proper API integration. + */ +class BillerRepositoryImpl( + private val billerDataSource: BillerDataSource, +) : BillerRepository { + + override fun getAllBillers(): Flow> { + return billerDataSource.billers + } + + override suspend fun getBillerById(id: String): Biller? { + return try { + val billers = billerDataSource.billers.first() + billers.find { it.id == id } + } catch (e: Exception) { + null + } + } + + override suspend fun saveBiller(biller: Biller): DataState { + return try { + val existingBillers = billerDataSource.billers.first() + + // Check if biller already exists + val existingBiller = existingBillers.find { + it.name == biller.name && it.accountNumber == biller.accountNumber + } + + if (existingBiller != null) { + DataState.Error(Exception("Biller with this name and account number already exists")) + } else { + billerDataSource.addBiller(biller) + DataState.Success(biller) + } + } catch (e: Exception) { + DataState.Error(Exception("Failed to save biller: ${e.message}")) + } + } + + override suspend fun updateBiller(biller: Biller): DataState { + return try { + val existingBillers = billerDataSource.billers.first().toMutableList() + val index = existingBillers.indexOfFirst { it.id == biller.id } + + if (index != -1) { + existingBillers[index] = biller + billerDataSource.updateBillers(existingBillers) + DataState.Success(biller) + } else { + DataState.Error(Exception("Biller not found")) + } + } catch (e: Exception) { + DataState.Error(Exception("Failed to update biller: ${e.message}")) + } + } + + override suspend fun deleteBiller(id: String): DataState { + return try { + billerDataSource.removeBiller(id) + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(Exception("Failed to delete biller: ${e.message}")) + } + } + + override suspend fun getBillersByCategory(category: BillerCategory): List { + return try { + val billers = billerDataSource.billers.first() + billers.filter { it.category == category } + } catch (e: Exception) { + emptyList() + } + } + + override suspend fun searchBillersByName(query: String): List { + return try { + val billers = billerDataSource.billers.first() + billers.filter { it.name.contains(query, ignoreCase = true) } + } catch (e: Exception) { + emptyList() + } + } + + override suspend fun isBillerExists(name: String, accountNumber: String): Boolean { + return try { + val billers = billerDataSource.billers.first() + billers.any { it.name == name && it.accountNumber == accountNumber } + } catch (e: Exception) { + false + } + } + + override suspend fun clearAllBillers(): DataState { + return try { + billerDataSource.clearBillers() + DataState.Success(Unit) + } catch (e: Exception) { + DataState.Error(Exception("Failed to clear billers: ${e.message}")) + } + } +} diff --git a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/di/PreferenceModule.kt b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/di/PreferenceModule.kt index e74bee4cf..220bde71e 100644 --- a/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/di/PreferenceModule.kt +++ b/core/datastore/src/commonMain/kotlin/org/mifospay/core/datastore/di/PreferenceModule.kt @@ -13,6 +13,15 @@ import com.russhwolf.settings.Settings import org.koin.core.qualifier.named import org.koin.dsl.module import org.mifospay.core.common.MifosDispatchers +import org.mifospay.core.datastore.AutoPayPreferencesDataSource +import org.mifospay.core.datastore.AutoPayPreferencesRepository +import org.mifospay.core.datastore.AutoPayPreferencesRepositoryImpl +import org.mifospay.core.datastore.BillDataSource +import org.mifospay.core.datastore.BillRepository +import org.mifospay.core.datastore.BillRepositoryImpl +import org.mifospay.core.datastore.BillerDataSource +import org.mifospay.core.datastore.BillerRepository +import org.mifospay.core.datastore.BillerRepositoryImpl import org.mifospay.core.datastore.UserPreferencesDataSource import org.mifospay.core.datastore.UserPreferencesRepository import org.mifospay.core.datastore.UserPreferencesRepositoryImpl @@ -21,6 +30,9 @@ val PreferencesModule = module { factory { Settings() } // Use the IO dispatcher name - MifosDispatchers.IO.name factory { UserPreferencesDataSource(get(), get(named(MifosDispatchers.IO.name))) } + factory { AutoPayPreferencesDataSource(get(), get(named(MifosDispatchers.IO.name))) } + factory { BillerDataSource(get(), get(named(MifosDispatchers.IO.name))) } + factory { BillDataSource(get(), get(named(MifosDispatchers.IO.name))) } single { UserPreferencesRepositoryImpl( @@ -29,4 +41,24 @@ val PreferencesModule = module { unconfinedDispatcher = get(named(MifosDispatchers.Unconfined.name)), ) } + + single { + AutoPayPreferencesRepositoryImpl( + autoPayPreferencesDataSource = get(), + ioDispatcher = get(named(MifosDispatchers.IO.name)), + unconfinedDispatcher = get(named(MifosDispatchers.Unconfined.name)), + ) + } + + single { + BillerRepositoryImpl( + billerDataSource = get(), + ) + } + + single { + BillRepositoryImpl( + billDataSource = get(), + ) + } } diff --git a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt index 5bca46905..1cc74a07b 100644 --- a/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt +++ b/core/designsystem/src/commonMain/kotlin/org/mifospay/core/designsystem/icon/MifosIcons.kt @@ -11,6 +11,9 @@ package org.mifospay.core.designsystem.icon import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.automirrored.filled.Rule import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.filled.ArrowOutward import androidx.compose.material.icons.filled.AttachMoney @@ -18,32 +21,47 @@ import androidx.compose.material.icons.filled.Badge import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Camera import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.CurrencyRupee import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.FlashOff import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Photo import androidx.compose.material.icons.filled.PhotoLibrary +import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Receipt +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.Cancel import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Email import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Lock @@ -124,9 +142,33 @@ object MifosIcons { val CalenderMonth = Icons.Filled.CalendarMonth val OutlinedDoneAll = Icons.Outlined.DoneAll val Person = Icons.Filled.Person + val PersonAdd = Icons.Filled.PersonAdd val Badge = Icons.Filled.Badge val DataInfo = Icons.Filled.Description val Scan = Icons.Outlined.QrCodeScanner val RadioButtonUnchecked = Icons.Default.RadioButtonUnchecked val RadioButtonChecked = Icons.Filled.RadioButtonChecked + + val ArrowForward = Icons.AutoMirrored.Filled.ArrowForward + + val CurrencyRupee = Icons.Filled.CurrencyRupee + val History = Icons.Filled.History + val CheckCircle = Icons.Filled.CheckCircle + val Error = Icons.Filled.Error + + // AutoPay specific icons + val Warning = Icons.Filled.Warning + val Schedule = Icons.Filled.Schedule + val Security = Icons.Filled.Security + val Power = Icons.Filled.Power + val CreditCard = Icons.Filled.CreditCard + val Rule = Icons.AutoMirrored.Filled.Rule + val Receipt = Icons.Filled.Receipt + val List = Icons.AutoMirrored.Filled.List + + val Email = Icons.Outlined.Email + + val Repeat = Icons.Filled.Repeat + + val Refresh = Icons.Filled.Refresh } diff --git a/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPay.kt b/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPay.kt new file mode 100644 index 000000000..d221e4de4 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPay.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.model.autopay + +import kotlinx.serialization.Serializable + +// TODO: Align data models with final API response schema once confirmed by backend + +@Serializable +data class AutoPay( + val id: Long? = null, + val name: String? = null, + val description: String? = null, + val amount: Double? = null, + val currency: String? = null, + val frequency: String? = null, + val frequencyInterval: Int? = null, + val nextPaymentDate: String? = null, + val status: AutoPayStatus? = null, + val recipientName: String? = null, + val recipientAccountNumber: String? = null, + val recipientBankCode: String? = null, + val sourceAccountId: Long? = null, + val sourceAccountNumber: String? = null, + val sourceAccountType: String? = null, + val clientId: Long? = null, + val createdDate: String? = null, + val lastModifiedDate: String? = null, + val validFrom: String? = null, + val validTill: String? = null, + val maxAmount: Double? = null, + val minAmount: Double? = null, + val paymentMethod: String? = null, + val isActive: Boolean? = null, +) + +@Serializable +data class AutoPayTemplate( + val id: Long? = null, + val name: String? = null, + val description: String? = null, + val frequencyOptions: List? = emptyList(), + val paymentMethods: List? = emptyList(), + val currencyOptions: List? = emptyList(), + val accountTypes: List? = emptyList(), + val maxAmount: Double? = null, + val minAmount: Double? = null, +) + +@Serializable +data class FrequencyOption( + val id: Long, + val code: String, + val value: String, + val description: String? = null, +) + +@Serializable +data class PaymentMethod( + val id: Long, + val code: String, + val value: String, + val description: String? = null, +) + +@Serializable +data class CurrencyOption( + val code: String, + val name: String, + val symbol: String? = null, +) + +@Serializable +data class AccountType( + val id: Long, + val code: String, + val value: String, + val description: String? = null, +) + +@Serializable +enum class AutoPayStatus { + ACTIVE, + PAUSED, + CANCELLED, + COMPLETED, + FAILED, + PENDING, +} + +@Serializable +enum class PaymentStatus { + UPCOMING, + PROCESSING, + PENDING, + COMPLETED, + FAILED, + CANCELLED, +} + +@Serializable +data class AutoPayGlobalSettings( + val isAutoPayEnabled: Boolean = false, + val defaultPaymentMethod: String? = null, + val defaultSourceAccount: String? = null, + val maxPaymentAmount: Double? = null, + val notificationSettings: NotificationSettings = NotificationSettings(), + val securitySettings: SecuritySettings = SecuritySettings(), + val globalAutoPayRules: AutoPayRules = AutoPayRules(), +) + +@Serializable +data class NotificationSettings( + val paymentConfirmations: Boolean = true, + val failedPaymentAlerts: Boolean = true, + val scheduleReminders: Boolean = true, + val reminderDaysBefore: Int = 3, + val emailNotifications: Boolean = true, + val pushNotifications: Boolean = true, + val smsNotifications: Boolean = false, +) + +@Serializable +data class SecuritySettings( + val requireTwoFactorAuth: Boolean = false, + val maxDailyAmount: Double? = null, + val requireConfirmationForLargePayments: Boolean = true, + val largePaymentThreshold: Double = 1000.0, + val allowMultiplePaymentsPerDay: Boolean = true, + val maxPaymentsPerDay: Int = 10, +) + +@Serializable +data class AutoPayRules( + val autoApprovePayments: Boolean = false, + val requireManualApprovalAbove: Double? = null, + val skipPaymentsOnHolidays: Boolean = true, + val retryFailedPayments: Boolean = true, + val maxRetryAttempts: Int = 3, + val retryIntervalHours: Int = 24, +) diff --git a/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPayPayload.kt b/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPayPayload.kt new file mode 100644 index 000000000..402283f2e --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/AutoPayPayload.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.model.autopay + +import kotlinx.serialization.Serializable +import org.mifospay.core.common.Parcelable +import org.mifospay.core.common.Parcelize + +// TODO: Align data models with final API response schema once confirmed by backend +@Serializable +@Parcelize +data class AutoPayPayload( + val name: String = "", + val description: String = "", + val amount: String = "", + val currency: String = "", + val frequency: String = "", + val frequencyInterval: String = "", + val recipientName: String = "", + val recipientAccountNumber: String = "", + val recipientBankCode: String = "", + val sourceAccountId: Long = 0, + val sourceAccountNumber: String = "", + val sourceAccountType: String = "", + val clientId: Long = 0, + val validFrom: String = "", + val validTill: String = "", + val maxAmount: String = "", + val minAmount: String = "", + val paymentMethod: String = "", + val locale: String = "en", + val dateFormat: String = "dd MMMM yyyy", +) : Parcelable + +@Serializable +@Parcelize +data class AutoPayUpdatePayload( + val name: String? = null, + val description: String? = null, + val amount: String? = null, + val currency: String? = null, + val frequency: String? = null, + val frequencyInterval: String? = null, + val recipientName: String? = null, + val recipientAccountNumber: String? = null, + val recipientBankCode: String? = null, + val validFrom: String? = null, + val validTill: String? = null, + val maxAmount: String? = null, + val minAmount: String? = null, + val paymentMethod: String? = null, + val status: String? = null, + val locale: String = "en", + val dateFormat: String = "dd MMMM yyyy", +) : Parcelable + +@Serializable +@Parcelize +data class AutoPayHistory( + val id: Long? = null, + val autoPayId: Long? = null, + val amount: Double? = null, + val currency: String? = null, + val status: PaymentStatus? = null, + val transactionDate: String? = null, + val recipientName: String? = null, + val recipientAccountNumber: String? = null, + val sourceAccountNumber: String? = null, + val referenceNumber: String? = null, + val failureReason: String? = null, + val createdDate: String? = null, +) : Parcelable + +@Serializable +@Parcelize +data class UpcomingPayment( + val id: String? = null, + val autoPayId: Long? = null, + val scheduleName: String? = null, + val amount: Double? = null, + val currency: String? = null, + val dueDate: String? = null, + val status: PaymentStatus? = null, + val recipientName: String? = null, + val recipientAccountNumber: String? = null, + val sourceAccountNumber: String? = null, +) : Parcelable diff --git a/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/Bill.kt b/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/Bill.kt new file mode 100644 index 000000000..363428f7e --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/Bill.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.model.autopay + +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable +import org.mifospay.core.common.Parcelable +import org.mifospay.core.common.Parcelize + +@Serializable +@Parcelize +data class Bill( + val id: String? = null, + val name: String, + val amount: Double, + val currency: String = "USD", + val dueDate: Long, + val recurrencePattern: RecurrencePattern, + val billerId: String? = null, + val billerName: String? = null, + val description: String? = null, + val isActive: Boolean = true, + val status: BillStatus = BillStatus.ACTIVE, + // AutoPay configuration + val autoPayEnabled: Boolean = false, + val autoPayPaymentMethod: String? = null, + val autoPaySourceAccount: String? = null, + val autoPayMaxAmount: Double? = null, + val createdAt: Long = Clock.System.now().toEpochMilliseconds(), + val updatedAt: Long = Clock.System.now().toEpochMilliseconds(), +) : Parcelable + +@Serializable +enum class RecurrencePattern(val displayName: String, val interval: Int) { + NONE("No Recurrence", 0), + DAILY("Daily", 1), + WEEKLY("Weekly", 7), + BIWEEKLY("Bi-weekly", 14), + MONTHLY("Monthly", 30), + QUARTERLY("Quarterly", 90), + SEMI_ANNUALLY("Semi-annually", 180), + ANNUALLY("Annually", 365), + ; + + companion object { + fun fromDisplayName(displayName: String): RecurrencePattern? { + return entries.find { it.displayName == displayName } + } + } +} + +@Serializable +enum class BillStatus { + ACTIVE, + PAUSED, + CANCELLED, + COMPLETED, +} + +@Serializable +data class BillFormData( + val name: String = "", + val amount: String = "", + val currency: String = "USD", + val dueDate: Long = 0L, + val recurrencePattern: RecurrencePattern = RecurrencePattern.NONE, + val billerId: String? = null, + val billerName: String? = null, + val description: String = "", + // AutoPay configuration + val enableAutoPay: Boolean = false, + val autoPayPaymentMethod: String = "", + val autoPaySourceAccount: String = "", + val autoPayMaxAmount: String = "", +) + +@Serializable +data class BillValidationResult( + val isValid: Boolean, + val nameError: String? = null, + val amountError: String? = null, + val dueDateError: String? = null, + val recurrencePatternError: String? = null, + val billerError: String? = null, + // AutoPay validation + val autoPayPaymentMethodError: String? = null, + val autoPaySourceAccountError: String? = null, + val autoPayMaxAmountError: String? = null, +) + +@Serializable +data class NextPaymentDate( + val date: Long, + val formattedDate: String, + val isOverdue: Boolean = false, +) diff --git a/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/Biller.kt b/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/Biller.kt new file mode 100644 index 000000000..f0de1ce05 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/mifospay/core/model/autopay/Biller.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.model.autopay + +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable +import org.mifospay.core.common.Parcelable +import org.mifospay.core.common.Parcelize + +@Serializable +@Parcelize +data class Biller( + val id: String? = null, + val name: String, + val accountNumber: String, + val contactNumber: String, + val email: String? = null, + val category: BillerCategory, + val address: String? = null, + val isActive: Boolean = true, + val createdAt: Long = Clock.System.now().toEpochMilliseconds(), + val updatedAt: Long = Clock.System.now().toEpochMilliseconds(), +) : Parcelable + +@Serializable +enum class BillerCategory(val displayName: String) { + UTILITIES("Utilities"), + INSURANCE("Insurance"), + TELECOM("Telecommunications"), + INTERNET("Internet & Cable"), + LOAN("Loan Payments"), + CREDIT_CARD("Credit Card"), + RENT("Rent"), + SUBSCRIPTION("Subscriptions"), + OTHER("Other"), + ; + + companion object { + fun fromDisplayName(displayName: String): BillerCategory? { + return entries.find { it.displayName == displayName } + } + } +} + +@Serializable +data class BillerFormData( + val name: String = "", + val accountNumber: String = "", + val contactNumber: String = "", + val email: String = "", + val category: BillerCategory? = null, + val address: String = "", +) + +@Serializable +data class BillerValidationResult( + val isValid: Boolean, + val nameError: String? = null, + val accountNumberError: String? = null, + val contactNumberError: String? = null, + val emailError: String? = null, + val categoryError: String? = null, +) diff --git a/core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt b/core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt new file mode 100644 index 000000000..861d4c6bb --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/mifospay/core/model/utils/StandardUpiQrData.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.model.utils + +import kotlinx.serialization.Serializable + +/** + * Data class representing standard UPI QR code data + * Based on UPI QR code specification + */ +@Serializable +data class StandardUpiQrData( + val payeeName: String, + val payeeVpa: String, + val amount: String = "", + val currency: String = "INR", + val transactionNote: String = "", + val merchantCode: String = "", + val transactionReference: String = "", + val url: String = "", + // 02 for QR code + val mode: String = "02", +) { + companion object { + const val DEFAULT_CURRENCY = "INR" + } +} diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/FineractApiManager.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/FineractApiManager.kt index 388b1df68..31d927a34 100644 --- a/core/network/src/commonMain/kotlin/org/mifospay/core/network/FineractApiManager.kt +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/FineractApiManager.kt @@ -44,4 +44,10 @@ class FineractApiManager( val savingsAccountsApi by lazy { ktorfitClient.savingsAccountsApi } val standingInstructionApi by lazy { ktorfitClient.standingInstructionApi } + + val autoPayApi by lazy { ktorfitClient.autoPayApi } + + val billerApi by lazy { ktorfitClient.billerApi } + + val billApi by lazy { ktorfitClient.billApi } } diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/KtorfitClient.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/KtorfitClient.kt index 66f05fc41..d5eb67126 100644 --- a/core/network/src/commonMain/kotlin/org/mifospay/core/network/KtorfitClient.kt +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/KtorfitClient.kt @@ -12,7 +12,10 @@ package org.mifospay.core.network import de.jensklingenberg.ktorfit.Ktorfit import org.mifospay.core.network.services.createAccountTransfersService import org.mifospay.core.network.services.createAuthenticationService +import org.mifospay.core.network.services.createAutoPayService import org.mifospay.core.network.services.createBeneficiaryService +import org.mifospay.core.network.services.createBillService +import org.mifospay.core.network.services.createBillerService import org.mifospay.core.network.services.createClientService import org.mifospay.core.network.services.createDocumentService import org.mifospay.core.network.services.createInvoiceService @@ -63,5 +66,11 @@ class KtorfitClient( internal val standingInstructionApi by lazy { ktorfit.createStandingInstructionService() } + internal val autoPayApi by lazy { ktorfit.createAutoPayService() } + internal val beneficiaryApi by lazy { ktorfit.createBeneficiaryService() } + + internal val billerApi by lazy { ktorfit.createBillerService() } + + internal val billApi by lazy { ktorfit.createBillService() } } diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/AutoPayService.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/AutoPayService.kt new file mode 100644 index 000000000..33fb3edc3 --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/AutoPayService.kt @@ -0,0 +1,139 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.network.services + +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.PUT +import de.jensklingenberg.ktorfit.http.Path +import de.jensklingenberg.ktorfit.http.Query +import kotlinx.coroutines.flow.Flow +import org.mifospay.core.model.autopay.AutoPay +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.AutoPayPayload +import org.mifospay.core.model.autopay.AutoPayTemplate +import org.mifospay.core.model.autopay.AutoPayUpdatePayload +import org.mifospay.core.model.autopay.UpcomingPayment +import org.mifospay.core.network.model.entity.Page +import org.mifospay.core.network.utils.ApiEndPoints + +/** +* TODO: Sync with backend team and update service layer according to finalized API contract, +* also use Flow only in get operations where List is returned, do not use Flow for one-shot operations +*/ + +interface AutoPayService { + + /** + * Get AutoPay template for creating new AutoPay schedules + */ + @GET("${ApiEndPoints.AUTO_PAY}/template") + fun getAutoPayTemplate( + @Query("clientId") clientId: Long, + @Query("sourceAccountId") sourceAccountId: Long, + ): Flow + + /** + * Get all AutoPay schedules for a client + */ + @GET(ApiEndPoints.AUTO_PAY) + fun getAllAutoPaySchedules( + @Query("clientId") clientId: Long, + ): Flow> + + /** + * Get AutoPay schedule by ID + */ + @GET("${ApiEndPoints.AUTO_PAY}/{autoPayId}") + fun getAutoPaySchedule( + @Path("autoPayId") autoPayId: Long, + ): Flow + + /** + * Create a new AutoPay schedule + */ + @POST(ApiEndPoints.AUTO_PAY) + suspend fun createAutoPaySchedule( + @Body payload: AutoPayPayload, + ) + + /** + * Update an existing AutoPay schedule + */ + @PUT("${ApiEndPoints.AUTO_PAY}/{autoPayId}") + suspend fun updateAutoPaySchedule( + @Path("autoPayId") autoPayId: Long, + @Body payload: AutoPayUpdatePayload, + @Query("command") command: String = "update", + ) + + /** + * Delete an AutoPay schedule + */ + @PUT("${ApiEndPoints.AUTO_PAY}/{autoPayId}") + suspend fun deleteAutoPaySchedule( + @Path("autoPayId") autoPayId: Long, + @Query("command") command: String = "delete", + ) + + /** + * Pause an AutoPay schedule + */ + @PUT("${ApiEndPoints.AUTO_PAY}/{autoPayId}") + suspend fun pauseAutoPaySchedule( + @Path("autoPayId") autoPayId: Long, + @Query("command") command: String = "pause", + ) + + /** + * Resume a paused AutoPay schedule + */ + @PUT("${ApiEndPoints.AUTO_PAY}/{autoPayId}") + suspend fun resumeAutoPaySchedule( + @Path("autoPayId") autoPayId: Long, + @Query("command") command: String = "resume", + ) + + /** + * Get AutoPay payment history + */ + @GET("${ApiEndPoints.AUTO_PAY}/{autoPayId}/history") + fun getAutoPayHistory( + @Path("autoPayId") autoPayId: Long, + @Query("limit") limit: Int = 20, + ): Flow> + + /** + * Get upcoming payments for all AutoPay schedules + */ + @GET("${ApiEndPoints.AUTO_PAY}/upcoming-payments") + fun getUpcomingPayments( + @Query("clientId") clientId: Long, + @Query("limit") limit: Int = 10, + ): Flow> + + /** + * Get AutoPay statistics for dashboard + */ + @GET("${ApiEndPoints.AUTO_PAY}/statistics") + fun getAutoPayStatistics( + @Query("clientId") clientId: Long, + ): Flow +} + +data class AutoPayStatisticsResponse( + val totalActiveSchedules: Int = 0, + val totalPausedSchedules: Int = 0, + val totalCompletedSchedules: Int = 0, + val totalUpcomingPayments: Int = 0, + val totalAmountThisMonth: Double = 0.0, + val currency: String = "USD", +) diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/BillService.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/BillService.kt new file mode 100644 index 000000000..fe23d4c4e --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/BillService.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.network.services + +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.DELETE +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.PUT +import de.jensklingenberg.ktorfit.http.Path +import de.jensklingenberg.ktorfit.http.Query +import kotlinx.coroutines.flow.Flow +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.model.autopay.BillStatus +import org.mifospay.core.model.autopay.RecurrencePattern + +/** + * TODO: Update endpoint paths when backend APIs are finalized, also use + * Flow only in get operations where List is returned, do not use Flow for one-shot operations + */ +interface BillService { + @GET("bills") + fun getAllBills(@Query("clientId") clientId: Long): Flow> + + @GET("bills/{billId}") + suspend fun getBillById(@Path("billId") billId: String): Flow + + @POST("bills") + suspend fun createBill(@Body bill: Bill): Flow + + @PUT("bills/{billId}") + suspend fun updateBill( + @Path("billId") billId: String, + @Body bill: Bill, + ): Flow + + @DELETE("bills/{billId}") + suspend fun deleteBill(@Path("billId") billId: String): Flow + + @GET("bills/status/{status}") + suspend fun getBillsByStatus(@Path("status") status: BillStatus): Flow> + + @GET("bills/biller/{billerId}") + suspend fun getBillsByBillerId(@Path("billerId") billerId: String): Flow> + + @GET("bills/recurrence/{pattern}") + suspend fun getBillsByRecurrencePattern(@Path("pattern") pattern: RecurrencePattern): Flow> + + @GET("bills/search") + suspend fun searchBillsByName(@Query("query") query: String): Flow> + + @GET("bills/overdue") + suspend fun getOverdueBills(): Flow> + + @GET("bills/upcoming") + suspend fun getUpcomingBills( + @Query("fromDate") fromDate: Long, + @Query("toDate") toDate: Long, + ): Flow> + + @PUT("bills/{billId}/status") + suspend fun updateBillStatus( + @Path("billId") billId: String, + @Query("status") status: BillStatus, + ): Flow + + @GET("bills/statistics") + fun getBillStatistics(@Query("clientId") clientId: Long): Flow + + @DELETE("bills") + suspend fun clearAllBills(): Flow +} + +data class BillStatisticsResponse( + val totalBills: Int = 0, + val activeBills: Int = 0, + val overdueBills: Int = 0, + val totalAmount: Double = 0.0, + val currency: String = "USD", + val upcomingPayments: Int = 0, + val totalAmountThisMonth: Double = 0.0, +) diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/BillerService.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/BillerService.kt new file mode 100644 index 000000000..16f58b0fb --- /dev/null +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/services/BillerService.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.core.network.services + +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.DELETE +import de.jensklingenberg.ktorfit.http.GET +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.PUT +import de.jensklingenberg.ktorfit.http.Path +import de.jensklingenberg.ktorfit.http.Query +import kotlinx.coroutines.flow.Flow +import org.mifospay.core.model.autopay.Biller +import org.mifospay.core.model.autopay.BillerCategory + +/** + * TODO: Update endpoint paths when backend APIs are finalized, also use + * Flow only in get operations where List is returned, do not use Flow for one-shot operations + */ +interface BillerService { + @GET("billers") + fun getAllBillers(): Flow> + + @GET("billers/{billerId}") + suspend fun getBillerById(@Path("billerId") billerId: String): Flow + + @POST("billers") + suspend fun createBiller(@Body biller: Biller): Flow + + @PUT("billers/{billerId}") + suspend fun updateBiller( + @Path("billerId") billerId: String, + @Body biller: Biller, + ): Flow + + @DELETE("billers/{billerId}") + suspend fun deleteBiller(@Path("billerId") billerId: String): Flow + + @GET("billers/category/{category}") + suspend fun getBillersByCategory( + @Path("category") category: BillerCategory, + ): Flow> + + @GET("billers/search") + suspend fun searchBillersByName( + @Query("query") query: String, + ): Flow> + + @GET("billers/exists") + suspend fun isBillerExists( + @Query("name") name: String, + @Query("accountNumber") accountNumber: String, + ): Flow + + @DELETE("billers") + suspend fun clearAllBillers(): Flow +} diff --git a/core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/ApiEndPoints.kt b/core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/ApiEndPoints.kt index 19d8424b2..346175ba2 100644 --- a/core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/ApiEndPoints.kt +++ b/core/network/src/commonMain/kotlin/org/mifospay/core/network/utils/ApiEndPoints.kt @@ -27,4 +27,10 @@ object ApiEndPoints { const val RUN_REPORT = "runreports" const val USER = "users" const val STANDING_INSTRUCTION = "standinginstructions" + + // TODO: Verify with backend team and update according to finalized API contract + const val AUTO_PAY = "autopay" + + // TODO: Update endpoint path when backend APIs are finalized + const val BILLERS = "billers" } diff --git a/feature/autopay/README.md b/feature/autopay/README.md new file mode 100644 index 000000000..6fb08aaca --- /dev/null +++ b/feature/autopay/README.md @@ -0,0 +1,16 @@ +# AutoPay Feature + +## Overview +The AutoPay feature module provides functionality for setting up and managing automatic payment schedules. This module allows users to configure recurring payments, set up payment rules, and manage their automatic payment preferences. + +## Screenshots +### Android +*Screenshots will be added as the feature is developed* + +### Desktop +*Screenshots will be added as the feature is developed* + +### Web +*Screenshots will be added as the feature is developed* + + diff --git a/feature/autopay/build.gradle.kts b/feature/autopay/build.gradle.kts new file mode 100644 index 000000000..5780cd6c5 --- /dev/null +++ b/feature/autopay/build.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +plugins { + alias(libs.plugins.cmp.feature.convention) +} + +android { + namespace = "org.mifospay.feature.autopay" +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(compose.ui) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + + implementation(projects.core.common) + implementation(projects.core.ui) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AddBillScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AddBillScreen.kt new file mode 100644 index 000000000..3b573cf93 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AddBillScreen.kt @@ -0,0 +1,475 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.datetime.Clock +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.BasicDialogState +import org.mifospay.core.designsystem.component.LoadingDialogState +import org.mifospay.core.designsystem.component.MifosBasicDialog +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosLoadingDialog +import org.mifospay.core.designsystem.component.MifosOutlinedButton +import org.mifospay.core.designsystem.component.MifosOutlinedTextField +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTextField +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.designsystem.utils.onClick +import org.mifospay.core.model.autopay.RecurrencePattern +import org.mifospay.core.ui.DropdownBox +import org.mifospay.core.ui.DropdownBoxItem +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddBillScreen( + onNavigateBack: () -> Unit, + onNavigateToBillList: () -> Unit, + onNavigateToAddBiller: () -> Unit, + viewModel: AddBillViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + var showRecurrenceDropdown by remember { mutableStateOf(false) } + var showBillerDropdown by remember { mutableStateOf(false) } + var showDatePicker by remember { mutableStateOf(false) } + + EventsEffect(viewModel) { event -> + when (event) { + is AddBillEvent.BillSaved -> { + onNavigateToBillList() + } + } + } + + LaunchedEffect(Unit) { + viewModel.trySendAction(AddBillAction.RefreshBillers) + } + + MifosScaffold( + topBar = { + MifosTopBar( + topBarTitle = "Add New Bill", + backPress = onNavigateBack, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Enter bill details to set up automatic payments", + modifier = Modifier.padding(bottom = 8.dp), + ) + + MifosOutlinedTextField( + label = "Bill Name *", + value = state.formData.name, + onValueChange = { viewModel.trySendAction(AddBillAction.UpdateBillName(it)) }, + isError = state.validationResult.nameError != null, + errorMessage = state.validationResult.nameError, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + ), + ) + + DropdownBox( + expanded = showBillerDropdown, + label = "Select Biller *", + value = state.formData.billerName ?: "Select a biller", + readOnly = true, + isError = state.validationResult.billerError != null, + errorText = state.validationResult.billerError, + onExpandChange = { showBillerDropdown = it }, + ) { + state.availableBillers.forEach { biller -> + DropdownBoxItem( + text = biller.name, + onClick = { + viewModel.trySendAction(AddBillAction.SelectBiller(biller)) + showBillerDropdown = false + }, + ) + } + DropdownBoxItem( + text = "+ Add New Biller", + onClick = { + showBillerDropdown = false + onNavigateToAddBiller() + }, + ) + } + + MifosOutlinedTextField( + label = "Amount *", + value = state.formData.amount, + onValueChange = { viewModel.trySendAction(AddBillAction.UpdateAmount(it)) }, + isError = state.validationResult.amountError != null, + errorMessage = state.validationResult.amountError, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Next, + ), + ) + + Box( + modifier = Modifier.onClick { showDatePicker = true }, + ) { + MifosTextField( + label = "Due Date *", + value = if (state.formData.dueDate > 0L) { + formatDateForDisplay(state.formData.dueDate) + } else { + "" + }, + onValueChange = { }, + isError = state.validationResult.dueDateError != null, + errorText = state.validationResult.dueDateError, + singleLine = true, + readOnly = true, + showClearIcon = false, + trailingIcon = { + IconButton( + onClick = { showDatePicker = true }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = KptTheme.colorScheme.tertiary, + contentColor = KptTheme.colorScheme.tertiaryContainer, + ), + ) { + Icon( + imageVector = MifosIcons.CalenderMonth, + contentDescription = "Choose Date", + ) + } + }, + ) + } + + DropdownBox( + expanded = showRecurrenceDropdown, + label = "Recurrence Pattern *", + value = state.formData.recurrencePattern.displayName, + readOnly = true, + isError = state.validationResult.recurrencePatternError != null, + errorText = state.validationResult.recurrencePatternError, + onExpandChange = { showRecurrenceDropdown = it }, + ) { + RecurrencePattern.entries.forEach { pattern -> + DropdownBoxItem( + text = pattern.displayName, + onClick = { + viewModel.trySendAction(AddBillAction.UpdateRecurrencePattern(pattern)) + showRecurrenceDropdown = false + }, + ) + } + } + + MifosOutlinedTextField( + label = "Description (Optional)", + value = state.formData.description, + onValueChange = { viewModel.trySendAction(AddBillAction.UpdateDescription(it)) }, + singleLine = false, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + ) + + // AutoPay Section + AutoPaySection( + enableAutoPay = state.formData.enableAutoPay, + paymentMethod = state.formData.autoPayPaymentMethod, + sourceAccount = state.formData.autoPaySourceAccount, + maxAmount = state.formData.autoPayMaxAmount, + paymentMethodError = state.validationResult.autoPayPaymentMethodError, + sourceAccountError = state.validationResult.autoPaySourceAccountError, + maxAmountError = state.validationResult.autoPayMaxAmountError, + onEnableAutoPayChanged = { enabled -> + viewModel.trySendAction(AddBillAction.UpdateAutoPayEnabled(enabled)) + }, + onPaymentMethodChanged = { paymentMethod -> + viewModel.trySendAction(AddBillAction.UpdateAutoPayPaymentMethod(paymentMethod)) + }, + onSourceAccountChanged = { sourceAccount -> + viewModel.trySendAction(AddBillAction.UpdateAutoPaySourceAccount(sourceAccount)) + }, + onMaxAmountChanged = { maxAmount -> + viewModel.trySendAction(AddBillAction.UpdateAutoPayMaxAmount(maxAmount)) + }, + ) + + if (state.nextPaymentDates.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Next Payment Dates:", + style = KptTheme.typography.titleMedium, + ) + + state.nextPaymentDates.forEach { nextDate -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = nextDate.formattedDate, + style = KptTheme.typography.bodyMedium, + ) + if (nextDate.isOverdue) { + Text( + text = "Overdue", + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.error, + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + MifosOutlinedButton( + text = { Text("Cancel") }, + onClick = onNavigateBack, + modifier = Modifier.weight(1f), + ) + MifosButton( + text = { Text("Save Bill") }, + onClick = { viewModel.trySendAction(AddBillAction.SaveBill) }, + modifier = Modifier.weight(1f), + enabled = !state.isLoading, + ) + } + } + } + + AnimatedVisibility(showDatePicker) { + val dateState = rememberDatePickerState( + initialSelectedDateMillis = if (state.formData.dueDate > 0L) { + state.formData.dueDate + } else { + Clock.System.now().toEpochMilliseconds() + }, + ) + + val confirmEnabled = remember { + derivedStateOf { dateState.selectedDateMillis != null } + } + + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton( + onClick = { + dateState.selectedDateMillis?.let { timestamp -> + viewModel.trySendAction(AddBillAction.UpdateDueDate(timestamp)) + } + showDatePicker = false + }, + enabled = confirmEnabled.value, + ) { + Text("OK") + } + }, + dismissButton = { + TextButton( + onClick = { showDatePicker = false }, + ) { + Text("Cancel") + } + }, + content = { + DatePicker(state = dateState) + }, + ) + } + + if (state.isLoading) { + MifosLoadingDialog( + visibilityState = LoadingDialogState.Shown, + ) + } + + state.error?.let { error -> + MifosBasicDialog( + visibilityState = BasicDialogState.Shown( + title = "Error", + message = error, + ), + onDismissRequest = { viewModel.trySendAction(AddBillAction.ClearError) }, + ) + } +} + +@Composable +private fun AutoPaySection( + enableAutoPay: Boolean, + paymentMethod: String, + sourceAccount: String, + maxAmount: String, + paymentMethodError: String?, + sourceAccountError: String?, + maxAmountError: String?, + onEnableAutoPayChanged: (Boolean) -> Unit, + onPaymentMethodChanged: (String) -> Unit, + onSourceAccountChanged: (String) -> Unit, + onMaxAmountChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = "AutoPay Settings", + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = "Automatically pay this bill when due", + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + } + Checkbox( + checked = enableAutoPay, + onCheckedChange = onEnableAutoPayChanged, + ) + } + + if (enableAutoPay) { + HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) + + var showPaymentMethodDropdown by remember { mutableStateOf(false) } + DropdownBox( + expanded = showPaymentMethodDropdown, + label = "Payment Method *", + value = paymentMethod.ifBlank { "Select payment method" }, + readOnly = true, + isError = paymentMethodError != null, + errorText = paymentMethodError, + onExpandChange = { showPaymentMethodDropdown = it }, + ) { + listOf("Bank Account", "Credit Card", "UPI").forEach { method -> + DropdownBoxItem( + text = method, + onClick = { + onPaymentMethodChanged(method) + showPaymentMethodDropdown = false + }, + ) + } + } + + MifosOutlinedTextField( + label = "Source Account *", + value = sourceAccount, + onValueChange = onSourceAccountChanged, + isError = sourceAccountError != null, + errorMessage = sourceAccountError, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + ), + ) + + MifosOutlinedTextField( + label = "Maximum Amount Limit (Optional)", + value = maxAmount, + onValueChange = onMaxAmountChanged, + isError = maxAmountError != null, + errorMessage = maxAmountError, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done, + ), + ) + + Text( + text = "This amount will be used as a safety limit for automatic payments", + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AddBillViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AddBillViewModel.kt new file mode 100644 index 000000000..6a2e48eea --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AddBillViewModel.kt @@ -0,0 +1,413 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable +import org.mifospay.core.common.DataState +import org.mifospay.core.common.DateHelper +import org.mifospay.core.common.getSerialized +import org.mifospay.core.common.setSerialized +import org.mifospay.core.data.util.BillValidator +import org.mifospay.core.datastore.BillRepository +import org.mifospay.core.datastore.BillerRepository +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.model.autopay.BillFormData +import org.mifospay.core.model.autopay.BillValidationResult +import org.mifospay.core.model.autopay.Biller +import org.mifospay.core.model.autopay.NextPaymentDate +import org.mifospay.core.model.autopay.RecurrencePattern +import org.mifospay.core.ui.utils.BaseViewModel +import kotlin.random.Random + +class AddBillViewModel( + savedStateHandle: SavedStateHandle, + private val billRepository: BillRepository, + private val billerRepository: BillerRepository, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: AddBillState(), +) { + + companion object { + private const val KEY_STATE = "add_bill_state" + } + + init { + stateFlow + .onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } + .launchIn(viewModelScope) + + loadAvailableBillers() + } + + override fun handleAction(action: AddBillAction) { + when (action) { + is AddBillAction.UpdateBillName -> { + updateBillName(action.name) + } + is AddBillAction.UpdateAmount -> { + updateAmount(action.amount) + } + is AddBillAction.UpdateDueDate -> { + updateDueDate(action.dueDate) + } + is AddBillAction.UpdateRecurrencePattern -> { + updateRecurrencePattern(action.recurrencePattern) + } + is AddBillAction.UpdateDescription -> { + updateDescription(action.description) + } + is AddBillAction.SaveBill -> { + saveBill() + } + is AddBillAction.ValidateForm -> { + validateForm() + } + is AddBillAction.ClearError -> { + clearError() + } + is AddBillAction.ClearValidationErrors -> { + clearValidationErrors() + } + is AddBillAction.CalculateNextPaymentDates -> { + calculateNextPaymentDates() + } + is AddBillAction.SelectBiller -> { + selectBiller(action.biller) + } + is AddBillAction.RefreshBillers -> { + loadAvailableBillers() + } + is AddBillAction.UpdateAutoPayEnabled -> { + updateAutoPayEnabled(action.enabled) + } + is AddBillAction.UpdateAutoPayPaymentMethod -> { + updateAutoPayPaymentMethod(action.paymentMethod) + } + is AddBillAction.UpdateAutoPaySourceAccount -> { + updateAutoPaySourceAccount(action.sourceAccount) + } + is AddBillAction.UpdateAutoPayMaxAmount -> { + updateAutoPayMaxAmount(action.maxAmount) + } + } + } + + private fun updateBillName(name: String) { + val nameError = BillValidator.validateBillFormData( + BillFormData(name = name), + ).nameError + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(name = name), + validationResult = it.validationResult.copy(nameError = nameError), + ) + } + } + + private fun updateAmount(amount: String) { + val amountError = BillValidator.validateBillFormData( + BillFormData(amount = amount), + ).amountError + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(amount = amount), + validationResult = it.validationResult.copy(amountError = amountError), + ) + } + } + + private fun updateDueDate(dueDate: Long) { + val dueDateError = BillValidator.validateBillFormData( + BillFormData(dueDate = dueDate), + ).dueDateError + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(dueDate = dueDate), + validationResult = it.validationResult.copy(dueDateError = dueDateError), + ) + } + calculateNextPaymentDates() + } + + private fun updateRecurrencePattern(recurrencePattern: RecurrencePattern) { + val recurrencePatternError = BillValidator.validateBillFormData( + BillFormData(recurrencePattern = recurrencePattern), + ).recurrencePatternError + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(recurrencePattern = recurrencePattern), + validationResult = it.validationResult.copy(recurrencePatternError = recurrencePatternError), + ) + } + calculateNextPaymentDates() + } + + private fun updateDescription(description: String) { + mutableStateFlow.update { + it.copy(formData = it.formData.copy(description = description)) + } + } + + private fun validateForm(): BillValidationResult { + val currentState = stateFlow.value + val formData = currentState.formData + + val validationResult = BillValidator.validateBillFormData(formData) + + mutableStateFlow.update { it.copy(validationResult = validationResult) } + return validationResult + } + + private fun generateUniqueId(): String { + val timestamp = Clock.System.now().toEpochMilliseconds() + val random = Random.nextInt(100000, 999999) + return "$timestamp-$random" + } + + private fun calculateNextPaymentDates() { + val currentState = stateFlow.value + val formData = currentState.formData + + if (formData.dueDate == 0L || formData.recurrencePattern == RecurrencePattern.NONE) { + mutableStateFlow.update { it.copy(nextPaymentDates = emptyList()) } + return + } + + val nextDates = mutableListOf() + val currentTime = Clock.System.now().toEpochMilliseconds() + var nextDate = formData.dueDate + + // Generate next 5 payment dates + repeat(5) { + if (nextDate >= currentTime) { + val isOverdue = nextDate < currentTime + val formattedDate = formatDate(nextDate) + nextDates.add(NextPaymentDate(nextDate, formattedDate, isOverdue)) + } + + // Calculate next date based on recurrence pattern + nextDate += (formData.recurrencePattern.interval * 24 * 60 * 60 * 1000L) + } + + mutableStateFlow.update { it.copy(nextPaymentDates = nextDates) } + } + + private fun formatDate(timestamp: Long): String { + return DateHelper.getDateAsStringFromLong(timestamp) + } + + private fun saveBill() { + val validationResult = validateForm() + + if (!validationResult.isValid) { + return + } + + mutableStateFlow.update { it.copy(isLoading = true) } + + viewModelScope.launch { + try { + val currentState = stateFlow.value + val formData = currentState.formData + + val bill = Bill( + id = generateUniqueId(), + name = formData.name.trim(), + amount = formData.amount.toDoubleOrNull() ?: 0.0, + currency = formData.currency, + dueDate = formData.dueDate, + recurrencePattern = formData.recurrencePattern, + billerId = formData.billerId, + billerName = formData.billerName, + description = formData.description.takeIf { it.isNotBlank() }, + // AutoPay configuration + autoPayEnabled = formData.enableAutoPay, + autoPayPaymentMethod = formData.autoPayPaymentMethod.takeIf { it.isNotBlank() }, + autoPaySourceAccount = formData.autoPaySourceAccount.takeIf { it.isNotBlank() }, + autoPayMaxAmount = formData.autoPayMaxAmount.toDoubleOrNull(), + ) + + val result = billRepository.saveBill(bill) + + when (result) { + is DataState.Loading -> { + // Loading state is already handled by setting isLoading = true above + } + is DataState.Success -> { + mutableStateFlow.update { + it.copy( + isLoading = false, + isSuccess = true, + ) + } + sendEvent(AddBillEvent.BillSaved(result.data)) + } + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to save bill: ${result.exception.message}", + ) + } + } + } + } catch (exception: Exception) { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to save bill: ${exception.message}", + ) + } + } + } + } + + private fun loadAvailableBillers() { + viewModelScope.launch { + try { + billerRepository.getAllBillers().collect { billers -> + mutableStateFlow.update { it.copy(availableBillers = billers) } + } + } catch (exception: Exception) { + // Handle error silently for now + } + } + } + + private fun selectBiller(biller: Biller) { + val billerError = BillValidator.validateBillFormData( + BillFormData(billerId = biller.id, billerName = biller.name), + ).billerError + mutableStateFlow.update { + it.copy( + formData = it.formData.copy( + billerId = biller.id, + billerName = biller.name, + ), + validationResult = it.validationResult.copy(billerError = billerError), + ) + } + } + + private fun updateAutoPayEnabled(enabled: Boolean) { + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(enableAutoPay = enabled), + ) + } + } + + private fun updateAutoPayPaymentMethod(paymentMethod: String) { + val paymentMethodError = if (stateFlow.value.formData.enableAutoPay && paymentMethod.isBlank()) { + "Payment method is required when AutoPay is enabled" + } else { + null + } + + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(autoPayPaymentMethod = paymentMethod), + validationResult = it.validationResult.copy(autoPayPaymentMethodError = paymentMethodError), + ) + } + } + + private fun updateAutoPaySourceAccount(sourceAccount: String) { + val sourceAccountError = if (stateFlow.value.formData.enableAutoPay && sourceAccount.isBlank()) { + "Source account is required when AutoPay is enabled" + } else { + null + } + + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(autoPaySourceAccount = sourceAccount), + validationResult = it.validationResult.copy(autoPaySourceAccountError = sourceAccountError), + ) + } + } + + private fun updateAutoPayMaxAmount(maxAmount: String) { + val maxAmountError = if (maxAmount.isNotBlank()) { + val amount = maxAmount.toDoubleOrNull() + if (amount == null) { + "Invalid amount format" + } else if (amount <= 0) { + "Maximum amount must be greater than 0" + } else { + null + } + } else { + null + } + + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(autoPayMaxAmount = maxAmount), + validationResult = it.validationResult.copy(autoPayMaxAmountError = maxAmountError), + ) + } + } + + private fun clearError() { + mutableStateFlow.update { it.copy(error = null) } + } + + private fun clearValidationErrors() { + mutableStateFlow.update { + it.copy( + validationResult = BillValidationResult(isValid = false), + ) + } + } +} + +@Serializable +data class AddBillState( + val formData: BillFormData = BillFormData(), + val validationResult: BillValidationResult = BillValidationResult(isValid = false), + val isLoading: Boolean = false, + val isSuccess: Boolean = false, + val error: String? = null, + val nextPaymentDates: List = emptyList(), + val availableBillers: List = emptyList(), +) + +sealed interface AddBillEvent { + data class BillSaved(val bill: Bill) : AddBillEvent +} + +sealed interface AddBillAction { + data class UpdateBillName(val name: String) : AddBillAction + data class UpdateAmount(val amount: String) : AddBillAction + data class UpdateDueDate(val dueDate: Long) : AddBillAction + data class UpdateRecurrencePattern(val recurrencePattern: RecurrencePattern) : AddBillAction + data class UpdateDescription(val description: String) : AddBillAction + data object SaveBill : AddBillAction + data object ValidateForm : AddBillAction + data object ClearError : AddBillAction + data object ClearValidationErrors : AddBillAction + data object CalculateNextPaymentDates : AddBillAction + data class SelectBiller(val biller: Biller) : AddBillAction + data object RefreshBillers : AddBillAction + + // AutoPay actions + data class UpdateAutoPayEnabled(val enabled: Boolean) : AddBillAction + data class UpdateAutoPayPaymentMethod(val paymentMethod: String) : AddBillAction + data class UpdateAutoPaySourceAccount(val sourceAccount: String) : AddBillAction + data class UpdateAutoPayMaxAmount(val maxAmount: String) : AddBillAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AddBillerScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AddBillerScreen.kt new file mode 100644 index 000000000..5b40d15b0 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AddBillerScreen.kt @@ -0,0 +1,216 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.BasicDialogState +import org.mifospay.core.designsystem.component.LoadingDialogState +import org.mifospay.core.designsystem.component.MifosBasicDialog +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosLoadingDialog +import org.mifospay.core.designsystem.component.MifosOutlinedButton +import org.mifospay.core.designsystem.component.MifosOutlinedTextField +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.model.autopay.BillerCategory +import org.mifospay.core.ui.DropdownBox +import org.mifospay.core.ui.DropdownBoxItem +import org.mifospay.core.ui.utils.EventsEffect + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddBillerScreen( + onNavigateBack: () -> Unit, + onNavigateToBillerList: () -> Unit, + viewModel: AddBillerViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + var showCategoryDropdown by remember { mutableStateOf(false) } + + EventsEffect(viewModel) { event -> + when (event) { + is AddBillerEvent.BillerSaved -> { + // Check if we should go back or to biller list based on source + val source = viewModel.getSource() + if (source == "bill_creation") { + onNavigateBack() + } else { + onNavigateToBillerList() + } + } + } + } + + MifosScaffold( + topBar = { + MifosTopBar( + topBarTitle = "Add New Biller", + backPress = onNavigateBack, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Enter biller details to set up automatic payments", + modifier = Modifier.padding(bottom = 8.dp), + ) + + MifosOutlinedTextField( + label = "Biller Name *", + value = state.formData.name, + onValueChange = { viewModel.trySendAction(AddBillerAction.UpdateBillerName(it)) }, + isError = state.validationResult.nameError != null, + errorMessage = state.validationResult.nameError, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + ), + ) + + MifosOutlinedTextField( + label = "Account Number *", + value = state.formData.accountNumber, + onValueChange = { viewModel.trySendAction(AddBillerAction.UpdateAccountNumber(it)) }, + isError = state.validationResult.accountNumberError != null, + errorMessage = state.validationResult.accountNumberError, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + ) + + MifosOutlinedTextField( + label = "Contact Number *", + value = state.formData.contactNumber, + onValueChange = { viewModel.trySendAction(AddBillerAction.UpdateContactNumber(it)) }, + isError = state.validationResult.contactNumberError != null, + errorMessage = state.validationResult.contactNumberError, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Next, + ), + ) + + MifosOutlinedTextField( + label = "Email (Optional)", + value = state.formData.email, + onValueChange = { viewModel.trySendAction(AddBillerAction.UpdateEmail(it)) }, + isError = state.validationResult.emailError != null, + errorMessage = state.validationResult.emailError, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + ) + + DropdownBox( + expanded = showCategoryDropdown, + label = "Biller Category *", + value = state.formData.category?.displayName ?: "", + isError = state.validationResult.categoryError != null, + errorText = state.validationResult.categoryError, + onExpandChange = { showCategoryDropdown = it }, + ) { + BillerCategory.entries.forEach { category -> + DropdownBoxItem( + text = category.displayName, + onClick = { + viewModel.trySendAction(AddBillerAction.UpdateCategory(category)) + showCategoryDropdown = false + }, + ) + } + } + + MifosOutlinedTextField( + label = "Address (Optional)", + value = state.formData.address, + onValueChange = { viewModel.trySendAction(AddBillerAction.UpdateAddress(it)) }, + singleLine = false, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + MifosOutlinedButton( + text = { Text("Cancel") }, + onClick = onNavigateBack, + modifier = Modifier.weight(1f), + ) + MifosButton( + text = { Text("Save Biller") }, + onClick = { viewModel.trySendAction(AddBillerAction.SaveBiller) }, + modifier = Modifier.weight(1f), + enabled = !state.isLoading, + ) + } + } + } + + if (state.isLoading) { + MifosLoadingDialog( + visibilityState = LoadingDialogState.Shown, + ) + } + + state.error?.let { error -> + MifosBasicDialog( + visibilityState = BasicDialogState.Shown( + title = "Error", + message = error, + ), + onDismissRequest = { viewModel.trySendAction(AddBillerAction.ClearError) }, + ) + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AddBillerViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AddBillerViewModel.kt new file mode 100644 index 000000000..37efa0fa4 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AddBillerViewModel.kt @@ -0,0 +1,256 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable +import org.mifospay.core.common.DataState +import org.mifospay.core.common.getSerialized +import org.mifospay.core.common.setSerialized +import org.mifospay.core.data.util.BillerValidator +import org.mifospay.core.datastore.BillerRepository +import org.mifospay.core.model.autopay.Biller +import org.mifospay.core.model.autopay.BillerCategory +import org.mifospay.core.model.autopay.BillerFormData +import org.mifospay.core.model.autopay.BillerValidationResult +import org.mifospay.core.ui.utils.BaseViewModel +import kotlin.random.Random + +class AddBillerViewModel( + savedStateHandle: SavedStateHandle, + private val billerRepository: BillerRepository, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: AddBillerState(), +) { + + companion object { + private const val KEY_STATE = "add_biller_state" + private const val SOURCE_ARG = "source" + } + + private val source: String = savedStateHandle.get(SOURCE_ARG) ?: "direct" + + fun getSource(): String = source + + init { + stateFlow + .onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } + .launchIn(viewModelScope) + } + + override fun handleAction(action: AddBillerAction) { + when (action) { + is AddBillerAction.UpdateBillerName -> { + updateBillerName(action.name) + } + is AddBillerAction.UpdateAccountNumber -> { + updateAccountNumber(action.accountNumber) + } + is AddBillerAction.UpdateContactNumber -> { + updateContactNumber(action.contactNumber) + } + is AddBillerAction.UpdateEmail -> { + updateEmail(action.email) + } + is AddBillerAction.UpdateCategory -> { + updateCategory(action.category) + } + is AddBillerAction.UpdateAddress -> { + updateAddress(action.address) + } + is AddBillerAction.SaveBiller -> { + saveBiller() + } + is AddBillerAction.ValidateForm -> { + validateForm() + } + is AddBillerAction.ClearError -> { + clearError() + } + is AddBillerAction.ClearValidationErrors -> { + clearValidationErrors() + } + } + } + + private fun updateBillerName(name: String) { + val nameError = BillerValidator.validateNameField(name) + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(name = name), + validationResult = it.validationResult.copy(nameError = nameError), + ) + } + } + + private fun updateAccountNumber(accountNumber: String) { + val accountNumberError = BillerValidator.validateAccountNumberField(accountNumber) + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(accountNumber = accountNumber), + validationResult = it.validationResult.copy(accountNumberError = accountNumberError), + ) + } + } + + private fun updateContactNumber(contactNumber: String) { + val contactNumberError = BillerValidator.validateContactNumberField(contactNumber) + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(contactNumber = contactNumber), + validationResult = it.validationResult.copy(contactNumberError = contactNumberError), + ) + } + } + + private fun updateEmail(email: String) { + val emailError = BillerValidator.validateEmailField(email) + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(email = email), + validationResult = it.validationResult.copy(emailError = emailError), + ) + } + } + + private fun updateCategory(category: BillerCategory) { + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(category = category), + validationResult = it.validationResult.copy(categoryError = null), + ) + } + } + + private fun updateAddress(address: String) { + mutableStateFlow.update { + it.copy(formData = it.formData.copy(address = address)) + } + } + + private fun validateForm(): BillerValidationResult { + val currentState = stateFlow.value + val formData = currentState.formData + + val validationResult = BillerValidator.validateBillerForm(formData) + + mutableStateFlow.update { it.copy(validationResult = validationResult) } + return validationResult + } + + private fun generateUniqueId(): String { + val timestamp = Clock.System.now().toEpochMilliseconds() + val random = Random.nextInt(100000, 999999) + return "$timestamp-$random" + } + + private fun saveBiller() { + val validationResult = validateForm() + + if (!validationResult.isValid) { + return + } + + mutableStateFlow.update { it.copy(isLoading = true) } + + viewModelScope.launch { + try { + val currentState = stateFlow.value + val formData = currentState.formData + + val biller = Biller( + id = generateUniqueId(), + name = formData.name.trim(), + accountNumber = formData.accountNumber.trim(), + contactNumber = formData.contactNumber.trim(), + email = formData.email.takeIf { it.isNotBlank() }, + category = formData.category!!, + address = formData.address.takeIf { it.isNotBlank() }, + ) + + val result = billerRepository.saveBiller(biller) + + when (result) { + is DataState.Loading -> { + // Loading state is already handled by setting isLoading = true above + } + is DataState.Success -> { + mutableStateFlow.update { + it.copy( + isLoading = false, + isSuccess = true, + ) + } + sendEvent(AddBillerEvent.BillerSaved(result.data)) + } + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = result.message, + ) + } + } + } + } catch (exception: Exception) { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to save biller: ${exception.message}", + ) + } + } + } + } + + private fun clearError() { + mutableStateFlow.update { it.copy(error = null) } + } + + private fun clearValidationErrors() { + mutableStateFlow.update { + it.copy( + validationResult = BillerValidationResult(isValid = false), + ) + } + } +} + +@Serializable +data class AddBillerState( + val formData: BillerFormData = BillerFormData(), + val validationResult: BillerValidationResult = BillerValidationResult(isValid = false), + val isLoading: Boolean = false, + val isSuccess: Boolean = false, + val error: String? = null, +) + +sealed interface AddBillerEvent { + data class BillerSaved(val biller: Biller) : AddBillerEvent +} + +sealed interface AddBillerAction { + data class UpdateBillerName(val name: String) : AddBillerAction + data class UpdateAccountNumber(val accountNumber: String) : AddBillerAction + data class UpdateContactNumber(val contactNumber: String) : AddBillerAction + data class UpdateEmail(val email: String) : AddBillerAction + data class UpdateCategory(val category: BillerCategory) : AddBillerAction + data class UpdateAddress(val address: String) : AddBillerAction + data object SaveBiller : AddBillerAction + data object ValidateForm : AddBillerAction + data object ClearError : AddBillerAction + data object ClearValidationErrors : AddBillerAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt new file mode 100644 index 000000000..751fcdbec --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryScreen.kt @@ -0,0 +1,259 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.common.CurrencyFormatter +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.PaymentStatus + +@Composable +fun AutoPayHistoryScreen( + onNavigateBack: () -> Unit, + viewModel: AutoPayHistoryViewModel = koinViewModel(), + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsState() + + MifosScaffold( + modifier = modifier, + topBarTitle = "AutoPay History", + backPress = onNavigateBack, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + ) { + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + uiState.error != null -> { + val errorMessage = uiState.error + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = MifosIcons.Error, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = errorMessage ?: "Unknown error occurred", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + uiState.displayHistoryList.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = MifosIcons.History, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No history available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(uiState.displayHistoryList) { historyItem -> + HistoryItemCard(historyItem = historyItem) + } + } + } + } + } + } +} + +@Composable +private fun HistoryItemCard( + historyItem: AutoPayHistory, + modifier: Modifier = Modifier, +) { + val currentStatus = historyItem.status + + val statusColor = when (currentStatus) { + // Green color for success + PaymentStatus.COMPLETED -> Color(0xFF4CAF50) + PaymentStatus.FAILED -> MaterialTheme.colorScheme.error + PaymentStatus.PROCESSING -> MaterialTheme.colorScheme.tertiary + PaymentStatus.PENDING -> MaterialTheme.colorScheme.tertiary + PaymentStatus.UPCOMING -> MaterialTheme.colorScheme.primary + PaymentStatus.CANCELLED -> MaterialTheme.colorScheme.outline + null -> MaterialTheme.colorScheme.primary + } + + val statusIcon = when (currentStatus) { + PaymentStatus.COMPLETED -> MifosIcons.CheckCircle + PaymentStatus.FAILED -> MifosIcons.Error + PaymentStatus.PROCESSING -> MifosIcons.Schedule + PaymentStatus.PENDING -> MifosIcons.Schedule + PaymentStatus.UPCOMING -> MifosIcons.Schedule + PaymentStatus.CANCELLED -> MifosIcons.Cancel + null -> MifosIcons.History + } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = statusIcon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = statusColor, + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = historyItem.recipientName ?: "Unknown Recipient", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + + Text( + text = "Account: ${historyItem.recipientAccountNumber ?: "N/A"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Column( + horizontalAlignment = Alignment.End, + ) { + Text( + text = CurrencyFormatter.format(historyItem.amount, historyItem.currency, 2), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + + Text( + text = historyItem.transactionDate ?: "N/A", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (currentStatus != null) { + Spacer(modifier = Modifier.height(8.dp)) + + HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Status: ${currentStatus.name}", + style = MaterialTheme.typography.bodySmall, + color = statusColor, + fontWeight = FontWeight.Medium, + ) + + if (historyItem.referenceNumber != null) { + Text( + text = "Ref: ${historyItem.referenceNumber}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (historyItem.failureReason != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Reason: ${historyItem.failureReason}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryViewModel.kt new file mode 100644 index 000000000..8bab29e61 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayHistoryViewModel.kt @@ -0,0 +1,436 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.mifospay.core.common.DataState +import org.mifospay.core.data.repository.AutoPayHistoryRepository +import org.mifospay.core.data.repository.AutoPayHistoryStatistics +import org.mifospay.core.model.autopay.AutoPayHistory +import org.mifospay.core.model.autopay.PaymentStatus +import org.mifospay.core.network.model.entity.Page + +/** + * ViewModel for AutoPay history screen. + * + * This ViewModel provides read-only access to AutoPay history data. + * Users can view, search, and filter history but cannot edit or delete entries. + */ +class AutoPayHistoryViewModel( + private val autoPayHistoryRepository: AutoPayHistoryRepository, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val autoPayId: Long = savedStateHandle.get("autoPayId") ?: 0L + + private val _uiState = MutableStateFlow(AutoPayHistoryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadAutoPayHistory() + loadHistoryStatistics() + } + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _selectedStatus = MutableStateFlow(null) + val selectedStatus: StateFlow = _selectedStatus.asStateFlow() + + private val _selectedDateRange = MutableStateFlow>(null to null) + val selectedDateRange: StateFlow> = _selectedDateRange.asStateFlow() + + private fun loadAutoPayHistory() { + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Loading history for AutoPay ID: $autoPayId") + + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + viewModelScope.launch { + try { + delay(2000) + + val dummyHistory = createDummyHistoryData() + _uiState.value = _uiState.value.copy( + isLoading = false, + historyList = dummyHistory, + error = null, + ) + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Successfully loaded ${dummyHistory.size} dummy history entries") + } catch (exception: Exception) { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = exception.message ?: "Unknown error occurred", + ) + Logger.e("AUTOPAY_HISTORY AutoPayHistoryViewModel Exception in history flow: ${exception.message}") + } + } + } + + fun loadAutoPayHistoryWithPagination( + autoPayId: Long, + limit: Int = 20, + offset: Int = 0, + ) { + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Loading paginated history for AutoPay ID: $autoPayId, limit: $limit, offset: $offset") + + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + + viewModelScope.launch { + autoPayHistoryRepository.getAutoPayHistoryWithPagination(autoPayId, limit, offset) + .onEach { dataState -> + when (dataState) { + is DataState.Loading -> { + _uiState.value = _uiState.value.copy(isLoading = true, error = null) + } + is DataState.Success -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + historyPage = dataState.data, + error = null, + ) + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Successfully loaded paginated history: ${dataState.data.pageItems.size} entries") + } + is DataState.Error -> { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = dataState.exception.message ?: "Failed to load history", + ) + Logger.e("AUTOPAY_HISTORY AutoPayHistoryViewModel Error loading paginated history: ${dataState.exception.message}") + } + } + } + .catch { exception -> + _uiState.value = _uiState.value.copy( + isLoading = false, + error = exception.message ?: "Unknown error occurred", + ) + Logger.e("AUTOPAY_HISTORY AutoPayHistoryViewModel Exception in paginated history flow: ${exception.message}") + } + .launchIn(this) + } + } + + private fun loadHistoryStatistics() { + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Loading statistics for AutoPay ID: $autoPayId") + + viewModelScope.launch { + try { + delay(1500) + + val dummyStatistics = createDummyStatistics() + _uiState.value = _uiState.value.copy(statistics = dummyStatistics) + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Successfully loaded dummy statistics: ${dummyStatistics.totalTransactions} total transactions") + } catch (exception: Exception) { + Logger.e("AUTOPAY_HISTORY AutoPayHistoryViewModel Exception in statistics flow: ${exception.message}") + } + } + } + + fun searchHistory(query: String) { + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Searching history with query: $query") + + _searchQuery.value = query + + if (query.isBlank()) { + _uiState.value = _uiState.value.copy(filteredHistoryList = _uiState.value.historyList) + return + } + + viewModelScope.launch { + autoPayHistoryRepository.searchHistory(query) + .onEach { dataState -> + when (dataState) { + is DataState.Success -> { + _uiState.value = _uiState.value.copy(filteredHistoryList = dataState.data) + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Search completed: ${dataState.data.size} results") + } + is DataState.Error -> { + Logger.e("AUTOPAY_HISTORY AutoPayHistoryViewModel Error searching history: ${dataState.exception.message}") + } + is DataState.Loading -> { + // Search loading is handled separately + } + } + } + .catch { exception -> + Logger.e("AUTOPAY_HISTORY AutoPayHistoryViewModel Exception in search flow: ${exception.message}") + } + .launchIn(this) + } + } + + fun filterByStatus(status: String?) { + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Filtering by status: $status") + + _selectedStatus.value = status + + if (status == null) { + _uiState.value = _uiState.value.copy(filteredHistoryList = _uiState.value.historyList) + return + } + + viewModelScope.launch { + autoPayHistoryRepository.getHistoryByStatus(status) + .onEach { dataState -> + when (dataState) { + is DataState.Success -> { + _uiState.value = _uiState.value.copy(filteredHistoryList = dataState.data) + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Status filter applied: ${dataState.data.size} results") + } + is DataState.Error -> { + Logger.e("AUTOPAY_HISTORY AutoPayHistoryViewModel Error filtering by status: ${dataState.exception.message}") + } + is DataState.Loading -> { + // Filter loading is handled separately + } + } + } + .catch { exception -> + Logger.e("AUTOPAY_HISTORY AutoPayHistoryViewModel Exception in status filter flow: ${exception.message}") + } + .launchIn(this) + } + } + + fun filterByDateRange(fromDate: String?, toDate: String?) { + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Filtering by date range: $fromDate to $toDate") + + _selectedDateRange.value = fromDate to toDate + + if (fromDate == null || toDate == null) { + _uiState.value = _uiState.value.copy(filteredHistoryList = _uiState.value.historyList) + return + } + + viewModelScope.launch { + autoPayHistoryRepository.getHistoryByDateRange(fromDate, toDate) + .onEach { dataState -> + when (dataState) { + is DataState.Success -> { + _uiState.value = _uiState.value.copy(filteredHistoryList = dataState.data) + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Date range filter applied: ${dataState.data.size} results") + } + is DataState.Error -> { + Logger.e("AUTOPAY_HISTORY AutoPayHistoryViewModel Error filtering by date range: ${dataState.exception.message}") + } + is DataState.Loading -> { + // Filter loading is handled separately + } + } + } + .catch { exception -> + Logger.e("AUTOPAY_HISTORY AutoPayHistoryViewModel Exception in date range filter flow: ${exception.message}") + } + .launchIn(this) + } + } + + fun clearFilters() { + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Clearing all filters") + + _searchQuery.value = "" + _selectedStatus.value = null + _selectedDateRange.value = null to null + _uiState.value = _uiState.value.copy(filteredHistoryList = _uiState.value.historyList) + } + + fun refreshHistory() { + Logger.d("AUTOPAY_HISTORY AutoPayHistoryViewModel Refreshing history for AutoPay ID: $autoPayId") + + loadAutoPayHistory() + loadHistoryStatistics() + } + + fun clearError() { + _uiState.value = _uiState.value.copy(error = null) + } + + private fun createDummyHistoryData(): List { + return listOf( + AutoPayHistory( + id = 1L, + autoPayId = autoPayId, + amount = 1200.00, + currency = "USD", + status = PaymentStatus.COMPLETED, + transactionDate = "Jan 15, 2025", + recipientName = "Landlord Corp", + recipientAccountNumber = "****1234", + sourceAccountNumber = "****5678", + referenceNumber = "REF001234", + failureReason = null, + createdDate = "Jan 15, 2025 10:30 AM", + ), + AutoPayHistory( + id = 2L, + autoPayId = autoPayId, + amount = 89.99, + currency = "USD", + status = PaymentStatus.COMPLETED, + transactionDate = "Jan 10, 2025", + recipientName = "Comcast", + recipientAccountNumber = "****5678", + sourceAccountNumber = "****5678", + referenceNumber = "REF001235", + failureReason = null, + createdDate = "Jan 10, 2025 08:15 AM", + ), + AutoPayHistory( + id = 3L, + autoPayId = autoPayId, + amount = 156.75, + currency = "USD", + status = PaymentStatus.FAILED, + transactionDate = "Jan 5, 2025", + recipientName = "Power Company", + recipientAccountNumber = "****9012", + sourceAccountNumber = "****5678", + referenceNumber = "REF001236", + failureReason = "Insufficient funds", + createdDate = "Jan 5, 2025 14:20 PM", + ), + AutoPayHistory( + id = 4L, + autoPayId = autoPayId, + amount = 85.50, + currency = "USD", + status = PaymentStatus.COMPLETED, + transactionDate = "Dec 28, 2024", + recipientName = "Verizon", + recipientAccountNumber = "****3456", + sourceAccountNumber = "****5678", + referenceNumber = "REF001237", + failureReason = null, + createdDate = "Dec 28, 2024 09:45 AM", + ), + AutoPayHistory( + id = 5L, + autoPayId = autoPayId, + amount = 45.00, + currency = "USD", + status = PaymentStatus.COMPLETED, + transactionDate = "Dec 20, 2024", + recipientName = "Fitness Center", + recipientAccountNumber = "****7890", + sourceAccountNumber = "****5678", + referenceNumber = "REF001238", + failureReason = null, + createdDate = "Dec 20, 2024 07:30 AM", + ), + AutoPayHistory( + id = 6L, + autoPayId = autoPayId, + amount = 15.99, + currency = "USD", + status = PaymentStatus.UPCOMING, + transactionDate = "Dec 15, 2024", + recipientName = "Netflix", + recipientAccountNumber = "****2468", + sourceAccountNumber = "****5678", + referenceNumber = null, + failureReason = null, + createdDate = "Dec 15, 2024 16:00 PM", + ), + AutoPayHistory( + id = 7L, + autoPayId = autoPayId, + amount = 9.99, + currency = "USD", + status = PaymentStatus.CANCELLED, + transactionDate = "Dec 10, 2024", + recipientName = "Spotify", + recipientAccountNumber = "****1357", + sourceAccountNumber = "****5678", + referenceNumber = null, + failureReason = "Schedule cancelled by user", + createdDate = "Dec 10, 2024 11:15 AM", + ), + AutoPayHistory( + id = 8L, + autoPayId = autoPayId, + amount = 250.00, + currency = "USD", + status = PaymentStatus.PROCESSING, + transactionDate = "Jan 20, 2023", + recipientName = "Insurance Company", + recipientAccountNumber = "****9753", + sourceAccountNumber = "****5678", + referenceNumber = "REF001239", + failureReason = null, + createdDate = "Jan 20, 2023 12:00 PM", + ), + AutoPayHistory( + id = 9L, + autoPayId = autoPayId, + amount = 75.25, + currency = "USD", + status = PaymentStatus.PENDING, + transactionDate = "Jan 18, 2023", + recipientName = "Water Department", + recipientAccountNumber = "****8642", + sourceAccountNumber = "****5678", + referenceNumber = "REF001240", + failureReason = null, + createdDate = "Jan 18, 2023 15:30 PM", + ), + AutoPayHistory( + id = 10L, + autoPayId = autoPayId, + amount = 199.99, + currency = "USD", + status = PaymentStatus.COMPLETED, + transactionDate = "Jan 12, 2023", + recipientName = "Credit Card Payment", + recipientAccountNumber = "****7531", + sourceAccountNumber = "****5678", + referenceNumber = "REF001241", + failureReason = null, + createdDate = "Jan 12, 2023 13:45 PM", + ), + ) + } + + private fun createDummyStatistics(): AutoPayHistoryStatistics { + return AutoPayHistoryStatistics( + totalTransactions = 10, + successfulTransactions = 6, + failedTransactions = 1, + pendingTransactions = 1, + totalAmount = 2128.46, + successfulAmount = 1476.48, + failedAmount = 156.75, + currency = "USD", + ) + } +} + +data class AutoPayHistoryUiState( + val isLoading: Boolean = false, + val historyList: List = emptyList(), + val filteredHistoryList: List = emptyList(), + val historyPage: Page? = null, + val statistics: AutoPayHistoryStatistics? = null, + val error: String? = null, + val isReadOnly: Boolean = true, +) { + val displayHistoryList: List + get() = filteredHistoryList.ifEmpty { historyList } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt new file mode 100644 index 000000000..8055e3f32 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayNavigation.kt @@ -0,0 +1,304 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import androidx.navigation.navOptions + +object AutoPayNavigation { + const val AUTO_PAY_ROUTE = "autopay_route" + const val AUTO_PAY_SCHEDULE_MANAGEMENT_ROUTE = "autopay_schedule_management_route" + const val AUTO_PAY_PREFERENCES_ROUTE = "autopay_preferences_route" + const val AUTO_PAY_HISTORY_ROUTE = "autopay_history_route" + const val AUTO_PAY_SCHEDULE_DETAILS_ROUTE = "autopay_schedule_details_route" + const val AUTO_PAY_ADD_BILLER_ROUTE = "autopay_add_biller_route" + const val AUTO_PAY_BILLER_LIST_ROUTE = "autopay_biller_list_route" + const val AUTO_PAY_EDIT_BILLER_ROUTE = "autopay_edit_biller_route" + const val AUTO_PAY_ADD_BILL_ROUTE = "autopay_add_bill_route" + const val AUTO_PAY_BILL_LIST_ROUTE = "autopay_bill_list_route" + const val AUTO_PAY_EDIT_BILL_ROUTE = "autopay_edit_bill_route" + const val SCHEDULE_ID_ARG = "scheduleId" + const val BILLER_ID_ARG = "billerId" + const val BILL_ID_ARG = "billId" + const val SOURCE_ARG = "source" + const val AUTO_PAY_ID_ARG = "autoPayId" +} + +/** + * Custom composable function that uses fade transitions to prevent the state issue + * where both screens are visible momentarily during navigation. + */ +fun NavGraphBuilder.composableWithFadeTransitions( + route: String, + arguments: List = emptyList(), + deepLinks: List = emptyList(), + content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { + this.composable( + route = route, + arguments = arguments, + deepLinks = deepLinks, + enterTransition = { + fadeIn(animationSpec = tween(300)) + }, + exitTransition = { + fadeOut(animationSpec = tween(300)) + }, + popEnterTransition = { + fadeIn(animationSpec = tween(300)) + }, + popExitTransition = { + fadeOut(animationSpec = tween(300)) + }, + content = content, + ) +} + +fun NavController.navigateToAutoPay(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_ROUTE, navOptions) +} + +fun NavController.navigateToScheduleManagement(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_SCHEDULE_MANAGEMENT_ROUTE, navOptions) +} + +fun NavController.navigateToAutoPayPreferences(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_PREFERENCES_ROUTE, navOptions) +} + +fun NavController.navigateToAutoPayHistory(autoPayId: Long = 0L, navOptions: NavOptions? = null) { + val route = "${AutoPayNavigation.AUTO_PAY_HISTORY_ROUTE}?${AutoPayNavigation.AUTO_PAY_ID_ARG}=$autoPayId" + navigate(route, navOptions) +} + +fun NavController.navigateToAutoPayScheduleDetails(scheduleId: String, navOptions: NavOptions? = null) { + val route = "${AutoPayNavigation.AUTO_PAY_SCHEDULE_DETAILS_ROUTE}?${AutoPayNavigation.SCHEDULE_ID_ARG}=$scheduleId" + navigate(route, navOptions) +} + +fun NavController.navigateToAddBiller(source: String = "direct", navOptions: NavOptions? = null) { + val route = "${AutoPayNavigation.AUTO_PAY_ADD_BILLER_ROUTE}?${AutoPayNavigation.SOURCE_ARG}=$source" + navigate(route, navOptions) +} + +fun NavController.navigateToBillerList(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_BILLER_LIST_ROUTE, navOptions) +} + +fun NavController.navigateToEditBiller(billerId: String, navOptions: NavOptions? = null) { + val route = "${AutoPayNavigation.AUTO_PAY_EDIT_BILLER_ROUTE}?${AutoPayNavigation.BILLER_ID_ARG}=$billerId" + navigate(route, navOptions) +} + +fun NavController.navigateToAddBill(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_ADD_BILL_ROUTE, navOptions) +} + +fun NavController.navigateToBillList(navOptions: NavOptions? = null) { + navigate(AutoPayNavigation.AUTO_PAY_BILL_LIST_ROUTE, navOptions) +} + +fun NavController.navigateToEditBill(billId: String, navOptions: NavOptions? = null) { + val route = "${AutoPayNavigation.AUTO_PAY_EDIT_BILL_ROUTE}?${AutoPayNavigation.BILL_ID_ARG}=$billId" + navigate(route, navOptions) +} + +fun NavGraphBuilder.autoPayGraph( + navController: NavController, + onNavigateBack: () -> Unit = { navController.navigateUp() }, +) { + composableWithFadeTransitions(AutoPayNavigation.AUTO_PAY_ROUTE) { + AutoPayScreen( + onNavigateToScheduleManagement = { + navController.navigateToScheduleManagement() + }, + onNavigateToPreferences = { + navController.navigateToAutoPayPreferences() + }, + onNavigateToHistory = { + navController.navigateToAutoPayHistory(autoPayId = 0L) + }, + onNavigateToScheduleDetails = { scheduleId -> + navController.navigateToAutoPayScheduleDetails(scheduleId) + }, + onNavigateToAddBiller = { + navController.navigateToAddBiller() + }, + onNavigateToBillerList = { + navController.navigateToBillerList() + }, + onNavigateToAddBill = { + navController.navigateToAddBill() + }, + onNavigateToBillList = { + navController.navigateToBillList() + }, + onNavigateBack = onNavigateBack, + showTopBar = true, + ) + } + + composableWithFadeTransitions(AutoPayNavigation.AUTO_PAY_SCHEDULE_MANAGEMENT_ROUTE) { + AutoPayScheduleManagementScreen( + onNavigateBack = onNavigateBack, + onNavigateToAddBill = { + navController.navigateToAddBill() + }, + onNavigateToEditBill = { billId -> + navController.navigateToEditBill(billId) + }, + onNavigateToBillList = { + navController.navigateToBillList() + }, + ) + } + + composableWithFadeTransitions(AutoPayNavigation.AUTO_PAY_PREFERENCES_ROUTE) { + AutoPayPreferencesScreen( + onNavigateBack = onNavigateBack, + ) + } + + composableWithFadeTransitions( + route = "${AutoPayNavigation.AUTO_PAY_HISTORY_ROUTE}?${AutoPayNavigation.AUTO_PAY_ID_ARG}={${AutoPayNavigation.AUTO_PAY_ID_ARG}}", + arguments = listOf( + navArgument(AutoPayNavigation.AUTO_PAY_ID_ARG) { + type = NavType.LongType + nullable = false + defaultValue = 0L + }, + ), + ) { backStackEntry -> + AutoPayHistoryScreen( + onNavigateBack = onNavigateBack, + ) + } + + composableWithFadeTransitions( + route = "${AutoPayNavigation.AUTO_PAY_SCHEDULE_DETAILS_ROUTE}?${AutoPayNavigation.SCHEDULE_ID_ARG}={${AutoPayNavigation.SCHEDULE_ID_ARG}}", + arguments = listOf( + navArgument(AutoPayNavigation.SCHEDULE_ID_ARG) { + type = NavType.StringType + nullable = false + }, + ), + ) { + AutoPayScheduleDetailsScreen( + onNavigateBack = onNavigateBack, + ) + } + + composableWithFadeTransitions( + route = "${AutoPayNavigation.AUTO_PAY_ADD_BILLER_ROUTE}?${AutoPayNavigation.SOURCE_ARG}={${AutoPayNavigation.SOURCE_ARG}}", + arguments = listOf( + navArgument(AutoPayNavigation.SOURCE_ARG) { + type = NavType.StringType + nullable = false + defaultValue = "direct" + }, + ), + ) { + AddBillerScreen( + onNavigateBack = onNavigateBack, + onNavigateToBillerList = { + navController.navigateToBillerList() + }, + ) + } + + composableWithFadeTransitions(AutoPayNavigation.AUTO_PAY_BILLER_LIST_ROUTE) { + BillerListScreen( + onNavigateBack = { + navController.popBackStack(AutoPayNavigation.AUTO_PAY_ROUTE, false) + }, + onNavigateToAddBiller = { + navController.navigateToAddBiller(source = "direct") + }, + onNavigateToEditBiller = { billerId -> + navController.navigateToEditBiller(billerId) + }, + ) + } + + composableWithFadeTransitions( + route = "${AutoPayNavigation.AUTO_PAY_EDIT_BILLER_ROUTE}?${AutoPayNavigation.BILLER_ID_ARG}={${AutoPayNavigation.BILLER_ID_ARG}}", + arguments = listOf( + navArgument(AutoPayNavigation.BILLER_ID_ARG) { + type = NavType.StringType + nullable = false + }, + ), + ) { + EditBillerScreen( + onNavigateBack = onNavigateBack, + ) + } + + composableWithFadeTransitions(AutoPayNavigation.AUTO_PAY_ADD_BILL_ROUTE) { + AddBillScreen( + onNavigateBack = onNavigateBack, + onNavigateToBillList = { + navController.navigateToBillList( + navOptions = navOptions { + popUpTo(AutoPayNavigation.AUTO_PAY_ROUTE) { + inclusive = false + } + }, + ) + }, + onNavigateToAddBiller = { + navController.navigateToAddBiller(source = "bill_creation") + }, + ) + } + + composableWithFadeTransitions(AutoPayNavigation.AUTO_PAY_BILL_LIST_ROUTE) { + BillListScreen( + onNavigateBack = onNavigateBack, + onNavigateToAddBill = { + navController.navigateToAddBill() + }, + onNavigateToEditBill = { billId -> + navController.navigateToEditBill(billId) + }, + onNavigateToAutopay = { + navController.popBackStack(AutoPayNavigation.AUTO_PAY_ROUTE, false) + }, + ) + } + + composableWithFadeTransitions( + route = "${AutoPayNavigation.AUTO_PAY_EDIT_BILL_ROUTE}?${AutoPayNavigation.BILL_ID_ARG}={${AutoPayNavigation.BILL_ID_ARG}}", + arguments = listOf( + navArgument(AutoPayNavigation.BILL_ID_ARG) { + type = NavType.StringType + nullable = false + }, + ), + ) { + EditBillScreen( + onNavigateBack = onNavigateBack, + onNavigateToAddBiller = { + navController.navigateToAddBiller(source = "bill_creation") + }, + ) + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsScreen.kt new file mode 100644 index 000000000..d09056c2c --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsScreen.kt @@ -0,0 +1,434 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.common.CurrencyFormatter +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect + +@Composable +fun AutoPayScheduleDetailsScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AutoPayScheduleDetailsViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + MifosScaffold( + modifier = modifier, + topBarTitle = "Schedule Details", + backPress = onNavigateBack, + ) { paddingValues -> + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (state.schedule == null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = MifosIcons.Info, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Schedule Not Found", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "The requested AutoPay schedule could not be found.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + ScheduleDetailsContent( + schedule = state.schedule!!, + modifier = Modifier.padding(paddingValues), + onPauseResume = { + if (state.schedule!!.status == AutoPayStatus.ACTIVE) { + viewModel.trySendAction(AutoPayScheduleDetailsAction.PauseSchedule) + } else { + viewModel.trySendAction(AutoPayScheduleDetailsAction.ResumeSchedule) + } + }, + onEdit = { viewModel.trySendAction(AutoPayScheduleDetailsAction.EditSchedule) }, + onCancel = { viewModel.trySendAction(AutoPayScheduleDetailsAction.CancelSchedule) }, + ) + } + } + + EventsEffect(viewModel) { event -> + when (event) { + is AutoPayScheduleDetailsEvent.NavigateBack -> onNavigateBack() + is AutoPayScheduleDetailsEvent.SchedulePaused -> { /* TODO: Show success message */ } + is AutoPayScheduleDetailsEvent.ScheduleResumed -> { /* TODO: Show success message */ } + is AutoPayScheduleDetailsEvent.ScheduleCancelled -> { /* TODO: Show success message */ } + is AutoPayScheduleDetailsEvent.NavigateToEdit -> { /* TODO: Navigate to edit screen */ } + } + } +} + +@Composable +private fun ScheduleDetailsContent( + schedule: AutoPaySchedule, + onPauseResume: () -> Unit, + onEdit: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ScheduleHeaderCard(schedule = schedule) + + ScheduleInfoCard(schedule = schedule) + + PaymentDetailsCard(schedule = schedule) + + ScheduleActionsCard( + schedule = schedule, + onPauseResume = onPauseResume, + onEdit = onEdit, + onCancel = onCancel, + ) + } +} + +@Composable +private fun ScheduleHeaderCard( + schedule: AutoPaySchedule, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = schedule.name, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + StatusChip(status = schedule.status) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = CurrencyFormatter.format(schedule.amount, schedule.currency, 2), + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + Text( + text = "per ${schedule.frequency.lowercase()}", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } + } +} + +@Composable +private fun ScheduleInfoCard( + schedule: AutoPaySchedule, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Schedule Information", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + InfoRow( + label = "Recipient", + value = schedule.recipientName, + icon = MifosIcons.Person, + ) + + InfoRow( + label = "Account Number", + value = schedule.accountNumber, + icon = MifosIcons.Bank, + ) + + InfoRow( + label = "Frequency", + value = schedule.frequency, + icon = MifosIcons.CalenderMonth, + ) + + InfoRow( + label = "Next Payment", + value = schedule.nextPaymentDate, + icon = MifosIcons.CalenderMonth, + ) + } + } +} + +@Composable +private fun PaymentDetailsCard( + schedule: AutoPaySchedule, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Payment Details", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + InfoRow( + label = "Amount", + value = CurrencyFormatter.format(schedule.amount, schedule.currency, 2), + icon = MifosIcons.AttachMoney, + ) + + InfoRow( + label = "Status", + value = schedule.status.name, + icon = MifosIcons.Info, + ) + } + } +} + +@Composable +private fun ScheduleActionsCard( + schedule: AutoPaySchedule, + onPauseResume: () -> Unit, + onEdit: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Actions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ActionButton( + text = if (schedule.status == AutoPayStatus.ACTIVE) "Pause" else "Resume", + icon = if (schedule.status == AutoPayStatus.ACTIVE) MifosIcons.FlashOff else MifosIcons.FlashOn, + onClick = onPauseResume, + modifier = Modifier.weight(1f), + ) + + ActionButton( + text = "Edit", + icon = MifosIcons.Edit, + onClick = onEdit, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + ActionButton( + text = "Cancel Schedule", + icon = MifosIcons.Delete, + onClick = onCancel, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun InfoRow( + label: String, + value: String, + icon: ImageVector, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + } +} + +@Composable +private fun ActionButton( + text: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Button( + onClick = onClick, + modifier = modifier, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + + Spacer(modifier = Modifier.size(8.dp)) + + Text(text = text) + } +} + +@Composable +private fun StatusChip( + status: AutoPayStatus, + modifier: Modifier = Modifier, +) { + val (backgroundColor, textColor) = when (status) { + AutoPayStatus.ACTIVE -> MaterialTheme.colorScheme.primary to MaterialTheme.colorScheme.onPrimary + AutoPayStatus.PAUSED -> MaterialTheme.colorScheme.tertiary to MaterialTheme.colorScheme.onTertiary + AutoPayStatus.CANCELLED -> MaterialTheme.colorScheme.error to MaterialTheme.colorScheme.onError + AutoPayStatus.COMPLETED -> MaterialTheme.colorScheme.secondary to MaterialTheme.colorScheme.onSecondary + } + + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = backgroundColor), + ) { + Text( + text = status.name, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = textColor, + fontWeight = FontWeight.Medium, + ) + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsViewModel.kt new file mode 100644 index 000000000..8fb3c46d2 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleDetailsViewModel.kt @@ -0,0 +1,148 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.mifospay.core.common.getSerialized +import org.mifospay.core.common.setSerialized +import org.mifospay.core.ui.utils.BaseViewModel + +class AutoPayScheduleDetailsViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: AutoPayScheduleDetailsState( + scheduleId = requireNotNull(savedStateHandle.get("scheduleId")), + ), +) { + + companion object { + private const val KEY_STATE = "autopay_schedule_details_state" + } + + init { + stateFlow + .onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } + .launchIn(viewModelScope) + + loadScheduleDetails() + } + + override fun handleAction(action: AutoPayScheduleDetailsAction) { + when (action) { + is AutoPayScheduleDetailsAction.PauseSchedule -> { + pauseSchedule() + } + is AutoPayScheduleDetailsAction.ResumeSchedule -> { + resumeSchedule() + } + is AutoPayScheduleDetailsAction.EditSchedule -> { + editSchedule() + } + is AutoPayScheduleDetailsAction.CancelSchedule -> { + cancelSchedule() + } + is AutoPayScheduleDetailsAction.NavigateBack -> { + sendEvent(AutoPayScheduleDetailsEvent.NavigateBack) + } + } + } + + private fun loadScheduleDetails() { + mutableStateFlow.update { it.copy(isLoading = true) } + + // Simulate API call delay + viewModelScope.launch { + delay(500) + + // For now, we'll use dummy data + // In a real implementation, this would fetch from a repository + val dummySchedule = AutoPaySchedule( + id = state.scheduleId, + name = "Monthly Rent Payment", + amount = 1200.0, + currency = "USD", + frequency = "Monthly", + nextPaymentDate = "2024-02-15", + status = AutoPayStatus.ACTIVE, + recipientName = "Landlord Corp", + accountNumber = "****1234", + ) + + mutableStateFlow.update { + it.copy( + isLoading = false, + schedule = dummySchedule, + ) + } + } + } + + private fun pauseSchedule() { + mutableStateFlow.update { + it.copy( + schedule = it.schedule?.copy(status = AutoPayStatus.PAUSED), + ) + } + sendEvent(AutoPayScheduleDetailsEvent.SchedulePaused) + } + + private fun resumeSchedule() { + mutableStateFlow.update { + it.copy( + schedule = it.schedule?.copy(status = AutoPayStatus.ACTIVE), + ) + } + sendEvent(AutoPayScheduleDetailsEvent.ScheduleResumed) + } + + private fun editSchedule() { + sendEvent(AutoPayScheduleDetailsEvent.NavigateToEdit(state.scheduleId)) + } + + private fun cancelSchedule() { + mutableStateFlow.update { + it.copy( + schedule = it.schedule?.copy(status = AutoPayStatus.CANCELLED), + ) + } + sendEvent(AutoPayScheduleDetailsEvent.ScheduleCancelled) + } +} + +@Serializable +data class AutoPayScheduleDetailsState( + val scheduleId: String, + val isLoading: Boolean = false, + val schedule: AutoPaySchedule? = null, + val error: String? = null, +) + +sealed interface AutoPayScheduleDetailsEvent { + data object NavigateBack : AutoPayScheduleDetailsEvent + data object SchedulePaused : AutoPayScheduleDetailsEvent + data object ScheduleResumed : AutoPayScheduleDetailsEvent + data object ScheduleCancelled : AutoPayScheduleDetailsEvent + data class NavigateToEdit(val scheduleId: String) : AutoPayScheduleDetailsEvent +} + +sealed interface AutoPayScheduleDetailsAction { + data object PauseSchedule : AutoPayScheduleDetailsAction + data object ResumeSchedule : AutoPayScheduleDetailsAction + data object EditSchedule : AutoPayScheduleDetailsAction + data object CancelSchedule : AutoPayScheduleDetailsAction + data object NavigateBack : AutoPayScheduleDetailsAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleManagementScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleManagementScreen.kt new file mode 100644 index 000000000..13886f001 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleManagementScreen.kt @@ -0,0 +1,505 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +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.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.common.CurrencyFormatter +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.rememberMifosPullToRefreshState +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.model.autopay.BillStatus +import org.mifospay.core.ui.utils.EventsEffect + +@Composable +fun AutoPayScheduleManagementScreen( + onNavigateBack: () -> Unit, + onNavigateToAddBill: () -> Unit, + onNavigateToEditBill: (String) -> Unit, + onNavigateToBillList: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AutoPayScheduleManagementViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val pullRefreshState = rememberMifosPullToRefreshState( + isEnabled = true, + isRefreshing = state.isLoading, + onRefresh = { viewModel.trySendAction(AutoPayScheduleManagementAction.RefreshSchedules) }, + ) + + EventsEffect(viewModel) { event -> + when (event) { + is AutoPayScheduleManagementEvent.NavigateToAddBill -> onNavigateToAddBill() + is AutoPayScheduleManagementEvent.NavigateToEditBill -> onNavigateToEditBill(event.billId) + is AutoPayScheduleManagementEvent.NavigateToBillList -> onNavigateToBillList() + is AutoPayScheduleManagementEvent.AutoPayToggled -> { + // AutoPay toggled successfully + } + } + } + + MifosScaffold( + modifier = modifier, + topBarTitle = "AutoPay Schedules", + backPress = onNavigateBack, + pullToRefreshState = pullRefreshState, + ) { paddingValues -> + if (state.isLoading && state.billsWithAutoPay.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 80.dp), + ) { + item { + ScheduleManagementHeader( + totalSchedules = state.billsWithAutoPay.size, + activeSchedules = state.billsWithAutoPay.count { it.status == BillStatus.ACTIVE }, + ) + } + + if (state.billsWithAutoPay.isEmpty()) { + item { + EmptySchedulesCard( + onAddBill = { viewModel.trySendAction(AutoPayScheduleManagementAction.AddNewBill) }, + onViewAllBills = { viewModel.trySendAction(AutoPayScheduleManagementAction.ViewAllBills) }, + ) + } + } else { + items(state.billsWithAutoPay) { bill -> + AutoPayScheduleCard( + bill = bill, + onEdit = { viewModel.trySendAction(AutoPayScheduleManagementAction.EditBill(bill.id ?: "")) }, + onToggleAutoPay = { enabled -> + viewModel.trySendAction(AutoPayScheduleManagementAction.ToggleAutoPay(bill.id ?: "", enabled)) + }, + onDelete = { viewModel.trySendAction(AutoPayScheduleManagementAction.DeleteBill(bill.id ?: "")) }, + ) + } + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + MifosButton( + text = { Text("Add Bill") }, + onClick = { viewModel.trySendAction(AutoPayScheduleManagementAction.AddNewBill) }, + modifier = Modifier.weight(1f), + ) + + MifosButton( + text = { Text("All Bills") }, + onClick = { viewModel.trySendAction(AutoPayScheduleManagementAction.ViewAllBills) }, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun ScheduleManagementHeader( + totalSchedules: Int, + activeSchedules: Int, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(48.dp), + ) { + ScheduleStatItem( + label = "Total Schedules", + value = totalSchedules.toString(), + icon = MifosIcons.List, + ) + + ScheduleStatItem( + label = "Active Schedules", + value = activeSchedules.toString(), + icon = MifosIcons.CheckCircle, + ) + } + } + } +} + +@Composable +private fun ScheduleStatItem( + label: String, + value: String, + icon: ImageVector, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } +} + +@Composable +private fun AutoPayScheduleCard( + bill: Bill, + onEdit: () -> Unit, + onToggleAutoPay: (Boolean) -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + var showMenu by remember { mutableStateOf(false) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = bill.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + + Text( + text = bill.billerName ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + checked = bill.autoPayEnabled, + onCheckedChange = onToggleAutoPay, + ) + + Box { + IconButton( + onClick = { showMenu = true }, + ) { + Icon( + imageVector = MifosIcons.MoreVert, + contentDescription = "More options", + ) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Edit") }, + leadingIcon = { + Icon( + imageVector = MifosIcons.Edit, + contentDescription = null, + ) + }, + onClick = { + showMenu = false + onEdit() + }, + ) + DropdownMenuItem( + text = { Text("Delete") }, + leadingIcon = { + Icon( + imageVector = MifosIcons.Delete, + contentDescription = null, + ) + }, + onClick = { + showMenu = false + onDelete() + }, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = "Amount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = CurrencyFormatter.format(bill.amount, bill.currency, 2), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + + Column { + Text( + text = "Next Payment", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatDate(bill.dueDate), + style = MaterialTheme.typography.bodyMedium, + ) + } + + Column { + Text( + text = "Frequency", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = bill.recurrencePattern.displayName, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + if (bill.autoPayPaymentMethod != null || bill.autoPaySourceAccount != null) { + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + if (bill.autoPayPaymentMethod != null) { + Column { + Text( + text = "Payment Method", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = bill.autoPayPaymentMethod ?: "", + style = MaterialTheme.typography.bodySmall, + ) + } + } + + if (bill.autoPaySourceAccount != null) { + Column { + Text( + text = "Source Account", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = bill.autoPaySourceAccount ?: "", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + } + } +} + +@Composable +private fun EmptySchedulesCard( + onAddBill: () -> Unit, + onViewAllBills: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = MifosIcons.Payment, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "No AutoPay Schedules", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Create bills with AutoPay enabled to see your schedules here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Button( + onClick = onAddBill, + ) { + Icon( + imageVector = MifosIcons.Add, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Add Bill") + } + + Button( + onClick = onViewAllBills, + ) { + Icon( + imageVector = MifosIcons.List, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("View All Bills") + } + } + } + } +} + +private fun formatDate(timestamp: Long): String { + return try { + val instant = Instant.fromEpochMilliseconds(timestamp) + val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + "${localDateTime.monthNumber}/${localDateTime.dayOfMonth}/${localDateTime.year}" + } catch (e: Exception) { + "Invalid Date" + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleManagementViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleManagementViewModel.kt new file mode 100644 index 000000000..e8433ab8b --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScheduleManagementViewModel.kt @@ -0,0 +1,206 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable +import org.mifospay.core.common.getSerialized +import org.mifospay.core.common.setSerialized +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.model.autopay.BillStatus +import org.mifospay.core.ui.utils.BaseViewModel + +class AutoPayScheduleManagementViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: AutoPayScheduleManagementState(), +) { + + companion object { + private const val KEY_STATE = "autopay_schedule_management_state" + } + + init { + stateFlow + .onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } + .launchIn(viewModelScope) + + loadSchedules() + } + + override fun handleAction(action: AutoPayScheduleManagementAction) { + when (action) { + is AutoPayScheduleManagementAction.RefreshSchedules -> { + loadSchedules() + } + is AutoPayScheduleManagementAction.ToggleAutoPay -> { + toggleAutoPay(action.billId, action.enabled) + } + is AutoPayScheduleManagementAction.EditBill -> { + sendEvent(AutoPayScheduleManagementEvent.NavigateToEditBill(action.billId)) + } + is AutoPayScheduleManagementAction.DeleteBill -> { + deleteBill(action.billId) + } + is AutoPayScheduleManagementAction.AddNewBill -> { + sendEvent(AutoPayScheduleManagementEvent.NavigateToAddBill) + } + is AutoPayScheduleManagementAction.ViewAllBills -> { + sendEvent(AutoPayScheduleManagementEvent.NavigateToBillList) + } + } + } + + private fun loadSchedules() { + mutableStateFlow.update { it.copy(isLoading = true) } + + viewModelScope.launch { + try { + delay(1000) + + val dummyBillsWithAutoPay = listOf( + Bill( + id = "1", + name = "Monthly Rent Payment", + amount = 1200.0, + currency = "USD", + dueDate = Clock.System.now().toEpochMilliseconds() + (15 * 24 * 60 * 60 * 1000L), + recurrencePattern = org.mifospay.core.model.autopay.RecurrencePattern.MONTHLY, + billerId = "biller1", + billerName = "Landlord Corp", + description = "Monthly rent payment", + isActive = true, + status = BillStatus.ACTIVE, + autoPayEnabled = true, + autoPayPaymentMethod = "Bank Account", + autoPaySourceAccount = "****1234", + autoPayMaxAmount = 1500.0, + ), + Bill( + id = "2", + name = "Internet Bill", + amount = 89.99, + currency = "USD", + dueDate = Clock.System.now().toEpochMilliseconds() + (20 * 24 * 60 * 60 * 1000L), + recurrencePattern = org.mifospay.core.model.autopay.RecurrencePattern.MONTHLY, + billerId = "biller2", + billerName = "NetConnect", + description = "Monthly internet service", + isActive = true, + status = BillStatus.ACTIVE, + autoPayEnabled = true, + autoPayPaymentMethod = "Credit Card", + autoPaySourceAccount = "****5678", + autoPayMaxAmount = 100.0, + ), + Bill( + id = "3", + name = "Gym Membership", + amount = 45.0, + currency = "USD", + dueDate = Clock.System.now().toEpochMilliseconds() + (25 * 24 * 60 * 60 * 1000L), + recurrencePattern = org.mifospay.core.model.autopay.RecurrencePattern.MONTHLY, + billerId = "biller3", + billerName = "FitLife Gym", + description = "Monthly gym membership", + isActive = true, + status = BillStatus.PAUSED, + autoPayEnabled = true, + autoPayPaymentMethod = "Bank Account", + autoPaySourceAccount = "****9012", + autoPayMaxAmount = 50.0, + ), + ) + + mutableStateFlow.update { + it.copy( + isLoading = false, + billsWithAutoPay = dummyBillsWithAutoPay, + error = null, + ) + } + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = e.message ?: "Failed to load schedules", + ) + } + } + } + } + + private fun toggleAutoPay(billId: String, enabled: Boolean) { + mutableStateFlow.update { currentState -> + val updatedBills = currentState.billsWithAutoPay.map { bill -> + if (bill.id == billId) { + bill.copy(autoPayEnabled = enabled) + } else { + bill + } + } + currentState.copy(billsWithAutoPay = updatedBills) + } + + viewModelScope.launch { + try { + delay(500) + sendEvent(AutoPayScheduleManagementEvent.AutoPayToggled(billId, enabled)) + } catch (e: Exception) { + loadSchedules() + } + } + } + + private fun deleteBill(billId: String) { + mutableStateFlow.update { currentState -> + val updatedBills = currentState.billsWithAutoPay.filter { it.id != billId } + currentState.copy(billsWithAutoPay = updatedBills) + } + + viewModelScope.launch { + try { + delay(500) + } catch (e: Exception) { + loadSchedules() + } + } + } +} + +@Serializable +data class AutoPayScheduleManagementState( + val billsWithAutoPay: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) + +sealed interface AutoPayScheduleManagementEvent { + data object NavigateToAddBill : AutoPayScheduleManagementEvent + data class NavigateToEditBill(val billId: String) : AutoPayScheduleManagementEvent + data object NavigateToBillList : AutoPayScheduleManagementEvent + data class AutoPayToggled(val billId: String, val enabled: Boolean) : AutoPayScheduleManagementEvent +} + +sealed interface AutoPayScheduleManagementAction { + data object RefreshSchedules : AutoPayScheduleManagementAction + data class ToggleAutoPay(val billId: String, val enabled: Boolean) : AutoPayScheduleManagementAction + data class EditBill(val billId: String) : AutoPayScheduleManagementAction + data class DeleteBill(val billId: String) : AutoPayScheduleManagementAction + data object AddNewBill : AutoPayScheduleManagementAction + data object ViewAllBills : AutoPayScheduleManagementAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt new file mode 100644 index 000000000..2939eda78 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayScreen.kt @@ -0,0 +1,694 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.common.CurrencyFormatter +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.rememberMifosPullToRefreshState +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect + +@Composable +fun AutoPayScreen( + onNavigateToScheduleManagement: () -> Unit, + onNavigateToPreferences: () -> Unit, + onNavigateToHistory: () -> Unit, + onNavigateToScheduleDetails: (String) -> Unit, + onNavigateToAddBiller: () -> Unit, + onNavigateToBillerList: () -> Unit, + onNavigateToAddBill: () -> Unit, + onNavigateToBillList: () -> Unit, + onNavigateBack: () -> Unit = {}, + showTopBar: Boolean = true, + modifier: Modifier = Modifier, + viewModel: AutoPayViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val pullRefreshState = rememberMifosPullToRefreshState( + isEnabled = true, + isRefreshing = state.isLoading, + onRefresh = { viewModel.trySendAction(AutoPayAction.RefreshDashboard) }, + ) + + MifosScaffold( + modifier = modifier, + topBarTitle = if (showTopBar) "AutoPay Dashboard" else null, + backPress = onNavigateBack, + pullToRefreshState = pullRefreshState, + ) { paddingValues -> + if (state.isLoading && state.activeSchedules.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + AutoPayDashboardContent( + state = state, + viewModel = viewModel, + onRefresh = { viewModel.trySendAction(AutoPayAction.RefreshDashboard) }, + onViewScheduleDetails = { scheduleId -> + viewModel.trySendAction(AutoPayAction.ViewScheduleDetails(scheduleId)) + }, + onNavigateToScheduleManagement = onNavigateToScheduleManagement, + onNavigateToPreferences = onNavigateToPreferences, + onNavigateToHistory = onNavigateToHistory, + onNavigateToScheduleDetails = onNavigateToScheduleDetails, + modifier = Modifier.padding(paddingValues), + ) + } + } + + EventsEffect(viewModel) { event -> + when (event) { + is AutoPayEvent.NavigateToScheduleManagement -> onNavigateToScheduleManagement() + is AutoPayEvent.NavigateToPreferences -> onNavigateToPreferences() + is AutoPayEvent.NavigateToHistory -> onNavigateToHistory() + is AutoPayEvent.NavigateToAddBiller -> onNavigateToAddBiller() + is AutoPayEvent.NavigateToBillerList -> onNavigateToBillerList() + is AutoPayEvent.NavigateToAddBill -> onNavigateToAddBill() + is AutoPayEvent.NavigateToBillList -> onNavigateToBillList() + is AutoPayEvent.NavigateToScheduleDetails -> onNavigateToScheduleDetails(event.scheduleId) + } + } +} + +@Composable +private fun AutoPayDashboardContent( + state: AutoPayState, + viewModel: AutoPayViewModel, + onRefresh: () -> Unit, + onViewScheduleDetails: (String) -> Unit, + onNavigateToScheduleManagement: () -> Unit, + onNavigateToPreferences: () -> Unit, + onNavigateToHistory: () -> Unit, + onNavigateToScheduleDetails: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + DashboardHeader( + totalActiveSchedules = state.totalActiveSchedules, + totalUpcomingPayments = state.totalUpcomingPayments, + ) + } + + item { + QuickActionsSection( + onAddBill = { viewModel.trySendAction(AutoPayAction.AddNewBill) }, + onManageBills = { viewModel.trySendAction(AutoPayAction.ViewBillList) }, + onAddBiller = { viewModel.trySendAction(AutoPayAction.AddNewBiller) }, + onManageBillers = { viewModel.trySendAction(AutoPayAction.ViewBillerList) }, + onAutoPaySettings = { viewModel.trySendAction(AutoPayAction.ManagePaymentPreferences) }, + onScheduleManagement = onNavigateToScheduleManagement, + onViewHistory = { viewModel.trySendAction(AutoPayAction.GetPaymentHistory) }, + ) + } + + item { + Text( + text = "Active Schedules", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Medium, + ) + } + + if (state.activeSchedules.isEmpty()) { + item { + EmptyStateCard( + title = "No Active Schedules", + description = "You don't have any active AutoPay schedules. Create one to get started!", + icon = MifosIcons.Payment, + ) + } + } else { + items(state.activeSchedules) { schedule -> + ActiveScheduleCard( + schedule = schedule, + onClick = { onViewScheduleDetails(schedule.id) }, + ) + } + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Upcoming Payments", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Medium, + ) + } + + if (state.upcomingPayments.isEmpty()) { + item { + EmptyStateCard( + title = "No Upcoming Payments", + description = "No payments are scheduled for the near future.", + icon = MifosIcons.CalenderMonth, + ) + } + } else { + items(state.upcomingPayments) { payment -> + UpcomingPaymentCard( + payment = payment, + ) + } + } + } +} + +@Composable +private fun DashboardHeader( + totalActiveSchedules: Int, + totalUpcomingPayments: Int, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + DashboardStat( + label = "Active Schedules", + value = totalActiveSchedules.toString(), + icon = MifosIcons.Payment, + ) + + DashboardStat( + label = "Upcoming Payments", + value = totalUpcomingPayments.toString(), + icon = MifosIcons.CalenderMonth, + ) + } + } + } +} + +@Composable +private fun DashboardStat( + label: String, + value: String, + icon: ImageVector, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } +} + +@Composable +private fun QuickActionsSection( + onAddBill: () -> Unit, + onManageBills: () -> Unit, + onAddBiller: () -> Unit, + onManageBillers: () -> Unit, + onAutoPaySettings: () -> Unit, + onScheduleManagement: () -> Unit, + onViewHistory: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Quick Actions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SquareActionButton( + text = "Add Bill", + icon = MifosIcons.Receipt, + onClick = onAddBill, + modifier = Modifier.weight(1f), + ) + + SquareActionButton( + text = "Manage Bills", + icon = MifosIcons.List, + onClick = onManageBills, + modifier = Modifier.weight(1f), + ) + + SquareActionButton( + text = "Add Biller", + icon = MifosIcons.PersonAdd, + onClick = onAddBiller, + modifier = Modifier.weight(1f), + ) + + SquareActionButton( + text = "Manage Billers", + icon = MifosIcons.Person, + onClick = onManageBillers, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SquareActionButton( + text = "AutoPay Settings", + icon = MifosIcons.Settings, + onClick = onAutoPaySettings, + modifier = Modifier.weight(1f), + ) + + SquareActionButton( + text = "Manage Schedules", + icon = MifosIcons.Schedule, + onClick = onScheduleManagement, + modifier = Modifier.weight(1f), + ) + + SquareActionButton( + text = "View History", + icon = MifosIcons.History, + onClick = onViewHistory, + modifier = Modifier.weight(1f), + ) + + Spacer(modifier = Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun SquareActionButton( + text: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + androidx.compose.material3.Surface( + modifier = modifier + .clickable { onClick() }, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 2.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(56.dp) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = text, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + ) + } + } +} + +@Composable +private fun ActiveScheduleCard( + schedule: AutoPaySchedule, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Button( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = schedule.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + + Text( + text = schedule.recipientName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + StatusChip(status = schedule.status) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = "Amount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = CurrencyFormatter.format(schedule.amount, schedule.currency, 2), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + + Column { + Text( + text = "Next Payment", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = schedule.nextPaymentDate, + style = MaterialTheme.typography.bodyMedium, + ) + } + + Column { + Text( + text = "Frequency", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = schedule.frequency, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + } +} + +@Composable +private fun UpcomingPaymentCard( + payment: UpcomingPayment, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = payment.scheduleName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + + Text( + text = payment.recipientName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + StatusChip(status = payment.status) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = "Amount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = CurrencyFormatter.format(payment.amount, payment.currency, 2), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + + Column { + Text( + text = "Due Date", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = payment.dueDate, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +@Composable +private fun StatusChip( + status: AutoPayStatus, + modifier: Modifier = Modifier, +) { + val (backgroundColor, textColor) = when (status) { + AutoPayStatus.ACTIVE -> MaterialTheme.colorScheme.primary to MaterialTheme.colorScheme.onPrimary + AutoPayStatus.PAUSED -> MaterialTheme.colorScheme.tertiary to MaterialTheme.colorScheme.onTertiary + AutoPayStatus.CANCELLED -> MaterialTheme.colorScheme.error to MaterialTheme.colorScheme.onError + AutoPayStatus.COMPLETED -> MaterialTheme.colorScheme.secondary to MaterialTheme.colorScheme.onSecondary + } + + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = backgroundColor), + ) { + Text( + text = status.name, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = textColor, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun StatusChip( + status: PaymentStatus, + modifier: Modifier = Modifier, +) { + val (backgroundColor, textColor) = when (status) { + PaymentStatus.UPCOMING -> MaterialTheme.colorScheme.primary to MaterialTheme.colorScheme.onPrimary + PaymentStatus.PROCESSING -> MaterialTheme.colorScheme.tertiary to MaterialTheme.colorScheme.onTertiary + PaymentStatus.COMPLETED -> MaterialTheme.colorScheme.secondary to MaterialTheme.colorScheme.onSecondary + PaymentStatus.FAILED -> MaterialTheme.colorScheme.error to MaterialTheme.colorScheme.onError + PaymentStatus.CANCELLED -> MaterialTheme.colorScheme.error to MaterialTheme.colorScheme.onError + } + + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = backgroundColor), + ) { + Text( + text = status.name, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = textColor, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun EmptyStateCard( + title: String, + description: String, + icon: ImageVector, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySettingsScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySettingsScreen.kt new file mode 100644 index 000000000..050f25ac8 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySettingsScreen.kt @@ -0,0 +1,380 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect + +@Composable +fun AutoPayPreferencesScreen( + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AutoPayPreferencesViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + is AutoPayPreferencesEvent.SettingsSaved -> { + onNavigateBack() + } + is AutoPayPreferencesEvent.ShowError -> { + // Handle error display + } + } + } + + MifosScaffold( + modifier = modifier, + topBarTitle = "AutoPay Settings", + backPress = onNavigateBack, + ) { paddingValues -> + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + GeneralSettingsSection( + settings = state.globalSettings, + onToggleAutoPay = { enabled -> + viewModel.trySendAction(AutoPayPreferencesAction.ToggleAutoPayEnabled(enabled)) + }, + ) + + NotificationSettingsSection( + settings = state.globalSettings.notificationSettings, + onSettingsChanged = { notificationSettings -> + viewModel.trySendAction(AutoPayPreferencesAction.UpdateNotificationSettings(notificationSettings)) + }, + ) + + SecuritySettingsSection( + settings = state.globalSettings.securitySettings, + onSettingsChanged = { securitySettings -> + viewModel.trySendAction(AutoPayPreferencesAction.UpdateSecuritySettings(securitySettings)) + }, + ) + + AutoPayRulesSection( + rules = state.globalSettings.globalAutoPayRules, + onRulesChanged = { rules -> + viewModel.trySendAction(AutoPayPreferencesAction.UpdateAutoPayRules(rules)) + }, + ) + + if (state.hasUnsavedChanges) { + Spacer(modifier = Modifier.height(16.dp)) + + MifosButton( + onClick = { viewModel.trySendAction(AutoPayPreferencesAction.SaveSettings) }, + enabled = !state.isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + if (state.isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } else { + Text("Save Settings") + } + } + } + } + } + } +} + +@Composable +private fun GeneralSettingsSection( + settings: org.mifospay.core.model.autopay.AutoPayGlobalSettings, + onToggleAutoPay: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + SettingsCard( + title = "General", + modifier = modifier, + ) { + SettingRow( + title = "AutoPay Enabled", + description = "Enable or disable AutoPay functionality globally", + icon = MifosIcons.Power, + checked = settings.isAutoPayEnabled, + onCheckedChange = onToggleAutoPay, + ) + } +} + +@Composable +private fun NotificationSettingsSection( + settings: org.mifospay.core.model.autopay.NotificationSettings, + onSettingsChanged: (org.mifospay.core.model.autopay.NotificationSettings) -> Unit, + modifier: Modifier = Modifier, +) { + SettingsCard( + title = "Notifications", + modifier = modifier, + ) { + SettingRow( + title = "Payment Confirmations", + description = "Receive notifications when payments are processed", + icon = MifosIcons.OutlinedNotifications, + checked = settings.paymentConfirmations, + onCheckedChange = { enabled -> + onSettingsChanged(settings.copy(paymentConfirmations = enabled)) + }, + ) + + SettingRow( + title = "Failed Payment Alerts", + description = "Get notified when payments fail", + icon = MifosIcons.Warning, + checked = settings.failedPaymentAlerts, + onCheckedChange = { enabled -> + onSettingsChanged(settings.copy(failedPaymentAlerts = enabled)) + }, + ) + + SettingRow( + title = "Schedule Reminders", + description = "Receive reminders before scheduled payments", + icon = MifosIcons.Schedule, + checked = settings.scheduleReminders, + onCheckedChange = { enabled -> + onSettingsChanged(settings.copy(scheduleReminders = enabled)) + }, + ) + + SettingRow( + title = "Email Notifications", + description = "Receive notifications via email", + icon = MifosIcons.Email, + checked = settings.emailNotifications, + onCheckedChange = { enabled -> + onSettingsChanged(settings.copy(emailNotifications = enabled)) + }, + ) + + SettingRow( + title = "Push Notifications", + description = "Receive push notifications on your device", + icon = MifosIcons.OutlinedNotifications, + checked = settings.pushNotifications, + onCheckedChange = { enabled -> + onSettingsChanged(settings.copy(pushNotifications = enabled)) + }, + ) + } +} + +@Composable +private fun SecuritySettingsSection( + settings: org.mifospay.core.model.autopay.SecuritySettings, + onSettingsChanged: (org.mifospay.core.model.autopay.SecuritySettings) -> Unit, + modifier: Modifier = Modifier, +) { + SettingsCard( + title = "Security", + modifier = modifier, + ) { + SettingRow( + title = "Two-Factor Authentication", + description = "Require 2FA for AutoPay changes", + icon = MifosIcons.Security, + checked = settings.requireTwoFactorAuth, + onCheckedChange = { enabled -> + onSettingsChanged(settings.copy(requireTwoFactorAuth = enabled)) + }, + ) + + SettingRow( + title = "Large Payment Confirmation", + description = "Require confirmation for payments above threshold", + icon = MifosIcons.AttachMoney, + checked = settings.requireConfirmationForLargePayments, + onCheckedChange = { enabled -> + onSettingsChanged(settings.copy(requireConfirmationForLargePayments = enabled)) + }, + ) + + SettingRow( + title = "Multiple Payments Per Day", + description = "Allow multiple AutoPay transactions per day", + icon = MifosIcons.Repeat, + checked = settings.allowMultiplePaymentsPerDay, + onCheckedChange = { enabled -> + onSettingsChanged(settings.copy(allowMultiplePaymentsPerDay = enabled)) + }, + ) + } +} + +@Composable +private fun AutoPayRulesSection( + rules: org.mifospay.core.model.autopay.AutoPayRules, + onRulesChanged: (org.mifospay.core.model.autopay.AutoPayRules) -> Unit, + modifier: Modifier = Modifier, +) { + SettingsCard( + title = "AutoPay Rules", + modifier = modifier, + ) { + SettingRow( + title = "Auto-Approve Payments", + description = "Automatically approve payments without manual confirmation", + icon = MifosIcons.CheckCircle, + checked = rules.autoApprovePayments, + onCheckedChange = { enabled -> + onRulesChanged(rules.copy(autoApprovePayments = enabled)) + }, + ) + + SettingRow( + title = "Skip Payments on Holidays", + description = "Skip AutoPay on bank holidays", + icon = MifosIcons.CalenderMonth, + checked = rules.skipPaymentsOnHolidays, + onCheckedChange = { enabled -> + onRulesChanged(rules.copy(skipPaymentsOnHolidays = enabled)) + }, + ) + + SettingRow( + title = "Retry Failed Payments", + description = "Automatically retry failed payment attempts", + icon = MifosIcons.Refresh, + checked = rules.retryFailedPayments, + onCheckedChange = { enabled -> + onRulesChanged(rules.copy(retryFailedPayments = enabled)) + }, + ) + } +} + +@Composable +private fun SettingsCard( + title: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + content() + } + } + } +} + +@Composable +private fun SettingRow( + title: String, + description: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.size(12.dp)) + + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySettingsViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySettingsViewModel.kt new file mode 100644 index 000000000..b6f27dc41 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPaySettingsViewModel.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.mifospay.core.common.getSerialized +import org.mifospay.core.common.setSerialized +import org.mifospay.core.model.autopay.AutoPayGlobalSettings +import org.mifospay.core.model.autopay.AutoPayRules +import org.mifospay.core.model.autopay.NotificationSettings +import org.mifospay.core.model.autopay.SecuritySettings +import org.mifospay.core.ui.utils.BaseViewModel + +class AutoPayPreferencesViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: AutoPayPreferencesState(), +) { + + companion object { + private const val KEY_STATE = "autopay_preferences_state" + } + + init { + stateFlow + .onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } + .launchIn(viewModelScope) + + loadSettings() + } + + override fun handleAction(action: AutoPayPreferencesAction) { + when (action) { + is AutoPayPreferencesAction.UpdateGlobalSettings -> { + updateGlobalSettings(action.settings) + } + is AutoPayPreferencesAction.UpdateNotificationSettings -> { + updateNotificationSettings(action.settings) + } + is AutoPayPreferencesAction.UpdateSecuritySettings -> { + updateSecuritySettings(action.settings) + } + is AutoPayPreferencesAction.UpdateAutoPayRules -> { + updateAutoPayRules(action.rules) + } + is AutoPayPreferencesAction.ToggleAutoPayEnabled -> { + toggleAutoPayEnabled(action.enabled) + } + is AutoPayPreferencesAction.SaveSettings -> { + saveSettings() + } + is AutoPayPreferencesAction.LoadSettings -> { + loadSettings() + } + } + } + + private fun loadSettings() { + mutableStateFlow.update { it.copy(isLoading = true) } + + viewModelScope.launch { + try { + val defaultSettings = AutoPayGlobalSettings() + + mutableStateFlow.update { + it.copy( + isLoading = false, + globalSettings = defaultSettings, + hasUnsavedChanges = false, + ) + } + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = e.message ?: "Failed to load settings", + ) + } + } + } + } + + private fun updateGlobalSettings(settings: AutoPayGlobalSettings) { + mutableStateFlow.update { + it.copy( + globalSettings = settings, + hasUnsavedChanges = true, + ) + } + } + + private fun updateNotificationSettings(settings: NotificationSettings) { + val currentSettings = stateFlow.value.globalSettings + val updatedSettings = currentSettings.copy(notificationSettings = settings) + + mutableStateFlow.update { + it.copy( + globalSettings = updatedSettings, + hasUnsavedChanges = true, + ) + } + } + + private fun updateSecuritySettings(settings: SecuritySettings) { + val currentSettings = stateFlow.value.globalSettings + val updatedSettings = currentSettings.copy(securitySettings = settings) + + mutableStateFlow.update { + it.copy( + globalSettings = updatedSettings, + hasUnsavedChanges = true, + ) + } + } + + private fun updateAutoPayRules(rules: AutoPayRules) { + val currentSettings = stateFlow.value.globalSettings + val updatedSettings = currentSettings.copy(globalAutoPayRules = rules) + + mutableStateFlow.update { + it.copy( + globalSettings = updatedSettings, + hasUnsavedChanges = true, + ) + } + } + + private fun toggleAutoPayEnabled(enabled: Boolean) { + val currentSettings = stateFlow.value.globalSettings + val updatedSettings = currentSettings.copy(isAutoPayEnabled = enabled) + + mutableStateFlow.update { + it.copy( + globalSettings = updatedSettings, + hasUnsavedChanges = true, + ) + } + } + + private fun saveSettings() { + mutableStateFlow.update { it.copy(isSaving = true) } + + viewModelScope.launch { + try { + val settings = stateFlow.value.globalSettings + + mutableStateFlow.update { + it.copy( + isSaving = false, + hasUnsavedChanges = false, + ) + } + + sendEvent(AutoPayPreferencesEvent.SettingsSaved) + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + isSaving = false, + error = e.message ?: "Failed to save settings", + ) + } + } + } + } +} + +@Serializable +data class AutoPayPreferencesState( + val globalSettings: AutoPayGlobalSettings = AutoPayGlobalSettings(), + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val hasUnsavedChanges: Boolean = false, + val error: String? = null, +) + +sealed interface AutoPayPreferencesEvent { + data object SettingsSaved : AutoPayPreferencesEvent + data class ShowError(val message: String) : AutoPayPreferencesEvent +} + +sealed interface AutoPayPreferencesAction { + data class UpdateGlobalSettings(val settings: AutoPayGlobalSettings) : AutoPayPreferencesAction + data class UpdateNotificationSettings(val settings: NotificationSettings) : AutoPayPreferencesAction + data class UpdateSecuritySettings(val settings: SecuritySettings) : AutoPayPreferencesAction + data class UpdateAutoPayRules(val rules: AutoPayRules) : AutoPayPreferencesAction + data class ToggleAutoPayEnabled(val enabled: Boolean) : AutoPayPreferencesAction + data object SaveSettings : AutoPayPreferencesAction + data object LoadSettings : AutoPayPreferencesAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayUtils.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayUtils.kt new file mode 100644 index 000000000..3ab0b5e33 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayUtils.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.model.autopay.BillStatus + +/** + * Converts a Bill with AutoPay enabled to an AutoPaySchedule + */ +fun Bill.toAutoPaySchedule(): AutoPaySchedule { + return AutoPaySchedule( + id = this.id ?: "", + name = this.name, + amount = this.amount, + currency = this.currency, + frequency = this.recurrencePattern.displayName, + nextPaymentDate = formatDateForDisplay(this.dueDate), + status = mapBillStatusToAutoPayStatus(this.status), + recipientName = this.billerName ?: "", + accountNumber = this.autoPaySourceAccount ?: "", + ) +} + +/** + * Converts a Bill with AutoPay enabled to an UpcomingPayment + */ +fun Bill.toUpcomingPayment(): UpcomingPayment { + return UpcomingPayment( + id = this.id ?: "", + scheduleName = this.name, + amount = this.amount, + currency = this.currency, + dueDate = formatDateForDisplay(this.dueDate), + status = mapBillStatusToPaymentStatus(this.status), + recipientName = this.billerName ?: "", + ) +} + +/** + * Maps BillStatus to AutoPayStatus + */ +fun mapBillStatusToAutoPayStatus(billStatus: BillStatus): AutoPayStatus { + return when (billStatus) { + BillStatus.ACTIVE -> AutoPayStatus.ACTIVE + BillStatus.PAUSED -> AutoPayStatus.PAUSED + BillStatus.CANCELLED -> AutoPayStatus.CANCELLED + BillStatus.COMPLETED -> AutoPayStatus.COMPLETED + } +} + +/** + * Maps BillStatus to PaymentStatus + */ +fun mapBillStatusToPaymentStatus(billStatus: BillStatus): PaymentStatus { + return when (billStatus) { + BillStatus.ACTIVE -> PaymentStatus.UPCOMING + BillStatus.PAUSED -> PaymentStatus.UPCOMING + BillStatus.CANCELLED -> PaymentStatus.CANCELLED + BillStatus.COMPLETED -> PaymentStatus.COMPLETED + } +} + +/** + * Calculates the next payment date based on the bill's due date and recurrence pattern + */ +fun calculateNextPaymentDate(bill: Bill): Long { + val currentTime = Clock.System.now().toEpochMilliseconds() + val dueDate = bill.dueDate + + return when { + dueDate > currentTime -> dueDate + else -> { + // Calculate next occurrence based on recurrence pattern + val interval = bill.recurrencePattern.interval * 24 * 60 * 60 * 1000L // Convert days to milliseconds + val occurrencesSinceDue = ((currentTime - dueDate) / interval) + 1 + dueDate + (occurrencesSinceDue * interval) + } + } +} + +/** + * Formats a timestamp to a readable date string + */ +fun formatDateForDisplay(timestamp: Long): String { + return try { + val instant = Instant.fromEpochMilliseconds(timestamp) + val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + "${localDateTime.monthNumber}/${localDateTime.dayOfMonth}/${localDateTime.year}" + } catch (e: Exception) { + "Invalid Date" + } +} + +/** + * Checks if a bill is overdue + */ +fun isBillOverdue(bill: Bill): Boolean { + val currentTime = Clock.System.now().toEpochMilliseconds() + return bill.dueDate < currentTime && bill.status == BillStatus.ACTIVE +} + +/** + * Gets bills with AutoPay enabled from a list of bills + */ +fun List.getBillsWithAutoPay(): List { + return this.filter { it.autoPayEnabled } +} + +/** + * Gets active bills with AutoPay enabled + */ +fun List.getActiveBillsWithAutoPay(): List { + return this.filter { it.autoPayEnabled && it.status == BillStatus.ACTIVE } +} + +/** + * Calculates upcoming payments for bills with AutoPay enabled + */ +fun List.calculateUpcomingPayments(): List { + return this.getActiveBillsWithAutoPay() + .map { it.toUpcomingPayment() } + .sortedBy { it.dueDate } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt new file mode 100644 index 000000000..5d1dc47f9 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/AutoPayViewModel.kt @@ -0,0 +1,313 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable +import org.mifospay.core.common.getSerialized +import org.mifospay.core.common.setSerialized +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.model.autopay.BillStatus +import org.mifospay.core.model.autopay.RecurrencePattern +import org.mifospay.core.ui.utils.BaseViewModel + +class AutoPayViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: AutoPayState(), +) { + + companion object { + private const val KEY_STATE = "autopay_state" + } + + init { + stateFlow + .onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } + .launchIn(viewModelScope) + + loadDashboardData() + } + + override fun handleAction(action: AutoPayAction) { + when (action) { + is AutoPayAction.ManageRecurringPayment -> { + setupRecurringPayment() + } + is AutoPayAction.ManagePaymentPreferences -> { + managePaymentPreferences() + } + is AutoPayAction.ToggleAutoPay -> { + toggleAutoPay(action.enabled) + } + is AutoPayAction.GetPaymentHistory -> { + getPaymentHistory() + } + is AutoPayAction.RefreshDashboard -> { + refreshDashboard() + } + is AutoPayAction.AddNewSchedule -> { + addNewSchedule() + } + is AutoPayAction.ManageExistingSchedules -> { + manageExistingSchedules() + } + is AutoPayAction.ViewScheduleDetails -> { + viewScheduleDetails(action.scheduleId) + } + is AutoPayAction.AddNewBiller -> { + addNewBiller() + } + is AutoPayAction.ViewBillerList -> { + viewBillerList() + } + is AutoPayAction.AddNewBill -> { + addNewBill() + } + is AutoPayAction.ViewBillList -> { + viewBillList() + } + } + } + + private fun loadDashboardData() { + mutableStateFlow.update { it.copy(isLoading = true) } + + viewModelScope.launch { + try { + delay(1000) + + val billsWithAutoPay = getBillsWithAutoPay() + + val activeSchedules = billsWithAutoPay + .filter { it.status == BillStatus.ACTIVE } + .map { it.toAutoPaySchedule() } + + val upcomingPayments = billsWithAutoPay + .getActiveBillsWithAutoPay() + .calculateUpcomingPayments() + + mutableStateFlow.update { + it.copy( + isLoading = false, + activeSchedules = activeSchedules, + upcomingPayments = upcomingPayments, + totalActiveSchedules = activeSchedules.size, + totalUpcomingPayments = upcomingPayments.size, + error = null, + ) + } + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = e.message ?: "Failed to load dashboard data", + ) + } + } + } + } + + /** + * Gets bills with AutoPay enabled + * In a real implementation, this would come from a repository + */ + private fun getBillsWithAutoPay(): List { + return listOf( + Bill( + id = "1", + name = "Monthly Rent Payment", + amount = 1200.0, + currency = "USD", + dueDate = Clock.System.now().toEpochMilliseconds() + (15 * 24 * 60 * 60 * 1000L), + recurrencePattern = RecurrencePattern.MONTHLY, + billerId = "biller1", + billerName = "Landlord Corp", + description = "Monthly rent payment", + isActive = true, + status = BillStatus.ACTIVE, + autoPayEnabled = true, + autoPayPaymentMethod = "Bank Account", + autoPaySourceAccount = "****1234", + autoPayMaxAmount = 1500.0, + ), + Bill( + id = "2", + name = "Internet Bill", + amount = 89.99, + currency = "USD", + dueDate = Clock.System.now().toEpochMilliseconds() + (20 * 24 * 60 * 60 * 1000L), + recurrencePattern = RecurrencePattern.MONTHLY, + billerId = "biller2", + billerName = "NetConnect", + description = "Monthly internet service", + isActive = true, + status = BillStatus.ACTIVE, + autoPayEnabled = true, + autoPayPaymentMethod = "Credit Card", + autoPaySourceAccount = "****5678", + autoPayMaxAmount = 100.0, + ), + Bill( + id = "3", + name = "Gym Membership", + amount = 45.0, + currency = "USD", + dueDate = Clock.System.now().toEpochMilliseconds() + (25 * 24 * 60 * 60 * 1000L), + recurrencePattern = RecurrencePattern.MONTHLY, + billerId = "biller3", + billerName = "FitLife Gym", + description = "Monthly gym membership", + isActive = true, + status = BillStatus.PAUSED, + autoPayEnabled = true, + autoPayPaymentMethod = "Bank Account", + autoPaySourceAccount = "****9012", + autoPayMaxAmount = 50.0, + ), + ) + } + + private fun refreshDashboard() { + loadDashboardData() + } + + private fun addNewSchedule() { + sendEvent(AutoPayEvent.NavigateToScheduleManagement) + } + + private fun manageExistingSchedules() { + sendEvent(AutoPayEvent.NavigateToScheduleManagement) + } + + private fun viewScheduleDetails(scheduleId: String) { + sendEvent(AutoPayEvent.NavigateToScheduleDetails(scheduleId)) + } + + private fun setupRecurringPayment() { + sendEvent(AutoPayEvent.NavigateToScheduleManagement) + } + + private fun managePaymentPreferences() { + sendEvent(AutoPayEvent.NavigateToPreferences) + } + + private fun toggleAutoPay(enabled: Boolean) { + mutableStateFlow.update { + it.copy(isAutoPayEnabled = enabled) + } + } + + private fun getPaymentHistory() { + sendEvent(AutoPayEvent.NavigateToHistory) + } + + private fun addNewBiller() { + sendEvent(AutoPayEvent.NavigateToAddBiller) + } + + private fun viewBillerList() { + sendEvent(AutoPayEvent.NavigateToBillerList) + } + + private fun addNewBill() { + sendEvent(AutoPayEvent.NavigateToAddBill) + } + + private fun viewBillList() { + sendEvent(AutoPayEvent.NavigateToBillList) + } +} + +@Serializable +data class AutoPayState( + val isAutoPayEnabled: Boolean = false, + val isLoading: Boolean = false, + val error: String? = null, + val activeSchedules: List = emptyList(), + val upcomingPayments: List = emptyList(), + val totalActiveSchedules: Int = 0, + val totalUpcomingPayments: Int = 0, +) + +@Serializable +data class AutoPaySchedule( + val id: String, + val name: String, + val amount: Double, + val currency: String, + val frequency: String, + val nextPaymentDate: String, + val status: AutoPayStatus, + val recipientName: String, + val accountNumber: String, +) + +@Serializable +data class UpcomingPayment( + val id: String, + val scheduleName: String, + val amount: Double, + val currency: String, + val dueDate: String, + val status: PaymentStatus, + val recipientName: String, +) + +@Serializable +enum class AutoPayStatus { + ACTIVE, + PAUSED, + CANCELLED, + COMPLETED, +} + +@Serializable +enum class PaymentStatus { + UPCOMING, + PROCESSING, + COMPLETED, + FAILED, + + CANCELLED, +} + +sealed interface AutoPayEvent { + data object NavigateToScheduleManagement : AutoPayEvent + data object NavigateToPreferences : AutoPayEvent + data object NavigateToHistory : AutoPayEvent + data object NavigateToAddBiller : AutoPayEvent + data object NavigateToBillerList : AutoPayEvent + data object NavigateToAddBill : AutoPayEvent + data object NavigateToBillList : AutoPayEvent + data class NavigateToScheduleDetails(val scheduleId: String) : AutoPayEvent +} + +sealed interface AutoPayAction { + data object ManageRecurringPayment : AutoPayAction + data object ManagePaymentPreferences : AutoPayAction + data object GetPaymentHistory : AutoPayAction + data class ToggleAutoPay(val enabled: Boolean) : AutoPayAction + data object RefreshDashboard : AutoPayAction + data object AddNewSchedule : AutoPayAction + data object ManageExistingSchedules : AutoPayAction + data object AddNewBiller : AutoPayAction + data object ViewBillerList : AutoPayAction + data object AddNewBill : AutoPayAction + data object ViewBillList : AutoPayAction + data class ViewScheduleDetails(val scheduleId: String) : AutoPayAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/BillListScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/BillListScreen.kt new file mode 100644 index 000000000..3ba7be21d --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/BillListScreen.kt @@ -0,0 +1,412 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.common.CurrencyFormatter +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.rememberMifosPullToRefreshState +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.model.autopay.BillStatus +import org.mifospay.core.ui.utils.EventsEffect + +@Composable +fun BillListScreen( + onNavigateBack: () -> Unit, + onNavigateToAddBill: () -> Unit, + onNavigateToEditBill: (String) -> Unit, + onNavigateToAutopay: () -> Unit, + modifier: Modifier = Modifier, + viewModel: BillListViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val pullRefreshState = rememberMifosPullToRefreshState( + isEnabled = true, + isRefreshing = state.isLoading, + onRefresh = { viewModel.trySendAction(BillListAction.RefreshBills) }, + ) + + Box(modifier = modifier.fillMaxSize()) { + MifosScaffold( + topBarTitle = "Bill List", + backPress = onNavigateBack, + pullToRefreshState = pullRefreshState, + ) { paddingValues -> + if (state.isLoading && state.bills.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 80.dp), + ) { + if (state.bills.isEmpty()) { + item { + EmptyStateCard( + title = "No Bills Found", + description = "You don't have any bills yet. Add your first bill to get started!", + icon = MifosIcons.Receipt, + onAddBill = onNavigateToAddBill, + ) + } + } else { + items(state.bills) { bill -> + BillCard( + bill = bill, + onEdit = { onNavigateToEditBill(bill.id ?: "") }, + onDelete = { viewModel.trySendAction(BillListAction.DeleteBill(bill.id ?: "")) }, + ) + } + } + } + } + } + + MifosButton( + text = { Text("Back to AutoPay") }, + onClick = onNavigateToAutopay, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp) + .fillMaxWidth(), + ) + } + + EventsEffect(viewModel) { event -> + when (event) { + is BillListEvent.NavigateToAddBill -> onNavigateToAddBill() + is BillListEvent.NavigateToEditBill -> onNavigateToEditBill(event.billId) + is BillListEvent.BillDeleted -> { + // Bill deleted successfully, no action needed + } + } + } +} + +@Composable +private fun BillCard( + bill: Bill, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + var showMenu by remember { mutableStateOf(false) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = bill.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + + Text( + text = bill.billerName ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (bill.autoPayEnabled) { + AutoPayChip() + } + BillStatusChip(status = bill.status) + } + + Box { + IconButton( + onClick = { showMenu = true }, + ) { + Icon( + imageVector = MifosIcons.MoreVert, + contentDescription = "More options", + ) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Edit") }, + leadingIcon = { + Icon( + imageVector = MifosIcons.Edit, + contentDescription = null, + ) + }, + onClick = { + showMenu = false + onEdit() + }, + ) + DropdownMenuItem( + text = { Text("Delete") }, + leadingIcon = { + Icon( + imageVector = MifosIcons.Delete, + contentDescription = null, + ) + }, + onClick = { + showMenu = false + onDelete() + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = "Amount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = CurrencyFormatter.format(bill.amount, bill.currency, 2), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + + Column { + Text( + text = "Due Date", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatDate(bill.dueDate), + style = MaterialTheme.typography.bodyMedium, + ) + } + + Column { + Text( + text = "Frequency", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = bill.recurrencePattern.displayName, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +@Composable +private fun BillStatusChip( + status: BillStatus, + modifier: Modifier = Modifier, +) { + val (backgroundColor, textColor) = when (status) { + BillStatus.ACTIVE -> MaterialTheme.colorScheme.primary to MaterialTheme.colorScheme.onPrimary + BillStatus.PAUSED -> MaterialTheme.colorScheme.tertiary to MaterialTheme.colorScheme.onTertiary + BillStatus.CANCELLED -> MaterialTheme.colorScheme.error to MaterialTheme.colorScheme.onError + BillStatus.COMPLETED -> MaterialTheme.colorScheme.secondary to MaterialTheme.colorScheme.onSecondary + } + + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = backgroundColor), + ) { + Text( + text = status.name, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + color = textColor, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun AutoPayChip( + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondary, + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = MifosIcons.Payment, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onSecondary, + ) + Text( + text = "AutoPay", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondary, + fontWeight = FontWeight.Medium, + ) + } + } +} + +@Composable +private fun EmptyStateCard( + title: String, + description: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + onAddBill: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onAddBill, + ) { + Icon( + imageVector = MifosIcons.Add, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Add Bill") + } + } + } +} + +private fun formatDate(timestamp: Long): String { + return try { + val instant = Instant.fromEpochMilliseconds(timestamp) + val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) + "${localDateTime.monthNumber}/${localDateTime.dayOfMonth}/${localDateTime.year}" + } catch (e: Exception) { + "Invalid Date" + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/BillListViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/BillListViewModel.kt new file mode 100644 index 000000000..db1ede37d --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/BillListViewModel.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.mifospay.core.common.DataState +import org.mifospay.core.common.getSerialized +import org.mifospay.core.common.setSerialized +import org.mifospay.core.datastore.BillRepository +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.ui.utils.BaseViewModel + +class BillListViewModel( + savedStateHandle: SavedStateHandle, + private val billRepository: BillRepository, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: BillListState(), +) { + + companion object { + private const val KEY_STATE = "bill_list_state" + } + + init { + stateFlow + .onEach { savedStateHandle.setSerialized(key = KEY_STATE, value = it) } + .launchIn(viewModelScope) + + loadBills() + } + + override fun handleAction(action: BillListAction) { + when (action) { + is BillListAction.RefreshBills -> { + loadBills() + } + is BillListAction.AddNewBill -> { + addNewBill() + } + is BillListAction.EditBill -> { + editBill(action.billId) + } + is BillListAction.DeleteBill -> { + deleteBill(action.billId) + } + } + } + + private fun loadBills() { + viewModelScope.launch { + mutableStateFlow.update { it.copy(isLoading = true) } + + try { + billRepository.getAllBills().collect { bills -> + mutableStateFlow.update { + it.copy( + bills = bills, + isLoading = false, + error = null, + ) + } + } + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to load bills: ${e.message}", + ) + } + } + } + } + + private fun addNewBill() { + sendEvent(BillListEvent.NavigateToAddBill) + } + + private fun editBill(billId: String) { + sendEvent(BillListEvent.NavigateToEditBill(billId)) + } + + private fun deleteBill(billId: String) { + viewModelScope.launch { + mutableStateFlow.update { it.copy(isLoading = true) } + + when (val result = billRepository.deleteBill(billId)) { + is DataState.Loading -> { + // Already set loading state above + } + is DataState.Success -> { + sendEvent(BillListEvent.BillDeleted) + loadBills() // Reload the list + } + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to delete bill: ${result.exception.message}", + ) + } + } + } + } + } +} + +@Serializable +data class BillListState( + val isLoading: Boolean = false, + val error: String? = null, + val bills: List = emptyList(), +) + +sealed interface BillListEvent { + data object NavigateToAddBill : BillListEvent + data class NavigateToEditBill(val billId: String) : BillListEvent + data object BillDeleted : BillListEvent +} + +sealed interface BillListAction { + data object RefreshBills : BillListAction + data object AddNewBill : BillListAction + data class EditBill(val billId: String) : BillListAction + data class DeleteBill(val billId: String) : BillListAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/BillerListScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/BillerListScreen.kt new file mode 100644 index 000000000..c14d66c90 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/BillerListScreen.kt @@ -0,0 +1,360 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +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.Spacer +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.model.autopay.Biller +import org.mifospay.core.ui.utils.EventsEffect + +@Composable +fun BillerListScreen( + onNavigateBack: () -> Unit, + onNavigateToAddBiller: () -> Unit, + onNavigateToEditBiller: (String) -> Unit, + viewModel: BillerListViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + var searchQuery by remember { mutableStateOf("") } + var showSearchBar by remember { mutableStateOf(false) } + + EventsEffect(viewModel) { event -> + when (event) { + is BillerListEvent.NavigateToAddBiller -> onNavigateToAddBiller() + is BillerListEvent.NavigateToEditBiller -> onNavigateToEditBiller(event.billerId) + is BillerListEvent.BillerDeleted -> { + // Biller deleted successfully, no action needed + } + } + } + + MifosScaffold( + topBar = { + MifosTopBar( + topBarTitle = "My Billers", + backPress = onNavigateBack, + actions = { + IconButton( + onClick = { showSearchBar = !showSearchBar }, + ) { + Icon( + imageVector = MifosIcons.Search, + contentDescription = "Search billers", + ) + } + }, + ) + }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + ) { + if (showSearchBar) { + OutlinedTextField( + value = searchQuery, + onValueChange = { + searchQuery = it + viewModel.trySendAction(BillerListAction.SearchBillers(it)) + }, + placeholder = { Text("Search billers...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (state.billers.isEmpty()) { + EmptyStateCard( + title = "No Billers Found", + description = "You don't have any billers yet. Add your first biller to get started!", + icon = MifosIcons.Person, + onAddBiller = { viewModel.trySendAction(BillerListAction.AddNewBiller) }, + ) + } else { + BillerList( + billers = state.billers, + onEditBiller = { billerId -> + viewModel.trySendAction(BillerListAction.EditBiller(billerId)) + }, + onDeleteBiller = { billerId -> + viewModel.trySendAction(BillerListAction.DeleteBiller(billerId)) + }, + ) + } + } + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + ) { + Spacer(modifier = Modifier.weight(1f)) + + FloatingActionButton( + onClick = { viewModel.trySendAction(BillerListAction.AddNewBiller) }, + modifier = Modifier.padding(bottom = 16.dp), + ) { + Icon( + imageVector = MifosIcons.Add, + contentDescription = "Add biller", + ) + } + } + + MifosButton( + text = { Text("Back to AutoPay") }, + onClick = onNavigateBack, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun EmptyStateCard( + title: String, + description: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + onAddBiller: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = onAddBiller, + ) { + Icon( + imageVector = MifosIcons.Add, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Add Biller") + } + } + } +} + +@Composable +private fun BillerList( + billers: List, + onEditBiller: (String) -> Unit, + onDeleteBiller: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(bottom = 100.dp), + ) { + items( + items = billers, + key = { it.id ?: it.name }, + ) { biller -> + BillerCard( + biller = biller, + onEdit = { onEditBiller(biller.id ?: "") }, + onDelete = { onDeleteBiller(biller.id ?: "") }, + ) + } + } +} + +@Composable +private fun BillerCard( + biller: Biller, + onEdit: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + var showMenu by remember { mutableStateOf(false) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = biller.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = "Account: ${biller.accountNumber}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Text( + text = biller.category.displayName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + + Box { + IconButton( + onClick = { showMenu = true }, + ) { + Icon( + imageVector = MifosIcons.MoreVert, + contentDescription = "More options", + ) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Edit") }, + leadingIcon = { + Icon( + imageVector = MifosIcons.Edit, + contentDescription = null, + ) + }, + onClick = { + showMenu = false + onEdit() + }, + ) + DropdownMenuItem( + text = { Text("Delete") }, + leadingIcon = { + Icon( + imageVector = MifosIcons.Delete, + contentDescription = null, + ) + }, + onClick = { + showMenu = false + onDelete() + }, + ) + } + } + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/BillerListViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/BillerListViewModel.kt new file mode 100644 index 000000000..efe3f38f0 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/BillerListViewModel.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.mifospay.core.common.DataState +import org.mifospay.core.common.getSerialized +import org.mifospay.core.datastore.BillerRepository +import org.mifospay.core.model.autopay.Biller +import org.mifospay.core.ui.utils.BaseViewModel + +class BillerListViewModel( + savedStateHandle: SavedStateHandle, + private val billerRepository: BillerRepository, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: BillerListState(), +) { + + init { + loadBillers() + } + + override fun handleAction(action: BillerListAction) { + when (action) { + is BillerListAction.LoadBillers -> { + loadBillers() + } + is BillerListAction.SearchBillers -> { + searchBillers(action.query) + } + is BillerListAction.DeleteBiller -> { + deleteBiller(action.billerId) + } + is BillerListAction.EditBiller -> { + sendEvent(BillerListEvent.NavigateToEditBiller(action.billerId)) + } + is BillerListAction.AddNewBiller -> { + sendEvent(BillerListEvent.NavigateToAddBiller) + } + } + } + + private fun loadBillers() { + viewModelScope.launch { + mutableStateFlow.update { it.copy(isLoading = true) } + + try { + billerRepository.getAllBillers().collect { billers -> + mutableStateFlow.update { + it.copy( + billers = billers, + isLoading = false, + error = null, + ) + } + } + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to load billers: ${e.message}", + ) + } + } + } + } + + private fun searchBillers(query: String) { + viewModelScope.launch { + if (query.isBlank()) { + loadBillers() + } else { + try { + val searchResults = billerRepository.searchBillersByName(query) + mutableStateFlow.update { + it.copy( + billers = searchResults, + isLoading = false, + error = null, + ) + } + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to search billers: ${e.message}", + ) + } + } + } + } + } + + private fun deleteBiller(billerId: String) { + viewModelScope.launch { + mutableStateFlow.update { it.copy(isLoading = true) } + + when (val result = billerRepository.deleteBiller(billerId)) { + is DataState.Loading -> { + // Already set loading state above + } + is DataState.Success -> { + sendEvent(BillerListEvent.BillerDeleted) + loadBillers() // Reload the list + } + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to delete biller: ${result.exception.message}", + ) + } + } + } + } + } + + companion object { + private const val KEY_STATE = "biller_list_state" + } +} + +@Serializable +data class BillerListState( + val billers: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, +) + +sealed interface BillerListEvent { + data class NavigateToEditBiller(val billerId: String) : BillerListEvent + data object NavigateToAddBiller : BillerListEvent + data object BillerDeleted : BillerListEvent +} + +sealed interface BillerListAction { + data object LoadBillers : BillerListAction + data class SearchBillers(val query: String) : BillerListAction + data class DeleteBiller(val billerId: String) : BillerListAction + data class EditBiller(val billerId: String) : BillerListAction + data object AddNewBiller : BillerListAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/EditBillScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/EditBillScreen.kt new file mode 100644 index 000000000..3d584898e --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/EditBillScreen.kt @@ -0,0 +1,468 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DividerDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +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.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.datetime.Clock +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.BasicDialogState +import org.mifospay.core.designsystem.component.LoadingDialogState +import org.mifospay.core.designsystem.component.MifosBasicDialog +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosLoadingDialog +import org.mifospay.core.designsystem.component.MifosOutlinedButton +import org.mifospay.core.designsystem.component.MifosOutlinedTextField +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTextField +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.designsystem.utils.onClick +import org.mifospay.core.model.autopay.RecurrencePattern +import org.mifospay.core.ui.DropdownBox +import org.mifospay.core.ui.DropdownBoxItem +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditBillScreen( + onNavigateBack: () -> Unit, + onNavigateToAddBiller: () -> Unit, + viewModel: EditBillViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + var showRecurrenceDropdown by remember { mutableStateOf(false) } + var showBillerDropdown by remember { mutableStateOf(false) } + var showDatePicker by remember { mutableStateOf(false) } + + EventsEffect(viewModel) { event -> + when (event) { + is EditBillEvent.BillUpdated -> { + onNavigateBack() + } + } + } + + MifosScaffold( + topBar = { + MifosTopBar( + topBarTitle = "Edit Bill", + backPress = onNavigateBack, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Edit bill details", + modifier = Modifier.padding(bottom = 8.dp), + ) + + MifosOutlinedTextField( + label = "Bill Name *", + value = state.formData.name, + onValueChange = { viewModel.trySendAction(EditBillAction.UpdateBillName(it)) }, + isError = state.validationResult.nameError != null, + errorMessage = state.validationResult.nameError, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + ), + ) + + DropdownBox( + expanded = showBillerDropdown, + label = "Select Biller *", + value = state.formData.billerName ?: "Select a biller", + readOnly = true, + isError = state.validationResult.billerError != null, + errorText = state.validationResult.billerError, + onExpandChange = { showBillerDropdown = it }, + ) { + state.availableBillers.forEach { biller -> + DropdownBoxItem( + text = biller.name, + onClick = { + viewModel.trySendAction(EditBillAction.SelectBiller(biller)) + showBillerDropdown = false + }, + ) + } + DropdownBoxItem( + text = "+ Add New Biller", + onClick = { + showBillerDropdown = false + onNavigateToAddBiller() + }, + ) + } + + MifosOutlinedTextField( + label = "Amount *", + value = state.formData.amount, + onValueChange = { viewModel.trySendAction(EditBillAction.UpdateAmount(it)) }, + isError = state.validationResult.amountError != null, + errorMessage = state.validationResult.amountError, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Next, + ), + ) + + Box( + modifier = Modifier.onClick { showDatePicker = true }, + ) { + MifosTextField( + label = "Due Date *", + value = if (state.formData.dueDate > 0L) { + formatDateForDisplay(state.formData.dueDate) + } else { + "" + }, + onValueChange = { }, + isError = state.validationResult.dueDateError != null, + errorText = state.validationResult.dueDateError, + singleLine = true, + readOnly = true, + showClearIcon = false, + trailingIcon = { + IconButton( + onClick = { showDatePicker = true }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = KptTheme.colorScheme.tertiary, + contentColor = KptTheme.colorScheme.tertiaryContainer, + ), + ) { + Icon( + imageVector = MifosIcons.CalenderMonth, + contentDescription = "Choose Date", + ) + } + }, + ) + } + + DropdownBox( + expanded = showRecurrenceDropdown, + label = "Recurrence Pattern *", + value = state.formData.recurrencePattern.displayName, + readOnly = true, + isError = state.validationResult.recurrencePatternError != null, + errorText = state.validationResult.recurrencePatternError, + onExpandChange = { showRecurrenceDropdown = it }, + ) { + RecurrencePattern.entries.forEach { pattern -> + DropdownBoxItem( + text = pattern.displayName, + onClick = { + viewModel.trySendAction(EditBillAction.UpdateRecurrencePattern(pattern)) + showRecurrenceDropdown = false + }, + ) + } + } + + MifosOutlinedTextField( + label = "Description (Optional)", + value = state.formData.description, + onValueChange = { viewModel.trySendAction(EditBillAction.UpdateDescription(it)) }, + singleLine = false, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + ) + + AutoPaySection( + enableAutoPay = state.formData.enableAutoPay, + paymentMethod = state.formData.autoPayPaymentMethod, + sourceAccount = state.formData.autoPaySourceAccount, + maxAmount = state.formData.autoPayMaxAmount, + paymentMethodError = state.validationResult.autoPayPaymentMethodError, + sourceAccountError = state.validationResult.autoPaySourceAccountError, + maxAmountError = state.validationResult.autoPayMaxAmountError, + onEnableAutoPayChanged = { enabled -> + viewModel.trySendAction(EditBillAction.UpdateAutoPayEnabled(enabled)) + }, + onPaymentMethodChanged = { paymentMethod -> + viewModel.trySendAction(EditBillAction.UpdateAutoPayPaymentMethod(paymentMethod)) + }, + onSourceAccountChanged = { sourceAccount -> + viewModel.trySendAction(EditBillAction.UpdateAutoPaySourceAccount(sourceAccount)) + }, + onMaxAmountChanged = { maxAmount -> + viewModel.trySendAction(EditBillAction.UpdateAutoPayMaxAmount(maxAmount)) + }, + ) + + if (state.nextPaymentDates.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Next Payment Dates:", + style = KptTheme.typography.titleMedium, + ) + + state.nextPaymentDates.forEach { nextDate -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = nextDate.formattedDate, + style = KptTheme.typography.bodyMedium, + ) + if (nextDate.isOverdue) { + Text( + text = "Overdue", + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.error, + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + MifosOutlinedButton( + text = { Text("Cancel") }, + onClick = onNavigateBack, + modifier = Modifier.weight(1f), + ) + MifosButton( + text = { Text("Update Bill") }, + onClick = { viewModel.trySendAction(EditBillAction.UpdateBill) }, + modifier = Modifier.weight(1f), + enabled = !state.isLoading, + ) + } + } + } + + AnimatedVisibility(showDatePicker) { + val dateState = rememberDatePickerState( + initialSelectedDateMillis = if (state.formData.dueDate > 0L) { + state.formData.dueDate + } else { + Clock.System.now().toEpochMilliseconds() + }, + ) + + val confirmEnabled = remember { + derivedStateOf { dateState.selectedDateMillis != null } + } + + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton( + onClick = { + dateState.selectedDateMillis?.let { timestamp -> + viewModel.trySendAction(EditBillAction.UpdateDueDate(timestamp)) + } + showDatePicker = false + }, + enabled = confirmEnabled.value, + ) { + Text("OK") + } + }, + dismissButton = { + TextButton( + onClick = { showDatePicker = false }, + ) { + Text("Cancel") + } + }, + content = { + DatePicker(state = dateState) + }, + ) + } + + if (state.isLoading) { + MifosLoadingDialog( + visibilityState = LoadingDialogState.Shown, + ) + } + + state.error?.let { error -> + MifosBasicDialog( + visibilityState = BasicDialogState.Shown( + title = "Error", + message = error, + ), + onDismissRequest = { viewModel.trySendAction(EditBillAction.ClearError) }, + ) + } +} + +@Composable +private fun AutoPaySection( + enableAutoPay: Boolean, + paymentMethod: String, + sourceAccount: String, + maxAmount: String, + paymentMethodError: String?, + sourceAccountError: String?, + maxAmountError: String?, + onEnableAutoPayChanged: (Boolean) -> Unit, + onPaymentMethodChanged: (String) -> Unit, + onSourceAccountChanged: (String) -> Unit, + onMaxAmountChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = "AutoPay Settings", + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = "Automatically pay this bill when due", + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + } + Checkbox( + checked = enableAutoPay, + onCheckedChange = onEnableAutoPayChanged, + ) + } + + if (enableAutoPay) { + HorizontalDivider(Modifier, DividerDefaults.Thickness, DividerDefaults.color) + + var showPaymentMethodDropdown by remember { mutableStateOf(false) } + DropdownBox( + expanded = showPaymentMethodDropdown, + label = "Payment Method *", + value = paymentMethod.ifBlank { "Select payment method" }, + readOnly = true, + isError = paymentMethodError != null, + errorText = paymentMethodError, + onExpandChange = { showPaymentMethodDropdown = it }, + ) { + listOf("Bank Account", "Credit Card", "UPI").forEach { method -> + DropdownBoxItem( + text = method, + onClick = { + onPaymentMethodChanged(method) + showPaymentMethodDropdown = false + }, + ) + } + } + + MifosOutlinedTextField( + label = "Source Account *", + value = sourceAccount, + onValueChange = onSourceAccountChanged, + isError = sourceAccountError != null, + errorMessage = sourceAccountError, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + ), + ) + + MifosOutlinedTextField( + label = "Maximum Amount Limit (Optional)", + value = maxAmount, + onValueChange = onMaxAmountChanged, + isError = maxAmountError != null, + errorMessage = maxAmountError, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done, + ), + ) + + Text( + text = "This amount will be used as a safety limit for automatic payments", + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/EditBillViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/EditBillViewModel.kt new file mode 100644 index 000000000..4b4e269bd --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/EditBillViewModel.kt @@ -0,0 +1,444 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.serialization.Serializable +import org.mifospay.core.common.DataState +import org.mifospay.core.common.DateHelper +import org.mifospay.core.common.getSerialized +import org.mifospay.core.data.util.BillValidator +import org.mifospay.core.datastore.BillRepository +import org.mifospay.core.model.autopay.Bill +import org.mifospay.core.model.autopay.BillFormData +import org.mifospay.core.model.autopay.BillValidationResult +import org.mifospay.core.model.autopay.Biller +import org.mifospay.core.model.autopay.NextPaymentDate +import org.mifospay.core.model.autopay.RecurrencePattern +import org.mifospay.core.ui.utils.BaseViewModel + +class EditBillViewModel( + savedStateHandle: SavedStateHandle, + private val billRepository: BillRepository, + private val billerRepository: org.mifospay.core.datastore.BillerRepository, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: EditBillState(), +) { + + private val billId: String = savedStateHandle["billId"] ?: "" + + init { + loadBill() + loadAvailableBillers() + } + + override fun handleAction(action: EditBillAction) { + when (action) { + is EditBillAction.UpdateBillName -> { + updateBillName(action.name) + } + is EditBillAction.UpdateAmount -> { + updateAmount(action.amount) + } + is EditBillAction.UpdateDueDate -> { + updateDueDate(action.dueDate) + } + is EditBillAction.UpdateRecurrencePattern -> { + updateRecurrencePattern(action.recurrencePattern) + } + is EditBillAction.UpdateDescription -> { + updateDescription(action.description) + } + is EditBillAction.UpdateBill -> { + updateBill() + } + is EditBillAction.ValidateForm -> { + validateForm() + } + is EditBillAction.ClearError -> { + clearError() + } + is EditBillAction.ClearValidationErrors -> { + clearValidationErrors() + } + is EditBillAction.CalculateNextPaymentDates -> { + calculateNextPaymentDates() + } + is EditBillAction.SelectBiller -> { + selectBiller(action.biller) + } + is EditBillAction.UpdateAutoPayEnabled -> { + updateAutoPayEnabled(action.enabled) + } + is EditBillAction.UpdateAutoPayPaymentMethod -> { + updateAutoPayPaymentMethod(action.paymentMethod) + } + is EditBillAction.UpdateAutoPaySourceAccount -> { + updateAutoPaySourceAccount(action.sourceAccount) + } + is EditBillAction.UpdateAutoPayMaxAmount -> { + updateAutoPayMaxAmount(action.maxAmount) + } + } + } + + private fun loadBill() { + viewModelScope.launch { + mutableStateFlow.update { it.copy(isLoading = true) } + + try { + val bill = billRepository.getBillById(billId) + if (bill != null) { + val formData = BillFormData( + name = bill.name, + amount = bill.amount.toString(), + currency = bill.currency, + dueDate = bill.dueDate, + recurrencePattern = bill.recurrencePattern, + billerId = bill.billerId, + billerName = bill.billerName, + description = bill.description ?: "", + enableAutoPay = bill.autoPayEnabled, + autoPayPaymentMethod = bill.autoPayPaymentMethod ?: "", + autoPaySourceAccount = bill.autoPaySourceAccount ?: "", + autoPayMaxAmount = bill.autoPayMaxAmount?.toString() ?: "", + ) + mutableStateFlow.update { + it.copy( + formData = formData, + isLoading = false, + error = null, + ) + } + calculateNextPaymentDates() + } else { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Bill not found", + ) + } + } + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to load bill: ${e.message}", + ) + } + } + } + } + + private fun updateBillName(name: String) { + val nameError = BillValidator.validateBillFormData( + BillFormData(name = name), + ).nameError + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(name = name), + validationResult = it.validationResult.copy(nameError = nameError), + ) + } + } + + private fun updateAmount(amount: String) { + val amountError = BillValidator.validateBillFormData( + BillFormData(amount = amount), + ).amountError + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(amount = amount), + validationResult = it.validationResult.copy(amountError = amountError), + ) + } + } + + private fun updateDueDate(dueDate: Long) { + val dueDateError = BillValidator.validateBillFormData( + BillFormData(dueDate = dueDate), + ).dueDateError + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(dueDate = dueDate), + validationResult = it.validationResult.copy(dueDateError = dueDateError), + ) + } + calculateNextPaymentDates() + } + + private fun updateRecurrencePattern(recurrencePattern: RecurrencePattern) { + val recurrencePatternError = BillValidator.validateBillFormData( + BillFormData(recurrencePattern = recurrencePattern), + ).recurrencePatternError + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(recurrencePattern = recurrencePattern), + validationResult = it.validationResult.copy(recurrencePatternError = recurrencePatternError), + ) + } + calculateNextPaymentDates() + } + + private fun updateDescription(description: String) { + mutableStateFlow.update { + it.copy(formData = it.formData.copy(description = description)) + } + } + + private fun validateForm(): BillValidationResult { + val currentState = stateFlow.value + val formData = currentState.formData + + val validationResult = BillValidator.validateBillFormData(formData) + + mutableStateFlow.update { it.copy(validationResult = validationResult) } + return validationResult + } + + private fun calculateNextPaymentDates() { + val currentState = stateFlow.value + val formData = currentState.formData + + if (formData.dueDate == 0L || formData.recurrencePattern == RecurrencePattern.NONE) { + mutableStateFlow.update { it.copy(nextPaymentDates = emptyList()) } + return + } + + val nextDates = mutableListOf() + val currentTime = Clock.System.now().toEpochMilliseconds() + var nextDate = formData.dueDate + + // Generate next 5 payment dates + repeat(5) { + if (nextDate >= currentTime) { + val isOverdue = nextDate < currentTime + val formattedDate = formatDate(nextDate) + nextDates.add(NextPaymentDate(nextDate, formattedDate, isOverdue)) + } + + // Calculate next date based on recurrence pattern + nextDate += (formData.recurrencePattern.interval * 24 * 60 * 60 * 1000L) + } + + mutableStateFlow.update { it.copy(nextPaymentDates = nextDates) } + } + + private fun formatDate(timestamp: Long): String { + return DateHelper.getDateAsStringFromLong(timestamp) + } + + private fun updateBill() { + val validationResult = validateForm() + + if (!validationResult.isValid) { + return + } + + viewModelScope.launch { + mutableStateFlow.update { it.copy(isLoading = true) } + + try { + val formData = mutableStateFlow.value.formData + + val bill = Bill( + id = billId, + name = formData.name.trim(), + amount = formData.amount.toDoubleOrNull() ?: 0.0, + currency = formData.currency, + dueDate = formData.dueDate, + recurrencePattern = formData.recurrencePattern, + billerId = formData.billerId, + billerName = formData.billerName, + description = formData.description.takeIf { it.isNotBlank() }, + // AutoPay configuration + autoPayEnabled = formData.enableAutoPay, + autoPayPaymentMethod = formData.autoPayPaymentMethod.takeIf { it.isNotBlank() }, + autoPaySourceAccount = formData.autoPaySourceAccount.takeIf { it.isNotBlank() }, + autoPayMaxAmount = formData.autoPayMaxAmount.toDoubleOrNull(), + ) + + val result = billRepository.updateBill(bill) + + when (result) { + is DataState.Loading -> { + // Loading state is already handled by setting isLoading = true above + } + is DataState.Success -> { + mutableStateFlow.update { + it.copy( + isLoading = false, + isSuccess = true, + ) + } + sendEvent(EditBillEvent.BillUpdated(result.data)) + } + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to update bill: ${result.exception.message}", + ) + } + } + } + } catch (exception: Exception) { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to update bill: ${exception.message}", + ) + } + } + } + } + + private fun loadAvailableBillers() { + viewModelScope.launch { + try { + billerRepository.getAllBillers().collect { billers -> + mutableStateFlow.update { it.copy(availableBillers = billers) } + } + } catch (exception: Exception) { + // Handle error silently for now + } + } + } + + private fun selectBiller(biller: Biller) { + val billerError = BillValidator.validateBillFormData( + BillFormData(billerId = biller.id, billerName = biller.name), + ).billerError + mutableStateFlow.update { + it.copy( + formData = it.formData.copy( + billerId = biller.id, + billerName = biller.name, + ), + validationResult = it.validationResult.copy(billerError = billerError), + ) + } + } + + private fun clearError() { + mutableStateFlow.update { it.copy(error = null) } + } + + private fun clearValidationErrors() { + mutableStateFlow.update { + it.copy( + validationResult = BillValidationResult(isValid = false), + ) + } + } + + private fun updateAutoPayEnabled(enabled: Boolean) { + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(enableAutoPay = enabled), + ) + } + } + + private fun updateAutoPayPaymentMethod(paymentMethod: String) { + val paymentMethodError = if (stateFlow.value.formData.enableAutoPay && paymentMethod.isBlank()) { + "Payment method is required when AutoPay is enabled" + } else { + null + } + + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(autoPayPaymentMethod = paymentMethod), + validationResult = it.validationResult.copy(autoPayPaymentMethodError = paymentMethodError), + ) + } + } + + private fun updateAutoPaySourceAccount(sourceAccount: String) { + val sourceAccountError = if (stateFlow.value.formData.enableAutoPay && sourceAccount.isBlank()) { + "Source account is required when AutoPay is enabled" + } else { + null + } + + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(autoPaySourceAccount = sourceAccount), + validationResult = it.validationResult.copy(autoPaySourceAccountError = sourceAccountError), + ) + } + } + + private fun updateAutoPayMaxAmount(maxAmount: String) { + val maxAmountError = if (maxAmount.isNotBlank()) { + val amount = maxAmount.toDoubleOrNull() + if (amount == null) { + "Invalid amount format" + } else if (amount <= 0) { + "Maximum amount must be greater than 0" + } else { + null + } + } else { + null + } + + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(autoPayMaxAmount = maxAmount), + validationResult = it.validationResult.copy(autoPayMaxAmountError = maxAmountError), + ) + } + } + + companion object { + private const val KEY_STATE = "edit_bill_state" + } +} + +@Serializable +data class EditBillState( + val formData: BillFormData = BillFormData(), + val validationResult: BillValidationResult = BillValidationResult(isValid = false), + val isLoading: Boolean = false, + val isSuccess: Boolean = false, + val error: String? = null, + val nextPaymentDates: List = emptyList(), + val availableBillers: List = emptyList(), +) + +sealed interface EditBillEvent { + data class BillUpdated(val bill: Bill) : EditBillEvent +} + +sealed interface EditBillAction { + data class UpdateBillName(val name: String) : EditBillAction + data class UpdateAmount(val amount: String) : EditBillAction + data class UpdateDueDate(val dueDate: Long) : EditBillAction + data class UpdateRecurrencePattern(val recurrencePattern: RecurrencePattern) : EditBillAction + data class UpdateDescription(val description: String) : EditBillAction + data object UpdateBill : EditBillAction + data object ValidateForm : EditBillAction + data object ClearError : EditBillAction + data object ClearValidationErrors : EditBillAction + data object CalculateNextPaymentDates : EditBillAction + data class SelectBiller(val biller: Biller) : EditBillAction + + // AutoPay actions + data class UpdateAutoPayEnabled(val enabled: Boolean) : EditBillAction + data class UpdateAutoPayPaymentMethod(val paymentMethod: String) : EditBillAction + data class UpdateAutoPaySourceAccount(val sourceAccount: String) : EditBillAction + data class UpdateAutoPayMaxAmount(val maxAmount: String) : EditBillAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/EditBillerScreen.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/EditBillerScreen.kt new file mode 100644 index 000000000..8ae4702f7 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/EditBillerScreen.kt @@ -0,0 +1,202 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.BasicDialogState +import org.mifospay.core.designsystem.component.LoadingDialogState +import org.mifospay.core.designsystem.component.MifosBasicDialog +import org.mifospay.core.designsystem.component.MifosButton +import org.mifospay.core.designsystem.component.MifosLoadingDialog +import org.mifospay.core.designsystem.component.MifosOutlinedButton +import org.mifospay.core.designsystem.component.MifosOutlinedTextField +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.model.autopay.BillerCategory +import org.mifospay.core.ui.DropdownBox +import org.mifospay.core.ui.DropdownBoxItem +import org.mifospay.core.ui.utils.EventsEffect + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditBillerScreen( + onNavigateBack: () -> Unit, + viewModel: EditBillerViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + var showCategoryDropdown by remember { mutableStateOf(false) } + + EventsEffect(viewModel) { event -> + when (event) { + is EditBillerEvent.BillerUpdated -> { + onNavigateBack() + } + } + } + + MifosScaffold( + topBar = { + MifosTopBar( + topBarTitle = "Edit Biller", + backPress = onNavigateBack, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Update biller details", + modifier = Modifier.padding(bottom = 8.dp), + ) + + MifosOutlinedTextField( + label = "Biller Name *", + value = state.formData.name, + onValueChange = { viewModel.trySendAction(EditBillerAction.UpdateBillerName(it)) }, + isError = state.validationResult.nameError != null, + errorMessage = state.validationResult.nameError, + singleLine = true, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + ), + ) + + MifosOutlinedTextField( + label = "Account Number *", + value = state.formData.accountNumber, + onValueChange = { viewModel.trySendAction(EditBillerAction.UpdateAccountNumber(it)) }, + isError = state.validationResult.accountNumberError != null, + errorMessage = state.validationResult.accountNumberError, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + ) + + MifosOutlinedTextField( + label = "Contact Number *", + value = state.formData.contactNumber, + onValueChange = { viewModel.trySendAction(EditBillerAction.UpdateContactNumber(it)) }, + isError = state.validationResult.contactNumberError != null, + errorMessage = state.validationResult.contactNumberError, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Next, + ), + ) + + MifosOutlinedTextField( + label = "Email (Optional)", + value = state.formData.email, + onValueChange = { viewModel.trySendAction(EditBillerAction.UpdateEmail(it)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + ) + + DropdownBox( + expanded = showCategoryDropdown, + label = "Biller Category *", + value = state.formData.category?.displayName ?: "", + isError = state.validationResult.categoryError != null, + errorText = state.validationResult.categoryError, + onExpandChange = { showCategoryDropdown = it }, + ) { + BillerCategory.entries.forEach { category -> + DropdownBoxItem( + text = category.displayName, + onClick = { + viewModel.trySendAction(EditBillerAction.UpdateCategory(category)) + showCategoryDropdown = false + }, + ) + } + } + + MifosOutlinedTextField( + label = "Address (Optional)", + value = state.formData.address, + onValueChange = { viewModel.trySendAction(EditBillerAction.UpdateAddress(it)) }, + singleLine = false, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + ), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + MifosOutlinedButton( + text = { Text("Cancel") }, + onClick = onNavigateBack, + modifier = Modifier.weight(1f), + ) + MifosButton( + text = { Text("Update Biller") }, + onClick = { viewModel.trySendAction(EditBillerAction.UpdateBiller) }, + modifier = Modifier.weight(1f), + enabled = !state.isLoading, + ) + } + } + } + + if (state.isLoading) { + MifosLoadingDialog( + visibilityState = LoadingDialogState.Shown, + ) + } + + state.error?.let { error -> + MifosBasicDialog( + visibilityState = BasicDialogState.Shown( + title = "Error", + message = error, + ), + onDismissRequest = { viewModel.trySendAction(EditBillerAction.ClearError) }, + ) + } +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/EditBillerViewModel.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/EditBillerViewModel.kt new file mode 100644 index 000000000..a2309c139 --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/EditBillerViewModel.kt @@ -0,0 +1,273 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import org.mifospay.core.common.DataState +import org.mifospay.core.common.getSerialized +import org.mifospay.core.data.util.BillerValidator +import org.mifospay.core.datastore.BillerRepository +import org.mifospay.core.model.autopay.Biller +import org.mifospay.core.model.autopay.BillerCategory +import org.mifospay.core.model.autopay.BillerFormData +import org.mifospay.core.model.autopay.BillerValidationResult +import org.mifospay.core.ui.utils.BaseViewModel + +class EditBillerViewModel( + savedStateHandle: SavedStateHandle, + private val billerRepository: BillerRepository, +) : BaseViewModel( + initialState = savedStateHandle.getSerialized(KEY_STATE) ?: EditBillerState(), +) { + + private val billerId: String = savedStateHandle["billerId"] ?: "" + + init { + loadBiller() + } + + override fun handleAction(action: EditBillerAction) { + when (action) { + is EditBillerAction.UpdateBillerName -> { + updateBillerName(action.name) + } + is EditBillerAction.UpdateAccountNumber -> { + updateAccountNumber(action.accountNumber) + } + is EditBillerAction.UpdateContactNumber -> { + updateContactNumber(action.contactNumber) + } + is EditBillerAction.UpdateEmail -> { + updateEmail(action.email) + } + is EditBillerAction.UpdateCategory -> { + updateCategory(action.category) + } + is EditBillerAction.UpdateAddress -> { + updateAddress(action.address) + } + is EditBillerAction.UpdateBiller -> { + updateBiller() + } + is EditBillerAction.ValidateForm -> { + validateForm() + } + is EditBillerAction.ClearError -> { + clearError() + } + is EditBillerAction.ClearValidationErrors -> { + clearValidationErrors() + } + } + } + + private fun loadBiller() { + viewModelScope.launch { + mutableStateFlow.update { it.copy(isLoading = true) } + + try { + val biller = billerRepository.getBillerById(billerId) + if (biller != null) { + val formData = BillerFormData( + name = biller.name, + accountNumber = biller.accountNumber, + contactNumber = biller.contactNumber, + email = biller.email ?: "", + category = biller.category, + address = biller.address ?: "", + ) + mutableStateFlow.update { + it.copy( + formData = formData, + isLoading = false, + error = null, + ) + } + } else { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Biller not found", + ) + } + } + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to load biller: ${e.message}", + ) + } + } + } + } + + private fun updateBillerName(name: String) { + val nameError = BillerValidator.validateNameField(name) + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(name = name), + validationResult = it.validationResult.copy(nameError = nameError), + ) + } + } + + private fun updateAccountNumber(accountNumber: String) { + val accountNumberError = BillerValidator.validateAccountNumberField(accountNumber) + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(accountNumber = accountNumber), + validationResult = it.validationResult.copy(accountNumberError = accountNumberError), + ) + } + } + + private fun updateContactNumber(contactNumber: String) { + val contactNumberError = BillerValidator.validateContactNumberField(contactNumber) + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(contactNumber = contactNumber), + validationResult = it.validationResult.copy(contactNumberError = contactNumberError), + ) + } + } + + private fun updateEmail(email: String) { + val emailError = BillerValidator.validateEmailField(email) + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(email = email), + validationResult = it.validationResult.copy(emailError = emailError), + ) + } + } + + private fun updateCategory(category: BillerCategory) { + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(category = category), + validationResult = it.validationResult.copy(categoryError = null), + ) + } + } + + private fun updateAddress(address: String) { + mutableStateFlow.update { + it.copy( + formData = it.formData.copy(address = address), + ) + } + } + + private fun validateForm(): BillerValidationResult { + val formData = mutableStateFlow.value.formData + + val validationResult = BillerValidator.validateBillerForm(formData) + + mutableStateFlow.update { + it.copy(validationResult = validationResult) + } + + return validationResult + } + + private fun updateBiller() { + val validationResult = validateForm() + if (!validationResult.isValid) { + return + } + + viewModelScope.launch { + mutableStateFlow.update { it.copy(isLoading = true) } + + try { + val formData = mutableStateFlow.value.formData + val biller = Biller( + id = billerId, + name = formData.name, + accountNumber = formData.accountNumber, + contactNumber = formData.contactNumber, + email = formData.email.takeIf { it.isNotBlank() }, + category = formData.category!!, + address = formData.address.takeIf { it.isNotBlank() }, + ) + + when (val result = billerRepository.updateBiller(biller)) { + is DataState.Success -> { + sendEvent(EditBillerEvent.BillerUpdated(result.data)) + } + is DataState.Error -> { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to update biller: ${result.exception.message}", + ) + } + } + is DataState.Loading -> { + // Loading state is already handled by setting isLoading = true above + } + } + } catch (e: Exception) { + mutableStateFlow.update { + it.copy( + isLoading = false, + error = "Failed to update biller: ${e.message}", + ) + } + } + } + } + + private fun clearError() { + mutableStateFlow.update { it.copy(error = null) } + } + + private fun clearValidationErrors() { + mutableStateFlow.update { + it.copy( + validationResult = BillerValidationResult(isValid = false), + ) + } + } + + companion object { + private const val KEY_STATE = "edit_biller_state" + } +} + +@Serializable +data class EditBillerState( + val formData: BillerFormData = BillerFormData(), + val validationResult: BillerValidationResult = BillerValidationResult(isValid = false), + val isLoading: Boolean = false, + val error: String? = null, +) + +sealed interface EditBillerEvent { + data class BillerUpdated(val biller: Biller) : EditBillerEvent +} + +sealed interface EditBillerAction { + data class UpdateBillerName(val name: String) : EditBillerAction + data class UpdateAccountNumber(val accountNumber: String) : EditBillerAction + data class UpdateContactNumber(val contactNumber: String) : EditBillerAction + data class UpdateEmail(val email: String) : EditBillerAction + data class UpdateCategory(val category: BillerCategory) : EditBillerAction + data class UpdateAddress(val address: String) : EditBillerAction + data object UpdateBiller : EditBillerAction + data object ValidateForm : EditBillerAction + data object ClearError : EditBillerAction + data object ClearValidationErrors : EditBillerAction +} diff --git a/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt new file mode 100644 index 000000000..0fdba410e --- /dev/null +++ b/feature/autopay/src/commonMain/kotlin/org/mifospay/feature/autopay/di/AutoPayModule.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.autopay.di + +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module +import org.mifospay.feature.autopay.AddBillViewModel +import org.mifospay.feature.autopay.AddBillerViewModel +import org.mifospay.feature.autopay.AutoPayHistoryViewModel +import org.mifospay.feature.autopay.AutoPayPreferencesViewModel +import org.mifospay.feature.autopay.AutoPayScheduleDetailsViewModel +import org.mifospay.feature.autopay.AutoPayScheduleManagementViewModel +import org.mifospay.feature.autopay.AutoPayViewModel +import org.mifospay.feature.autopay.BillListViewModel +import org.mifospay.feature.autopay.BillerListViewModel +import org.mifospay.feature.autopay.EditBillViewModel +import org.mifospay.feature.autopay.EditBillerViewModel + +val AutoPayModule = module { + viewModelOf(::AutoPayViewModel) + viewModelOf(::AutoPayScheduleDetailsViewModel) + viewModelOf(::AutoPayHistoryViewModel) + viewModelOf(::AddBillerViewModel) + viewModelOf(::BillerListViewModel) + viewModelOf(::EditBillerViewModel) + viewModelOf(::AddBillViewModel) + viewModelOf(::EditBillViewModel) + viewModelOf(::BillListViewModel) + viewModelOf(::AutoPayPreferencesViewModel) + viewModelOf(::AutoPayScheduleManagementViewModel) + viewModelOf(::AutoPayHistoryViewModel) +} diff --git a/feature/home/src/commonMain/composeResources/values/strings.xml b/feature/home/src/commonMain/composeResources/values/strings.xml index 6c0266da6..4c44be41b 100644 --- a/feature/home/src/commonMain/composeResources/values/strings.xml +++ b/feature/home/src/commonMain/composeResources/values/strings.xml @@ -22,6 +22,7 @@ Request Money Send Send Money + AutoPay Coin Account type diff --git a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeScreen.kt b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeScreen.kt index a2b31672f..eea5654fe 100644 --- a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeScreen.kt +++ b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeScreen.kt @@ -78,6 +78,7 @@ import mobile_wallet.feature.home.generated.resources.arrow_backward import mobile_wallet.feature.home.generated.resources.coin_image import mobile_wallet.feature.home.generated.resources.feature_home_account_type import mobile_wallet.feature.home.generated.resources.feature_home_arrow_up +import mobile_wallet.feature.home.generated.resources.feature_home_autopay import mobile_wallet.feature.home.generated.resources.feature_home_coin_image import mobile_wallet.feature.home.generated.resources.feature_home_desc import mobile_wallet.feature.home.generated.resources.feature_home_loading @@ -124,6 +125,7 @@ internal fun HomeScreen( onNavigateBack: () -> Unit, onRequest: (String) -> Unit, onPay: () -> Unit, + onAutoPay: () -> Unit, navigateToTransactionDetail: (Long, Long) -> Unit, navigateToAccountDetail: (Long) -> Unit, modifier: Modifier = Modifier, @@ -141,6 +143,7 @@ internal fun HomeScreen( is HomeEvent.NavigateBack -> onNavigateBack.invoke() is HomeEvent.NavigateToRequestScreen -> onRequest(event.vpa) is HomeEvent.NavigateToSendScreen -> onPay.invoke() + is HomeEvent.NavigateToAutoPayScreen -> onAutoPay.invoke() is HomeEvent.NavigateToClientDetailScreen -> {} is HomeEvent.NavigateToTransactionDetail -> { navigateToTransactionDetail(event.accountId, event.transactionId) @@ -277,6 +280,9 @@ private fun HomeScreenContent( onSend = { onAction(HomeAction.SendClicked) }, + onAutoPay = { + onAction(HomeAction.AutoPayClicked) + }, ) } @@ -507,45 +513,66 @@ fun CardDropdownBox( private fun PayRequestScreen( onRequest: () -> Unit, onSend: () -> Unit, + onAutoPay: () -> Unit, modifier: Modifier = Modifier, ) { - Row( + Column( modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - PaymentButton( - modifier = Modifier - .weight(1f) - .height(55.dp), - text = stringResource(Res.string.feature_home_request), - onClick = onRequest, - leadingIcon = { - Icon( - modifier = Modifier - .size(26.dp), - imageVector = vectorResource( - Res.drawable.arrow_backward, - ), - contentDescription = stringResource(Res.string.feature_home_request_money), - ) - }, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + PaymentButton( + modifier = Modifier + .weight(1f) + .height(55.dp), + text = stringResource(Res.string.feature_home_request), + onClick = onRequest, + leadingIcon = { + Icon( + modifier = Modifier + .size(26.dp), + imageVector = vectorResource( + Res.drawable.arrow_backward, + ), + contentDescription = stringResource(Res.string.feature_home_request_money), + ) + }, + ) + + Spacer(modifier = Modifier.width(20.dp)) - Spacer(modifier = Modifier.width(20.dp)) + PaymentButton( + modifier = Modifier + .weight(1f) + .height(55.dp), + text = stringResource(Res.string.feature_home_send), + onClick = onSend, + leadingIcon = { + Icon( + modifier = Modifier + .size(26.dp) + .graphicsLayer(rotationZ = 180f), + imageVector = vectorResource(Res.drawable.arrow_backward), + contentDescription = stringResource(Res.string.feature_home_send_money), + ) + }, + ) + } PaymentButton( modifier = Modifier - .weight(1f) + .fillMaxWidth() .height(55.dp), - text = stringResource(Res.string.feature_home_send), - onClick = onSend, + text = stringResource(Res.string.feature_home_autopay), + onClick = onAutoPay, leadingIcon = { Icon( - modifier = Modifier - .size(26.dp) - .graphicsLayer(rotationZ = 180f), - imageVector = vectorResource(Res.drawable.arrow_backward), - contentDescription = stringResource(Res.string.feature_home_send_money), + modifier = Modifier.size(26.dp), + imageVector = MifosIcons.Payment, + contentDescription = stringResource(Res.string.feature_home_autopay), ) }, ) diff --git a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeViewModel.kt b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeViewModel.kt index 35b00c469..68db98948 100644 --- a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeViewModel.kt +++ b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/HomeViewModel.kt @@ -117,6 +117,10 @@ class HomeViewModel( sendEvent(HomeEvent.NavigateToSendScreen) } + is HomeAction.AutoPayClicked -> { + sendEvent(HomeEvent.NavigateToAutoPayScreen) + } + is HomeAction.ClientDetailsClicked -> { sendEvent(HomeEvent.NavigateToClientDetailScreen) } @@ -218,6 +222,7 @@ sealed interface ViewState { sealed interface HomeEvent { data object NavigateBack : HomeEvent data object NavigateToSendScreen : HomeEvent + data object NavigateToAutoPayScreen : HomeEvent data object NavigateToTransactionScreen : HomeEvent data object NavigateToClientDetailScreen : HomeEvent data class NavigateToRequestScreen(val vpa: String) : HomeEvent @@ -230,6 +235,7 @@ sealed interface HomeEvent { sealed interface HomeAction { data object RequestClicked : HomeAction data object SendClicked : HomeAction + data object AutoPayClicked : HomeAction data object ClientDetailsClicked : HomeAction data object OnClickSeeAllTransactions : HomeAction data object OnDismissDialog : HomeAction diff --git a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/navigation/HomeNavigation.kt b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/navigation/HomeNavigation.kt index 5ea8e9776..e028a5192 100644 --- a/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/navigation/HomeNavigation.kt +++ b/feature/home/src/commonMain/kotlin/org/mifospay/feature/home/navigation/HomeNavigation.kt @@ -23,6 +23,7 @@ fun NavGraphBuilder.homeScreen( onNavigateBack: () -> Unit, onRequest: (String) -> Unit, onPay: () -> Unit, + onAutoPay: () -> Unit, navigateToTransactionDetail: (Long, Long) -> Unit, navigateToAccountDetail: (Long) -> Unit, ) { @@ -30,6 +31,7 @@ fun NavGraphBuilder.homeScreen( HomeScreen( onRequest = onRequest, onPay = onPay, + onAutoPay = onAutoPay, onNavigateBack = onNavigateBack, navigateToTransactionDetail = navigateToTransactionDetail, navigateToAccountDetail = navigateToAccountDetail, diff --git a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt index 7df3fb44d..fe7c7a09d 100644 --- a/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt +++ b/feature/make-transfer/src/commonMain/kotlin/org/mifospay/feature/make/transfer/MakeTransferViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import mobile_wallet.feature.make_transfer.generated.resources.Res import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_error_empty_amount import mobile_wallet.feature.make_transfer.generated.resources.feature_make_transfer_error_empty_description @@ -207,7 +208,7 @@ internal data class MakeTransferState( val amount: String = toClientData.amount, val description: String = "", val selectedAccount: Account? = null, - val dialogState: DialogState? = null, + @Transient val dialogState: DialogState? = null, ) { val amountIsValid: Boolean get() = amount.isNotEmpty() && amount.toDoubleOrNull() != null @@ -232,12 +233,9 @@ internal data class MakeTransferState( transferDate = DateHelper.formattedShortDate, ) - @Serializable sealed interface DialogState { - @Serializable data object Loading : DialogState - @Serializable sealed interface Error : DialogState { data class StringMessage(val message: String) : Error data class ResourceMessage(val message: StringResource) : Error diff --git a/feature/payments/src/commonMain/kotlin/org/mifospay/feature/payments/PaymentsScreen.kt b/feature/payments/src/commonMain/kotlin/org/mifospay/feature/payments/PaymentsScreen.kt index 66263af9c..59e9b02bd 100644 --- a/feature/payments/src/commonMain/kotlin/org/mifospay/feature/payments/PaymentsScreen.kt +++ b/feature/payments/src/commonMain/kotlin/org/mifospay/feature/payments/PaymentsScreen.kt @@ -62,6 +62,7 @@ enum class PaymentsScreenContents { HISTORY, SI, INVOICES, + AUTOPAY, } @Preview diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt index 66513f281..0cbfbadb1 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrCodeScreen.kt @@ -30,6 +30,7 @@ import org.mifospay.core.designsystem.component.MifosScaffold internal fun ScanQrCodeScreen( navigateBack: () -> Unit, navigateToSendScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, modifier: Modifier = Modifier, viewModel: ScanQrViewModel = koinViewModel(), ) { @@ -44,6 +45,10 @@ internal fun ScanQrCodeScreen( navigateToSendScreen.invoke((eventFlow as ScanQrEvent.OnNavigateToSendScreen).data) } + is ScanQrEvent.OnNavigateToPayeeDetails -> { + navigateToPayeeDetailsScreen.invoke((eventFlow as ScanQrEvent.OnNavigateToPayeeDetails).data) + } + is ScanQrEvent.ShowToast -> { scope.launch { snackbarHostState.showSnackbar((eventFlow as ScanQrEvent.ShowToast).message) diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt index cea7f82b7..33b8d7e20 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/ScanQrViewModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.update +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor import org.mifospay.core.data.util.UpiQrCodeProcessor class ScanQrViewModel : ViewModel() { @@ -22,10 +23,24 @@ class ScanQrViewModel : ViewModel() { fun onScanned(data: String): Boolean { return try { - UpiQrCodeProcessor.decodeUpiString(data) + val isUpiQr = try { + UpiQrCodeProcessor.decodeUpiString(data) + true + } catch (e: Exception) { + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(data)) { + StandardUpiQrCodeProcessor.parseUpiQrCode(data) + true + } else { + false + } + } _eventFlow.update { - ScanQrEvent.OnNavigateToSendScreen(data) + if (isUpiQr) { + ScanQrEvent.OnNavigateToPayeeDetails(data) + } else { + ScanQrEvent.OnNavigateToSendScreen(data) + } } true @@ -40,5 +55,6 @@ class ScanQrViewModel : ViewModel() { sealed interface ScanQrEvent { data class OnNavigateToSendScreen(val data: String) : ScanQrEvent + data class OnNavigateToPayeeDetails(val data: String) : ScanQrEvent data class ShowToast(val message: String) : ScanQrEvent } diff --git a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt index 89bbe6b19..c8a3e25dd 100644 --- a/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt +++ b/feature/qr/src/commonMain/kotlin/org/mifospay/feature/qr/navigation/ReadQrNavigation.kt @@ -23,11 +23,13 @@ fun NavController.navigateToScanQr(navOptions: NavOptions? = null) = fun NavGraphBuilder.scanQrScreen( navigateBack: () -> Unit, navigateToSendScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, ) { composableWithSlideTransitions(route = SCAN_QR_ROUTE) { ScanQrCodeScreen( navigateBack = navigateBack, navigateToSendScreen = navigateToSendScreen, + navigateToPayeeDetailsScreen = navigateToPayeeDetailsScreen, ) } } diff --git a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt index 135592b86..5d2e1ff55 100644 --- a/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt +++ b/feature/send-money/src/androidMain/kotlin/org/mifospay/feature/send/money/QrScanner.android.kt @@ -39,11 +39,12 @@ class QrScannerImp( override fun startScanning(): Flow { return callbackFlow { scanner.startScan() - .addOnSuccessListener { + .addOnSuccessListener { barcode -> launch { - send(it.rawValue) + val rawValue = barcode.rawValue + send(rawValue) } - }.addOnFailureListener { + }.addOnFailureListener { exception -> launch { send(null) } diff --git a/feature/send-money/src/commonMain/composeResources/values/strings.xml b/feature/send-money/src/commonMain/composeResources/values/strings.xml index a4680e64d..a1ea3ebb2 100644 --- a/feature/send-money/src/commonMain/composeResources/values/strings.xml +++ b/feature/send-money/src/commonMain/composeResources/values/strings.xml @@ -38,4 +38,15 @@ Account cannot be empty Requesting payment QR but found - %1$s Failed to request payment QR: required data is missing + UPI QR code parsed successfully + External UPI Payment + Choose how you want to send money + Scan any QR code + Pay anyone + Bank Transfer + Fineract Payments + People + Merchants + More + AutoPay \ No newline at end of file diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt new file mode 100644 index 000000000..59a4fdc13 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsScreen.kt @@ -0,0 +1,526 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.repeatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +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.Spacer +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.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +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.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.times +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosGradientBackground +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun PayeeDetailsScreen( + onBackClick: () -> Unit, + onNavigateToUpiPayment: (PayeeDetailsState) -> Unit, + onNavigateToFineractPayment: (PayeeDetailsState) -> Unit, + modifier: Modifier = Modifier, + viewModel: PayeeDetailsViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + PayeeDetailsEvent.NavigateBack -> onBackClick.invoke() + is PayeeDetailsEvent.NavigateToUpiPayment -> onNavigateToUpiPayment.invoke(event.state) + is PayeeDetailsEvent.NavigateToFineractPayment -> onNavigateToFineractPayment.invoke(event.state) + } + } + + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = "Payee Details", + backPress = { + viewModel.trySendAction(PayeeDetailsAction.NavigateBack) + }, + ) + }, + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.lg) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + PayeeProfileSection(state) + + Spacer(modifier = Modifier.height(KptTheme.spacing.xs)) + + PaymentDetailsSection( + state = state, + onAmountChange = { amount -> + viewModel.trySendAction(PayeeDetailsAction.UpdateAmount(amount)) + }, + onNoteChange = { note -> + viewModel.trySendAction(PayeeDetailsAction.UpdateNote(note)) + }, + onNoteFieldFocused = { + viewModel.trySendAction(PayeeDetailsAction.NoteFieldFocused) + }, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.xl)) + } + + ProceedButton( + state = state, + onProceedClick = { + viewModel.trySendAction(PayeeDetailsAction.ProceedToPayment) + }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding( + end = KptTheme.spacing.lg, + bottom = KptTheme.spacing.lg, + ), + ) + } + } + } +} + +@Composable +private fun PayeeProfileSection( + state: PayeeDetailsState, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.surface, + ), + shape = RoundedCornerShape(KptTheme.spacing.md), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.lg), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .size(80.dp) + .background( + color = KptTheme.colorScheme.primaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (state.payeeName.isNotEmpty() && state.payeeName != "UNKNOWN") { + val firstLetter = state.payeeName + .replace("%20", " ") + .trim() + .firstOrNull() + ?.uppercase() + + if (firstLetter != null) { + Text( + text = firstLetter, + style = KptTheme.typography.headlineLarge.copy( + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + ), + color = KptTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center, + ) + } else { + Icon( + imageVector = MifosIcons.Person, + contentDescription = "Payee Profile", + modifier = Modifier.size(40.dp), + tint = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } else { + Icon( + imageVector = MifosIcons.Person, + contentDescription = "Payee Profile", + modifier = Modifier.size(40.dp), + tint = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } + + if (state.payeeName.isNotEmpty() && state.payeeName != "UNKNOWN") { + val decodedName = state.payeeName + .replace("%20", " ") + .trim() + + Text( + text = "Paying ${decodedName.uppercase()}", + style = KptTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + } + + val contactInfo = if (state.isUpiCode) { + "UPI ID: ${state.upiId}" + } else { + state.phoneNumber + } + + if (contactInfo.isNotEmpty()) { + Text( + text = contactInfo, + style = KptTheme.typography.bodyLarge, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PaymentDetailsSection( + state: PayeeDetailsState, + onAmountChange: (String) -> Unit, + onNoteChange: (String) -> Unit, + onNoteFieldFocused: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + ExpandableAmountInput( + value = state.formattedAmount, + onValueChange = onAmountChange, + enabled = state.isAmountEditable, + modifier = Modifier.wrapContentWidth(), + ) + + AnimatedVisibility( + visible = state.showMaxAmountMessage, + enter = fadeIn(animationSpec = tween(300)), + exit = fadeOut(animationSpec = tween(300)), + ) { + val vibrationOffset by animateFloatAsState( + targetValue = if (state.showMaxAmountMessage) 1f else 0f, + animationSpec = repeatable( + iterations = 3, + animation = tween(100, delayMillis = 0), + ), + label = "vibration", + ) + + Text( + text = "Amount cannot be more than ₹ 5,00,000", + style = KptTheme.typography.bodySmall, + color = KptTheme.colorScheme.error, + modifier = Modifier + .padding(top = KptTheme.spacing.xs) + .graphicsLayer { + translationX = if (state.showMaxAmountMessage) { + (vibrationOffset * 10f * (if (vibrationOffset % 2 == 0f) 1f else -1f)) + } else { + 0f + } + }, + ) + } + + ExpandableNoteInput( + value = state.note, + onValueChange = onNoteChange, + onFieldFocused = onNoteFieldFocused, + modifier = Modifier.wrapContentWidth(), + ) + } +} + +// TODO improve amount validation and UI/UX +@Composable +private fun ExpandableAmountInput( + value: String, + onValueChange: (String) -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + val displayValue = value.ifEmpty { "0" } + + /** + * Calculate width based on the display value + * When showing "0" (single digit), use minimal width + * When user enters decimal or additional digits, expand dynamically + * Maximum amount is ₹5,00,000 (6 digits + decimal + up to 2 decimal places = max 9 characters) + */ + val textFieldWidth = when { + displayValue == "0" -> 24.dp + displayValue.length == 2 -> 32.dp + displayValue.length == 3 -> 48.dp + displayValue.length == 4 -> 64.dp + displayValue.length == 5 -> 80.dp + displayValue.length == 6 -> 96.dp + displayValue.length == 7 -> 112.dp + displayValue.length == 8 -> 128.dp + displayValue.length == 9 -> 144.dp + else -> 144.dp // Maximum width for ₹5,00,000.00 + } + + LaunchedEffect(enabled) { + if (enabled) { + focusRequester.requestFocus() + } + } + + Column(modifier = modifier) { + Row( + modifier = Modifier + .wrapContentWidth() + .clip(RoundedCornerShape(KptTheme.spacing.sm)) + .background( + color = KptTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(KptTheme.spacing.sm), + ) + .padding( + horizontal = KptTheme.spacing.md, + vertical = KptTheme.spacing.sm, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = MifosIcons.CurrencyRupee, + contentDescription = "Rupee Icon", + tint = KptTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.width(KptTheme.spacing.sm)) + + BasicTextField( + value = displayValue, + onValueChange = { newValue -> + val cleanValue = newValue.replace(",", "") + if (cleanValue.isEmpty() || cleanValue.toDoubleOrNull() != null) { + val amount = cleanValue.toDoubleOrNull() ?: 0.0 + + /** + * Allow the input to be processed by ViewModel for error handling + * The ViewModel will show error message briefly for invalid amounts + */ + onValueChange(cleanValue) + } + }, + enabled = enabled, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + textStyle = TextStyle( + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ), + modifier = Modifier + .width(textFieldWidth) + .focusRequester(focusRequester), + singleLine = true, + ) + } + } +} + +// TODO improve add note UI/UX +@Composable +private fun ExpandableNoteInput( + value: String, + onValueChange: (String) -> Unit, + onFieldFocused: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + var isFocused by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + Row( + modifier = Modifier + .wrapContentWidth() + .clip(RoundedCornerShape(KptTheme.spacing.sm)) + .background( + color = KptTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = RoundedCornerShape(KptTheme.spacing.sm), + ) + .padding( + horizontal = KptTheme.spacing.md, + vertical = KptTheme.spacing.sm, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + BasicTextField( + value = value, + onValueChange = { newValue -> + if (newValue.length <= 50) { + onValueChange(newValue) + } + }, + enabled = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + textStyle = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = if (value.isEmpty()) KptTheme.colorScheme.onSurfaceVariant else KptTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ), + modifier = Modifier + .width( + when { + value.length <= 7 -> 7 * 12.dp + value.length <= 28 -> (value.length + 1) * 12.dp + else -> 28 * 12.dp + }, + ) + .focusRequester(focusRequester) + .onFocusChanged { focusState -> + if (focusState.isFocused && !isFocused) { + isFocused = true + onFieldFocused() + } + }, + singleLine = value.length <= 28, + maxLines = if (value.length > 28) 2 else 1, + decorationBox = { innerTextField -> + if (value.isEmpty()) { + Text( + text = "Add note", + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + color = KptTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ), + ) + } + innerTextField() + }, + ) + } + } +} + +// TODO improve UI/UX of proceed button +@Composable +private fun ProceedButton( + state: PayeeDetailsState, + onProceedClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isAmountValid = if (state.isUpiCode) { + state.amount.isNotEmpty() && + state.amount.toDoubleOrNull() != null && + state.amount.toDouble() >= 0 && + !state.isAmountExceedingMax + } else { + state.amount.isNotEmpty() && + state.amount.toDoubleOrNull() != null && + state.amount.toDouble() > 0 && + !state.isAmountExceedingMax + } + val isContactValid = state.upiId.isNotEmpty() || state.phoneNumber.isNotEmpty() + val isAmountPrefilled = !state.isAmountEditable + val showCheckMark = isAmountValid && isContactValid && (isAmountPrefilled || state.hasNoteFieldBeenFocused) + + Button( + onClick = onProceedClick, + enabled = isAmountValid && isContactValid, + modifier = modifier.size(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isAmountValid && isContactValid) { + KptTheme.colorScheme.primary + } else { + KptTheme.colorScheme.surfaceVariant + }, + contentColor = if (isAmountValid && isContactValid) { + KptTheme.colorScheme.onPrimary + } else { + KptTheme.colorScheme.onSurfaceVariant + }, + ), + shape = RoundedCornerShape(KptTheme.spacing.sm), + contentPadding = PaddingValues(0.dp), + ) { + Icon( + imageVector = if (showCheckMark) MifosIcons.Check else MifosIcons.ArrowForward, + contentDescription = if (showCheckMark) "Proceed" else "Next", + modifier = Modifier.size(32.dp), + ) + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt new file mode 100644 index 000000000..87baf0e9e --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/PayeeDetailsViewModel.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor +import org.mifospay.core.ui.utils.BaseViewModel + +class PayeeDetailsViewModel( + savedStateHandle: SavedStateHandle, +) : BaseViewModel( + initialState = PayeeDetailsState(), +) { + + init { + val safeQrCodeDataString = savedStateHandle.get("qrCodeData") ?: "" + + if (safeQrCodeDataString.isNotEmpty()) { + val qrCodeDataString = safeQrCodeDataString.urlDecode() + val isUpiCode = StandardUpiQrCodeProcessor.isValidUpiQrCode(qrCodeDataString) + + val qrCodeData = if (isUpiCode) { + StandardUpiQrCodeProcessor.parseUpiQrCode(qrCodeDataString) + } else { + StandardUpiQrCodeProcessor.parseUpiQrCode("upi://pay?pa=$qrCodeDataString&pn=Unknown") + } + + mutableStateFlow.update { + it.copy( + payeeName = qrCodeData.payeeName, + upiId = qrCodeData.payeeVpa, + phoneNumber = "", + amount = qrCodeData.amount, + note = qrCodeData.transactionNote, + isAmountEditable = qrCodeData.amount.isEmpty(), + isUpiCode = true, + ) + } + } + } + + override fun handleAction(action: PayeeDetailsAction) { + when (action) { + is PayeeDetailsAction.NavigateBack -> { + sendEvent(PayeeDetailsEvent.NavigateBack) + } + is PayeeDetailsAction.UpdateAmount -> { + val cleanAmount = action.amount.replace(",", "") + val isValidAmount = cleanAmount.isEmpty() || cleanAmount.toDoubleOrNull() != null + + if (isValidAmount) { + val amountValue = cleanAmount.toDoubleOrNull() ?: 0.0 + val showMessage = amountValue > 500000 + + mutableStateFlow.value = stateFlow.value.copy( + amount = cleanAmount, + showMaxAmountMessage = showMessage, + ) + + if (showMessage) { + viewModelScope.launch { + delay(2000) + mutableStateFlow.value = stateFlow.value.copy( + showMaxAmountMessage = false, + ) + } + } + } + } + is PayeeDetailsAction.UpdateNote -> { + mutableStateFlow.value = stateFlow.value.copy(note = action.note) + } + is PayeeDetailsAction.NoteFieldFocused -> { + mutableStateFlow.value = stateFlow.value.copy(hasNoteFieldBeenFocused = true) + } + is PayeeDetailsAction.ProceedToPayment -> { + val currentState = stateFlow.value + if (currentState.isUpiCode) { + sendEvent(PayeeDetailsEvent.NavigateToUpiPayment(currentState)) + } else { + sendEvent(PayeeDetailsEvent.NavigateToFineractPayment(currentState)) + } + } + } + } +} + +data class PayeeDetailsState( + val payeeName: String = "", + val upiId: String = "", + val phoneNumber: String = "", + val amount: String = "", + val note: String = "", + val isAmountEditable: Boolean = true, + val isUpiCode: Boolean = false, + val isLoading: Boolean = false, + val showMaxAmountMessage: Boolean = false, + val hasNoteFieldBeenFocused: Boolean = false, +) { + val formattedAmount: String + get() = if (amount.isEmpty()) "0" else formatAmountWithCommas(amount) + + val isAmountExceedingMax: Boolean + get() = amount.toDoubleOrNull()?.let { it > 500000 } ?: false + + private fun formatAmountWithCommas(amountStr: String): String { + val cleanAmount = amountStr.replace(",", "") + return try { + val amount = cleanAmount.toDouble() + if (amount == 0.0) return if (isUpiCode) "0.00" else "0" + + val parts = amount.toString().split(".") + val integerPart = parts[0] + val decimalPart = if (parts.size > 1) parts[1] else "" + + val formattedInteger = integerPart.reversed() + .chunked(3) + .joinToString(",") + .reversed() + + if (isUpiCode) { + val paddedDecimalPart = decimalPart.padEnd(2, '0').take(2) + "$formattedInteger.$paddedDecimalPart" + } else { + if (decimalPart.isNotEmpty()) { + "$formattedInteger.$decimalPart" + } else { + formattedInteger + } + } + } catch (e: NumberFormatException) { + amountStr + } + } +} + +sealed interface PayeeDetailsEvent { + data object NavigateBack : PayeeDetailsEvent + data class NavigateToUpiPayment(val state: PayeeDetailsState) : PayeeDetailsEvent + data class NavigateToFineractPayment(val state: PayeeDetailsState) : PayeeDetailsEvent +} + +sealed interface PayeeDetailsAction { + data object NavigateBack : PayeeDetailsAction + data class UpdateAmount(val amount: String) : PayeeDetailsAction + data class UpdateNote(val note: String) : PayeeDetailsAction + data object NoteFieldFocused : PayeeDetailsAction + data object ProceedToPayment : PayeeDetailsAction +} + +/** + * URL decodes a string to restore special characters from navigation + * + * Optimized for UPI QR codes with future-proofing for common special characters. + * + * Essential UPI characters (12): + * - URL structure: ?, &, =, % + * - VPA format: @ + * - Common text: space, ", ', comma + * - URLs: /, :, #, + + * + * Future-proofing characters (5): + * - Currency symbols: $ + * - URL parameters: ; + * - JSON/structured data: [, ], {, } + * + * Note: %25 (percent) must be decoded last to avoid double decoding. + */ +private fun String.urlDecode(): String { + return this.replace("%20", " ") + .replace("%26", "&") + .replace("%3D", "=") + .replace("%3F", "?") + .replace("%40", "@") + .replace("%2B", "+") + .replace("%2F", "/") + .replace("%3A", ":") + .replace("%23", "#") + .replace("%22", "\"") + .replace("%27", "'") + .replace("%2C", ",") + .replace("%24", "$") + .replace("%3B", ";") + .replace("%5B", "[") + .replace("%5D", "]") + .replace("%7B", "{") + .replace("%7D", "}") + .replace("%25", "%") +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt new file mode 100644 index 000000000..71db8acbe --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsScreen.kt @@ -0,0 +1,494 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +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.Row +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import mobile_wallet.feature.send_money.generated.resources.Res +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_autopay +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_bank_transfer +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_choose_method +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_fineract_payments +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_merchants +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_more +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_pay_anyone +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_people +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_scan_qr_code +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_send +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import org.mifospay.core.designsystem.component.MifosGradientBackground +import org.mifospay.core.designsystem.component.MifosScaffold +import org.mifospay.core.designsystem.component.MifosTopBar +import org.mifospay.core.designsystem.icon.MifosIcons +import org.mifospay.core.ui.utils.EventsEffect +import template.core.base.designsystem.theme.KptTheme + +@Composable +fun SendMoneyOptionsScreen( + onBackClick: () -> Unit, + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + onAutoPayClick: () -> Unit, + onQrCodeScanned: (String) -> Unit, + onNavigateToPayeeDetails: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: SendMoneyOptionsViewModel = koinViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + EventsEffect(viewModel) { event -> + when (event) { + SendMoneyOptionsEvent.NavigateBack -> { + onBackClick.invoke() + } + SendMoneyOptionsEvent.NavigateToPayAnyone -> { + onPayAnyoneClick.invoke() + } + SendMoneyOptionsEvent.NavigateToBankTransfer -> { + onBankTransferClick.invoke() + } + SendMoneyOptionsEvent.NavigateToFineractPayments -> { + onFineractPaymentsClick.invoke() + } + SendMoneyOptionsEvent.NavigateToAutoPay -> { + onAutoPayClick.invoke() + } + is SendMoneyOptionsEvent.QrCodeScanned -> { + onQrCodeScanned.invoke(event.data) + } + is SendMoneyOptionsEvent.NavigateToPayeeDetails -> { + onNavigateToPayeeDetails.invoke(event.qrCodeData) + } + } + } + MifosGradientBackground { + MifosScaffold( + modifier = modifier, + topBar = { + MifosTopBar( + topBarTitle = stringResource(Res.string.feature_send_money_send), + backPress = { + viewModel.trySendAction(SendMoneyOptionsAction.NavigateBack) + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + .padding(horizontal = KptTheme.spacing.lg) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + SendMoneyBanner() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + SendMoneyOptionsRow( + onScanQrClick = { + viewModel.trySendAction(SendMoneyOptionsAction.ScanQrClicked) + }, + onPayAnyoneClick = { + viewModel.trySendAction(SendMoneyOptionsAction.PayAnyoneClicked) + }, + onBankTransferClick = { + viewModel.trySendAction(SendMoneyOptionsAction.BankTransferClicked) + }, + onFineractPaymentsClick = { + viewModel.trySendAction(SendMoneyOptionsAction.FineractPaymentsClicked) + }, + onAutoPayClick = { + viewModel.trySendAction(SendMoneyOptionsAction.AutoPayClicked) + }, + ) + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + PeopleSection() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + + MerchantsSection() + + Spacer(modifier = Modifier.height(KptTheme.spacing.md)) + } + } + } +} + +@Composable +private fun SendMoneyBanner( + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = KptTheme.colorScheme.primaryContainer, + ), + shape = RoundedCornerShape(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(KptTheme.spacing.xl), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.feature_send_money_choose_method), + style = KptTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } +} + +@Composable +private fun SendMoneyOptionsRow( + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + onAutoPayClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.lg), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + SendMoneyOptionButton( + icon = MifosIcons.Scan, + label = stringResource(Res.string.feature_send_money_scan_qr_code), + onClick = onScanQrClick, + modifier = Modifier.weight(1f), + ) + + SendMoneyOptionButton( + icon = MifosIcons.Person, + label = stringResource(Res.string.feature_send_money_pay_anyone), + onClick = onPayAnyoneClick, + modifier = Modifier.weight(1f), + ) + + SendMoneyOptionButton( + icon = MifosIcons.Bank, + label = stringResource(Res.string.feature_send_money_bank_transfer), + onClick = onBankTransferClick, + modifier = Modifier.weight(1f), + ) + + SendMoneyOptionButton( + icon = MifosIcons.Payment, + label = stringResource(Res.string.feature_send_money_fineract_payments), + onClick = onFineractPaymentsClick, + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + SendMoneyOptionButton( + icon = MifosIcons.CalenderMonth, + label = stringResource(Res.string.feature_send_money_autopay), + onClick = onAutoPayClick, + modifier = Modifier.weight(1f), + ) + + // Empty space for future icons (UPI Lite, Tap & Pay, etc.) + Spacer(modifier = Modifier.weight(3f)) + } + } +} + +@Composable +private fun SendMoneyOptionButton( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .clickable { onClick() }, + color = KptTheme.colorScheme.surface, + tonalElevation = 2.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.xs), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Box( + modifier = Modifier + .size(56.dp) + .background( + color = KptTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(KptTheme.spacing.sm), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = label, + modifier = Modifier.size(28.dp), + tint = KptTheme.colorScheme.onPrimaryContainer, + ) + } + + Text( + text = label, + style = KptTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onSurface, + maxLines = 2, + ) + } + } +} + +@Composable +private fun PeopleSection( + modifier: Modifier = Modifier, +) { + // TODO: This is a placeholder section. People functionality is not implemented yet. + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_send_money_people), + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "John Doe", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Jane Smith", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Mike Johnson", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Sarah Wilson", + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "David Brown", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Lisa Davis", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Tom Miller", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = stringResource(Res.string.feature_send_money_more), + isMoreButton = true, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun MerchantsSection( + modifier: Modifier = Modifier, +) { + // TODO: This is a placeholder section. Merchants functionality is not implemented yet. + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Text( + text = stringResource(Res.string.feature_send_money_merchants), + style = KptTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = KptTheme.colorScheme.onSurface, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "Coffee Shop", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Grocery Store", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Restaurant", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Gas Station", + modifier = Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(KptTheme.spacing.md), + ) { + PersonItem( + name = "Pharmacy", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Bookstore", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = "Bakery", + modifier = Modifier.weight(1f), + ) + PersonItem( + name = stringResource(Res.string.feature_send_money_more), + isMoreButton = true, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun PersonItem( + name: String, + isMoreButton: Boolean = false, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .clickable { /* TODO: Handle click */ } + .clip(RoundedCornerShape(KptTheme.spacing.sm)), + color = KptTheme.colorScheme.surface, + tonalElevation = 1.dp, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = KptTheme.spacing.xs), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KptTheme.spacing.xs), + ) { + Box( + modifier = Modifier + .size(48.dp) + .background( + color = if (isMoreButton) { + KptTheme.colorScheme.secondaryContainer + } else { + KptTheme.colorScheme.primaryContainer + }, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + if (isMoreButton) { + Icon( + imageVector = MifosIcons.Add, + contentDescription = name, + modifier = Modifier.size(24.dp), + tint = KptTheme.colorScheme.onSecondaryContainer, + ) + } else { + Text( + text = name.take(1).uppercase(), + style = KptTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = KptTheme.colorScheme.onPrimaryContainer, + ) + } + } + + Text( + text = name, + style = KptTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + color = KptTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt new file mode 100644 index 000000000..0df5f00f6 --- /dev/null +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyOptionsViewModel.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2024 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * See https://github.com/openMF/mobile-wallet/blob/master/LICENSE.md + */ +package org.mifospay.feature.send.money + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor +import org.mifospay.core.ui.utils.BackgroundEvent +import org.mifospay.core.ui.utils.BaseViewModel + +class SendMoneyOptionsViewModel( + private val scanner: QrScanner, +) : BaseViewModel( + initialState = SendMoneyOptionsState(), +) { + + override fun handleAction(action: SendMoneyOptionsAction) { + when (action) { + is SendMoneyOptionsAction.NavigateBack -> { + sendEvent(SendMoneyOptionsEvent.NavigateBack) + } + is SendMoneyOptionsAction.ScanQrClicked -> { + // Use ML Kit QR scanner directly + scanner.startScanning().onEach { data -> + data?.let { result -> + // Check if it's a UPI QR code or regular QR code + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(result)) { + // Navigate to payee details screen for UPI QR codes + sendEvent(SendMoneyOptionsEvent.NavigateToPayeeDetails(result)) + } else { + // For non-UPI QR codes, navigate to Fineract payment + sendEvent(SendMoneyOptionsEvent.QrCodeScanned(result)) + } + } + }.launchIn(viewModelScope) + } + is SendMoneyOptionsAction.PayAnyoneClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToPayAnyone) + } + is SendMoneyOptionsAction.BankTransferClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToBankTransfer) + } + is SendMoneyOptionsAction.FineractPaymentsClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToFineractPayments) + } + is SendMoneyOptionsAction.AutoPayClicked -> { + sendEvent(SendMoneyOptionsEvent.NavigateToAutoPay) + } + } + } +} + +data class SendMoneyOptionsState( + val isLoading: Boolean = false, +) + +sealed interface SendMoneyOptionsEvent { + data object NavigateBack : SendMoneyOptionsEvent + data object NavigateToPayAnyone : SendMoneyOptionsEvent + data object NavigateToBankTransfer : SendMoneyOptionsEvent + data object NavigateToFineractPayments : SendMoneyOptionsEvent + data object NavigateToAutoPay : SendMoneyOptionsEvent + data class QrCodeScanned(val data: String) : SendMoneyOptionsEvent, BackgroundEvent + data class NavigateToPayeeDetails(val qrCodeData: String) : SendMoneyOptionsEvent, BackgroundEvent +} + +sealed interface SendMoneyOptionsAction { + data object NavigateBack : SendMoneyOptionsAction + data object ScanQrClicked : SendMoneyOptionsAction + data object PayAnyoneClicked : SendMoneyOptionsAction + data object BankTransferClicked : SendMoneyOptionsAction + data object FineractPaymentsClicked : SendMoneyOptionsAction + data object AutoPayClicked : SendMoneyOptionsAction +} diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt index 66d7bebd2..cd49c6f32 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -92,6 +91,7 @@ import template.core.base.designsystem.theme.KptTheme fun SendMoneyScreen( onBackClick: () -> Unit, navigateToTransferScreen: (String) -> Unit, + navigateToPayeeDetails: (String) -> Unit, navigateToScanQrScreen: () -> Unit, showTopBar: Boolean = true, modifier: Modifier = Modifier, @@ -108,7 +108,16 @@ fun SendMoneyScreen( navigateToTransferScreen(event.data) } + is SendMoneyEvent.NavigateToPayeeDetails -> { + navigateToPayeeDetails(event.qrCodeData) + } + is SendMoneyEvent.NavigateToScanQrScreen -> navigateToScanQrScreen.invoke() + + is SendMoneyEvent.ShowToast -> { + // TODO: Implement toast message display + // For now, we'll just ignore it + } } } @@ -130,7 +139,6 @@ fun SendMoneyScreen( ) } -@OptIn(ExperimentalFoundationApi::class) @Composable private fun SendMoneyScreen( state: SendMoneyState, diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt index 3ee69208a..6a9e9bb73 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/SendMoneyViewModel.kt @@ -25,23 +25,26 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.serialization.Contextual import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import mobile_wallet.feature.send_money.generated.resources.Res import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_account_cannot_be_empty import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_amount_cannot_be_empty import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_invalid_amount import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_requesting_payment_qr_but_found import mobile_wallet.feature.send_money.generated.resources.feature_send_money_error_requesting_payment_qr_data_missing +import mobile_wallet.feature.send_money.generated.resources.feature_send_money_upi_qr_parsed_successfully import org.jetbrains.compose.resources.StringResource import org.mifospay.core.common.DataState import org.mifospay.core.common.getSerialized import org.mifospay.core.common.setSerialized import org.mifospay.core.data.repository.AccountRepository +import org.mifospay.core.data.util.StandardUpiQrCodeProcessor import org.mifospay.core.data.util.UpiQrCodeProcessor import org.mifospay.core.model.search.AccountResult import org.mifospay.core.model.utils.PaymentQrData import org.mifospay.core.model.utils.toAccount +import org.mifospay.core.ui.utils.BackgroundEvent import org.mifospay.core.ui.utils.BaseViewModel import org.mifospay.feature.send.money.SendMoneyAction.HandleRequestData import org.mifospay.feature.send.money.SendMoneyState.DialogState.Error @@ -120,7 +123,11 @@ class SendMoneyViewModel( SendMoneyAction.OnClickScan -> { scanner.startScanning().onEach { data -> data?.let { result -> - sendAction(HandleRequestData(result)) + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(result)) { + sendEvent(SendMoneyEvent.NavigateToPayeeDetails(result)) + } else { + sendAction(HandleRequestData(result)) + } } }.launchIn(viewModelScope) // Using Play Service Code Scanner until Qr Scan module is stable @@ -176,7 +183,16 @@ class SendMoneyViewModel( private fun handleRequestData(action: HandleRequestData) { viewModelScope.launch { try { - val requestData = UpiQrCodeProcessor.decodeUpiString(action.requestData) + val requestData = try { + UpiQrCodeProcessor.decodeUpiString(action.requestData) + } catch (e: Exception) { + if (StandardUpiQrCodeProcessor.isValidUpiQrCode(action.requestData)) { + val standardData = StandardUpiQrCodeProcessor.parseUpiQrCode(action.requestData) + StandardUpiQrCodeProcessor.toPaymentQrData(standardData) + } else { + throw e + } + } mutableStateFlow.update { state -> state.copy( @@ -185,6 +201,8 @@ class SendMoneyViewModel( selectedAccount = requestData.toAccount(), ) } + + sendEvent(SendMoneyEvent.ShowToast(Res.string.feature_send_money_upi_qr_parsed_successfully)) } catch (e: Exception) { val errorState = if (action.requestData.isNotEmpty()) { Error.GenericResourceMessage( @@ -210,7 +228,7 @@ data class SendMoneyState( val amount: String = "", val accountNumber: String = "", val selectedAccount: AccountResult? = null, - val dialogState: DialogState? = null, + @Transient val dialogState: DialogState? = null, ) { val amountIsValid: Boolean get() = amount.isNotEmpty() && @@ -229,19 +247,16 @@ data class SendMoneyState( amount = amount, ) - @Serializable sealed interface DialogState { - @Serializable + data object Loading : DialogState - @Serializable sealed interface Error : DialogState { - @Serializable - data class ResourceMessage(@Contextual val message: StringResource) : Error - @Serializable + data class ResourceMessage(val message: StringResource) : Error + data class GenericResourceMessage( - @Contextual val message: StringResource, + val message: StringResource, val args: List, ) : Error } @@ -260,6 +275,9 @@ sealed interface SendMoneyEvent { data object OnNavigateBack : SendMoneyEvent data class NavigateToTransferScreen(val data: String) : SendMoneyEvent data object NavigateToScanQrScreen : SendMoneyEvent + + data class NavigateToPayeeDetails(val qrCodeData: String) : SendMoneyEvent, BackgroundEvent + data class ShowToast(val message: StringResource) : SendMoneyEvent } sealed interface SendMoneyAction { diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt index 16dd21815..8af69abde 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/di/SendMoneyModule.kt @@ -11,10 +11,14 @@ package org.mifospay.feature.send.money.di import org.koin.core.module.dsl.viewModelOf import org.koin.dsl.module +import org.mifospay.feature.send.money.PayeeDetailsViewModel import org.mifospay.feature.send.money.ScannerModule +import org.mifospay.feature.send.money.SendMoneyOptionsViewModel import org.mifospay.feature.send.money.SendMoneyViewModel val SendMoneyModule = module { includes(ScannerModule) viewModelOf(::SendMoneyViewModel) + viewModelOf(::SendMoneyOptionsViewModel) + viewModelOf(::PayeeDetailsViewModel) } diff --git a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt index 04af30a0a..74defd7a4 100644 --- a/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt +++ b/feature/send-money/src/commonMain/kotlin/org/mifospay/feature/send/money/navigation/SendNavigation.kt @@ -14,7 +14,11 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.NavType import androidx.navigation.navArgument +import androidx.navigation.navOptions import org.mifospay.core.ui.composableWithSlideTransitions +import org.mifospay.feature.send.money.PayeeDetailsScreen +import org.mifospay.feature.send.money.PayeeDetailsState +import org.mifospay.feature.send.money.SendMoneyOptionsScreen import org.mifospay.feature.send.money.SendMoneyScreen const val SEND_MONEY_ROUTE = "send_money_route" @@ -22,13 +26,36 @@ const val SEND_MONEY_ARG = "requestData" const val SEND_MONEY_BASE_ROUTE = "$SEND_MONEY_ROUTE?$SEND_MONEY_ARG={$SEND_MONEY_ARG}" +const val SEND_MONEY_OPTIONS_ROUTE = "send_money_options_route" +const val PAYEE_DETAILS_ROUTE = "payee_details_route" +const val PAYEE_DETAILS_ARG = "qrCodeData" + +const val PAYEE_DETAILS_BASE_ROUTE = "$PAYEE_DETAILS_ROUTE?$PAYEE_DETAILS_ARG={$PAYEE_DETAILS_ARG}" + fun NavController.navigateToSendMoneyScreen( navOptions: NavOptions? = null, ) = navigate(SEND_MONEY_ROUTE, navOptions) +fun NavController.navigateToSendMoneyOptionsScreen( + navOptions: NavOptions? = null, +) = navigate(SEND_MONEY_OPTIONS_ROUTE, navOptions) + +fun NavController.navigateToPayeeDetailsScreen( + qrCodeData: String, + navOptions: NavOptions? = null, +) { + // URL encode the QR code data to handle special characters like &, =, etc. + val encodedQrCodeData = qrCodeData.urlEncode() + val route = "$PAYEE_DETAILS_ROUTE?$PAYEE_DETAILS_ARG=$encodedQrCodeData" + val options = navOptions ?: navOptions { + popUpTo(SEND_MONEY_OPTIONS_ROUTE) { inclusive = false } + } + navigate(route, options) +} fun NavGraphBuilder.sendMoneyScreen( onBackClick: () -> Unit, navigateToTransferScreen: (String) -> Unit, + navigateToPayeeDetailsScreen: (String) -> Unit, navigateToScanQrScreen: () -> Unit, ) { composableWithSlideTransitions( @@ -45,6 +72,55 @@ fun NavGraphBuilder.sendMoneyScreen( onBackClick = onBackClick, navigateToTransferScreen = navigateToTransferScreen, navigateToScanQrScreen = navigateToScanQrScreen, + navigateToPayeeDetails = navigateToPayeeDetailsScreen, + ) + } +} + +fun NavGraphBuilder.sendMoneyOptionsScreen( + onBackClick: () -> Unit, + onScanQrClick: () -> Unit, + onPayAnyoneClick: () -> Unit, + onBankTransferClick: () -> Unit, + onFineractPaymentsClick: () -> Unit, + onAutoPayClick: () -> Unit, + onQrCodeScanned: (String) -> Unit, + onNavigateToPayeeDetails: (String) -> Unit, +) { + composableWithSlideTransitions( + route = SEND_MONEY_OPTIONS_ROUTE, + ) { + SendMoneyOptionsScreen( + onBackClick = onBackClick, + onScanQrClick = onScanQrClick, + onPayAnyoneClick = onPayAnyoneClick, + onBankTransferClick = onBankTransferClick, + onFineractPaymentsClick = onFineractPaymentsClick, + onAutoPayClick = onAutoPayClick, + onQrCodeScanned = onQrCodeScanned, + onNavigateToPayeeDetails = onNavigateToPayeeDetails, + ) + } +} + +fun NavGraphBuilder.payeeDetailsScreen( + onBackClick: () -> Unit, + onNavigateToUpiPayment: (PayeeDetailsState) -> Unit, + onNavigateToFineractPayment: (PayeeDetailsState) -> Unit, +) { + composableWithSlideTransitions( + route = PAYEE_DETAILS_BASE_ROUTE, + arguments = listOf( + navArgument(PAYEE_DETAILS_ARG) { + type = NavType.StringType + nullable = false + }, + ), + ) { + PayeeDetailsScreen( + onBackClick = onBackClick, + onNavigateToUpiPayment = onNavigateToUpiPayment, + onNavigateToFineractPayment = onNavigateToFineractPayment, ) } } @@ -54,9 +130,47 @@ fun NavController.navigateToSendMoneyScreen( navOptions: NavOptions? = null, ) { val route = "$SEND_MONEY_ROUTE?$SEND_MONEY_ARG=$requestData" - val options = navOptions ?: NavOptions.Builder() - .setPopUpTo(SEND_MONEY_ROUTE, inclusive = true) - .build() + val options = navOptions ?: navOptions { + popUpTo(SEND_MONEY_ROUTE) { inclusive = true } + } navigate(route, options) } + +/** + * URL encodes a string to handle special characters in navigation + * + * Optimized for UPI QR codes with future-proofing for common special characters. + * + * Essential UPI characters (12): + * - URL structure: ?, &, =, % + * - VPA format: @ + * - Common text: space, ", ', comma + * - URLs: /, :, #, + + * + * Future-proofing characters (5): + * - Currency symbols: $ + * - URL parameters: ; + * - JSON/structured data: [, ], {, } + */ +private fun String.urlEncode(): String { + return this.replace("%", "%25") + .replace(" ", "%20") + .replace("&", "%26") + .replace("=", "%3D") + .replace("?", "%3F") + .replace("@", "%40") + .replace("+", "%2B") + .replace("/", "%2F") + .replace(":", "%3A") + .replace("#", "%23") + .replace("\"", "%22") + .replace("'", "%27") + .replace(",", "%2C") + .replace("$", "%24") + .replace(";", "%3B") + .replace("[", "%5B") + .replace("]", "%5D") + .replace("{", "%7B") + .replace("}", "%7D") +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 63c88a74f..bc520718e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -83,5 +83,6 @@ include(":feature:payments") include(":feature:request-money") include(":feature:upi-setup") include(":feature:qr") +include(":feature:autopay") include(":libs:mifos-passcode")