2 Commits
10 changed files with 702 additions and 59 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 = 10
versionName = "1.0.9"
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,7 +458,8 @@ 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) {
} 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(
@@ -482,6 +484,20 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
)
}
}
FilledIconButton(
onClick = { viewModel.onShowFinancialReport() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White
),
modifier = Modifier.padding(start = 4.dp)
) {
Icon(
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) {
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,247 @@ 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))
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
)
}
}
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)
}
}
}
if (data.debtors.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f))
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "لیست بدهکاران (${data.totalDebtorsCount.toString().toPersianDigits()} نفر):",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
val debtorNamesText = data.debtors.joinToString("، ") {
"${it.personName} (${formatNumberAsPersian(it.totalDebtAmount)})"
}
Text(
text = debtorNamesText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
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("بستن")
}
}
}
}
}
@@ -1156,13 +1399,23 @@ fun PersonListItem(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small,
modifier = Modifier.clickable { onDebtClick(person) }
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "${person.debtCount.toString().toPersianDigits()} بدهی",
text = "${formatNumberAsPersian(person.totalDebtAmount)} تومان",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
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,23 +4,36 @@ 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.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.fillMaxHeight
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.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
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.AdminPanelSettings
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.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
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
@@ -131,16 +144,18 @@ fun SettingsScreen(onLogout: () -> Unit) {
}
)
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth()
.fillMaxHeight()
.fillMaxSize()
.verticalScroll(scrollState)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
stringResource(R.string.settings),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
style = MaterialTheme.typography.titleMedium
)
DefaultAmountSetting(
@@ -148,7 +163,15 @@ fun SettingsScreen(onLogout: () -> Unit) {
onSave = { viewModel.saveDefaultPaymentAmount(it) }
)
Spacer(Modifier.weight(1f))
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) }
)
val reminderDay by viewModel.reminderDay.collectAsState()
ReminderDaySetting(
@@ -156,28 +179,25 @@ fun SettingsScreen(onLogout: () -> Unit) {
onSave = { viewModel.saveReminderDay(it) }
)
Spacer(Modifier.weight(1f))
val cardNumbers by viewModel.cardNumbers.collectAsState()
CardNumbersSetting(
cardNumbers = cardNumbers,
isOperatorMode = isOperatorMode,
onAdd = { viewModel.addCardNumber(it) },
onRemove = { viewModel.removeCardNumber(it) }
)
Spacer(Modifier.weight(1f))
FontSelectionDropdown(
selectedFont = selectedFont,
onFontSelected = { viewModel.onFontSelected(it) }
)
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
if (!isOperatorMode) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
"پشتیبان‌گیری و بازیابی",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
style = MaterialTheme.typography.titleMedium
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -207,23 +227,26 @@ fun SettingsScreen(onLogout: () -> Unit) {
viewModel.loadSnapshots()
showSnapshotDialog = true
},
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
}
}
Spacer(Modifier.weight(1f))
Spacer(Modifier.height(8.dp))
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp)
contentPadding = PaddingValues(vertical = 12.dp)
) {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.logout))
}
Spacer(Modifier.height(16.dp))
}
if (showSnapshotDialog) {
@@ -505,10 +528,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 +547,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))
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 +566,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 +611,176 @@ 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(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (isOperatorMode) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (isOperatorMode) Icons.Default.Lock else Icons.Default.AdminPanelSettings,
contentDescription = null,
tint = if (isOperatorMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = "حالت دسترسی برنامه",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = if (isOperatorMode) "وضعیت فعلی: حالت اپراتور (محدود)" else "وضعیت فعلی: حالت مدیریت (کامل)",
style = MaterialTheme.typography.bodySmall,
color = if (isOperatorMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
)
}
}
}
HorizontalDivider(thickness = 0.5.dp)
if (isOperatorMode) {
Button(
onClick = {
pinAction = PinAction.UNLOCK_ADMIN
enteredPin = ""
errorMessage = ""
showPinDialog = true
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp)
) {
Icon(Icons.Default.LockOpen, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("خروج از حالت اپراتور (ورود رمز مدیر)")
}
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Button(
onClick = { onToggleMode(true) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(10.dp)
) {
Text("فعال‌سازی حالت اپراتور", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
}
OutlinedButton(
onClick = {
pinAction = PinAction.CHANGE_PIN
enteredPin = ""
newPinInput = ""
errorMessage = ""
showPinDialog = true
},
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(10.dp)
) {
Text("تغییر رمز مدیر", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
}
}
}
}
}
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,6 +150,7 @@ class PersonViewModel @Inject constructor(
if (token != null) {
networkRepository.observeRealtimeChanges()
coroutineScope {
launch {
combine(
networkRepository.getPersonsFlow(),
@@ -156,6 +174,7 @@ class PersonViewModel @Inject constructor(
}
}
}
}
networkRepository.refresh()
}
@@ -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": 10,
"versionName": "1.0.9",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.9/MonthlyPaymentApp-v1.0.9.apk",
"releaseNotes": "اصلاح اسکرول صفحه تنظیمات، بهبود ظاهری کارت حالت دسترسی و انتقال لیست اسامی بدهکاران به گزارش مالی",
"isForceUpdate": false
}