9 Commits
Author SHA1 Message Date
oqba26 9db6cf4fa1 بازگرداندن نسخه به 1.0.5 پس از تایید تست ظاهری
Android Release / build (push) Canceled after 24s
2026-08-05 10:29:15 +03:30
oqba26 11030e9109 تغییر موقت نسخه به 1.0.6 برای تست ظاهری دیالوگ 2026-08-05 10:02:29 +03:30
oqba26 309d0d0b19 تغییر متن دکمه آپدیت و جابجایی دکمه‌ها در دیالوگ به نسخه 1.0.5 2026-08-05 09:42:51 +03:30
oqba26 8b8696a75f اصلاح لینک دانلود در فایل آپدیت به نسخه 1.0.4 2026-08-05 09:30:38 +03:30
oqba26 83f0b82c7a بهبود طراحی دیالوگ آپدیت و رفع مشکل برش خوردگی دکمه‌ها
Android Release / build (push) Canceled after 2s
2026-08-05 03:55:53 +03:30
oqba26 5d80bb6f6a بهبود طراحی دیالوگ آپدیت و رفع مشکل برش خوردگی دکمه‌ها 2026-08-05 03:54:29 +03:30
oqba26 4dae0d4c0e بهبود طراحی دیالوگ آپدیت و رفع مشکل برش خوردگی دکمه‌ها
Android Release / build (push) Canceled after 1m7s
2026-08-04 20:03:50 +03:30
oqba26 6eb9ae8ed6 آپدیت نسخه برنامه به 1.0.3 و بروزرسانی فایل update.json 2026-08-04 19:02:01 +03:30
oqba26 ab905b14cb بهبود سیستم پرداخت دسته جمعی و اضافه شدن قابلیت نوسازی (Pull-to-Refresh) در لیست پرداخت شده‌ها 2026-08-04 18:55:41 +03:30
9 changed files with 340 additions and 257 deletions

No files matched your search

+2 -2
View File
@@ -30,8 +30,8 @@ android {
applicationId = "com.oqba26.monthlypaymentapp"
minSdk = 25
targetSdk = 34
versionCode = 3
versionName = "1.0.2"
versionCode = 6
versionName = "1.0.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}
@@ -7,7 +7,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.LayoutDirection
@@ -37,71 +36,81 @@ fun UpdateDialog(
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier
.padding(24.dp)
.verticalScroll(rememberScrollState())
) {
Text(
text = "به‌روزرسانی جدید موجود است",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "نسخه ${updateInfo.versionName}",
style = MaterialTheme.typography.labelLarge,
color = Color.Gray
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "تغییرات این نسخه:",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = updateInfo.releaseNotes,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
if (updateInfo.isForceUpdate) {
Text(
text = "نصب این به‌روزرسانی برای ادامه استفاده از برنامه الزامی است.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
Column(
modifier = Modifier.padding(24.dp)
) {
Button(
onClick = onConfirm,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
// Header
Text(
text = "به‌روزرسانی جدید موجود است",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "نسخه ${updateInfo.versionName}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
// Content (Scrollable)
Column(
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) {
Text("تایید و بروزرسانی")
Text(
text = "تغییرات این نسخه:",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = updateInfo.releaseNotes,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
}
if (!updateInfo.isForceUpdate) {
// Footer
if (updateInfo.isForceUpdate) {
Text(
text = "نصب این به‌روزرسانی برای ادامه استفاده از برنامه الزامی است.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
Spacer(modifier = Modifier.height(16.dp))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onDismiss,
onClick = onConfirm,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
shape = MaterialTheme.shapes.small
shape = MaterialTheme.shapes.medium,
elevation = ButtonDefaults.buttonElevation(defaultElevation = 2.dp)
) {
Text("بعداً")
Text("بروزرسانی")
}
if (!updateInfo.isForceUpdate) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
border = ButtonDefaults.outlinedButtonBorder.copy(brush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error)),
shape = MaterialTheme.shapes.medium
) {
Text("بعداً")
}
}
}
}
@@ -109,4 +118,3 @@ fun UpdateDialog(
}
}
}
}
@@ -4,13 +4,18 @@ 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -45,6 +50,7 @@ import org.burnoutcrew.reorderable.detectReorderAfterLongPress
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PaidListScreen(
viewModel: PersonViewModel,
@@ -53,8 +59,11 @@ fun PaidListScreen(
) {
val uiState by viewModel.uiState.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
val isRefreshing by viewModel.isRefreshing.collectAsState()
val personToArchive = remember { mutableStateOf<PersonUiModel?>(null) }
val personForBulkPayment = remember { mutableStateOf<PersonUiModel?>(null) }
val state = rememberReorderableLazyListState(
onMove = { from, to ->
@@ -69,40 +78,79 @@ fun PaidListScreen(
}
)
Scaffold { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
OutlinedTextField(
value = searchQuery,
onValueChange = { viewModel.onSearchQueryChange(it) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
label = { Text("جستجوی نام...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
singleLine = true
)
@OptIn(ExperimentalMaterialApi::class)
val pullRefreshState = rememberPullRefreshState(
refreshing = isRefreshing,
onRefresh = { viewModel.onEvent(PersonScreenEvent.RefreshData) }
)
LazyColumn(
state = state.listState,
modifier = Modifier.reorderable(state)
) {
itemsIndexed(uiState.paidPersons, key = { _, person -> person.id }) { index, person ->
ReorderableItem(state, key = person.id) { _ ->
val rowDragModifier = if (searchQuery.isBlank()) Modifier.detectReorderAfterLongPress(state) else Modifier
Row(modifier = rowDragModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.weight(1f)) {
PersonListItem(
person = person,
index = index + 1,
onPersonClick = { navController.navigate("person_detail/${person.id}") },
onQuickPayClick = { },
onArchiveClick = { personToArchive.value = it },
onSmsClick = { contactViewModel.sendSmsReminder(it) }
)
Scaffold { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.pullRefresh(pullRefreshState)
) {
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = searchQuery,
onValueChange = { viewModel.onSearchQueryChange(it) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
label = { Text("جستجوی نام...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
singleLine = true
)
LazyColumn(
state = state.listState,
modifier = Modifier.reorderable(state)
) {
itemsIndexed(uiState.paidPersons, key = { _, person -> person.id }) { index, person ->
ReorderableItem(state, key = person.id) { _ ->
val rowDragModifier = if (searchQuery.isBlank()) Modifier.detectReorderAfterLongPress(state) else Modifier
Row(modifier = rowDragModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.weight(1f)) {
PersonListItem(
person = person,
index = index + 1,
onPersonClick = { navController.navigate("person_detail/${person.id}") },
onQuickPayClick = { },
onArchiveClick = { personToArchive.value = it },
onSmsClick = { contactViewModel.sendSmsReminder(it) },
onDebtClick = { personForBulkPayment.value = it }
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
}
HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
}
}
}
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
personForBulkPayment.value?.let { person: PersonUiModel ->
BulkPaymentDialog(
availableMonths = person.unpaidMonths,
defaultAmount = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount,
onConfirm = { selected: List<Int>, amount: Double ->
viewModel.onEvent(
PersonScreenEvent.AddBulkPayments(
personId = person.id,
months = selected,
year = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear(),
amount = amount
)
)
personForBulkPayment.value = null
},
onDismiss = { personForBulkPayment.value = null }
)
}
personToArchive.value?.let { person ->
@@ -26,7 +26,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Payments
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -51,7 +50,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -102,7 +100,6 @@ fun PersonDetailScreen(
var editingPayment by remember { mutableStateOf<PaymentRecord?>(null) }
var addingPaymentMonth by remember { mutableStateOf<Int?>(null) }
var editingPerson by remember { mutableStateOf(value = false) }
var showBulkPaymentDialog by remember { mutableStateOf(value = false) }
Column(modifier = Modifier.fillMaxSize()) {
val displayName = detailState.person?.let { p ->
@@ -125,24 +122,6 @@ fun PersonDetailScreen(
viewModel.onEvent(PersonScreenEvent.ChangeYear(offset))
}
val availableMonths = detailState.monthStates
.asSequence()
.filter { it.status == MonthStatus.AVAILABLE }
.map { it.month }
.toList()
if (availableMonths.isNotEmpty()) {
Button(
onClick = { showBulkPaymentDialog = true },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary)
) {
Icon(Icons.Default.Payments, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("پرداخت دسته جمعی (${availableMonths.size.toString().toPersianDigits()} ماه)")
}
}
Spacer(modifier = Modifier.height(4.dp))
LazyColumn(modifier = Modifier.fillMaxSize().padding(bottom = 8.dp)) {
@@ -240,135 +219,6 @@ fun PersonDetailScreen(
}
)
}
if (showBulkPaymentDialog) {
val availableMonths = detailState.monthStates
.asSequence()
.filter { it.status == MonthStatus.AVAILABLE }
.map { it.month }
.toList()
val bulkDefaultAmount = if ((detailState.person?.monthlyCommitment ?: 0.0) > 0)
detailState.person!!.monthlyCommitment
else defaultPaymentAmount
BulkPaymentDialog(
availableMonths = availableMonths,
defaultAmount = bulkDefaultAmount,
onConfirm = { selected, amount ->
viewModel.onEvent(
PersonScreenEvent.AddBulkPayments(
personId = personId,
months = selected,
year = detailState.selectedYear,
amount = amount
)
)
showBulkPaymentDialog = false
},
) { showBulkPaymentDialog = false }
}
}
@Composable
fun BulkPaymentDialog(
availableMonths: List<Int>,
defaultAmount: Double,
onConfirm: (List<Int>, Double) -> Unit,
onDismiss: () -> Unit,
) {
val selectedMonths = remember { mutableStateListOf<Int>().apply { addAll(availableMonths) } }
var amountText by remember { mutableStateOf(defaultAmount.toInt().toString()) }
Dialog(
onDismissRequest = onDismiss
) {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "پرداخت دسته جمعی",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text("ماه‌های مورد نظر را انتخاب کنید:")
LazyColumn(modifier = Modifier.heightIn(max = 250.dp)) {
items(availableMonths) { month ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (selectedMonths.contains(month)) selectedMonths.remove(month)
else selectedMonths.add(month)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selectedMonths.contains(month),
onCheckedChange = { checked ->
if (checked) selectedMonths.add(month)
else selectedMonths.remove(month)
}
)
Text(getPersianMonthName(month))
}
}
}
OutlinedTextField(
value = amountText,
onValueChange = { value ->
// اجازه ورود فقط به اعداد (چه انگلیسی چه فارسی)
val digitsOnly = value.filter { it.isDigit() || (((it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9'))) }
// تبدیل اعداد فارسی به انگلیسی برای ذخیره در استیت
amountText = digitsOnly.map {
when (it) {
in '\u0660'..'\u0669' -> ((it.code - '\u0660'.code) + '0'.code).toChar()
in '\u06f0'..'\u06f9' -> ((it.code - '\u06f0'.code) + '0'.code).toChar()
else -> it
}
}.joinToString("")
},
label = { Text("مبلغ برای هر ماه (تومان)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
visualTransformation = PersianNumberVisualTransformation()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
val finalAmount = amountText.toDoubleOrNull() ?: defaultAmount
onConfirm(selectedMonths.toList(), finalAmount)
},
enabled = selectedMonths.isNotEmpty()
) {
Text("ثبت پرداخت‌ها")
}
TextButton(onClick = onDismiss) {
Text("لغو")
}
}
}
}
}
}
}
@Composable
@@ -83,6 +83,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
import com.oqba26.monthlypaymentapp.utils.toPersianDigits
import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch
import com.oqba26.monthlypaymentapp.viewmodel.ContactSuggestion
@@ -129,6 +134,7 @@ fun PersonScreen(
}
var personToArchive by remember { mutableStateOf<PersonUiModel?>(null) }
var personForBulkPayment by remember { mutableStateOf<PersonUiModel?>(null) }
val showBulkSelection = contactState.showBulkSmsDialog
val reorderableState = rememberReorderableLazyListState(
@@ -232,6 +238,9 @@ fun PersonScreen(
},
onSmsClick = { selectedPerson ->
contactViewModel.onSmsClicked(selectedPerson)
},
onDebtClick = { selectedPerson ->
personForBulkPayment = selectedPerson
}
)
}
@@ -271,6 +280,9 @@ fun PersonScreen(
},
onSmsClick = { selectedPerson ->
contactViewModel.onSmsClicked(selectedPerson)
},
onDebtClick = { selectedPerson ->
personForBulkPayment = selectedPerson
}
)
}
@@ -289,9 +301,26 @@ fun PersonScreen(
// Dialogs
personForPaymentDialog?.let { person ->
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 monthName = getPersianMonthName(targetMonth)
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
val isCurrentMonth = (targetMonth == currentMonth) && (targetYear == currentYear)
val initialDesc = if (isCurrentMonth) {
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
} else {
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
}
AddPaymentDialog(
personName = person.name,
defaultAmount = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount,
initialDescription = initialDesc,
onConfirm = { amount, description ->
viewModel.onEvent(PersonScreenEvent.AddQuickPayment(person.id, amount, description))
viewModel.onDismissPaymentDialog()
@@ -348,6 +377,25 @@ fun PersonScreen(
)
}
personForBulkPayment?.let { person: PersonUiModel ->
BulkPaymentDialog(
availableMonths = person.unpaidMonths,
defaultAmount = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount,
onConfirm = { selected: List<Int>, amount: Double ->
viewModel.onEvent(
PersonScreenEvent.AddBulkPayments(
personId = person.id,
months = selected,
year = getCurrentShamsiYear(),
amount = amount
)
)
personForBulkPayment = null
},
onDismiss = { personForBulkPayment = null }
)
}
if (showBulkSelection) {
val debtors = (uiState.unpaidPersons + uiState.paidPersons).filter { it.debtCount > 0 }
BulkSmsSelectionDialog(
@@ -511,8 +559,8 @@ fun AddNewPersonDialog(
var monthlyCommitment by remember { mutableStateOf("") }
var initialPaymentAmount by remember { mutableStateOf("") }
val currentShamsiYear = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear()
val currentShamsiMonth = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth()
val currentShamsiYear = getCurrentShamsiYear()
val currentShamsiMonth = getCurrentShamsiMonth()
var startMonth by remember { mutableIntStateOf(currentShamsiMonth) }
var startYear by remember { mutableIntStateOf(currentShamsiYear) }
@@ -755,14 +803,14 @@ fun AddNewPersonDialog(
onClick = { expandedMonth = true },
modifier = Modifier.fillMaxWidth()
) {
Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(startMonth))
Text(getPersianMonthName(startMonth))
}
DropdownMenu(
expanded = expandedMonth,
onDismissRequest = { expandedMonth = false }) {
(1..12).forEach { m ->
DropdownMenuItem(
text = { Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(m)) },
text = { Text(getPersianMonthName(m)) },
onClick = { startMonth = m; expandedMonth = false }
)
}
@@ -1058,7 +1106,8 @@ fun PersonListItem(
onLongClick: () -> Unit = {},
onQuickPayClick: (PersonUiModel) -> Unit,
onArchiveClick: (PersonUiModel) -> Unit,
onSmsClick: (PersonUiModel) -> Unit
onSmsClick: (PersonUiModel) -> Unit,
onDebtClick: (PersonUiModel) -> Unit
) {
Card(
modifier = modifier
@@ -1107,7 +1156,8 @@ fun PersonListItem(
if (person.debtCount > 0) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small
shape = MaterialTheme.shapes.small,
modifier = Modifier.clickable { onDebtClick(person) }
) {
Text(
text = "${person.debtCount.toString().toPersianDigits()} بدهی",
@@ -2,26 +2,33 @@
package com.oqba26.monthlypaymentapp.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -37,6 +44,7 @@ import com.oqba26.monthlypaymentapp.R
import androidx.compose.ui.window.Dialog
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
@Composable
fun EditPaymentDialog(
@@ -333,3 +341,104 @@ fun AddPaymentDialog(
)
}
}
@Composable
fun BulkPaymentDialog(
availableMonths: List<Int>,
defaultAmount: Double,
onConfirm: (List<Int>, Double) -> Unit,
onDismiss: () -> Unit,
) {
val selectedMonths = remember { mutableStateListOf<Int>().apply { addAll(availableMonths) } }
var amountText by remember { mutableStateOf(defaultAmount.toInt().toString()) }
Dialog(
onDismissRequest = onDismiss
) {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "پرداخت دسته جمعی",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text("ماه‌های مورد نظر را انتخاب کنید:")
LazyColumn(modifier = Modifier.heightIn(max = 250.dp)) {
items(availableMonths) { month ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (selectedMonths.contains(month)) selectedMonths.remove(month)
else selectedMonths.add(month)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selectedMonths.contains(month),
onCheckedChange = { checked ->
if (checked) selectedMonths.add(month)
else selectedMonths.remove(month)
}
)
Text(getPersianMonthName(month))
}
}
}
OutlinedTextField(
value = amountText,
onValueChange = { value ->
// اجازه ورود فقط به اعداد (چه انگلیسی چه فارسی)
val digitsOnly = value.filter { it.isDigit() || (((it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9'))) }
// تبدیل اعداد فارسی به انگلیسی برای ذخیره در استیت
amountText = digitsOnly.map {
when (it) {
in '\u0660'..'\u0669' -> ((it.code - '\u0660'.code) + '0'.code).toChar()
in '\u06f0'..'\u06f9' -> ((it.code - '\u06f0'.code) + '0'.code).toChar()
else -> it
}
}.joinToString("")
},
label = { Text("مبلغ برای هر ماه (تومان)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
visualTransformation = PersianNumberVisualTransformation()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
val finalAmount = amountText.toDoubleOrNull() ?: defaultAmount
onConfirm(selectedMonths.toList(), finalAmount)
},
enabled = selectedMonths.isNotEmpty()
) {
Text("ثبت پرداخت‌ها")
}
TextButton(onClick = onDismiss) {
Text("لغو")
}
}
}
}
}
}
}
@@ -9,6 +9,7 @@ import com.oqba26.monthlypaymentapp.data.model.Person
import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
@@ -188,7 +189,7 @@ class PersonViewModel @Inject constructor(
val uiModels = activePersons.map { person ->
val hasPaid = relevantPayments.any { it.personId == person.id }
val (debtCount, totalDebtAmount, unpaidMonths) = if (category == "mosque") {
val unpaidResult = if (category == "mosque") {
if (person.monthlyCommitment > 0) {
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
val debtStartMonth = if (person.startYear == currentYear) person.startMonth else 1
@@ -196,9 +197,9 @@ class PersonViewModel @Inject constructor(
val unpaid = (debtStartMonth..debtEndMonth).filter { m ->
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category }
}
Triple(unpaid.size, unpaid.size * person.monthlyCommitment, unpaid.map { getPersianMonthName(it) })
Triple(unpaid.size, unpaid.size * person.monthlyCommitment, unpaid.map { getPersianMonthName(it) } to unpaid)
} else {
Triple(0, 0.0, emptyList())
Triple(0, 0.0, emptyList<String>() to emptyList<Int>())
}
} else {
// Salary logic
@@ -209,9 +210,12 @@ 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) })
Triple(unpaid.size, unpaid.size * defaultAmount, unpaid.map { getPersianMonthName(it) } to unpaid)
}
val (debtCount, totalDebtAmount, unpaidData) = unpaidResult
val (unpaidMonthsNames, unpaidMonths) = unpaidData
val displayName = if (person.isAnonymous && category == "mosque") {
"خیر ناشناس (${person.name})"
} else {
@@ -229,7 +233,8 @@ class PersonViewModel @Inject constructor(
phoneNumber = person.phoneNumber,
isAnonymous = person.isAnonymous,
monthlyCommitment = person.monthlyCommitment,
unpaidMonthsNames = unpaidMonths,
unpaidMonthsNames = unpaidMonthsNames,
unpaidMonths = unpaidMonths,
needsSync = person.needsSync
)
}
@@ -440,13 +445,25 @@ class PersonViewModel @Inject constructor(
try {
val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == event.personId }
val category = person?.category ?: _currentCategory.value
val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear()
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
event.months.forEach { month ->
val monthName = getPersianMonthName(month)
val isCurrentMonth = (month == currentMonth) && (event.year == currentYear)
val description = if (isCurrentMonth) {
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
} else {
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
}
val record = createPaymentRecord(
personId = event.personId,
month = month,
year = event.year,
amount = event.amount,
description = "پرداخت دسته جمعی"
description = description
).copy(category = category)
localPersonRepository.insertPaymentLocally(record)
}
@@ -15,6 +15,7 @@ data class PersonUiModel(
val isAnonymous: Boolean = false,
val monthlyCommitment: Double = 0.0,
val unpaidMonthsNames: List<String> = emptyList(),
val unpaidMonths: List<Int> = emptyList(),
val needsSync: Boolean = false,
)
+5 -5
View File
@@ -1,7 +1,7 @@
{
"versionCode": 3,
"versionName": "1.0.2",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.2/MonthlyPaymentApp-v1.0.2.apk",
"releaseNotes": "به‌روزرسانی به نسخه ۱.۰.۲:\n- رفع مشکل چرخه بی‌انتهای آپدیت.\n- هماهنگ‌سازی نسخه‌های بیلد و فایل تنظیمات سرور.",
"isForceUpdate": true
"versionCode": 6,
"versionName": "1.0.5",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.5/MonthlyPaymentApp-v1.0.5.apk",
"releaseNotes": "اصلاح چیدمان دکمه‌های دیالوگ آپدیت و تغییر متن دکمه تایید",
"isForceUpdate": false
}