From 44fc513589ee5ce0ecd1f1272d292996dde15e47 Mon Sep 17 00:00:00 2001 From: Oqba Date: Fri, 16 Oct 2026 06:33:28 +0330 Subject: [PATCH] =?UTF-8?q?=D8=A8=D9=87=D8=A8=D9=88=D8=AF=D9=87=D8=A7?= =?UTF-8?q?=DB=8C=20=D8=AA=D8=A8=20=D9=85=D8=B3=D8=AC=D8=AF=20=D9=88=20?= =?UTF-8?q?=D8=A7=D9=81=D8=B2=D9=88=D8=AF=D9=86=20=D8=AD=D8=A7=D9=84=D8=AA?= =?UTF-8?q?=20=D8=A7=D9=BE=D8=B1=D8=A7=D8=AA=D9=88=D8=B1=20(=D9=86=D8=B3?= =?UTF-8?q?=D8=AE=D9=87=201.0.8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- .../oqba26/monthlypaymentapp/MainActivity.kt | 42 ++- .../data/repository/SettingsRepository.kt | 19 ++ .../ui/screens/PersonDetailScreen.kt | 38 ++- .../ui/screens/PersonScreen.kt | 276 ++++++++++++++++-- .../ui/screens/SettingsScreen.kt | 266 ++++++++++++++--- .../viewmodel/PersonViewModel.kt | 128 ++++++-- .../viewmodel/SettingsViewModel.kt | 20 ++ .../monthlypaymentapp/viewmodel/UiModels.kt | 25 ++ update.json | 8 +- 10 files changed, 712 insertions(+), 114 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70a28c4..3d17582 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { applicationId = "com.oqba26.monthlypaymentapp" minSdk = 25 targetSdk = 34 - versionCode = 8 - versionName = "1.0.7" + versionCode = 9 + versionName = "1.0.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } } diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt index ca1f95d..7bc60b4 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt @@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Assessment import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Paid @@ -457,18 +458,34 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi IconButton(onClick = { showConfirmMoveDialog = true }) { Icon(Icons.Default.Check, contentDescription = "Confirm Move", tint = Color.Green) } - } else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) { - FilledIconButton( - onClick = { viewModel.onAddPersonClicked() }, - colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = Color.White - ) - ) { - Icon(Icons.Default.Add, contentDescription = "افزودن شخص") + } else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Paid.route || currentRoute == Screen.Mosque.route) { + if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) { + FilledIconButton( + onClick = { viewModel.onAddPersonClicked() }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = Color.White + ) + ) { + Icon(Icons.Default.Add, contentDescription = "افزودن شخص") + } + FilledIconButton( + onClick = { contactViewModel.onBulkSmsClicked() }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = Color.White + ), + modifier = Modifier.padding(start = 4.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Message, + contentDescription = "Bulk SMS", + modifier = Modifier.graphicsLayer(scaleX = -1f) + ) + } } FilledIconButton( - onClick = { contactViewModel.onBulkSmsClicked() }, + onClick = { viewModel.onShowFinancialReport() }, colors = IconButtonDefaults.filledIconButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = Color.White @@ -476,9 +493,8 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi modifier = Modifier.padding(start = 4.dp) ) { Icon( - imageVector = Icons.AutoMirrored.Filled.Message, - contentDescription = "Bulk SMS", - modifier = Modifier.graphicsLayer(scaleX = -1f) + imageVector = Icons.Default.Assessment, + contentDescription = "گزارشات مالی" ) } } diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt index 635e823..14500d6 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt @@ -3,6 +3,7 @@ package com.oqba26.monthlypaymentapp.data.repository import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey @@ -26,6 +27,8 @@ class SettingsRepository(context: Context) { val REMINDER_DAY = stringPreferencesKey("reminder_day") val CARD_NUMBERS = stringPreferencesKey("card_numbers") val IGNORED_CONTACT_SUGGESTIONS = stringPreferencesKey("ignored_contact_suggestions") + val IS_OPERATOR_MODE = booleanPreferencesKey("is_operator_mode") + val ADMIN_PIN = stringPreferencesKey("admin_pin") /** آخرین versionCodeی که با آن اجرا شده — مبنای تشخیص ارتقای نسخه برای گرفتن snapshot. */ val LAST_RUN_VERSION_CODE = intPreferencesKey("last_run_version_code") @@ -116,4 +119,20 @@ class SettingsRepository(context: Context) { preferences[IGNORED_CONTACT_SUGGESTIONS] = set.joinToString(",") } } + + val isOperatorModeFlow: Flow = dataStore.data.map { + it[IS_OPERATOR_MODE] ?: false + } + + val adminPinFlow: Flow = dataStore.data.map { + it[ADMIN_PIN] ?: "1234" + } + + suspend fun setOperatorMode(isOperator: Boolean) { + dataStore.edit { it[IS_OPERATOR_MODE] = isOperator } + } + + suspend fun setAdminPin(pin: String) { + dataStore.edit { it[ADMIN_PIN] = pin } + } } diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt index 5ed627f..3f47a82 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt @@ -124,18 +124,20 @@ fun PersonDetailScreen( Spacer(modifier = Modifier.height(4.dp)) + val isMosque = detailState.person?.category == "mosque" LazyColumn(modifier = Modifier.fillMaxSize().padding(bottom = 8.dp)) { items(detailState.monthStates) { monthModel -> MonthListItem( month = monthModel, startMonth = detailState.person?.startMonth ?: 1, + isMosqueCategory = isMosque, onHeaderClick = { expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month }, onEditClick = { if (monthModel.status == MonthStatus.PAID) { editingPayment = monthModel.payment - } else if (monthModel.status == MonthStatus.AVAILABLE) { + } else { addingPaymentMonth = monthModel.month } } @@ -170,8 +172,11 @@ fun PersonDetailScreen( val monthName = getPersianMonthName(month) val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis()) + val isMosque = detailState.person?.category == "mosque" val isCurrentMonth = (month == getCurrentShamsiMonth()) && (detailState.selectedYear == getCurrentShamsiYear()) - val initialDesc = if (isCurrentMonth) { + val initialDesc = if (isMosque) { + "پرداخت کمک به مسجد در تاریخ $currentDateTime ثبت شد." + } else if (isCurrentMonth) { "پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد." } else { "پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد." @@ -600,6 +605,7 @@ fun YearSelector(year: Int, onYearChange: (Int) -> Unit) { fun MonthListItem( month: MonthUiModel, startMonth: Int, + isMosqueCategory: Boolean = false, onHeaderClick: () -> Unit, onEditClick: () -> Unit ) { @@ -607,7 +613,7 @@ fun MonthListItem( val containerColor = when (month.status) { MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.surface - MonthStatus.NOT_COMMITTED -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + MonthStatus.NOT_COMMITTED -> if (isMosqueCategory) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) else -> MaterialTheme.colorScheme.surfaceVariant } @@ -616,19 +622,27 @@ fun MonthListItem( .padding(horizontal = 16.dp, vertical = 4.dp) .fillMaxWidth() .clickable { - if (month.status == MonthStatus.NOT_COMMITTED) { - val monthName = getPersianMonthName(startMonth) - Toast.makeText( - context, - "ماه‌های قبل از $monthName ماه جزو تعهدات این فرد نیست", - Toast.LENGTH_SHORT - ).show() + if (month.status == MonthStatus.PAID) { + onHeaderClick() + } else if (month.status == MonthStatus.AVAILABLE) { + onEditClick() + } else if (month.status == MonthStatus.NOT_COMMITTED) { + if (isMosqueCategory) { + onEditClick() + } else { + val monthName = getPersianMonthName(startMonth) + Toast.makeText( + context, + "ماه‌های قبل از $monthName ماه جزو تعهدات این فرد نیست", + Toast.LENGTH_SHORT + ).show() + } } else { onHeaderClick() } }, colors = CardDefaults.cardColors(containerColor = containerColor), - elevation = CardDefaults.cardElevation(defaultElevation = if (month.status == MonthStatus.NOT_COMMITTED) 0.dp else 1.dp) + elevation = CardDefaults.cardElevation(defaultElevation = if (month.status == MonthStatus.NOT_COMMITTED && !isMosqueCategory) 0.dp else 1.dp) ) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) { Row( @@ -641,7 +655,7 @@ fun MonthListItem( text = getPersianMonthName(month.month), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = if (month.status == MonthStatus.NOT_COMMITTED) Color.Gray else Color.Unspecified + color = if (month.status == MonthStatus.NOT_COMMITTED && !isMosqueCategory) Color.Gray else Color.Unspecified ) if ((month.status == MonthStatus.PAID) && (month.payment != null)) { Spacer(modifier = Modifier.width(8.dp)) diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt index d348312..f559da7 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt @@ -30,7 +30,9 @@ import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Message import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Assessment import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Payments import androidx.compose.material.icons.filled.Search @@ -92,6 +94,7 @@ import com.oqba26.monthlypaymentapp.utils.toPersianDigits import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch import com.oqba26.monthlypaymentapp.viewmodel.ContactSuggestion import com.oqba26.monthlypaymentapp.viewmodel.DashboardUiModel +import com.oqba26.monthlypaymentapp.viewmodel.FinancialReportData import com.oqba26.monthlypaymentapp.viewmodel.PersonListType import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel @@ -112,6 +115,9 @@ fun PersonScreen( val searchQuery by viewModel.searchQuery.collectAsState() val uiState by viewModel.uiState.collectAsState() val dashboardData by viewModel.dashboardData.collectAsState() + val financialReportData by viewModel.financialReportData.collectAsState() + val showFinancialReportDialog by viewModel.showFinancialReportDialog.collectAsState() + val isOperatorMode by viewModel.isOperatorMode.collectAsState() val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0) val cardNumbers by viewModel.cardNumbersFlow.collectAsState(initial = emptyList()) val personForPaymentDialog by viewModel.personForPaymentDialog.collectAsState() @@ -179,7 +185,12 @@ fun PersonScreen( modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 2.dp, bottom = 0.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - DashboardCard(data = dashboardData) + DashboardCard( + data = dashboardData, + financialReportData = financialReportData, + isOperatorMode = isOperatorMode, + onOpenReportClick = { viewModel.onShowFinancialReport() } + ) OutlinedTextField( value = searchQuery, @@ -301,15 +312,19 @@ fun PersonScreen( // Dialogs personForPaymentDialog?.let { person -> + val currentCategory by viewModel.currentCategory.collectAsState() + val isMosque = currentCategory == "mosque" val currentDay = getCurrentShamsiDay() val currentMonth = getCurrentShamsiMonth() - val targetMonth = if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1 + val targetMonth = if (isMosque) currentMonth else (if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1) val monthName = getPersianMonthName(targetMonth) val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis()) val isCurrentMonth = targetMonth == currentMonth - val initialDesc = if (isCurrentMonth) { + val initialDesc = if (isMosque) { + "پرداخت کمک به مسجد در تاریخ $currentDateTime ثبت شد." + } else if (isCurrentMonth) { "پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد." } else { "پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد." @@ -405,6 +420,13 @@ fun PersonScreen( ) } + if (showFinancialReportDialog) { + FinancialReportDialog( + data = financialReportData, + onDismiss = { viewModel.onDismissFinancialReport() } + ) + } + if (contactState.currentBulkIndex != -1) { val person = contactState.bulkSmsQueue.getOrNull(contactState.currentBulkIndex) if (person != null) { @@ -1048,7 +1070,10 @@ fun SelectCardDialog( @Composable fun DashboardCard( - data: DashboardUiModel + data: DashboardUiModel, + financialReportData: FinancialReportData, + isOperatorMode: Boolean = false, + onOpenReportClick: () -> Unit ) { val animatedProgress by animateFloatAsState( targetValue = data.progress, @@ -1063,29 +1088,236 @@ fun DashboardCard( ) { Column( modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("وضعیت پرداخت‌ها", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } + Text("وضعیت پرداخت‌های ماه جاری", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("باقیمانده: ${(data.totalCount - data.paidCount).toString().toPersianDigits()}", style = MaterialTheme.typography.bodySmall) } LinearProgressIndicator( progress = { animatedProgress }, - modifier = Modifier.fillMaxWidth().height(8.dp).clip(MaterialTheme.shapes.medium) + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(MaterialTheme.shapes.medium) ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text("مجموع درآمد ماه", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) - Text("${formatNumberAsPersian(data.totalIncome)} تومان", style = MaterialTheme.typography.bodyMedium) + Text("مجموع درآمد ماه جاری", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text("${formatNumberAsPersian(data.totalIncome)} تومان", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + } + + if (financialReportData.totalOverdueDebt > 0 || financialReportData.debtors.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = 2.dp)) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "🔴 مجموع بدهی‌های معوقه:", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + Text( + text = "${formatNumberAsPersian(financialReportData.totalOverdueDebt)} تومان", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + } + + val debtorNamesText = financialReportData.debtors.joinToString("، ") { + "${it.personName} (${formatNumberAsPersian(it.totalDebtAmount)})" + } + + Text( + text = "بدهکاران (${financialReportData.totalDebtorsCount.toString().toPersianDigits()} نفر): $debtorNamesText", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + } + } + + if (!isOperatorMode) { + OutlinedButton( + onClick = onOpenReportClick, + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + shape = MaterialTheme.shapes.medium + ) { + Icon( + imageVector = Icons.Default.Assessment, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("مشاهده گزارش کامل مالی و درآمدهای گذشته", style = MaterialTheme.typography.labelLarge) + } + } + } + } +} + +@Composable +fun FinancialReportDialog( + data: FinancialReportData, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Card( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 600.dp), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "📊 گزارش آمار و درآمدهای مالی", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Default.Close, contentDescription = "بستن") + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("💰 کل درآمدهای تمام زمان‌ها:", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text("${formatNumberAsPersian(data.totalAllTimeIncome)} تومان", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("🔴 کل بدهی‌های معوقه فعلی:", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.error) + Text("${formatNumberAsPersian(data.totalOverdueDebt)} تومان (${data.totalDebtorsCount.toString().toPersianDigits()} نفر)", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "تفکیک درآمد ماهانه ماه‌های گذشته", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp) + ) + + if (data.monthlyReports.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Text("هیچ دریافتی تا کنون ثبت نشده است.", style = MaterialTheme.typography.bodyMedium) + } + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(data.monthlyReports) { report -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${report.monthName} ${report.year.toString().toPersianDigits()}", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = MaterialTheme.shapes.small + ) { + Text( + text = "${formatNumberAsPersian(report.totalAmount)} تومان (${report.paymentCount.toString().toPersianDigits()} پرداخت)", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + + if (report.payerNames.isNotEmpty()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "پرداخت‌کنندگان: ${report.payerNames.joinToString("، ")}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + TextButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.End) + ) { + Text("بستن") + } + } } } } @@ -1157,12 +1389,22 @@ fun PersonListItem( shape = MaterialTheme.shapes.small, modifier = Modifier.clickable { onDebtClick(person) } ) { - Text( - text = "${person.debtCount.toString().toPersianDigits()} بدهی", - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = "${formatNumberAsPersian(person.totalDebtAmount)} تومان", + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "${person.debtCount.toString().toPersianDigits()} ماه بدهکار", + color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f), + style = MaterialTheme.typography.labelSmall + ) + } } } diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt index f5da1ef..aebfa90 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt @@ -4,6 +4,7 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,8 +20,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -148,6 +153,16 @@ fun SettingsScreen(onLogout: () -> Unit) { onSave = { viewModel.saveDefaultPaymentAmount(it) } ) + val isOperatorMode by viewModel.isOperatorMode.collectAsState() + val adminPin by viewModel.adminPin.collectAsState() + + OperatorModeSetting( + isOperatorMode = isOperatorMode, + adminPin = adminPin, + onToggleMode = { viewModel.setOperatorMode(it) }, + onChangePin = { viewModel.setAdminPin(it) } + ) + Spacer(Modifier.weight(1f)) val reminderDay by viewModel.reminderDay.collectAsState() @@ -161,6 +176,7 @@ fun SettingsScreen(onLogout: () -> Unit) { val cardNumbers by viewModel.cardNumbers.collectAsState() CardNumbersSetting( cardNumbers = cardNumbers, + isOperatorMode = isOperatorMode, onAdd = { viewModel.addCardNumber(it) }, onRemove = { viewModel.removeCardNumber(it) } ) @@ -172,44 +188,46 @@ fun SettingsScreen(onLogout: () -> Unit) { onFontSelected = { viewModel.onFontSelected(it) } ) - HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + if (!isOperatorMode) { + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) - Text( - "پشتیبان‌گیری و بازیابی", - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 8.dp) - ) + Text( + "پشتیبان‌گیری و بازیابی", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp) + ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()) + val fileName = "payment_backup_${simpleDateFormat.format(Date())}.json" + backupLauncher.launch(fileName) + }, + modifier = Modifier.weight(1f) + ) { + Text("تهیه پشتیبان", fontSize = 13.sp) + } + + OutlinedButton( + onClick = { restoreLauncher.launch(arrayOf("application/json")) }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text("بازیابی فایل", fontSize = 13.sp) + } + } + + // تور نجات: نسخه‌هایی که خودِ برنامه قبل از عملیات پرخطر گرفته است. OutlinedButton( onClick = { - val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()) - val fileName = "payment_backup_${simpleDateFormat.format(Date())}.json" - backupLauncher.launch(fileName) + viewModel.loadSnapshots() + showSnapshotDialog = true }, - modifier = Modifier.weight(1f) + modifier = Modifier.fillMaxWidth().padding(top = 8.dp) ) { - Text("تهیه پشتیبان", fontSize = 13.sp) + Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp) } - - OutlinedButton( - onClick = { restoreLauncher.launch(arrayOf("application/json")) }, - modifier = Modifier.weight(1f), - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Text("بازیابی فایل", fontSize = 13.sp) - } - } - - // تور نجات: نسخه‌هایی که خودِ برنامه قبل از عملیات پرخطر گرفته است. - OutlinedButton( - onClick = { - viewModel.loadSnapshots() - showSnapshotDialog = true - }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp) - ) { - Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp) } Spacer(Modifier.weight(1f)) @@ -505,10 +523,17 @@ fun ReminderDaySetting(currentDay: Int?, onSave: (String) -> Unit) { } @Composable -fun CardNumbersSetting(cardNumbers: List, onAdd: (String) -> Unit, onRemove: (String) -> Unit) { +fun CardNumbersSetting( + cardNumbers: List, + isOperatorMode: Boolean = false, + onAdd: (String) -> Unit, + onRemove: (String) -> Unit +) { var isAdding by remember { mutableStateOf(false) } var newCardText by remember { mutableStateOf("") } var cardToRemove by remember { mutableStateOf(null) } + val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current Card( modifier = Modifier.fillMaxWidth(), @@ -517,9 +542,11 @@ fun CardNumbersSetting(cardNumbers: List, onAdd: (String) -> Unit, onRem Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text("شماره کارت‌های بانکی", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - IconButton(onClick = { isAdding = true }) { Icon(Icons.Default.Add, null) } + if (!isOperatorMode) { + IconButton(onClick = { isAdding = true }) { Icon(Icons.Default.Add, null) } + } } - if (isAdding) { + if (isAdding && !isOperatorMode) { OutlinedTextField( value = newCardText, onValueChange = { newCardText = it.filter { c -> c.isDigit() } }, @@ -534,9 +561,33 @@ fun CardNumbersSetting(cardNumbers: List, onAdd: (String) -> Unit, onRem } } cardNumbers.forEach { card -> - Row(verticalAlignment = Alignment.CenterVertically) { - Text(card.toPersianDigits(), modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge) - IconButton(onClick = { cardToRemove = card }) { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + clipboardManager.setText(AnnotatedString(card)) + Toast.makeText(context, "شماره کارت کپی شد", Toast.LENGTH_SHORT).show() + } + .padding(vertical = 4.dp) + ) { + Text( + card.toPersianDigits(), + modifier = Modifier.weight(1f), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyLarge + ) + IconButton(onClick = { + clipboardManager.setText(AnnotatedString(card)) + Toast.makeText(context, "شماره کارت کپی شد", Toast.LENGTH_SHORT).show() + }) { + Icon(Icons.Default.ContentCopy, contentDescription = "کپی شماره کارت") + } + if (!isOperatorMode) { + IconButton(onClick = { cardToRemove = card }) { + Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) + } + } } } } @@ -555,6 +606,147 @@ fun CardNumbersSetting(cardNumbers: List, onAdd: (String) -> Unit, onRem } } +@Composable +fun OperatorModeSetting( + isOperatorMode: Boolean, + adminPin: String, + onToggleMode: (Boolean) -> Unit, + onChangePin: (String) -> Unit +) { + var showPinDialog by remember { mutableStateOf(false) } + var pinAction by remember { mutableStateOf(null) } + var enteredPin by remember { mutableStateOf("") } + var newPinInput by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf("") } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (isOperatorMode) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) + ) + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("حالت دسترسی برنامه", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + if (isOperatorMode) "وضعیت فعلی: حالت اپراتور (محدود)" else "وضعیت فعلی: حالت مدیریت (کامل)", + style = MaterialTheme.typography.bodySmall, + color = if (isOperatorMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + if (isOperatorMode) { + Button( + onClick = { + pinAction = PinAction.UNLOCK_ADMIN + enteredPin = "" + errorMessage = "" + showPinDialog = true + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("خروج از حالت اپراتور") + } + } else { + Button( + onClick = { + onToggleMode(true) + }, + modifier = Modifier.weight(1f) + ) { + Text("فعال‌سازی حالت اپراتور") + } + + OutlinedButton( + onClick = { + pinAction = PinAction.CHANGE_PIN + enteredPin = "" + newPinInput = "" + errorMessage = "" + showPinDialog = true + }, + modifier = Modifier.weight(1f) + ) { + Text("تغییر رمز مدیر") + } + } + } + } + } + + if (showPinDialog) { + Dialog(onDismissRequest = { showPinDialog = false }) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Card(modifier = Modifier.fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(16.dp)) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + if (pinAction == PinAction.UNLOCK_ADMIN) "ورود رمز مدیریت" else "تغییر رمز مدیریت", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + + if (pinAction == PinAction.UNLOCK_ADMIN || pinAction == PinAction.CHANGE_PIN) { + OutlinedTextField( + value = enteredPin, + onValueChange = { enteredPin = it.filter { c -> c.isDigit() } }, + label = { Text(if (pinAction == PinAction.CHANGE_PIN) "رمز فعلی" else "رمز عبور مدیر") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + + if (pinAction == PinAction.CHANGE_PIN) { + OutlinedTextField( + value = newPinInput, + onValueChange = { newPinInput = it.filter { c -> c.isDigit() } }, + label = { Text("رمز جدید") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + + if (errorMessage.isNotBlank()) { + Text(errorMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = { showPinDialog = false }) { Text("لغو") } + Button(onClick = { + if (pinAction == PinAction.UNLOCK_ADMIN) { + if (enteredPin == adminPin) { + onToggleMode(false) + showPinDialog = false + } else { + errorMessage = "رمز عبور اشتباه است" + } + } else if (pinAction == PinAction.CHANGE_PIN) { + if (enteredPin != adminPin) { + errorMessage = "رمز فعلی اشتباه است" + } else if (newPinInput.length < 4) { + errorMessage = "رمز جدید باید حداقل ۴ رقم باشد" + } else { + onChangePin(newPinInput) + showPinDialog = false + } + } + }) { Text("تأیید") } + } + } + } + } + } + } +} + +private enum class PinAction { UNLOCK_ADMIN, CHANGE_PIN } + @Composable fun FontSelectionDropdown(selectedFont: String, onFontSelected: (String) -> Unit) { var expanded by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt index e2399f8..9a04706 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt @@ -16,6 +16,7 @@ import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear import com.oqba26.monthlypaymentapp.utils.getPersianMonthName import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -68,9 +69,18 @@ class PersonViewModel @Inject constructor( private val _infoMessage = MutableSharedFlow() val infoMessage = _infoMessage.asSharedFlow() + val isOperatorMode: StateFlow = settingsRepository.isOperatorModeFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false) + private val _uiState = MutableStateFlow(PersonListUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _financialReportData = MutableStateFlow(FinancialReportData()) + val financialReportData: StateFlow = _financialReportData.asStateFlow() + + private val _showFinancialReportDialog = MutableStateFlow(false) + val showFinancialReportDialog: StateFlow = _showFinancialReportDialog.asStateFlow() + private val _showAddPersonDialog = MutableStateFlow(false) val showAddPersonDialog: StateFlow = _showAddPersonDialog.asStateFlow() @@ -92,10 +102,11 @@ class PersonViewModel @Inject constructor( localPersonRepository.getAllPersonsFlow(), localPersonRepository.getAllPaymentsFlow(), _selectedYear, - _currentCategory + _currentCategory, ) { persons, allPayments, year, category -> val person = persons.find { it.id == personId } val personCategory = person?.category ?: category + val isMosque = (personCategory == "mosque") val paymentsForPersonInYear = allPayments.filter { (it.personId == personId) && (it.shamsiYear == year) && (it.category == personCategory) } @@ -106,12 +117,18 @@ class PersonViewModel @Inject constructor( val payment = paymentsForPersonInYear.find { it.shamsiMonth == month } // بررسی شروع تعهد - val isBeforeCommitment = if (person != null) { - year < person.startYear || (year == person.startYear && month < person.startMonth) - } else false + val isBeforeCommitment = (person != null) && + (year < person.startYear || (year == person.startYear && month < person.startMonth)) val status = when { payment != null -> MonthStatus.PAID + isMosque -> { + if (year == currentYear && month == currentMonth) { + MonthStatus.AVAILABLE + } else { + MonthStatus.NOT_COMMITTED + } + } isBeforeCommitment -> MonthStatus.NOT_COMMITTED year < currentYear -> MonthStatus.PAST_YEAR year > currentYear -> MonthStatus.FUTURE_YEAR @@ -133,26 +150,28 @@ class PersonViewModel @Inject constructor( if (token != null) { networkRepository.observeRealtimeChanges() - launch { - combine( - networkRepository.getPersonsFlow(), - networkRepository.getPaymentsFlow(), - networkRepository.hasServerDataFlow() - ) { persons, payments, hasServerData -> - if (hasServerData) { - if (!snapshotTakenThisSession) { - // آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی - // ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد، - // کاربر بتواند از تنظیمات برش گرداند. - backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC) - snapshotTakenThisSession = true + coroutineScope { + launch { + combine( + networkRepository.getPersonsFlow(), + networkRepository.getPaymentsFlow(), + networkRepository.hasServerDataFlow() + ) { persons, payments, hasServerData -> + if (hasServerData) { + if (!snapshotTakenThisSession) { + // آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی + // ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد، + // کاربر بتواند از تنظیمات برش گرداند. + backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC) + snapshotTakenThisSession = true + } + localPersonRepository.mergeFromServer(persons, payments) + true + } else false + }.collect { isUpdated -> + if (isUpdated) { + _toastMessage.emit("اطلاعات با سرور همگام‌سازی شد") } - localPersonRepository.mergeFromServer(persons, payments) - true - } else false - }.collect { isUpdated -> - if (isUpdated) { - _toastMessage.emit("اطلاعات با سرور همگام‌سازی شد") } } } @@ -199,7 +218,8 @@ class PersonViewModel @Inject constructor( allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category } } val defaultAmount = settingsRepository.defaultPaymentAmountFlow.first() - Triple(unpaid.size, unpaid.size * defaultAmount, unpaid.map { getPersianMonthName(it) } to unpaid) + val amountPerMonth = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultAmount + Triple(unpaid.size, unpaid.size * amountPerMonth, unpaid.map { getPersianMonthName(it) } to unpaid) } val (debtCount, totalDebtAmount, unpaidData) = unpaidResult @@ -232,6 +252,53 @@ class PersonViewModel @Inject constructor( val (paidModels, unpaidModels) = uiModels.partition { it.hasPaidThisMonth } + // محاسبه گزارشات مالی جامع + val personsMap = persons.associateBy { it.id } + val categoryPayments = allPayments.filter { it.category == category } + val totalAllTimeIncome = categoryPayments.sumOf { it.amount } + + val debtorsList = unpaidModels.filter { it.debtCount > 0 }.map { personModel -> + DebtorDetail( + personId = personModel.id, + personName = personModel.name, + debtCount = personModel.debtCount, + totalDebtAmount = personModel.totalDebtAmount, + unpaidMonthsNames = personModel.unpaidMonthsNames + ) + } + val totalOverdueDebt = debtorsList.sumOf { it.totalDebtAmount } + + val monthlyReportsList = categoryPayments + .groupBy { Pair(it.shamsiYear, it.shamsiMonth) } + .entries + .sortedWith(compareByDescending, List>> { it.key.first }.thenByDescending { it.key.second }) + .map { (yearMonth, paymentGroup) -> + val (year, month) = yearMonth + val totalAmount = paymentGroup.sumOf { it.amount } + val payerNames = paymentGroup.mapNotNull { payment -> + personsMap[payment.personId]?.let { person -> + if (person.isAnonymous && category == "mosque") "خیر ناشناس (${person.name})" else person.name + } + }.distinct() + + MonthlyFinancialReport( + year = year, + month = month, + monthName = getPersianMonthName(month), + totalAmount = totalAmount, + paymentCount = paymentGroup.size, + payerNames = payerNames + ) + } + + _financialReportData.value = FinancialReportData( + totalAllTimeIncome = totalAllTimeIncome, + totalOverdueDebt = totalOverdueDebt, + totalDebtorsCount = debtorsList.size, + debtors = debtorsList, + monthlyReports = monthlyReportsList + ) + val filteredPaid = if (query.isBlank()) paidModels else paidModels.filter { it.name.contains(query, ignoreCase = true) } val filteredUnpaid = if (query.isBlank()) unpaidModels else unpaidModels.filter { it.name.contains(query, ignoreCase = true) } @@ -269,6 +336,8 @@ class PersonViewModel @Inject constructor( fun onDismissAddPersonDialog() { _showAddPersonDialog.value = false } fun onQuickPayClicked(person: PersonUiModel) { _personForPaymentDialog.value = person } fun onDismissPaymentDialog() { _personForPaymentDialog.value = null } + fun onShowFinancialReport() { _showFinancialReportDialog.value = true } + fun onDismissFinancialReport() { _showFinancialReportDialog.value = false } fun onEvent(event: PersonScreenEvent) { viewModelScope.launch { @@ -291,9 +360,9 @@ class PersonViewModel @Inject constructor( val persons = localPersonRepository.getAllPersonsFlow().first() val finalName = if (event.isAnonymous && event.name.isBlank()) "ناشناس" else event.name - val isDuplicate = if (!event.isAnonymous) { - persons.any { it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true) } - } else false + val isDuplicate = !event.isAnonymous && persons.any { + it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true) + } if (isDuplicate) { _toastMessage.emit("خطا: این نام از قبل وجود دارد.") @@ -334,8 +403,9 @@ class PersonViewModel @Inject constructor( val currentDay = getCurrentShamsiDay() val currentMonth = getCurrentShamsiMonth() val currentYear = getCurrentShamsiYear() - val targetMonth = if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1 - val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear + val isMosque = _currentCategory.value == "mosque" + val targetMonth = if (isMosque) currentMonth else (if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1) + val targetYear = if (isMosque) currentYear else (if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear) val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description) .copy(category = _currentCategory.value) diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/SettingsViewModel.kt index e3fd341..064d61d 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/SettingsViewModel.kt @@ -46,9 +46,29 @@ class SettingsViewModel @Inject constructor( val cardNumbers: StateFlow> = settingsRepository.cardNumbersFlow .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), emptyList()) + val isOperatorMode: StateFlow = settingsRepository.isOperatorModeFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false) + + val adminPin: StateFlow = settingsRepository.adminPinFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), "1234") + private val _toastMessage = MutableSharedFlow() val toastMessage = _toastMessage.asSharedFlow() + fun setOperatorMode(enabled: Boolean) { + viewModelScope.launch { + settingsRepository.setOperatorMode(enabled) + _toastMessage.emit(if (enabled) "حالت اپراتور (محدود) فعال شد" else "حالت مدیریت فعال شد") + } + } + + fun setAdminPin(newPin: String) { + viewModelScope.launch { + settingsRepository.setAdminPin(newPin) + _toastMessage.emit("رمز مدیریت با موفقیت تغییر یافت") + } + } + fun saveReminderDay(day: String) { viewModelScope.launch { val dayInt = day.toIntOrNull() diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt index b24c814..857404b 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt @@ -145,3 +145,28 @@ data class DashboardUiModel( val totalIncome: Double = 0.0, val progress: Float = 0f ) + +data class MonthlyFinancialReport( + val year: Int, + val month: Int, + val monthName: String, + val totalAmount: Double, + val paymentCount: Int, + val payerNames: List +) + +data class DebtorDetail( + val personId: String, + val personName: String, + val debtCount: Int, + val totalDebtAmount: Double, + val unpaidMonthsNames: List = emptyList() +) + +data class FinancialReportData( + val totalAllTimeIncome: Double = 0.0, + val totalOverdueDebt: Double = 0.0, + val totalDebtorsCount: Int = 0, + val debtors: List = emptyList(), + val monthlyReports: List = emptyList() +) diff --git a/update.json b/update.json index ef57166..25931f9 100644 --- a/update.json +++ b/update.json @@ -1,7 +1,7 @@ { - "versionCode": 8, - "versionName": "1.0.7", - "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.7/MonthlyPaymentApp-v1.0.7.apk", - "releaseNotes": "مهاجرت به PocketBase، رفع خطاهای بیلد و بهبود پایداری همگام‌سازی", + "versionCode": 9, + "versionName": "1.0.8", + "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.8/MonthlyPaymentApp-v1.0.8.apk", + "releaseNotes": "بهبودهای تب مسجد و افزودن حالت اپراتور (محدودسازی دسترسی)", "isForceUpdate": false }