1 Commits
Author SHA1 Message Date
Oqba 44fc513589 بهبودهای تب مسجد و افزودن حالت اپراتور (نسخه 1.0.8)
Android Release / build (push) Canceled after 0s
2026-10-16 06:33:28 +03:30
10 changed files with 712 additions and 114 deletions

No files matched your search

+2 -2
View File
@@ -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 }
}
@@ -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 = "گزارشات مالی"
)
}
}
@@ -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<Boolean> = dataStore.data.map {
it[IS_OPERATOR_MODE] ?: false
}
val adminPinFlow: Flow<String> = 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 }
}
}
@@ -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))
@@ -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
)
}
}
}
@@ -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<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
fun CardNumbersSetting(
cardNumbers: List<String>,
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<String?>(null) }
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
Card(
modifier = Modifier.fillMaxWidth(),
@@ -517,9 +542,11 @@ fun CardNumbersSetting(cardNumbers: List<String>, 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<String>, 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<String>, 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<PinAction?>(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) }
@@ -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<String>()
val infoMessage = _infoMessage.asSharedFlow()
val isOperatorMode: StateFlow<Boolean> = settingsRepository.isOperatorModeFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false)
private val _uiState = MutableStateFlow(PersonListUiState())
val uiState: StateFlow<PersonListUiState> = _uiState.asStateFlow()
private val _financialReportData = MutableStateFlow(FinancialReportData())
val financialReportData: StateFlow<FinancialReportData> = _financialReportData.asStateFlow()
private val _showFinancialReportDialog = MutableStateFlow(false)
val showFinancialReportDialog: StateFlow<Boolean> = _showFinancialReportDialog.asStateFlow()
private val _showAddPersonDialog = MutableStateFlow(false)
val showAddPersonDialog: StateFlow<Boolean> = _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<Map.Entry<Pair<Int, Int>, List<PaymentRecord>>> { 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)
@@ -46,9 +46,29 @@ class SettingsViewModel @Inject constructor(
val cardNumbers: StateFlow<List<String>> = settingsRepository.cardNumbersFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), emptyList())
val isOperatorMode: StateFlow<Boolean> = settingsRepository.isOperatorModeFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false)
val adminPin: StateFlow<String> = settingsRepository.adminPinFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), "1234")
private val _toastMessage = MutableSharedFlow<String>()
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()
@@ -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<String>
)
data class DebtorDetail(
val personId: String,
val personName: String,
val debtCount: Int,
val totalDebtAmount: Double,
val unpaidMonthsNames: List<String> = emptyList()
)
data class FinancialReportData(
val totalAllTimeIncome: Double = 0.0,
val totalOverdueDebt: Double = 0.0,
val totalDebtorsCount: Int = 0,
val debtors: List<DebtorDetail> = emptyList(),
val monthlyReports: List<MonthlyFinancialReport> = emptyList()
)
+4 -4
View File
@@ -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
}