Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ed2b59d81 | ||
|
|
f1c19cba52 | ||
|
|
1931e20deb | ||
|
|
ee0bd0ba2b | ||
|
|
7d865a3d6d |
No files matched your search
@@ -30,8 +30,8 @@ android {
|
|||||||
applicationId = "com.oqba26.monthlypaymentapp"
|
applicationId = "com.oqba26.monthlypaymentapp"
|
||||||
minSdk = 25
|
minSdk = 25
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 13
|
versionCode = 16
|
||||||
versionName = "1.1.2"
|
versionName = "1.1.5"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables { useSupportLibrary = true }
|
vectorDrawables { useSupportLibrary = true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ fun PaidListScreen(
|
|||||||
PersonListItem(
|
PersonListItem(
|
||||||
person = person,
|
person = person,
|
||||||
index = index + 1,
|
index = index + 1,
|
||||||
|
listType = PersonListType.PAID,
|
||||||
onPersonClick = { navController.navigate("person_detail/${person.id}") },
|
onPersonClick = { navController.navigate("person_detail/${person.id}") },
|
||||||
onQuickPayClick = { },
|
onQuickPayClick = { },
|
||||||
onArchiveClick = { personToArchive.value = it },
|
onArchiveClick = { personToArchive.value = it },
|
||||||
|
|||||||
@@ -1114,6 +1114,19 @@ fun DashboardCard(
|
|||||||
Text("${formatNumberAsPersian(data.totalIncome)} تومان", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
Text("${formatNumberAsPersian(data.totalIncome)} تومان", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isMosqueCategory) "مجموع کمکهای جمعآوری شده دوره جاری" else "مجموع حقوق جمعآوری شده دوره جاری",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text("${formatNumberAsPersian(data.totalCycleIncome)} تومان", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
|
||||||
if (financialReportData.totalOverdueDebt > 0 || financialReportData.debtors.isNotEmpty()) {
|
if (financialReportData.totalOverdueDebt > 0 || financialReportData.debtors.isNotEmpty()) {
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 2.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 2.dp))
|
||||||
|
|
||||||
@@ -1319,6 +1332,7 @@ fun PersonListItem(
|
|||||||
person: PersonUiModel,
|
person: PersonUiModel,
|
||||||
index: Int,
|
index: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
listType: PersonListType = PersonListType.UNPAID,
|
||||||
isSelectionMode: Boolean = false,
|
isSelectionMode: Boolean = false,
|
||||||
isSelected: Boolean = false,
|
isSelected: Boolean = false,
|
||||||
reorderableState: org.burnoutcrew.reorderable.ReorderableLazyListState? = null,
|
reorderableState: org.burnoutcrew.reorderable.ReorderableLazyListState? = null,
|
||||||
@@ -1355,6 +1369,15 @@ fun PersonListItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (listType == PersonListType.PAID && person.hasPaidThisMonth) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = "Paid",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp).padding(end = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val dragModifier = if (reorderableState != null && !isSelectionMode) {
|
val dragModifier = if (reorderableState != null && !isSelectionMode) {
|
||||||
Modifier.detectReorderAfterLongPress(reorderableState)
|
Modifier.detectReorderAfterLongPress(reorderableState)
|
||||||
} else Modifier
|
} else Modifier
|
||||||
@@ -1364,14 +1387,33 @@ fun PersonListItem(
|
|||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier = dragModifier.padding(end = 4.dp)
|
modifier = dragModifier.padding(end = 4.dp)
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
person.name,
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
modifier = Modifier.weight(1f),
|
Text(
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
person.name,
|
||||||
fontWeight = FontWeight.Bold,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
maxLines = 2,
|
fontWeight = FontWeight.Bold,
|
||||||
overflow = TextOverflow.Visible
|
maxLines = 2,
|
||||||
)
|
overflow = TextOverflow.Visible
|
||||||
|
)
|
||||||
|
if (listType == PersonListType.PAID) {
|
||||||
|
person.lastPaymentTime?.let { time ->
|
||||||
|
Text(
|
||||||
|
text = formatTimestampToPersianDateTime(time),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
person.lastPaymentAmount?.let { amount ->
|
||||||
|
Text(
|
||||||
|
text = "مبلغ: ${formatNumberAsPersian(amount)} تومان",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (person.debtCount > 0) {
|
if (person.debtCount > 0) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -1407,29 +1449,31 @@ fun PersonListItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.width(4.dp))
|
if (listType != PersonListType.PAID) {
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
if (person.hasPaidThisMonth) {
|
if (person.hasPaidThisMonth) {
|
||||||
Icon(Icons.Filled.Check, contentDescription = "Paid", tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Filled.Check, contentDescription = "Paid", tint = MaterialTheme.colorScheme.primary)
|
||||||
} else {
|
} else {
|
||||||
IconButton(onClick = { onQuickPayClick(person) }, modifier = Modifier.size(32.dp)) {
|
IconButton(onClick = { onQuickPayClick(person) }, modifier = Modifier.size(32.dp)) {
|
||||||
Icon(Icons.Filled.Payments, contentDescription = "Pay", tint = MaterialTheme.colorScheme.primary)
|
Icon(Icons.Filled.Payments, contentDescription = "Pay", tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
IconButton(onClick = { onArchiveClick(person) }, modifier = Modifier.size(32.dp)) {
|
||||||
IconButton(onClick = { onArchiveClick(person) }, modifier = Modifier.size(32.dp)) {
|
Icon(Icons.Filled.Archive, contentDescription = "Archive", tint = MaterialTheme.colorScheme.secondary)
|
||||||
Icon(Icons.Filled.Archive, contentDescription = "Archive", tint = MaterialTheme.colorScheme.secondary)
|
}
|
||||||
}
|
if (person.debtCount > 0) {
|
||||||
if (person.debtCount > 0) {
|
IconButton(onClick = { onSmsClick(person) }, modifier = Modifier.size(32.dp)) {
|
||||||
IconButton(onClick = { onSmsClick(person) }, modifier = Modifier.size(32.dp)) {
|
Icon(
|
||||||
Icon(
|
imageVector = Icons.AutoMirrored.Filled.Message,
|
||||||
imageVector = Icons.AutoMirrored.Filled.Message,
|
contentDescription = "SMS",
|
||||||
contentDescription = "SMS",
|
tint = MaterialTheme.colorScheme.error,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.size(20.dp)
|
||||||
.size(20.dp)
|
.graphicsLayer(scaleX = -1f)
|
||||||
.graphicsLayer(scaleX = -1f)
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,3 +30,36 @@ fun formatTimestampToPersianDateTime(timestamp: Long): String {
|
|||||||
val time = formatTimestampToPersianTime(timestamp)
|
val time = formatTimestampToPersianTime(timestamp)
|
||||||
return "$date، ساعت $time"
|
return "$date، ساعت $time"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isTimestampInCurrentCycle(rawTimestamp: Long): Boolean {
|
||||||
|
if (rawTimestamp <= 0L) return false
|
||||||
|
|
||||||
|
val timestamp = if (rawTimestamp < 100000000000L) rawTimestamp * 1000L else rawTimestamp
|
||||||
|
|
||||||
|
val now = PersianDate()
|
||||||
|
val curYear = now.shYear
|
||||||
|
val curMonth = now.shMonth
|
||||||
|
val curDay = now.shDay
|
||||||
|
|
||||||
|
val (startYear, startMonth) = if (curDay >= 20) {
|
||||||
|
curYear to curMonth
|
||||||
|
} else {
|
||||||
|
if (curMonth == 1) (curYear - 1) to 12 else curYear to (curMonth - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val (endYear, endMonth) = if (startMonth == 12) {
|
||||||
|
(startYear + 1) to 1
|
||||||
|
} else {
|
||||||
|
startYear to (startMonth + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val recordDate = PersianDate(Date(timestamp))
|
||||||
|
val rYear = recordDate.shYear
|
||||||
|
val rMonth = recordDate.shMonth
|
||||||
|
val rDay = recordDate.shDay
|
||||||
|
|
||||||
|
val isFirstHalf = (rYear == startYear && rMonth == startMonth && rDay >= 20)
|
||||||
|
val isSecondHalf = (rYear == endYear && rMonth == endMonth && rDay < 20)
|
||||||
|
|
||||||
|
return isFirstHalf || isSecondHalf
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ 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
|
||||||
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
|
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
|
||||||
|
import com.oqba26.monthlypaymentapp.utils.isTimestampInCurrentCycle
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
@@ -69,9 +70,6 @@ class PersonViewModel @Inject constructor(
|
|||||||
private val _infoMessage = MutableSharedFlow<String>()
|
private val _infoMessage = MutableSharedFlow<String>()
|
||||||
val infoMessage = _infoMessage.asSharedFlow()
|
val infoMessage = _infoMessage.asSharedFlow()
|
||||||
|
|
||||||
val isOperatorMode: StateFlow<Boolean> = settingsRepository.isOperatorModeFlow
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false)
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(PersonListUiState())
|
private val _uiState = MutableStateFlow(PersonListUiState())
|
||||||
val uiState: StateFlow<PersonListUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<PersonListUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
@@ -200,6 +198,8 @@ class PersonViewModel @Inject constructor(
|
|||||||
it.shamsiMonth == targetMonth && it.shamsiYear == targetYear && it.category == category
|
it.shamsiMonth == targetMonth && it.shamsiYear == targetYear && it.category == category
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val categoryPayments = allPayments.filter { it.category == category }
|
||||||
|
|
||||||
val activePersons = persons
|
val activePersons = persons
|
||||||
.filter { !it.isArchived && it.category == category }
|
.filter { !it.isArchived && it.category == category }
|
||||||
.sortedWith(compareBy({ it.displayOrder }, { it.createdAt }))
|
.sortedWith(compareBy({ it.displayOrder }, { it.createdAt }))
|
||||||
@@ -207,6 +207,13 @@ 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 personCyclePayments = categoryPayments.filter {
|
||||||
|
it.personId == person.id && isTimestampInCurrentCycle(if ((it.createdAt ?: 0L) > 0L) it.createdAt!! else it.timestamp)
|
||||||
|
}
|
||||||
|
val latestPayment = personCyclePayments.maxByOrNull { if ((it.createdAt ?: 0L) > 0L) it.createdAt!! else it.timestamp }
|
||||||
|
val lastPaymentTime = latestPayment?.let { if ((it.createdAt ?: 0L) > 0L) it.createdAt!! else it.timestamp }
|
||||||
|
val lastPaymentAmount = if (personCyclePayments.isNotEmpty()) personCyclePayments.sumOf { it.amount } else null
|
||||||
|
|
||||||
val unpaidResult = if (category == "mosque" || person.isAnonymous) {
|
val unpaidResult = if (category == "mosque" || person.isAnonymous) {
|
||||||
Triple(0, 0.0, emptyList<String>() to emptyList())
|
Triple(0, 0.0, emptyList<String>() to emptyList())
|
||||||
} else {
|
} else {
|
||||||
@@ -244,17 +251,19 @@ class PersonViewModel @Inject constructor(
|
|||||||
monthlyCommitment = person.monthlyCommitment,
|
monthlyCommitment = person.monthlyCommitment,
|
||||||
unpaidMonthsNames = unpaidMonthsNames,
|
unpaidMonthsNames = unpaidMonthsNames,
|
||||||
unpaidMonths = unpaidMonths,
|
unpaidMonths = unpaidMonths,
|
||||||
needsSync = person.needsSync
|
needsSync = person.needsSync,
|
||||||
|
lastPaymentTime = lastPaymentTime,
|
||||||
|
lastPaymentAmount = lastPaymentAmount
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDashboard(uiModels.size, relevantPayments)
|
updateDashboard(uiModels.size, relevantPayments, categoryPayments)
|
||||||
|
|
||||||
val (paidModels, unpaidModels) = uiModels.partition { it.hasPaidThisMonth }
|
val (rawPaidModels, unpaidModels) = uiModels.partition { it.hasPaidThisMonth }
|
||||||
|
val paidModels = rawPaidModels.sortedByDescending { it.lastPaymentTime ?: 0L }
|
||||||
|
|
||||||
// محاسبه گزارشات مالی جامع
|
// محاسبه گزارشات مالی جامع
|
||||||
val personsMap = persons.associateBy { it.id }
|
val personsMap = persons.associateBy { it.id }
|
||||||
val categoryPayments = allPayments.filter { it.category == category }
|
|
||||||
val totalAllTimeIncome = categoryPayments.sumOf { it.amount }
|
val totalAllTimeIncome = categoryPayments.sumOf { it.amount }
|
||||||
|
|
||||||
val debtorsList = unpaidModels.filter { it.debtCount > 0 }.map { personModel ->
|
val debtorsList = unpaidModels.filter { it.debtCount > 0 }.map { personModel ->
|
||||||
@@ -590,11 +599,20 @@ class PersonViewModel @Inject constructor(
|
|||||||
exportManager.shareReceipt(payment, personName)
|
exportManager.shareReceipt(payment, personName)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateDashboard(totalPersonCount: Int, paymentsThisMonth: List<PaymentRecord>) {
|
private fun updateDashboard(
|
||||||
|
totalPersonCount: Int,
|
||||||
|
paymentsThisMonth: List<PaymentRecord>,
|
||||||
|
categoryPayments: List<PaymentRecord>
|
||||||
|
) {
|
||||||
val paidCount = paymentsThisMonth.map { it.personId }.distinct().size
|
val paidCount = paymentsThisMonth.map { it.personId }.distinct().size
|
||||||
val totalIncome = paymentsThisMonth.sumOf { it.amount }
|
val totalIncome = paymentsThisMonth.sumOf { it.amount }
|
||||||
|
val cyclePayments = categoryPayments.filter { payment ->
|
||||||
|
val time = if ((payment.createdAt ?: 0L) > 0L) payment.createdAt!! else payment.timestamp
|
||||||
|
isTimestampInCurrentCycle(time)
|
||||||
|
}
|
||||||
|
val totalCycleIncome = cyclePayments.sumOf { it.amount }
|
||||||
val progress = if (totalPersonCount > 0) paidCount.toFloat() / totalPersonCount.toFloat() else 0f
|
val progress = if (totalPersonCount > 0) paidCount.toFloat() / totalPersonCount.toFloat() else 0f
|
||||||
_dashboardData.value = DashboardUiModel(paidCount, totalPersonCount, totalIncome, progress)
|
_dashboardData.value = DashboardUiModel(paidCount, totalPersonCount, totalIncome, totalCycleIncome, progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPaymentRecord(personId: String, month: Int, year: Int, amount: Double, description: String): PaymentRecord {
|
private fun createPaymentRecord(personId: String, month: Int, year: Int, amount: Double, description: String): PaymentRecord {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ data class PersonUiModel(
|
|||||||
val unpaidMonthsNames: List<String> = emptyList(),
|
val unpaidMonthsNames: List<String> = emptyList(),
|
||||||
val unpaidMonths: List<Int> = emptyList(),
|
val unpaidMonths: List<Int> = emptyList(),
|
||||||
val needsSync: Boolean = false,
|
val needsSync: Boolean = false,
|
||||||
|
val lastPaymentTime: Long? = null,
|
||||||
|
val lastPaymentAmount: Double? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PersonListUiState(
|
data class PersonListUiState(
|
||||||
@@ -143,6 +145,7 @@ data class DashboardUiModel(
|
|||||||
val paidCount: Int = 0,
|
val paidCount: Int = 0,
|
||||||
val totalCount: Int = 0,
|
val totalCount: Int = 0,
|
||||||
val totalIncome: Double = 0.0,
|
val totalIncome: Double = 0.0,
|
||||||
|
val totalCycleIncome: Double = 0.0,
|
||||||
val progress: Float = 0f
|
val progress: Float = 0f
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
org.gradle.jvmargs=-Xmx2560m -XX:+UseParallelGC -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2560m -XX:+UseParallelGC -Dfile.encoding=UTF-8
|
||||||
|
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=false
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
android.suppressUnsupportedCompileSdk=34
|
android.suppressUnsupportedCompileSdk=34
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"versionCode": 13,
|
"versionCode": 15,
|
||||||
"versionName": "1.1.2",
|
"versionName": "1.1.4",
|
||||||
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.1.2/MonthlyPaymentApp-v1.1.2.apk",
|
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.1.4/MonthlyPaymentApp-v1.1.4.apk",
|
||||||
"releaseNotes": "بهبودهای پایداری و عملکرد همگامسازی",
|
"releaseNotes": "بهبودهای پایداری و عملکرد همگامسازی",
|
||||||
"isForceUpdate": false
|
"isForceUpdate": false
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user