11 Commits
Author SHA1 Message Date
Oqba a3a8448ee5 Fix Supabase column serialization mapping (snake_case) for Person and PaymentRecord models to prevent resetting archived status during sync
Android Release / build (push) Canceled after 14s
2026-09-11 16:45:51 +03:30
Oqba b05c7c7521 Update version to 1.0.6, fix compiler warnings in PersonScreen and PersonDetailScreen, and update release notes 2026-09-11 15:24:21 +03:30
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
16 changed files with 470 additions and 305 deletions

No files matched your search

+12
View File
@@ -6,6 +6,18 @@
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DialogSelection /> <DialogSelection />
</SelectionState> </SelectionState>
<SelectionState runConfigName="androidTest">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="main">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="unitTest">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates> </selectionStates>
</component> </component>
</project> </project>
+2 -2
View File
@@ -30,8 +30,8 @@ android {
applicationId = "com.oqba26.monthlypaymentapp" applicationId = "com.oqba26.monthlypaymentapp"
minSdk = 25 minSdk = 25
targetSdk = 34 targetSdk = 34
versionCode = 3 versionCode = 7
versionName = "1.0.2" versionName = "1.0.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true } vectorDrawables { useSupportLibrary = true }
} }
@@ -490,7 +490,7 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
) )
} }
composable(Screen.Archive.route) { composable(Screen.Archive.route) {
ArchiveScreen(viewModel) ArchiveScreen(viewModel = viewModel, navController = navController)
} }
composable(Screen.Settings.route) { composable(Screen.Settings.route) {
val authViewModel: AuthViewModel = hiltViewModel() val authViewModel: AuthViewModel = hiltViewModel()
@@ -25,6 +25,9 @@ interface PaymentDao {
@Query("DELETE FROM payments WHERE id = :paymentId") @Query("DELETE FROM payments WHERE id = :paymentId")
suspend fun deleteById(paymentId: String) suspend fun deleteById(paymentId: String)
@Query("DELETE FROM payments WHERE personId = :personId")
suspend fun deletePaymentsForPerson(personId: String)
/** حذف گروهی — برای اعمال [com.oqba26.monthlypaymentapp.data.sync.MergePlan] در یک کوئری. */ /** حذف گروهی — برای اعمال [com.oqba26.monthlypaymentapp.data.sync.MergePlan] در یک کوئری. */
@Query("DELETE FROM payments WHERE id IN (:ids)") @Query("DELETE FROM payments WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>) suspend fun deleteByIds(ids: List<String>)
@@ -1,11 +1,13 @@
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class) @file:OptIn(InternalSerializationApi::class)
package com.oqba26.monthlypaymentapp.data.model package com.oqba26.monthlypaymentapp.data.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.UUID import java.util.UUID
@Serializable @Serializable
@@ -14,22 +16,22 @@ data class PaymentRecord(
@PrimaryKey @PrimaryKey
@SerialName("id") val id: String = UUID.randomUUID().toString(), @SerialName("id") val id: String = UUID.randomUUID().toString(),
@SerialName("personId") val personId: String, @SerialName("person_id") val personId: String,
@SerialName("amount") val amount: Double, @SerialName("amount") val amount: Double,
@SerialName("shamsiYear") val shamsiYear: Int, @SerialName("shamsi_year") val shamsiYear: Int,
@SerialName("shamsiMonth") val shamsiMonth: Int, @SerialName("shamsi_month") val shamsiMonth: Int,
@SerialName("timestamp") val timestamp: Long = System.currentTimeMillis(), @SerialName("timestamp") val timestamp: Long = System.currentTimeMillis(),
@SerialName("description") val description: String? = "", // ⭐️ تغییر به String? برای پذیرش مقادیر null @SerialName("description") val description: String? = "",
@SerialName("createdAt") val createdAt: Long? = null, @SerialName("created_at") val createdAt: Long? = null,
@SerialName("category") val category: String = "salary", @SerialName("category") val category: String = "salary",
@kotlinx.serialization.Transient @Transient
val needsSync: Boolean = false val needsSync: Boolean = false
) )
@@ -1,11 +1,13 @@
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class) @file:OptIn(InternalSerializationApi::class)
package com.oqba26.monthlypaymentapp.data.model package com.oqba26.monthlypaymentapp.data.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable @Serializable
@Entity(tableName = "persons") @Entity(tableName = "persons")
@@ -15,24 +17,24 @@ data class Person(
@SerialName("name") val name: String, @SerialName("name") val name: String,
@SerialName("isArchived") val isArchived: Boolean = false, @SerialName("is_archived") val isArchived: Boolean = false,
@SerialName("displayOrder") val displayOrder: Long? = 0L, @SerialName("display_order") val displayOrder: Long? = 0L,
@SerialName("createdAt") val createdAt: Long? = 0L, @SerialName("created_at") val createdAt: Long? = 0L,
@SerialName("phoneNumber") val phoneNumber: String? = "", @SerialName("phone_number") val phoneNumber: String? = "",
@SerialName("category") val category: String = "salary", @SerialName("category") val category: String = "salary",
@SerialName("isAnonymous") val isAnonymous: Boolean = false, @SerialName("is_anonymous") val isAnonymous: Boolean = false,
@SerialName("monthlyCommitment") val monthlyCommitment: Double = 0.0, @SerialName("monthly_commitment") val monthlyCommitment: Double = 0.0,
@SerialName("startMonth") val startMonth: Int = 1, @SerialName("start_month") val startMonth: Int = 1,
@SerialName("startYear") val startYear: Int = 1403, @SerialName("start_year") val startYear: Int = 1403,
@kotlinx.serialization.Transient @Transient
val needsSync: Boolean = false val needsSync: Boolean = false
) )
@@ -135,6 +135,7 @@ class LocalPersonRepository(
suspend fun deletePersonLocally(personId: String) { suspend fun deletePersonLocally(personId: String) {
database.withTransaction { database.withTransaction {
personDao.deleteById(personId) personDao.deleteById(personId)
paymentDao.deletePaymentsForPerson(personId)
syncQueueDao.insert(SyncQueue(entityId = personId, type = SyncType.PERSON, operation = SyncOperation.DELETE)) syncQueueDao.insert(SyncQueue(entityId = personId, type = SyncType.PERSON, operation = SyncOperation.DELETE))
} }
} }
@@ -7,7 +7,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
@@ -37,71 +36,81 @@ fun UpdateDialog(
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(16.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier.padding(24.dp)
.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)
) { ) {
Button( // Header
onClick = onConfirm, Text(
modifier = Modifier.weight(1f), text = "به‌روزرسانی جدید موجود است",
shape = MaterialTheme.shapes.small 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( Button(
onClick = onDismiss, onClick = onConfirm,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), shape = MaterialTheme.shapes.medium,
shape = MaterialTheme.shapes.small 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(
} }
} }
} }
}
@@ -1,5 +1,6 @@
package com.oqba26.monthlypaymentapp.ui.screens package com.oqba26.monthlypaymentapp.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -38,12 +39,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.navigation.NavController
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
@Composable @Composable
fun ArchiveScreen(viewModel: PersonViewModel) { fun ArchiveScreen(viewModel: PersonViewModel, navController: NavController) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
var personToDelete by remember { mutableStateOf<PersonUiModel?>(null) } var personToDelete by remember { mutableStateOf<PersonUiModel?>(null) }
var personToRestore by remember { mutableStateOf<PersonUiModel?>(null) } var personToRestore by remember { mutableStateOf<PersonUiModel?>(null) }
@@ -63,6 +65,9 @@ fun ArchiveScreen(viewModel: PersonViewModel) {
items(uiState.archivedPersons, key = { it.id }) { person -> items(uiState.archivedPersons, key = { it.id }) { person ->
ArchivedPersonListItem( ArchivedPersonListItem(
person = person, person = person,
onPersonClick = {
navController.navigate("person_detail/${person.id}")
},
onRestore = { onRestore = {
personToRestore = person personToRestore = person
}, },
@@ -148,12 +153,14 @@ fun ArchiveScreen(viewModel: PersonViewModel) {
@Composable @Composable
fun ArchivedPersonListItem( fun ArchivedPersonListItem(
person: PersonUiModel, person: PersonUiModel,
onPersonClick: () -> Unit,
onRestore: () -> Unit, onRestore: () -> Unit,
onDelete: () -> Unit onDelete: () -> Unit
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onPersonClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
@@ -188,4 +195,4 @@ fun ArchivedPersonListItem(
} }
} }
} }
} }
@@ -4,13 +4,18 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -45,6 +50,7 @@ import org.burnoutcrew.reorderable.detectReorderAfterLongPress
import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable import org.burnoutcrew.reorderable.reorderable
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun PaidListScreen( fun PaidListScreen(
viewModel: PersonViewModel, viewModel: PersonViewModel,
@@ -53,8 +59,11 @@ fun PaidListScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val searchQuery by viewModel.searchQuery.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 personToArchive = remember { mutableStateOf<PersonUiModel?>(null) }
val personForBulkPayment = remember { mutableStateOf<PersonUiModel?>(null) }
val state = rememberReorderableLazyListState( val state = rememberReorderableLazyListState(
onMove = { from, to -> onMove = { from, to ->
@@ -69,40 +78,79 @@ fun PaidListScreen(
} }
) )
Scaffold { innerPadding -> @OptIn(ExperimentalMaterialApi::class)
Column(modifier = Modifier.padding(innerPadding)) { val pullRefreshState = rememberPullRefreshState(
OutlinedTextField( refreshing = isRefreshing,
value = searchQuery, onRefresh = { viewModel.onEvent(PersonScreenEvent.RefreshData) }
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( Scaffold { innerPadding ->
state = state.listState, Box(
modifier = Modifier.reorderable(state) modifier = Modifier
) { .fillMaxSize()
itemsIndexed(uiState.paidPersons, key = { _, person -> person.id }) { index, person -> .padding(innerPadding)
ReorderableItem(state, key = person.id) { _ -> .pullRefresh(pullRefreshState)
val rowDragModifier = if (searchQuery.isBlank()) Modifier.detectReorderAfterLongPress(state) else Modifier ) {
Row(modifier = rowDragModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f)) { OutlinedTextField(
PersonListItem( value = searchQuery,
person = person, onValueChange = { viewModel.onSearchQueryChange(it) },
index = index + 1, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
onPersonClick = { navController.navigate("person_detail/${person.id}") }, label = { Text("جستجوی نام...") },
onQuickPayClick = { }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
onArchiveClick = { personToArchive.value = it }, singleLine = true
onSmsClick = { contactViewModel.sendSmsReminder(it) } )
)
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 -> personToArchive.value?.let { person ->
@@ -25,8 +25,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Payments
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -44,14 +44,12 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -102,7 +100,6 @@ fun PersonDetailScreen(
var editingPayment by remember { mutableStateOf<PaymentRecord?>(null) } var editingPayment by remember { mutableStateOf<PaymentRecord?>(null) }
var addingPaymentMonth by remember { mutableStateOf<Int?>(null) } var addingPaymentMonth by remember { mutableStateOf<Int?>(null) }
var editingPerson by remember { mutableStateOf(value = false) } var editingPerson by remember { mutableStateOf(value = false) }
var showBulkPaymentDialog by remember { mutableStateOf(value = false) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
val displayName = detailState.person?.let { p -> val displayName = detailState.person?.let { p ->
@@ -125,31 +122,12 @@ fun PersonDetailScreen(
viewModel.onEvent(PersonScreenEvent.ChangeYear(offset)) 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)) Spacer(modifier = Modifier.height(4.dp))
LazyColumn(modifier = Modifier.fillMaxSize().padding(bottom = 8.dp)) { LazyColumn(modifier = Modifier.fillMaxSize().padding(bottom = 8.dp)) {
items(detailState.monthStates) { monthModel -> items(detailState.monthStates) { monthModel ->
MonthListItem( MonthListItem(
month = monthModel, month = monthModel,
personName = detailState.person?.name ?: "",
startMonth = detailState.person?.startMonth ?: 1, startMonth = detailState.person?.startMonth ?: 1,
onHeaderClick = { onHeaderClick = {
expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month
@@ -228,147 +206,24 @@ fun PersonDetailScreen(
initialPhone = detailState.person?.phoneNumber, initialPhone = detailState.person?.phoneNumber,
initialCommitment = detailState.person?.monthlyCommitment ?: 0.0, initialCommitment = detailState.person?.monthlyCommitment ?: 0.0,
initialStartMonth = detailState.person?.startMonth ?: 1, initialStartMonth = detailState.person?.startMonth ?: 1,
initialStartYear = detailState.person?.startYear ?: com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear(), initialStartYear = detailState.person?.startYear ?: getCurrentShamsiYear(),
initialIsAnonymous = detailState.person?.isAnonymous ?: false,
isMosqueCategory = currentCategory == "mosque", isMosqueCategory = currentCategory == "mosque",
onConfirm = { name, phone, commitment, month, year -> onConfirm = { name, phone, commitment, month, year, isAnon ->
viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone, commitment, month, year)) viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone, commitment, month, year, isAnon))
editingPerson = false editingPerson = false
}, },
onDelete = {
viewModel.onEvent(PersonScreenEvent.DeletePerson(personId))
editingPerson = false
navController.popBackStack()
},
onDismiss = { editingPerson = false }, onDismiss = { editingPerson = false },
onSearchContact = { name -> onSearchContact = { name ->
contactViewModel.findSimilarContacts(name) contactViewModel.findSimilarContacts(name)
} }
) )
} }
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 @Composable
@@ -406,16 +261,20 @@ fun UpdatePersonDialog(
initialCommitment: Double = 0.0, initialCommitment: Double = 0.0,
initialStartMonth: Int = 1, initialStartMonth: Int = 1,
initialStartYear: Int = 1403, initialStartYear: Int = 1403,
initialIsAnonymous: Boolean = false,
isMosqueCategory: Boolean = false, isMosqueCategory: Boolean = false,
onConfirm: (String, String, Double, Int, Int) -> Unit, onConfirm: (String, String, Double, Int, Int, Boolean) -> Unit,
onDelete: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSearchContact: (String) -> List<ContactMatch>, onSearchContact: (String) -> List<ContactMatch>,
) { ) {
var name by remember { mutableStateOf(initialName) } var name by remember { mutableStateOf(initialName) }
var phoneNumber by remember { mutableStateOf(initialPhone ?: "") } var phoneNumber by remember { mutableStateOf(initialPhone ?: "") }
var monthlyCommitment by remember { mutableStateOf(if (initialCommitment > 0) initialCommitment.toInt().toString() else "") } var monthlyCommitment by remember { mutableStateOf(if (initialCommitment > 0) initialCommitment.toInt().toString() else "") }
var isAnonymous by remember { mutableStateOf(initialIsAnonymous) }
var showDeleteConfirm by remember { mutableStateOf(false) }
val currentShamsiYear = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear() val currentShamsiYear = getCurrentShamsiYear()
var startMonth by remember { mutableIntStateOf(initialStartMonth) } var startMonth by remember { mutableIntStateOf(initialStartMonth) }
var startYear by remember { mutableIntStateOf(initialStartYear) } var startYear by remember { mutableIntStateOf(initialStartYear) }
@@ -457,6 +316,20 @@ fun UpdatePersonDialog(
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { isAnonymous = !isAnonymous },
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isAnonymous,
onCheckedChange = { isAnonymous = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text("ثبت به صورت خیر ناشناس")
}
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
@@ -608,12 +481,12 @@ fun UpdatePersonDialog(
onClick = { expandedMonth = true }, onClick = { expandedMonth = true },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(startMonth)) Text(getPersianMonthName(startMonth))
} }
DropdownMenu(expanded = expandedMonth, onDismissRequest = { expandedMonth = false }) { DropdownMenu(expanded = expandedMonth, onDismissRequest = { expandedMonth = false }) {
(1..12).forEach { m -> (1..12).forEach { m ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(m)) }, text = { Text(getPersianMonthName(m)) },
onClick = { startMonth = m; expandedMonth = false } onClick = { startMonth = m; expandedMonth = false }
) )
} }
@@ -631,7 +504,7 @@ fun UpdatePersonDialog(
onClick = { onClick = {
if (name.isNotBlank()) { if (name.isNotBlank()) {
val commitment = monthlyCommitment.toDoubleOrNull() ?: 0.0 val commitment = monthlyCommitment.toDoubleOrNull() ?: 0.0
onConfirm(name, phoneNumber, commitment, startMonth, startYear) onConfirm(name, phoneNumber, commitment, startMonth, startYear, isAnonymous)
} }
}, },
enabled = name.isNotBlank() enabled = name.isNotBlank()
@@ -648,6 +521,47 @@ fun UpdatePersonDialog(
Text("لغو") Text("لغو")
} }
} }
Button(
onClick = { showDeleteConfirm = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(Icons.Default.Delete, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("حذف این شخص")
}
if (showDeleteConfirm) {
Dialog(onDismissRequest = { showDeleteConfirm = false }) {
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(text = "آیا از حذف این شخص و تمام پرداخت‌های او مطمئن هستید؟", style = MaterialTheme.typography.bodyMedium)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Button(onClick = { showDeleteConfirm = false }) { Text("لغو") }
Button(
onClick = {
showDeleteConfirm = false
onDelete()
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) {
Text("حذف")
}
}
}
}
}
}
}
} }
} }
} }
@@ -685,7 +599,6 @@ fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
@Composable @Composable
fun MonthListItem( fun MonthListItem(
month: MonthUiModel, month: MonthUiModel,
personName: String,
startMonth: Int, startMonth: Int,
onHeaderClick: () -> Unit, onHeaderClick: () -> Unit,
onEditClick: () -> Unit onEditClick: () -> Unit
@@ -83,6 +83,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian 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.utils.toPersianDigits
import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch
import com.oqba26.monthlypaymentapp.viewmodel.ContactSuggestion import com.oqba26.monthlypaymentapp.viewmodel.ContactSuggestion
@@ -129,6 +134,7 @@ fun PersonScreen(
} }
var personToArchive by remember { mutableStateOf<PersonUiModel?>(null) } var personToArchive by remember { mutableStateOf<PersonUiModel?>(null) }
var personForBulkPayment by remember { mutableStateOf<PersonUiModel?>(null) }
val showBulkSelection = contactState.showBulkSmsDialog val showBulkSelection = contactState.showBulkSmsDialog
val reorderableState = rememberReorderableLazyListState( val reorderableState = rememberReorderableLazyListState(
@@ -195,10 +201,10 @@ fun PersonScreen(
if (currentCategory == "mosque" && uiState.unpaidPersons.isNotEmpty()) { if (currentCategory == "mosque" && uiState.unpaidPersons.isNotEmpty()) {
item { item {
Text( Text(
text = "لیست خیرین (بدهکار)", text = "لیست خیرین",
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.primary
) )
} }
} }
@@ -232,6 +238,9 @@ fun PersonScreen(
}, },
onSmsClick = { selectedPerson -> onSmsClick = { selectedPerson ->
contactViewModel.onSmsClicked(selectedPerson) contactViewModel.onSmsClicked(selectedPerson)
},
onDebtClick = { selectedPerson ->
personForBulkPayment = selectedPerson
} }
) )
} }
@@ -271,6 +280,9 @@ fun PersonScreen(
}, },
onSmsClick = { selectedPerson -> onSmsClick = { selectedPerson ->
contactViewModel.onSmsClicked(selectedPerson) contactViewModel.onSmsClicked(selectedPerson)
},
onDebtClick = { selectedPerson ->
personForBulkPayment = selectedPerson
} }
) )
} }
@@ -289,9 +301,24 @@ fun PersonScreen(
// Dialogs // Dialogs
personForPaymentDialog?.let { person -> personForPaymentDialog?.let { person ->
val currentDay = getCurrentShamsiDay()
val currentMonth = getCurrentShamsiMonth()
val targetMonth = 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) {
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
} else {
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
}
AddPaymentDialog( AddPaymentDialog(
personName = person.name, personName = person.name,
defaultAmount = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount, defaultAmount = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount,
initialDescription = initialDesc,
onConfirm = { amount, description -> onConfirm = { amount, description ->
viewModel.onEvent(PersonScreenEvent.AddQuickPayment(person.id, amount, description)) viewModel.onEvent(PersonScreenEvent.AddQuickPayment(person.id, amount, description))
viewModel.onDismissPaymentDialog() viewModel.onDismissPaymentDialog()
@@ -348,6 +375,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) { if (showBulkSelection) {
val debtors = (uiState.unpaidPersons + uiState.paidPersons).filter { it.debtCount > 0 } val debtors = (uiState.unpaidPersons + uiState.paidPersons).filter { it.debtCount > 0 }
BulkSmsSelectionDialog( BulkSmsSelectionDialog(
@@ -511,8 +557,8 @@ fun AddNewPersonDialog(
var monthlyCommitment by remember { mutableStateOf("") } var monthlyCommitment by remember { mutableStateOf("") }
var initialPaymentAmount by remember { mutableStateOf("") } var initialPaymentAmount by remember { mutableStateOf("") }
val currentShamsiYear = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear() val currentShamsiYear = getCurrentShamsiYear()
val currentShamsiMonth = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth() val currentShamsiMonth = getCurrentShamsiMonth()
var startMonth by remember { mutableIntStateOf(currentShamsiMonth) } var startMonth by remember { mutableIntStateOf(currentShamsiMonth) }
var startYear by remember { mutableIntStateOf(currentShamsiYear) } var startYear by remember { mutableIntStateOf(currentShamsiYear) }
@@ -755,14 +801,14 @@ fun AddNewPersonDialog(
onClick = { expandedMonth = true }, onClick = { expandedMonth = true },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(startMonth)) Text(getPersianMonthName(startMonth))
} }
DropdownMenu( DropdownMenu(
expanded = expandedMonth, expanded = expandedMonth,
onDismissRequest = { expandedMonth = false }) { onDismissRequest = { expandedMonth = false }) {
(1..12).forEach { m -> (1..12).forEach { m ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(m)) }, text = { Text(getPersianMonthName(m)) },
onClick = { startMonth = m; expandedMonth = false } onClick = { startMonth = m; expandedMonth = false }
) )
} }
@@ -1058,7 +1104,8 @@ fun PersonListItem(
onLongClick: () -> Unit = {}, onLongClick: () -> Unit = {},
onQuickPayClick: (PersonUiModel) -> Unit, onQuickPayClick: (PersonUiModel) -> Unit,
onArchiveClick: (PersonUiModel) -> Unit, onArchiveClick: (PersonUiModel) -> Unit,
onSmsClick: (PersonUiModel) -> Unit onSmsClick: (PersonUiModel) -> Unit,
onDebtClick: (PersonUiModel) -> Unit
) { ) {
Card( Card(
modifier = modifier modifier = modifier
@@ -1107,7 +1154,8 @@ fun PersonListItem(
if (person.debtCount > 0) { if (person.debtCount > 0) {
Surface( Surface(
color = MaterialTheme.colorScheme.errorContainer, color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small shape = MaterialTheme.shapes.small,
modifier = Modifier.clickable { onDebtClick(person) }
) { ) {
Text( Text(
text = "${person.debtCount.toString().toPersianDigits()} بدهی", text = "${person.debtCount.toString().toPersianDigits()} بدهی",
@@ -2,26 +2,33 @@
package com.oqba26.monthlypaymentapp.ui.screens package com.oqba26.monthlypaymentapp.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding 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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -37,6 +44,7 @@ import com.oqba26.monthlypaymentapp.R
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
@Composable @Composable
fun EditPaymentDialog( 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.LocalPersonRepository
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
@@ -188,18 +189,8 @@ class PersonViewModel @Inject constructor(
val uiModels = activePersons.map { person -> val uiModels = activePersons.map { person ->
val hasPaid = relevantPayments.any { it.personId == person.id } val hasPaid = relevantPayments.any { it.personId == person.id }
val (debtCount, totalDebtAmount, unpaidMonths) = if (category == "mosque") { val unpaidResult = if (category == "mosque" || person.isAnonymous) {
if (person.monthlyCommitment > 0) { Triple(0, 0.0, emptyList<String>() to emptyList())
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
val debtStartMonth = if (person.startYear == currentYear) person.startMonth else 1
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) })
} else {
Triple(0, 0.0, emptyList())
}
} else { } else {
// Salary logic // Salary logic
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1 val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
@@ -209,9 +200,12 @@ class PersonViewModel @Inject constructor(
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category } allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category }
} }
val defaultAmount = settingsRepository.defaultPaymentAmountFlow.first() 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") { val displayName = if (person.isAnonymous && category == "mosque") {
"خیر ناشناس (${person.name})" "خیر ناشناس (${person.name})"
} else { } else {
@@ -229,7 +223,8 @@ class PersonViewModel @Inject constructor(
phoneNumber = person.phoneNumber, phoneNumber = person.phoneNumber,
isAnonymous = person.isAnonymous, isAnonymous = person.isAnonymous,
monthlyCommitment = person.monthlyCommitment, monthlyCommitment = person.monthlyCommitment,
unpaidMonthsNames = unpaidMonths, unpaidMonthsNames = unpaidMonthsNames,
unpaidMonths = unpaidMonths,
needsSync = person.needsSync needsSync = person.needsSync
) )
} }
@@ -369,7 +364,8 @@ class PersonViewModel @Inject constructor(
is PersonScreenEvent.UpdatePerson -> { is PersonScreenEvent.UpdatePerson -> {
val persons = localPersonRepository.getAllPersonsFlow().first() val persons = localPersonRepository.getAllPersonsFlow().first()
val isDuplicate = persons.any { it.id != event.personId && it.category == _currentCategory.value && it.name.trim().equals(event.name.trim(), ignoreCase = true) } val finalName = if (event.isAnonymous && event.name.isBlank()) "ناشناس" else event.name
val isDuplicate = !event.isAnonymous && persons.any { it.id != event.personId && it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true) }
if (isDuplicate) { if (isDuplicate) {
_toastMessage.emit("خطا: این نام از قبل وجود دارد.") _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
@@ -377,13 +373,15 @@ class PersonViewModel @Inject constructor(
val currentPerson = persons.find { it.id == event.personId } val currentPerson = persons.find { it.id == event.personId }
if (currentPerson != null) { if (currentPerson != null) {
val updatedPerson = currentPerson.copy( val updatedPerson = currentPerson.copy(
name = event.name, name = finalName,
phoneNumber = event.phoneNumber, phoneNumber = event.phoneNumber,
monthlyCommitment = event.monthlyCommitment, monthlyCommitment = if (event.isAnonymous) 0.0 else event.monthlyCommitment,
startMonth = event.startMonth, startMonth = event.startMonth,
startYear = event.startYear startYear = event.startYear,
isAnonymous = event.isAnonymous
) )
localPersonRepository.updatePersonLocally(updatedPerson) localPersonRepository.updatePersonLocally(updatedPerson)
_toastMessage.emit("اطلاعات شخص با موفقیت بروز شد.")
} }
} }
} }
@@ -440,13 +438,25 @@ class PersonViewModel @Inject constructor(
try { try {
val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == event.personId } val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == event.personId }
val category = person?.category ?: _currentCategory.value val category = person?.category ?: _currentCategory.value
val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear()
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
event.months.forEach { month -> 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( val record = createPaymentRecord(
personId = event.personId, personId = event.personId,
month = month, month = month,
year = event.year, year = event.year,
amount = event.amount, amount = event.amount,
description = "پرداخت دسته جمعی" description = description
).copy(category = category) ).copy(category = category)
localPersonRepository.insertPaymentLocally(record) localPersonRepository.insertPaymentLocally(record)
} }
@@ -15,6 +15,7 @@ data class PersonUiModel(
val isAnonymous: Boolean = false, val isAnonymous: Boolean = false,
val monthlyCommitment: Double = 0.0, val monthlyCommitment: Double = 0.0,
val unpaidMonthsNames: List<String> = emptyList(), val unpaidMonthsNames: List<String> = emptyList(),
val unpaidMonths: List<Int> = emptyList(),
val needsSync: Boolean = false, val needsSync: Boolean = false,
) )
@@ -83,7 +84,8 @@ sealed class PersonScreenEvent {
val phoneNumber: String?, val phoneNumber: String?,
val monthlyCommitment: Double = 0.0, val monthlyCommitment: Double = 0.0,
val startMonth: Int = 1, val startMonth: Int = 1,
val startYear: Int = 1403 val startYear: Int = 1403,
val isAnonymous: Boolean = false
) : PersonScreenEvent() ) : PersonScreenEvent()
data class DeletePerson(val personId: String) : PersonScreenEvent() data class DeletePerson(val personId: String) : PersonScreenEvent()
data class ArchivePerson(val personId: String) : PersonScreenEvent() data class ArchivePerson(val personId: String) : PersonScreenEvent()
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"versionCode": 3, "versionCode": 7,
"versionName": "1.0.2", "versionName": "1.0.6",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.2/MonthlyPaymentApp-v1.0.2.apk", "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.6/MonthlyPaymentApp-v1.0.6.apk",
"releaseNotes": "به‌روزرسانی به نسخه ۱.۰.۲:\n- رفع مشکل چرخه بی‌انتهای آپدیت.\n- هماهنگ‌سازی نسخه‌های بیلد و فایل تنظیمات سرور.", "releaseNotes": "بهینه‌سازی کدهای UI، رفع هشدارهای کامپایلر در صفحات شخص و جزئیات شخص، و بهبود پایداری برنامه",
"isForceUpdate": true "isForceUpdate": false
} }