10 Commits
Author SHA1 Message Date
Oqba 1931e20deb ارتقا به نسخه 1.1.4 و نمایش جزئیات و تاریخ پرداخت در لیست پرداخت‌شده‌ها
Android Release / build (push) Canceled after 0s
2026-10-29 06:38:20 +03:30
github-actions[bot] ee0bd0ba2b auto-update update.json for release v1.1.3 2026-10-28 05:35:05 +00:00
Oqba 7d865a3d6d ارتقا به نسخه 1.1.3 و بهینه‌سازی محاسبات ماه جاری و تاریخ شمسی
Android Release / build (push) Canceled after 0s
2026-10-28 08:51:47 +03:30
Oqba fcf4d02caf خودکارسازی آپدیت update.json پس از انتشار موفق APK در گیت‌هاب اکشن
Android Release / build (push) Canceled after 0s
2026-10-28 07:16:54 +03:30
Oqba 6166a2f249 اصلاحات نهایی و ارتقا به نسخه 1.1.2 2026-10-28 06:57:09 +03:30
Oqba 6213bbf30f بهبودهای همگام‌سازی شبکه و ارتقا به نسخه 1.1.1
Android Release / build (push) Canceled after 5s
2026-10-24 07:37:35 +03:30
Oqba 2c52c2c538 بهبود ظاهری و بازطراحی کارت حالت دسترسی (اپراتور)
Android Release / build (push) Canceled after 3s
2026-10-17 19:03:51 +03:30
Oqba a7620302e9 اصلاح اسکرول یکپارچه دیالوگ گزارش مالی و نمایش تفکیک ماه‌های گذشته (نسخه 1.1.0) 2026-10-17 18:28:33 +03:30
Oqba af0e7a76de اصلاح اسکرول صفحه تنظیمات، زیباسازی حالت اپراتور و انتقال لیست اسامی بدهکاران به گزارش مالی (نسخه 1.0.9)
Android Release / build (push) Canceled after 3s
2026-10-16 20:25:58 +03:30
Oqba 44fc513589 بهبودهای تب مسجد و افزودن حالت اپراتور (نسخه 1.0.8)
Android Release / build (push) Canceled after 0s
2026-10-16 06:33:28 +03:30
16 changed files with 962 additions and 201 deletions

No files matched your search

+27
View File
@@ -46,3 +46,30 @@ jobs:
files: app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk files: app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update update.json after release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG_NAME="${{ github.ref_name }}"
VERSION_NAME="${TAG_NAME#v}"
VERSION_CODE=$(grep 'versionCode' app/build.gradle.kts | head -n 1 | awk '{print $3}')
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main
git checkout main
cat <<EOF > update.json
{
"versionCode": ${VERSION_CODE},
"versionName": "${VERSION_NAME}",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/${TAG_NAME}/MonthlyPaymentApp-${TAG_NAME}.apk",
"releaseNotes": "بهبودهای پایداری و عملکرد همگام‌سازی",
"isForceUpdate": false
}
EOF
git add update.json
git commit -m "auto-update update.json for release ${TAG_NAME}" || true
git push origin main
+9 -3
View File
@@ -30,8 +30,8 @@ android {
applicationId = "com.oqba26.monthlypaymentapp" applicationId = "com.oqba26.monthlypaymentapp"
minSdk = 25 minSdk = 25
targetSdk = 34 targetSdk = 34
versionCode = 8 versionCode = 15
versionName = "1.0.7" versionName = "1.1.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true } vectorDrawables { useSupportLibrary = true }
} }
@@ -76,7 +76,13 @@ android {
targetCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { jvmTarget = "21" } kotlinOptions {
jvmTarget = "21"
freeCompilerArgs += listOf(
"-opt-in=kotlinx.serialization.InternalSerializationApi",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
)
}
kotlin { jvmToolchain(21) } kotlin { jvmToolchain(21) }
packaging { packaging {
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Assessment
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Paid import androidx.compose.material.icons.filled.Paid
@@ -457,18 +458,34 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
IconButton(onClick = { showConfirmMoveDialog = true }) { IconButton(onClick = { showConfirmMoveDialog = true }) {
Icon(Icons.Default.Check, contentDescription = "Confirm Move", tint = Color.Green) Icon(Icons.Default.Check, contentDescription = "Confirm Move", tint = Color.Green)
} }
} else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) { } else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Paid.route || currentRoute == Screen.Mosque.route) {
FilledIconButton( if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) {
onClick = { viewModel.onAddPersonClicked() }, FilledIconButton(
colors = IconButtonDefaults.filledIconButtonColors( onClick = { viewModel.onAddPersonClicked() },
containerColor = MaterialTheme.colorScheme.primaryContainer, colors = IconButtonDefaults.filledIconButtonColors(
contentColor = Color.White containerColor = MaterialTheme.colorScheme.primaryContainer,
) contentColor = Color.White
) { )
Icon(Icons.Default.Add, contentDescription = "افزودن شخص") ) {
Icon(Icons.Default.Add, contentDescription = "افزودن شخص")
}
FilledIconButton(
onClick = { contactViewModel.onBulkSmsClicked() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White
),
modifier = Modifier.padding(start = 4.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Message,
contentDescription = "Bulk SMS",
modifier = Modifier.graphicsLayer(scaleX = -1f)
)
}
} }
FilledIconButton( FilledIconButton(
onClick = { contactViewModel.onBulkSmsClicked() }, onClick = { viewModel.onShowFinancialReport() },
colors = IconButtonDefaults.filledIconButtonColors( colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White contentColor = Color.White
@@ -476,9 +493,8 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
modifier = Modifier.padding(start = 4.dp) modifier = Modifier.padding(start = 4.dp)
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.Message, imageVector = Icons.Default.Assessment,
contentDescription = "Bulk SMS", contentDescription = "گزارشات مالی"
modifier = Modifier.graphicsLayer(scaleX = -1f)
) )
} }
} }
@@ -1,3 +1,5 @@
@file:OptIn(InternalSerializationApi::class)
package com.oqba26.monthlypaymentapp.data.remote package com.oqba26.monthlypaymentapp.data.remote
import android.util.Log import android.util.Log
@@ -5,6 +7,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.timeout
import io.ktor.client.request.delete import io.ktor.client.request.delete
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.header import io.ktor.client.request.header
@@ -25,6 +28,7 @@ import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
@@ -347,6 +351,10 @@ class PocketBaseClient(
authToken?.let { token -> authToken?.let { token ->
header(HttpHeaders.Authorization, "Bearer $token") header(HttpHeaders.Authorization, "Bearer $token")
} }
timeout {
requestTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
socketTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
}
}.execute { response -> }.execute { response ->
val channel: ByteReadChannel = response.bodyAsChannel() val channel: ByteReadChannel = response.bodyAsChannel()
var currentEvent = "" var currentEvent = ""
@@ -362,6 +370,7 @@ class PocketBaseClient(
val connectObj = json.decodeFromString<PocketBaseConnectEvent>(dataJson) val connectObj = json.decodeFromString<PocketBaseConnectEvent>(dataJson)
subscribeRealtime(cleanBaseUrl, connectObj.clientId, subscriptions) subscribeRealtime(cleanBaseUrl, connectObj.clientId, subscriptions)
Log.d(TAG, "اتصال ریل‌تایم برقرار شد. ClientId: ${connectObj.clientId}") Log.d(TAG, "اتصال ریل‌تایم برقرار شد. ClientId: ${connectObj.clientId}")
emit("PB_CONNECT")
} else if (currentEvent.isNotEmpty()) { } else if (currentEvent.isNotEmpty()) {
emit(currentEvent) emit(currentEvent)
} }
@@ -14,9 +14,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
class NetworkRepository( class NetworkRepository(
private val settingsRepository: SettingsRepository? = null settingsRepository: SettingsRepository? = null
) { ) {
private val pocketBase = ApiClient.pocketBase private val pocketBase = ApiClient.pocketBase
@@ -43,13 +44,16 @@ class NetworkRepository(
// --- Realtime Operations --- // --- Realtime Operations ---
fun observeRealtimeChanges() { fun observeRealtimeChanges() {
repositoryScope.launch { repositoryScope.launch {
try { while (true) {
pocketBase.listenRealtime(listOf("persons", "payments")).collect { event -> try {
Log.d("PocketBase-Realtime", "Change detected in PocketBase: $event") pocketBase.listenRealtime(listOf("persons", "payments")).collect { event ->
refresh() Log.d("PocketBase-Realtime", "Change detected in PocketBase: $event")
refresh()
}
} catch (e: Exception) {
Log.e("PocketBase-Realtime", "Realtime connection error, retrying...", e)
} }
} catch (e: Exception) { delay(3000.milliseconds)
Log.e("PocketBase-Realtime", "Realtime connection error, retrying...", e)
} }
} }
} }
@@ -152,31 +156,7 @@ class NetworkRepository(
} }
} }
suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean {
return try {
val currentPerson = _personsFlow.value.find { it.id == personId }
if (currentPerson != null) {
pocketBase.upsertRecord("persons", "id='$personId'", currentPerson.copy(isArchived = isArchived))
}
true
} catch (e: Exception) {
Log.e("PocketBase", "updateArchived error", e)
false
}
}
suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean {
return try {
val currentPerson = _personsFlow.value.find { it.id == personId }
if (currentPerson != null) {
pocketBase.upsertRecord("persons", "id='$personId'", currentPerson.copy(displayOrder = displayOrder))
}
true
} catch (e: Exception) {
Log.e("PocketBase", "updateOrder error", e)
false
}
}
fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow() fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow()
@@ -192,19 +172,17 @@ class NetworkRepository(
} }
suspend fun deletePayment(paymentId: String): Boolean { suspend fun deletePayment(paymentId: String): Boolean {
var retries = 0
val maxRetries = 2 val maxRetries = 2
for (retries in 1..maxRetries) {
while (retries <= maxRetries) {
try { try {
pocketBase.deleteRecordByFilter("payments", "id='$paymentId'") pocketBase.deleteRecordByFilter("payments", "id='$paymentId'")
Log.d("PocketBase", "deletePayment Success: $paymentId") Log.d("PocketBase", "deletePayment Success: $paymentId")
return true return true
} catch (e: Exception) { } catch (e: Exception) {
retries++
Log.e("PocketBase", "deletePayment error (Attempt $retries): ${e.message}", e) Log.e("PocketBase", "deletePayment error (Attempt $retries): ${e.message}", e)
if (retries > maxRetries) return false if (retries < maxRetries) {
delay(1000L * retries) delay((1000L * retries).milliseconds)
}
} }
} }
return false return false
@@ -3,6 +3,7 @@ package com.oqba26.monthlypaymentapp.data.repository
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
@@ -26,6 +27,8 @@ class SettingsRepository(context: Context) {
val REMINDER_DAY = stringPreferencesKey("reminder_day") val REMINDER_DAY = stringPreferencesKey("reminder_day")
val CARD_NUMBERS = stringPreferencesKey("card_numbers") val CARD_NUMBERS = stringPreferencesKey("card_numbers")
val IGNORED_CONTACT_SUGGESTIONS = stringPreferencesKey("ignored_contact_suggestions") val IGNORED_CONTACT_SUGGESTIONS = stringPreferencesKey("ignored_contact_suggestions")
val IS_OPERATOR_MODE = booleanPreferencesKey("is_operator_mode")
val ADMIN_PIN = stringPreferencesKey("admin_pin")
/** آخرین versionCodeی که با آن اجرا شده — مبنای تشخیص ارتقای نسخه برای گرفتن snapshot. */ /** آخرین versionCodeی که با آن اجرا شده — مبنای تشخیص ارتقای نسخه برای گرفتن snapshot. */
val LAST_RUN_VERSION_CODE = intPreferencesKey("last_run_version_code") val LAST_RUN_VERSION_CODE = intPreferencesKey("last_run_version_code")
@@ -116,4 +119,20 @@ class SettingsRepository(context: Context) {
preferences[IGNORED_CONTACT_SUGGESTIONS] = set.joinToString(",") preferences[IGNORED_CONTACT_SUGGESTIONS] = set.joinToString(",")
} }
} }
val isOperatorModeFlow: Flow<Boolean> = dataStore.data.map {
it[IS_OPERATOR_MODE] ?: false
}
val adminPinFlow: Flow<String> = dataStore.data.map {
it[ADMIN_PIN] ?: "1234"
}
suspend fun setOperatorMode(isOperator: Boolean) {
dataStore.edit { it[IS_OPERATOR_MODE] = isOperator }
}
suspend fun setAdminPin(pin: String) {
dataStore.edit { it[ADMIN_PIN] = pin }
}
} }
@@ -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 },
@@ -124,18 +124,20 @@ fun PersonDetailScreen(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
val isMosque = detailState.person?.category == "mosque"
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,
startMonth = detailState.person?.startMonth ?: 1, startMonth = detailState.person?.startMonth ?: 1,
isMosqueCategory = isMosque,
onHeaderClick = { onHeaderClick = {
expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month
}, },
onEditClick = { onEditClick = {
if (monthModel.status == MonthStatus.PAID) { if (monthModel.status == MonthStatus.PAID) {
editingPayment = monthModel.payment editingPayment = monthModel.payment
} else if (monthModel.status == MonthStatus.AVAILABLE) { } else {
addingPaymentMonth = monthModel.month addingPaymentMonth = monthModel.month
} }
} }
@@ -170,8 +172,11 @@ fun PersonDetailScreen(
val monthName = getPersianMonthName(month) val monthName = getPersianMonthName(month)
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis()) val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
val isMosque = detailState.person?.category == "mosque"
val isCurrentMonth = (month == getCurrentShamsiMonth()) && (detailState.selectedYear == getCurrentShamsiYear()) val isCurrentMonth = (month == getCurrentShamsiMonth()) && (detailState.selectedYear == getCurrentShamsiYear())
val initialDesc = if (isCurrentMonth) { val initialDesc = if (isMosque) {
"پرداخت کمک به مسجد در تاریخ $currentDateTime ثبت شد."
} else if (isCurrentMonth) {
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد." "پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
} else { } else {
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد." "پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
@@ -600,6 +605,7 @@ fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
fun MonthListItem( fun MonthListItem(
month: MonthUiModel, month: MonthUiModel,
startMonth: Int, startMonth: Int,
isMosqueCategory: Boolean = false,
onHeaderClick: () -> Unit, onHeaderClick: () -> Unit,
onEditClick: () -> Unit onEditClick: () -> Unit
) { ) {
@@ -607,7 +613,7 @@ fun MonthListItem(
val containerColor = when (month.status) { val containerColor = when (month.status) {
MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer
MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.surface MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.surface
MonthStatus.NOT_COMMITTED -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) MonthStatus.NOT_COMMITTED -> if (isMosqueCategory) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
else -> MaterialTheme.colorScheme.surfaceVariant else -> MaterialTheme.colorScheme.surfaceVariant
} }
@@ -616,19 +622,27 @@ fun MonthListItem(
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
if (month.status == MonthStatus.NOT_COMMITTED) { if (month.status == MonthStatus.PAID) {
val monthName = getPersianMonthName(startMonth) onHeaderClick()
Toast.makeText( } else if (month.status == MonthStatus.AVAILABLE) {
context, onEditClick()
"ماه‌های قبل از $monthName ماه جزو تعهدات این فرد نیست", } else if (month.status == MonthStatus.NOT_COMMITTED) {
Toast.LENGTH_SHORT if (isMosqueCategory) {
).show() onEditClick()
} else {
val monthName = getPersianMonthName(startMonth)
Toast.makeText(
context,
"ماه‌های قبل از $monthName ماه جزو تعهدات این فرد نیست",
Toast.LENGTH_SHORT
).show()
}
} else { } else {
onHeaderClick() onHeaderClick()
} }
}, },
colors = CardDefaults.cardColors(containerColor = containerColor), colors = CardDefaults.cardColors(containerColor = containerColor),
elevation = CardDefaults.cardElevation(defaultElevation = if (month.status == MonthStatus.NOT_COMMITTED) 0.dp else 1.dp) elevation = CardDefaults.cardElevation(defaultElevation = if (month.status == MonthStatus.NOT_COMMITTED && !isMosqueCategory) 0.dp else 1.dp)
) { ) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
Row( Row(
@@ -641,7 +655,7 @@ fun MonthListItem(
text = getPersianMonthName(month.month), text = getPersianMonthName(month.month),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = if (month.status == MonthStatus.NOT_COMMITTED) Color.Gray else Color.Unspecified color = if (month.status == MonthStatus.NOT_COMMITTED && !isMosqueCategory) Color.Gray else Color.Unspecified
) )
if ((month.status == MonthStatus.PAID) && (month.payment != null)) { if ((month.status == MonthStatus.PAID) && (month.payment != null)) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Message import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Payments import androidx.compose.material.icons.filled.Payments
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
@@ -92,6 +93,7 @@ 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
import com.oqba26.monthlypaymentapp.viewmodel.DashboardUiModel import com.oqba26.monthlypaymentapp.viewmodel.DashboardUiModel
import com.oqba26.monthlypaymentapp.viewmodel.FinancialReportData
import com.oqba26.monthlypaymentapp.viewmodel.PersonListType import com.oqba26.monthlypaymentapp.viewmodel.PersonListType
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
@@ -112,6 +114,8 @@ fun PersonScreen(
val searchQuery by viewModel.searchQuery.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState()
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val dashboardData by viewModel.dashboardData.collectAsState() val dashboardData by viewModel.dashboardData.collectAsState()
val financialReportData by viewModel.financialReportData.collectAsState()
val showFinancialReportDialog by viewModel.showFinancialReportDialog.collectAsState()
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0) val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
val cardNumbers by viewModel.cardNumbersFlow.collectAsState(initial = emptyList()) val cardNumbers by viewModel.cardNumbersFlow.collectAsState(initial = emptyList())
val personForPaymentDialog by viewModel.personForPaymentDialog.collectAsState() val personForPaymentDialog by viewModel.personForPaymentDialog.collectAsState()
@@ -179,7 +183,11 @@ fun PersonScreen(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 2.dp, bottom = 0.dp), modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 2.dp, bottom = 0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
DashboardCard(data = dashboardData) DashboardCard(
data = dashboardData,
financialReportData = financialReportData,
isMosqueCategory = (currentCategory == "mosque")
)
OutlinedTextField( OutlinedTextField(
value = searchQuery, value = searchQuery,
@@ -301,15 +309,19 @@ fun PersonScreen(
// Dialogs // Dialogs
personForPaymentDialog?.let { person -> personForPaymentDialog?.let { person ->
val currentCategory by viewModel.currentCategory.collectAsState()
val isMosque = currentCategory == "mosque"
val currentDay = getCurrentShamsiDay() val currentDay = getCurrentShamsiDay()
val currentMonth = getCurrentShamsiMonth() val currentMonth = getCurrentShamsiMonth()
val targetMonth = if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1 val targetMonth = if (isMosque) currentMonth else (if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1)
val monthName = getPersianMonthName(targetMonth) val monthName = getPersianMonthName(targetMonth)
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis()) val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
val isCurrentMonth = targetMonth == currentMonth val isCurrentMonth = targetMonth == currentMonth
val initialDesc = if (isCurrentMonth) { val initialDesc = if (isMosque) {
"پرداخت کمک به مسجد در تاریخ $currentDateTime ثبت شد."
} else if (isCurrentMonth) {
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد." "پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
} else { } else {
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد." "پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
@@ -405,6 +417,13 @@ fun PersonScreen(
) )
} }
if (showFinancialReportDialog) {
FinancialReportDialog(
data = financialReportData,
onDismiss = { viewModel.onDismissFinancialReport() }
)
}
if (contactState.currentBulkIndex != -1) { if (contactState.currentBulkIndex != -1) {
val person = contactState.bulkSmsQueue.getOrNull(contactState.currentBulkIndex) val person = contactState.bulkSmsQueue.getOrNull(contactState.currentBulkIndex)
if (person != null) { if (person != null) {
@@ -560,7 +579,7 @@ fun AddNewPersonDialog(
val currentShamsiYear = getCurrentShamsiYear() val currentShamsiYear = getCurrentShamsiYear()
val currentShamsiMonth = getCurrentShamsiMonth() val currentShamsiMonth = getCurrentShamsiMonth()
var startMonth by remember { mutableIntStateOf(currentShamsiMonth) } var startMonth by remember { mutableIntStateOf(if (isMosqueCategory) currentShamsiMonth else 1) }
var startYear by remember { mutableIntStateOf(currentShamsiYear) } var startYear by remember { mutableIntStateOf(currentShamsiYear) }
var similarContacts by remember { mutableStateOf<List<ContactMatch>>(emptyList()) } var similarContacts by remember { mutableStateOf<List<ContactMatch>>(emptyList()) }
@@ -1048,7 +1067,9 @@ fun SelectCardDialog(
@Composable @Composable
fun DashboardCard( fun DashboardCard(
data: DashboardUiModel data: DashboardUiModel,
financialReportData: FinancialReportData,
isMosqueCategory: Boolean = false
) { ) {
val animatedProgress by animateFloatAsState( val animatedProgress by animateFloatAsState(
targetValue = data.progress, targetValue = data.progress,
@@ -1063,29 +1084,243 @@ fun DashboardCard(
) { ) {
Column( Column(
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Text("وضعیت پرداخت‌های ماه جاری", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("وضعیت پرداخت‌ها", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text("باقیمانده: ${(data.totalCount - data.paidCount).toString().toPersianDigits()}", style = MaterialTheme.typography.bodySmall) Text("باقیمانده: ${(data.totalCount - data.paidCount).toString().toPersianDigits()}", style = MaterialTheme.typography.bodySmall)
} }
LinearProgressIndicator( LinearProgressIndicator(
progress = { animatedProgress }, progress = { animatedProgress },
modifier = Modifier.fillMaxWidth().height(8.dp).clip(MaterialTheme.shapes.medium) modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(MaterialTheme.shapes.medium)
) )
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text("مجموع درآمد ماه", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) Text(
Text("${formatNumberAsPersian(data.totalIncome)} تومان", style = MaterialTheme.typography.bodyMedium) text = if (isMosqueCategory) "کمک‌های جمع‌آوری شده ماه جاری" else "حقوق جمع‌آوری شده ماه جاری",
style = MaterialTheme.typography.titleSmall,
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()) {
HorizontalDivider(modifier = Modifier.padding(vertical = 2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🔴 مجموع بدهی‌های معوقه:",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
Text(
text = "${formatNumberAsPersian(financialReportData.totalOverdueDebt)} تومان",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
@Composable
fun FinancialReportDialog(
data: FinancialReportData,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Card(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 650.dp),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📊 گزارش آمار و درآمدهای مالی",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "بستن")
}
}
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f))
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("💰 کل درآمدهای تمام زمان‌ها:", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Text("${formatNumberAsPersian(data.totalAllTimeIncome)} تومان", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("🔴 کل بدهی‌های معوقه فعلی:", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.error)
Text("${formatNumberAsPersian(data.totalOverdueDebt)} تومان (${data.totalDebtorsCount.toString().toPersianDigits()} نفر)", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error)
}
}
}
}
if (data.debtors.isNotEmpty()) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f))
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "لیست بدهکاران (${data.totalDebtorsCount.toString().toPersianDigits()} نفر):",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
val debtorNamesText = data.debtors.joinToString("، ") {
"${it.personName} (${formatNumberAsPersian(it.totalDebtAmount)})"
}
Text(
text = debtorNamesText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
item {
Column {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = "تفکیک درآمد ماهانه ماه‌های گذشته",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 4.dp)
)
}
}
if (data.monthlyReports.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Text("هیچ دریافتی تا کنون ثبت نشده است.", style = MaterialTheme.typography.bodyMedium)
}
}
} else {
items(data.monthlyReports) { report ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${report.monthName} ${report.year.toString().toPersianDigits()}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = "${formatNumberAsPersian(report.totalAmount)} تومان (${report.paymentCount.toString().toPersianDigits()} پرداخت)",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
if (report.payerNames.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "پرداخت‌کنندگان: ${report.payerNames.joinToString("، ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
} }
} }
} }
@@ -1097,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,
@@ -1133,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
@@ -1142,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(
@@ -1157,12 +1421,22 @@ fun PersonListItem(
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
modifier = Modifier.clickable { onDebtClick(person) } modifier = Modifier.clickable { onDebtClick(person) }
) { ) {
Text( Column(
text = "${person.debtCount.toString().toPersianDigits()} بدهی", horizontalAlignment = Alignment.CenterHorizontally,
color = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
style = MaterialTheme.typography.labelSmall, ) {
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) Text(
) text = "${formatNumberAsPersian(person.totalDebtAmount)} تومان",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "${person.debtCount.toString().toPersianDigits()} ماه بدهکار",
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f),
style = MaterialTheme.typography.labelSmall
)
}
} }
} }
@@ -1175,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) )
) }
} }
} }
} }
@@ -4,23 +4,36 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.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.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
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.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AdminPanelSettings
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete 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.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -33,6 +46,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -131,16 +145,18 @@ fun SettingsScreen(onLogout: () -> Unit) {
} }
) )
val scrollState = rememberScrollState()
Column( Column(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp) .fillMaxSize()
.fillMaxWidth() .verticalScroll(scrollState)
.fillMaxHeight() .padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Text( Text(
stringResource(R.string.settings), stringResource(R.string.settings),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium
modifier = Modifier.padding(bottom = 8.dp)
) )
DefaultAmountSetting( DefaultAmountSetting(
@@ -148,7 +164,15 @@ fun SettingsScreen(onLogout: () -> Unit) {
onSave = { viewModel.saveDefaultPaymentAmount(it) } onSave = { viewModel.saveDefaultPaymentAmount(it) }
) )
Spacer(Modifier.weight(1f)) val isOperatorMode by viewModel.isOperatorMode.collectAsState()
val adminPin by viewModel.adminPin.collectAsState()
OperatorModeSetting(
isOperatorMode = isOperatorMode,
adminPin = adminPin,
onToggleMode = { viewModel.setOperatorMode(it) },
onChangePin = { viewModel.setAdminPin(it) }
)
val reminderDay by viewModel.reminderDay.collectAsState() val reminderDay by viewModel.reminderDay.collectAsState()
ReminderDaySetting( ReminderDaySetting(
@@ -156,74 +180,74 @@ fun SettingsScreen(onLogout: () -> Unit) {
onSave = { viewModel.saveReminderDay(it) } onSave = { viewModel.saveReminderDay(it) }
) )
Spacer(Modifier.weight(1f))
val cardNumbers by viewModel.cardNumbers.collectAsState() val cardNumbers by viewModel.cardNumbers.collectAsState()
CardNumbersSetting( CardNumbersSetting(
cardNumbers = cardNumbers, cardNumbers = cardNumbers,
isOperatorMode = isOperatorMode,
onAdd = { viewModel.addCardNumber(it) }, onAdd = { viewModel.addCardNumber(it) },
onRemove = { viewModel.removeCardNumber(it) } onRemove = { viewModel.removeCardNumber(it) }
) )
Spacer(Modifier.weight(1f))
FontSelectionDropdown( FontSelectionDropdown(
selectedFont = selectedFont, selectedFont = selectedFont,
onFontSelected = { viewModel.onFontSelected(it) } onFontSelected = { viewModel.onFontSelected(it) }
) )
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) if (!isOperatorMode) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text( Text(
"پشتیبان‌گیری و بازیابی", "پشتیبان‌گیری و بازیابی",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium
modifier = Modifier.padding(bottom = 8.dp) )
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault())
val fileName = "payment_backup_${simpleDateFormat.format(Date())}.json"
backupLauncher.launch(fileName)
},
modifier = Modifier.weight(1f)
) {
Text("تهیه پشتیبان", fontSize = 13.sp)
}
OutlinedButton(
onClick = { restoreLauncher.launch(arrayOf("application/json")) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text("بازیابی فایل", fontSize = 13.sp)
}
}
// تور نجات: نسخه‌هایی که خودِ برنامه قبل از عملیات پرخطر گرفته است.
OutlinedButton( OutlinedButton(
onClick = { onClick = {
val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()) viewModel.loadSnapshots()
val fileName = "payment_backup_${simpleDateFormat.format(Date())}.json" showSnapshotDialog = true
backupLauncher.launch(fileName)
}, },
modifier = Modifier.weight(1f) modifier = Modifier.fillMaxWidth()
) { ) {
Text("تهیه پشتیبان", fontSize = 13.sp) Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
}
OutlinedButton(
onClick = { restoreLauncher.launch(arrayOf("application/json")) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text("بازیابی فایل", fontSize = 13.sp)
} }
} }
// تور نجات: نسخه‌هایی که خودِ برنامه قبل از عملیات پرخطر گرفته است. Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = {
viewModel.loadSnapshots()
showSnapshotDialog = true
},
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
) {
Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
}
Spacer(Modifier.weight(1f))
Button( Button(
onClick = onLogout, onClick = onLogout,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp) contentPadding = PaddingValues(vertical = 12.dp)
) { ) {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null) Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.logout)) Text(stringResource(R.string.logout))
} }
Spacer(Modifier.height(16.dp))
} }
if (showSnapshotDialog) { if (showSnapshotDialog) {
@@ -505,10 +529,17 @@ fun ReminderDaySetting(currentDay: Int?, onSave: (String) -> Unit) {
} }
@Composable @Composable
fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) { fun CardNumbersSetting(
cardNumbers: List<String>,
isOperatorMode: Boolean = false,
onAdd: (String) -> Unit,
onRemove: (String) -> Unit
) {
var isAdding by remember { mutableStateOf(false) } var isAdding by remember { mutableStateOf(false) }
var newCardText by remember { mutableStateOf("") } var newCardText by remember { mutableStateOf("") }
var cardToRemove by remember { mutableStateOf<String?>(null) } var cardToRemove by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -517,9 +548,11 @@ fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRem
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("شماره کارت‌های بانکی", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) Text("شماره کارت‌های بانکی", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
IconButton(onClick = { isAdding = true }) { Icon(Icons.Default.Add, null) } if (!isOperatorMode) {
IconButton(onClick = { isAdding = true }) { Icon(Icons.Default.Add, null) }
}
} }
if (isAdding) { if (isAdding && !isOperatorMode) {
OutlinedTextField( OutlinedTextField(
value = newCardText, value = newCardText,
onValueChange = { newCardText = it.filter { c -> c.isDigit() } }, onValueChange = { newCardText = it.filter { c -> c.isDigit() } },
@@ -534,9 +567,33 @@ fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRem
} }
} }
cardNumbers.forEach { card -> cardNumbers.forEach { card ->
Row(verticalAlignment = Alignment.CenterVertically) { Row(
Text(card.toPersianDigits(), modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge) verticalAlignment = Alignment.CenterVertically,
IconButton(onClick = { cardToRemove = card }) { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) } modifier = Modifier
.fillMaxWidth()
.clickable {
clipboardManager.setText(AnnotatedString(card))
Toast.makeText(context, "شماره کارت کپی شد", Toast.LENGTH_SHORT).show()
}
.padding(vertical = 4.dp)
) {
Text(
card.toPersianDigits(),
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge
)
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(card))
Toast.makeText(context, "شماره کارت کپی شد", Toast.LENGTH_SHORT).show()
}) {
Icon(Icons.Default.ContentCopy, contentDescription = "کپی شماره کارت")
}
if (!isOperatorMode) {
IconButton(onClick = { cardToRemove = card }) {
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error)
}
}
} }
} }
} }
@@ -555,6 +612,195 @@ fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRem
} }
} }
@Composable
fun OperatorModeSetting(
isOperatorMode: Boolean,
adminPin: String,
onToggleMode: (Boolean) -> Unit,
onChangePin: (String) -> Unit
) {
var showPinDialog by remember { mutableStateOf(false) }
var pinAction by remember { mutableStateOf<PinAction?>(null) }
var enteredPin by remember { mutableStateOf("") }
var newPinInput by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (isOperatorMode) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (isOperatorMode) Icons.Default.Lock else Icons.Default.AdminPanelSettings,
contentDescription = null,
tint = if (isOperatorMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "حالت دسترسی برنامه",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
Surface(
color = if (isOperatorMode) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(12.dp)
) {
Text(
text = if (isOperatorMode) "اپراتور (محدود)" else "مدیریت (کامل)",
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = if (isOperatorMode) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
HorizontalDivider(thickness = 0.5.dp)
if (isOperatorMode) {
Button(
onClick = {
pinAction = PinAction.UNLOCK_ADMIN
enteredPin = ""
errorMessage = ""
showPinDialog = true
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Icon(Icons.Default.LockOpen, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("خروج از حالت اپراتور (ورود رمز مدیر)", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
}
} else {
OutlinedButton(
onClick = { onToggleMode(true) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp)
) {
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("فعال‌سازی حالت اپراتور (محدودسازی)", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
pinAction = PinAction.CHANGE_PIN
enteredPin = ""
newPinInput = ""
errorMessage = ""
showPinDialog = true
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Edit,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "تغییر رمز عبور مدیریت",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
if (showPinDialog) {
Dialog(onDismissRequest = { showPinDialog = false }) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Card(modifier = Modifier.fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
if (pinAction == PinAction.UNLOCK_ADMIN) "ورود رمز مدیریت" else "تغییر رمز مدیریت",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (pinAction == PinAction.UNLOCK_ADMIN || pinAction == PinAction.CHANGE_PIN) {
OutlinedTextField(
value = enteredPin,
onValueChange = { enteredPin = it.filter { c -> c.isDigit() } },
label = { Text(if (pinAction == PinAction.CHANGE_PIN) "رمز فعلی" else "رمز عبور مدیر") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
if (pinAction == PinAction.CHANGE_PIN) {
OutlinedTextField(
value = newPinInput,
onValueChange = { newPinInput = it.filter { c -> c.isDigit() } },
label = { Text("رمز جدید") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
if (errorMessage.isNotBlank()) {
Text(errorMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { showPinDialog = false }) { Text("لغو") }
Button(onClick = {
if (pinAction == PinAction.UNLOCK_ADMIN) {
if (enteredPin == adminPin) {
onToggleMode(false)
showPinDialog = false
} else {
errorMessage = "رمز عبور اشتباه است"
}
} else if (pinAction == PinAction.CHANGE_PIN) {
if (enteredPin != adminPin) {
errorMessage = "رمز فعلی اشتباه است"
} else if (newPinInput.length < 4) {
errorMessage = "رمز جدید باید حداقل ۴ رقم باشد"
} else {
onChangePin(newPinInput)
showPinDialog = false
}
}
}) { Text("تأیید") }
}
}
}
}
}
}
}
private enum class PinAction { UNLOCK_ADMIN, CHANGE_PIN }
@Composable @Composable
fun FontSelectionDropdown(selectedFont: String, onFontSelected: (String) -> Unit) { fun FontSelectionDropdown(selectedFont: String, onFontSelected: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
@@ -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,8 +14,10 @@ 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.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -71,6 +73,12 @@ class PersonViewModel @Inject constructor(
private val _uiState = MutableStateFlow(PersonListUiState()) private val _uiState = MutableStateFlow(PersonListUiState())
val uiState: StateFlow<PersonListUiState> = _uiState.asStateFlow() val uiState: StateFlow<PersonListUiState> = _uiState.asStateFlow()
private val _financialReportData = MutableStateFlow(FinancialReportData())
val financialReportData: StateFlow<FinancialReportData> = _financialReportData.asStateFlow()
private val _showFinancialReportDialog = MutableStateFlow(false)
val showFinancialReportDialog: StateFlow<Boolean> = _showFinancialReportDialog.asStateFlow()
private val _showAddPersonDialog = MutableStateFlow(false) private val _showAddPersonDialog = MutableStateFlow(false)
val showAddPersonDialog: StateFlow<Boolean> = _showAddPersonDialog.asStateFlow() val showAddPersonDialog: StateFlow<Boolean> = _showAddPersonDialog.asStateFlow()
@@ -92,10 +100,11 @@ class PersonViewModel @Inject constructor(
localPersonRepository.getAllPersonsFlow(), localPersonRepository.getAllPersonsFlow(),
localPersonRepository.getAllPaymentsFlow(), localPersonRepository.getAllPaymentsFlow(),
_selectedYear, _selectedYear,
_currentCategory _currentCategory,
) { persons, allPayments, year, category -> ) { persons, allPayments, year, category ->
val person = persons.find { it.id == personId } val person = persons.find { it.id == personId }
val personCategory = person?.category ?: category val personCategory = person?.category ?: category
val isMosque = (personCategory == "mosque")
val paymentsForPersonInYear = allPayments.filter { val paymentsForPersonInYear = allPayments.filter {
(it.personId == personId) && (it.shamsiYear == year) && (it.category == personCategory) (it.personId == personId) && (it.shamsiYear == year) && (it.category == personCategory)
} }
@@ -106,12 +115,18 @@ class PersonViewModel @Inject constructor(
val payment = paymentsForPersonInYear.find { it.shamsiMonth == month } val payment = paymentsForPersonInYear.find { it.shamsiMonth == month }
// بررسی شروع تعهد // بررسی شروع تعهد
val isBeforeCommitment = if (person != null) { val isBeforeCommitment = (person != null) &&
year < person.startYear || (year == person.startYear && month < person.startMonth) (year < person.startYear || (year == person.startYear && month < person.startMonth))
} else false
val status = when { val status = when {
payment != null -> MonthStatus.PAID payment != null -> MonthStatus.PAID
isMosque -> {
if (year == currentYear && month == currentMonth) {
MonthStatus.AVAILABLE
} else {
MonthStatus.NOT_COMMITTED
}
}
isBeforeCommitment -> MonthStatus.NOT_COMMITTED isBeforeCommitment -> MonthStatus.NOT_COMMITTED
year < currentYear -> MonthStatus.PAST_YEAR year < currentYear -> MonthStatus.PAST_YEAR
year > currentYear -> MonthStatus.FUTURE_YEAR year > currentYear -> MonthStatus.FUTURE_YEAR
@@ -133,26 +148,28 @@ class PersonViewModel @Inject constructor(
if (token != null) { if (token != null) {
networkRepository.observeRealtimeChanges() networkRepository.observeRealtimeChanges()
launch { coroutineScope {
combine( launch {
networkRepository.getPersonsFlow(), combine(
networkRepository.getPaymentsFlow(), networkRepository.getPersonsFlow(),
networkRepository.hasServerDataFlow() networkRepository.getPaymentsFlow(),
) { persons, payments, hasServerData -> networkRepository.hasServerDataFlow()
if (hasServerData) { ) { persons, payments, hasServerData ->
if (!snapshotTakenThisSession) { if (hasServerData) {
// آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی if (!snapshotTakenThisSession) {
// ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد، // آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی
// کاربر بتواند از تنظیمات برش گرداند. // ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد،
backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC) // کاربر بتواند از تنظیمات برش گرداند.
snapshotTakenThisSession = true backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC)
snapshotTakenThisSession = true
}
localPersonRepository.mergeFromServer(persons, payments)
true
} else false
}.collect { isUpdated ->
if (isUpdated) {
_toastMessage.emit("اطلاعات با سرور همگام‌سازی شد")
} }
localPersonRepository.mergeFromServer(persons, payments)
true
} else false
}.collect { isUpdated ->
if (isUpdated) {
_toastMessage.emit("اطلاعات با سرور همگام‌سازی شد")
} }
} }
} }
@@ -181,12 +198,21 @@ 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 }))
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 = latestPayment?.amount
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())
@@ -199,7 +225,8 @@ 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) } to unpaid) val amountPerMonth = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultAmount
Triple(unpaid.size, unpaid.size * amountPerMonth, unpaid.map { getPersianMonthName(it) } to unpaid)
} }
val (debtCount, totalDebtAmount, unpaidData) = unpaidResult val (debtCount, totalDebtAmount, unpaidData) = unpaidResult
@@ -224,13 +251,62 @@ 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 totalAllTimeIncome = categoryPayments.sumOf { it.amount }
val debtorsList = unpaidModels.filter { it.debtCount > 0 }.map { personModel ->
DebtorDetail(
personId = personModel.id,
personName = personModel.name,
debtCount = personModel.debtCount,
totalDebtAmount = personModel.totalDebtAmount,
unpaidMonthsNames = personModel.unpaidMonthsNames
)
}
val totalOverdueDebt = debtorsList.sumOf { it.totalDebtAmount }
val monthlyReportsList = categoryPayments
.groupBy { Pair(it.shamsiYear, it.shamsiMonth) }
.entries
.sortedWith(compareByDescending<Map.Entry<Pair<Int, Int>, List<PaymentRecord>>> { it.key.first }.thenByDescending { it.key.second })
.map { (yearMonth, paymentGroup) ->
val (year, month) = yearMonth
val totalAmount = paymentGroup.sumOf { it.amount }
val payerNames = paymentGroup.mapNotNull { payment ->
personsMap[payment.personId]?.let { person ->
if (person.isAnonymous && category == "mosque") "خیر ناشناس (${person.name})" else person.name
}
}.distinct()
MonthlyFinancialReport(
year = year,
month = month,
monthName = getPersianMonthName(month),
totalAmount = totalAmount,
paymentCount = paymentGroup.size,
payerNames = payerNames
)
}
_financialReportData.value = FinancialReportData(
totalAllTimeIncome = totalAllTimeIncome,
totalOverdueDebt = totalOverdueDebt,
totalDebtorsCount = debtorsList.size,
debtors = debtorsList,
monthlyReports = monthlyReportsList
)
val filteredPaid = if (query.isBlank()) paidModels else paidModels.filter { it.name.contains(query, ignoreCase = true) } val filteredPaid = if (query.isBlank()) paidModels else paidModels.filter { it.name.contains(query, ignoreCase = true) }
val filteredUnpaid = if (query.isBlank()) unpaidModels else unpaidModels.filter { it.name.contains(query, ignoreCase = true) } val filteredUnpaid = if (query.isBlank()) unpaidModels else unpaidModels.filter { it.name.contains(query, ignoreCase = true) }
@@ -269,6 +345,8 @@ class PersonViewModel @Inject constructor(
fun onDismissAddPersonDialog() { _showAddPersonDialog.value = false } fun onDismissAddPersonDialog() { _showAddPersonDialog.value = false }
fun onQuickPayClicked(person: PersonUiModel) { _personForPaymentDialog.value = person } fun onQuickPayClicked(person: PersonUiModel) { _personForPaymentDialog.value = person }
fun onDismissPaymentDialog() { _personForPaymentDialog.value = null } fun onDismissPaymentDialog() { _personForPaymentDialog.value = null }
fun onShowFinancialReport() { _showFinancialReportDialog.value = true }
fun onDismissFinancialReport() { _showFinancialReportDialog.value = false }
fun onEvent(event: PersonScreenEvent) { fun onEvent(event: PersonScreenEvent) {
viewModelScope.launch { viewModelScope.launch {
@@ -291,9 +369,9 @@ class PersonViewModel @Inject constructor(
val persons = localPersonRepository.getAllPersonsFlow().first() val persons = localPersonRepository.getAllPersonsFlow().first()
val finalName = if (event.isAnonymous && event.name.isBlank()) "ناشناس" else event.name val finalName = if (event.isAnonymous && event.name.isBlank()) "ناشناس" else event.name
val isDuplicate = if (!event.isAnonymous) { val isDuplicate = !event.isAnonymous && persons.any {
persons.any { it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true) } it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true)
} else false }
if (isDuplicate) { if (isDuplicate) {
_toastMessage.emit("خطا: این نام از قبل وجود دارد.") _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
@@ -334,8 +412,9 @@ class PersonViewModel @Inject constructor(
val currentDay = getCurrentShamsiDay() val currentDay = getCurrentShamsiDay()
val currentMonth = getCurrentShamsiMonth() val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear() val currentYear = getCurrentShamsiYear()
val targetMonth = if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1 val isMosque = _currentCategory.value == "mosque"
val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear val targetMonth = if (isMosque) currentMonth else (if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1)
val targetYear = if (isMosque) currentYear else (if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear)
val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description) val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description)
.copy(category = _currentCategory.value) .copy(category = _currentCategory.value)
@@ -520,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 {
@@ -46,9 +46,29 @@ class SettingsViewModel @Inject constructor(
val cardNumbers: StateFlow<List<String>> = settingsRepository.cardNumbersFlow val cardNumbers: StateFlow<List<String>> = settingsRepository.cardNumbersFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), emptyList())
val isOperatorMode: StateFlow<Boolean> = settingsRepository.isOperatorModeFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false)
val adminPin: StateFlow<String> = settingsRepository.adminPinFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), "1234")
private val _toastMessage = MutableSharedFlow<String>() private val _toastMessage = MutableSharedFlow<String>()
val toastMessage = _toastMessage.asSharedFlow() val toastMessage = _toastMessage.asSharedFlow()
fun setOperatorMode(enabled: Boolean) {
viewModelScope.launch {
settingsRepository.setOperatorMode(enabled)
_toastMessage.emit(if (enabled) "حالت اپراتور (محدود) فعال شد" else "حالت مدیریت فعال شد")
}
}
fun setAdminPin(newPin: String) {
viewModelScope.launch {
settingsRepository.setAdminPin(newPin)
_toastMessage.emit("رمز مدیریت با موفقیت تغییر یافت")
}
}
fun saveReminderDay(day: String) { fun saveReminderDay(day: String) {
viewModelScope.launch { viewModelScope.launch {
val dayInt = day.toIntOrNull() val dayInt = day.toIntOrNull()
@@ -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,5 +145,31 @@ 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
) )
data class MonthlyFinancialReport(
val year: Int,
val month: Int,
val monthName: String,
val totalAmount: Double,
val paymentCount: Int,
val payerNames: List<String>
)
data class DebtorDetail(
val personId: String,
val personName: String,
val debtCount: Int,
val totalDebtAmount: Double,
val unpaidMonthsNames: List<String> = emptyList()
)
data class FinancialReportData(
val totalAllTimeIncome: Double = 0.0,
val totalOverdueDebt: Double = 0.0,
val totalDebtorsCount: Int = 0,
val debtors: List<DebtorDetail> = emptyList(),
val monthlyReports: List<MonthlyFinancialReport> = emptyList()
)
+1 -1
View File
@@ -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
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"versionCode": 8, "versionCode": 14,
"versionName": "1.0.7", "versionName": "1.1.3",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.7/MonthlyPaymentApp-v1.0.7.apk", "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.1.3/MonthlyPaymentApp-v1.1.3.apk",
"releaseNotes": "مهاجرت به PocketBase، رفع خطاهای بیلد و بهبود پایداری همگام‌سازی", "releaseNotes": "بهبودهای پایداری و عملکرد همگام‌سازی",
"isForceUpdate": false "isForceUpdate": false
} }