11 Commits
Author SHA1 Message Date
Oqba 9ed2b59d81 ارتقا به نسخه 1.1.5 و محاسبه مجموع پرداختی‌های دوره جاری برای هر فرد
Android Release / build (push) Canceled after 0s
2026-10-29 07:00:22 +03:30
github-actions[bot] f1c19cba52 auto-update update.json for release v1.1.4 2026-10-29 03:12:13 +00:00
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
12 changed files with 313 additions and 150 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
env:
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"
minSdk = 25
targetSdk = 34
versionCode = 9
versionName = "1.0.8"
versionCode = 16
versionName = "1.1.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}
@@ -76,7 +76,13 @@ android {
targetCompatibility = JavaVersion.VERSION_21
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions { jvmTarget = "21" }
kotlinOptions {
jvmTarget = "21"
freeCompilerArgs += listOf(
"-opt-in=kotlinx.serialization.InternalSerializationApi",
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
)
}
kotlin { jvmToolchain(21) }
packaging {
@@ -1,3 +1,5 @@
@file:OptIn(InternalSerializationApi::class)
package com.oqba26.monthlypaymentapp.data.remote
import android.util.Log
@@ -5,6 +7,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.timeout
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.header
@@ -25,6 +28,7 @@ import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
@@ -347,6 +351,10 @@ class PocketBaseClient(
authToken?.let { token ->
header(HttpHeaders.Authorization, "Bearer $token")
}
timeout {
requestTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
socketTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
}
}.execute { response ->
val channel: ByteReadChannel = response.bodyAsChannel()
var currentEvent = ""
@@ -362,6 +370,7 @@ class PocketBaseClient(
val connectObj = json.decodeFromString<PocketBaseConnectEvent>(dataJson)
subscribeRealtime(cleanBaseUrl, connectObj.clientId, subscriptions)
Log.d(TAG, "اتصال ریل‌تایم برقرار شد. ClientId: ${connectObj.clientId}")
emit("PB_CONNECT")
} else if (currentEvent.isNotEmpty()) {
emit(currentEvent)
}
@@ -14,9 +14,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
class NetworkRepository(
private val settingsRepository: SettingsRepository? = null
settingsRepository: SettingsRepository? = null
) {
private val pocketBase = ApiClient.pocketBase
@@ -43,6 +44,7 @@ class NetworkRepository(
// --- Realtime Operations ---
fun observeRealtimeChanges() {
repositoryScope.launch {
while (true) {
try {
pocketBase.listenRealtime(listOf("persons", "payments")).collect { event ->
Log.d("PocketBase-Realtime", "Change detected in PocketBase: $event")
@@ -51,6 +53,8 @@ class NetworkRepository(
} catch (e: Exception) {
Log.e("PocketBase-Realtime", "Realtime connection error, retrying...", e)
}
delay(3000.milliseconds)
}
}
}
@@ -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()
@@ -192,19 +172,17 @@ class NetworkRepository(
}
suspend fun deletePayment(paymentId: String): Boolean {
var retries = 0
val maxRetries = 2
while (retries <= maxRetries) {
for (retries in 1..maxRetries) {
try {
pocketBase.deleteRecordByFilter("payments", "id='$paymentId'")
Log.d("PocketBase", "deletePayment Success: $paymentId")
return true
} catch (e: Exception) {
retries++
Log.e("PocketBase", "deletePayment error (Attempt $retries): ${e.message}", e)
if (retries > maxRetries) return false
delay(1000L * retries)
if (retries < maxRetries) {
delay((1000L * retries).milliseconds)
}
}
}
return false
@@ -113,6 +113,7 @@ fun PaidListScreen(
PersonListItem(
person = person,
index = index + 1,
listType = PersonListType.PAID,
onPersonClick = { navController.navigate("person_detail/${person.id}") },
onQuickPayClick = { },
onArchiveClick = { personToArchive.value = it },
@@ -30,7 +30,6 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Assessment
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Sync
@@ -117,7 +116,6 @@ fun PersonScreen(
val dashboardData by viewModel.dashboardData.collectAsState()
val financialReportData by viewModel.financialReportData.collectAsState()
val showFinancialReportDialog by viewModel.showFinancialReportDialog.collectAsState()
val isOperatorMode by viewModel.isOperatorMode.collectAsState()
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
val cardNumbers by viewModel.cardNumbersFlow.collectAsState(initial = emptyList())
val personForPaymentDialog by viewModel.personForPaymentDialog.collectAsState()
@@ -188,8 +186,7 @@ fun PersonScreen(
DashboardCard(
data = dashboardData,
financialReportData = financialReportData,
isOperatorMode = isOperatorMode,
onOpenReportClick = { viewModel.onShowFinancialReport() }
isMosqueCategory = (currentCategory == "mosque")
)
OutlinedTextField(
@@ -582,7 +579,7 @@ fun AddNewPersonDialog(
val currentShamsiYear = getCurrentShamsiYear()
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 similarContacts by remember { mutableStateOf<List<ContactMatch>>(emptyList()) }
@@ -1072,8 +1069,7 @@ fun SelectCardDialog(
fun DashboardCard(
data: DashboardUiModel,
financialReportData: FinancialReportData,
isOperatorMode: Boolean = false,
onOpenReportClick: () -> Unit
isMosqueCategory: Boolean = false
) {
val animatedProgress by animateFloatAsState(
targetValue = data.progress,
@@ -1110,17 +1106,30 @@ fun DashboardCard(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("مجموع درآمد ماه جاری", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Text(
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))
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -1139,37 +1148,6 @@ fun DashboardCard(
color = MaterialTheme.colorScheme.error
)
}
val debtorNamesText = financialReportData.debtors.joinToString("، ") {
"${it.personName} (${formatNumberAsPersian(it.totalDebtAmount)})"
}
Text(
text = "بدهکاران (${financialReportData.totalDebtorsCount.toString().toPersianDigits()} نفر): $debtorNamesText",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
if (!isOperatorMode) {
OutlinedButton(
onClick = onOpenReportClick,
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp),
shape = MaterialTheme.shapes.medium
) {
Icon(
imageVector = Icons.Default.Assessment,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text("مشاهده گزارش کامل مالی و درآمدهای گذشته", style = MaterialTheme.typography.labelLarge)
}
}
}
}
@@ -1185,7 +1163,7 @@ fun FinancialReportDialog(
Card(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 600.dp),
.heightIn(max = 650.dp),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
@@ -1212,6 +1190,11 @@ fun FinancialReportDialog(
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))
@@ -1236,19 +1219,51 @@ fun FinancialReportDialog(
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
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(bottom = 8.dp)
modifier = Modifier.padding(top = 4.dp)
)
}
}
if (data.monthlyReports.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
@@ -1257,11 +1272,8 @@ fun FinancialReportDialog(
) {
Text("هیچ دریافتی تا کنون ثبت نشده است.", style = MaterialTheme.typography.bodyMedium)
}
}
} else {
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(data.monthlyReports) { report ->
Card(
modifier = Modifier.fillMaxWidth(),
@@ -1308,15 +1320,6 @@ fun FinancialReportDialog(
}
}
}
Spacer(modifier = Modifier.height(12.dp))
TextButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.End)
) {
Text("بستن")
}
}
}
}
@@ -1329,6 +1332,7 @@ fun PersonListItem(
person: PersonUiModel,
index: Int,
modifier: Modifier = Modifier,
listType: PersonListType = PersonListType.UNPAID,
isSelectionMode: Boolean = false,
isSelected: Boolean = false,
reorderableState: org.burnoutcrew.reorderable.ReorderableLazyListState? = null,
@@ -1365,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) {
Modifier.detectReorderAfterLongPress(reorderableState)
} else Modifier
@@ -1374,14 +1387,33 @@ fun PersonListItem(
style = MaterialTheme.typography.bodyLarge,
modifier = dragModifier.padding(end = 4.dp)
)
Column(modifier = Modifier.weight(1f)) {
Text(
person.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
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) {
Surface(
@@ -1417,6 +1449,7 @@ fun PersonListItem(
)
}
if (listType != PersonListType.PAID) {
Spacer(Modifier.width(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
@@ -1446,4 +1479,5 @@ fun PersonListItem(
}
}
}
}
@@ -5,24 +5,32 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AdminPanelSettings
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -38,6 +46,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -136,16 +145,18 @@ fun SettingsScreen(onLogout: () -> Unit) {
}
)
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth()
.fillMaxHeight()
.fillMaxSize()
.verticalScroll(scrollState)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
stringResource(R.string.settings),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
style = MaterialTheme.typography.titleMedium
)
DefaultAmountSetting(
@@ -163,16 +174,12 @@ fun SettingsScreen(onLogout: () -> Unit) {
onChangePin = { viewModel.setAdminPin(it) }
)
Spacer(Modifier.weight(1f))
val reminderDay by viewModel.reminderDay.collectAsState()
ReminderDaySetting(
currentDay = reminderDay,
onSave = { viewModel.saveReminderDay(it) }
)
Spacer(Modifier.weight(1f))
val cardNumbers by viewModel.cardNumbers.collectAsState()
CardNumbersSetting(
cardNumbers = cardNumbers,
@@ -181,20 +188,17 @@ fun SettingsScreen(onLogout: () -> Unit) {
onRemove = { viewModel.removeCardNumber(it) }
)
Spacer(Modifier.weight(1f))
FontSelectionDropdown(
selectedFont = selectedFont,
onFontSelected = { viewModel.onFontSelected(it) }
)
if (!isOperatorMode) {
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
"پشتیبان‌گیری و بازیابی",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
style = MaterialTheme.typography.titleMedium
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -224,24 +228,26 @@ fun SettingsScreen(onLogout: () -> Unit) {
viewModel.loadSnapshots()
showSnapshotDialog = true
},
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
}
}
Spacer(Modifier.weight(1f))
Spacer(Modifier.height(8.dp))
Button(
onClick = onLogout,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp)
contentPadding = PaddingValues(vertical = 12.dp)
) {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.logout))
}
Spacer(Modifier.height(16.dp))
}
if (showSnapshotDialog) {
@@ -621,23 +627,51 @@ fun OperatorModeSetting(
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (isOperatorMode) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
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(8.dp)) {
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) {
Column(modifier = Modifier.weight(1f)) {
Text("حالت دسترسی برنامه", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
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(
if (isOperatorMode) "وضعیت فعلی: حالت اپراتور (محدود)" else "وضعیت فعلی: حالت مدیریت (کامل)",
style = MaterialTheme.typography.bodySmall,
color = if (isOperatorMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
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
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
HorizontalDivider(thickness = 0.5.dp)
if (isOperatorMode) {
Button(
onClick = {
@@ -646,32 +680,52 @@ fun OperatorModeSetting(
errorMessage = ""
showPinDialog = true
},
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Text("خروج از حالت اپراتور")
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 {
Button(
onClick = {
onToggleMode(true)
},
modifier = Modifier.weight(1f)
OutlinedButton(
onClick = { onToggleMode(true) },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp)
) {
Text("فعال‌سازی حالت اپراتور")
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)
}
OutlinedButton(
onClick = {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
pinAction = PinAction.CHANGE_PIN
enteredPin = ""
newPinInput = ""
errorMessage = ""
showPinDialog = true
},
modifier = Modifier.weight(1f)
) {
Text("تغییر رمز مدیر")
}
.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
)
}
}
}
@@ -30,3 +30,36 @@ fun formatTimestampToPersianDateTime(timestamp: Long): String {
val time = formatTimestampToPersianTime(timestamp)
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.getCurrentShamsiYear
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
import com.oqba26.monthlypaymentapp.utils.isTimestampInCurrentCycle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.coroutineScope
@@ -69,9 +70,6 @@ class PersonViewModel @Inject constructor(
private val _infoMessage = MutableSharedFlow<String>()
val infoMessage = _infoMessage.asSharedFlow()
val isOperatorMode: StateFlow<Boolean> = settingsRepository.isOperatorModeFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false)
private val _uiState = MutableStateFlow(PersonListUiState())
val uiState: StateFlow<PersonListUiState> = _uiState.asStateFlow()
@@ -200,6 +198,8 @@ class PersonViewModel @Inject constructor(
it.shamsiMonth == targetMonth && it.shamsiYear == targetYear && it.category == category
}
val categoryPayments = allPayments.filter { it.category == category }
val activePersons = persons
.filter { !it.isArchived && it.category == category }
.sortedWith(compareBy({ it.displayOrder }, { it.createdAt }))
@@ -207,6 +207,13 @@ class PersonViewModel @Inject constructor(
val uiModels = activePersons.map { person ->
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) {
Triple(0, 0.0, emptyList<String>() to emptyList())
} else {
@@ -244,17 +251,19 @@ class PersonViewModel @Inject constructor(
monthlyCommitment = person.monthlyCommitment,
unpaidMonthsNames = unpaidMonthsNames,
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 categoryPayments = allPayments.filter { it.category == category }
val totalAllTimeIncome = categoryPayments.sumOf { it.amount }
val debtorsList = unpaidModels.filter { it.debtCount > 0 }.map { personModel ->
@@ -590,11 +599,20 @@ class PersonViewModel @Inject constructor(
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 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
_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 {
@@ -17,6 +17,8 @@ data class PersonUiModel(
val unpaidMonthsNames: List<String> = emptyList(),
val unpaidMonths: List<Int> = emptyList(),
val needsSync: Boolean = false,
val lastPaymentTime: Long? = null,
val lastPaymentAmount: Double? = null
)
data class PersonListUiState(
@@ -143,6 +145,7 @@ data class DashboardUiModel(
val paidCount: Int = 0,
val totalCount: Int = 0,
val totalIncome: Double = 0.0,
val totalCycleIncome: Double = 0.0,
val progress: Float = 0f
)
+1 -1
View File
@@ -3,7 +3,7 @@
org.gradle.jvmargs=-Xmx2560m -XX:+UseParallelGC -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true
android.enableJetifier=false
kotlin.code.style=official
android.nonTransitiveRClass=true
android.suppressUnsupportedCompileSdk=34
+4 -4
View File
@@ -1,7 +1,7 @@
{
"versionCode": 9,
"versionName": "1.0.8",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.8/MonthlyPaymentApp-v1.0.8.apk",
"releaseNotes": "بهبودهای تب مسجد و افزودن حالت اپراتور (محدودسازی دسترسی)",
"versionCode": 15,
"versionName": "1.1.4",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.1.4/MonthlyPaymentApp-v1.1.4.apk",
"releaseNotes": "بهبودهای پایداری و عملکرد همگام‌سازی",
"isForceUpdate": false
}