18 Commits
Author SHA1 Message Date
Oqba 1aadcdca90 Use direct mirror and resilient sdk setup for Gitea runner
Android Release / build (push) Failing after 32s
2026-11-03 01:36:46 +03:30
Oqba 5ef94eaa41 Upgrade setup-android to v4 to fix Google 404 URL
Android Release / build (push) Failing after 30s
2026-11-03 00:22:11 +03:30
Oqba c135a98fd8 Add setup-android step for self-hosted runner
Android Release / build (push) Failing after 26s
2026-11-03 00:08:46 +03:30
Oqba 929182d234 Optimize Gradle memory with no-daemon and G1GC
Android Release / build (push) Failing after 14m4s
2026-11-02 23:55:22 +03:30
Oqba a9a9281f79 Use fast direct mirror for Gradle wrapper distribution
Android Release / build (push) Failing after 10m45s
2026-11-02 23:35:27 +03:30
Oqba c7065295b8 Fix payment duplicate and sync resurrection bug, update Gitea CI/CD and bump to v1.1.6
Android Release / build (push) Failing after 1h6m33s
2026-11-02 22:05:53 +03:30
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
Oqba 44fc513589 بهبودهای تب مسجد و افزودن حالت اپراتور (نسخه 1.0.8)
Android Release / build (push) Canceled after 0s
2026-10-16 06:33:28 +03:30
22 changed files with 1823 additions and 970 deletions

No files matched your search

+69 -5
View File
@@ -11,14 +11,31 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
- name: set up JDK 21
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Setup Android SDK (Self-Hosted / Gitea)
if: "!contains(github.server_url, 'github.com')"
run: |
which unzip || (apt-get update && apt-get install -y unzip curl)
mkdir -p /opt/android-sdk/cmdline-tools
curl -s -L -o /tmp/cmdline-tools.zip https://mirrors.cloud.tencent.com/AndroidSDK/commandlinetools-linux-11076708_latest.zip
unzip -q /tmp/cmdline-tools.zip -d /opt/android-sdk/cmdline-tools
mv /opt/android-sdk/cmdline-tools/cmdline-tools /opt/android-sdk/cmdline-tools/latest
rm /tmp/cmdline-tools.zip
echo "ANDROID_HOME=/opt/android-sdk" >> $GITHUB_ENV
echo "/opt/android-sdk/cmdline-tools/latest/bin" >> $GITHUB_PATH
echo "/opt/android-sdk/platform-tools" >> $GITHUB_PATH
export ANDROID_HOME=/opt/android-sdk
export PATH="/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:$PATH"
yes | sdkmanager --licenses || true
sdkmanager "platforms;android-34" "build-tools;34.0.0" "platform-tools"
- name: Grant execute permission for gradlew
run: chmod +x gradlew
@@ -34,15 +51,62 @@ jobs:
RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: ./gradlew assembleRelease
run: ./gradlew assembleRelease --no-daemon
- name: Rename APK
run: |
mv app/build/outputs/apk/release/app-release.apk app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk
- name: Create Release
- name: Upload APK Artifact
uses: actions/upload-artifact@v4
with:
name: MonthlyPaymentApp-${{ github.ref_name }}
path: app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk
- name: Create Release (GitHub)
if: contains(github.server_url, 'github.com')
uses: softprops/action-gh-release@v2
with:
files: app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release (Gitea)
if: "!contains(github.server_url, 'github.com')"
uses: akkuman/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch'
with:
files: app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk
name: "Release ${{ github.ref_name }}"
tag_name: ${{ github.ref_name }}
token: ${{ secrets.GITHUB_TOKEN }}
draft: false
prerelease: false
- 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 "actions-bot"
git config user.email "bot@mizbanplatform.ir"
git fetch origin main
git checkout main
cat <<EOF > update.json
{
"versionCode": ${VERSION_CODE},
"versionName": "${VERSION_NAME}",
"url": "${{ github.server_url }}/${{ github.repository }}/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 -5
View File
@@ -30,8 +30,8 @@ android {
applicationId = "com.oqba26.monthlypaymentapp"
minSdk = 25
targetSdk = 34
versionCode = 8
versionName = "1.0.7"
versionCode = 17
versionName = "1.1.6"
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 {
@@ -120,11 +126,9 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
// Networking
implementation(libs.retrofit.core)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.kotlin.serialization.json)
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
// Database (Room)
implementation(libs.androidx.room.runtime)
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.ArrowDownward
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.Close
import androidx.compose.material.icons.filled.Paid
@@ -332,9 +333,33 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
Screen.Settings.route
)
// هندل کردن دکمه Back
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
// هندل کردن دکمه Back:
// ۱. اگر در حالت انتخاب آیتم‌ها باشد، لغو انتخاب
// ۲. اگر در صفحه‌ای غیر از تب اصلی «پرداخت نشده» باشد، بازگشت به صفحه قبل / تب پرداخت نشده
// ۳. فقط در صفحه اصلی «پرداخت نشده»، دیالوگ خروج نمایش داده می‌شود
BackHandler {
showExitDialog = true
when {
isSelectionMode -> {
viewModel.onEvent(PersonScreenEvent.ClearSelection)
}
currentRoute != Screen.Unpaid.route -> {
val popped = navController.popBackStack()
if (!popped) {
navController.navigate(Screen.Unpaid.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
else -> {
showExitDialog = true
}
}
}
if (showExitDialog) {
@@ -346,7 +371,6 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
)
}
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
var showConfirmMoveDialog by remember { mutableStateOf(false) }
if (showConfirmMoveDialog) {
@@ -457,18 +481,34 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
IconButton(onClick = { showConfirmMoveDialog = true }) {
Icon(Icons.Default.Check, contentDescription = "Confirm Move", tint = Color.Green)
}
} else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) {
FilledIconButton(
onClick = { viewModel.onAddPersonClicked() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White
)
) {
Icon(Icons.Default.Add, contentDescription = "افزودن شخص")
} else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Paid.route || currentRoute == Screen.Mosque.route) {
if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) {
FilledIconButton(
onClick = { viewModel.onAddPersonClicked() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White
)
) {
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(
onClick = { contactViewModel.onBulkSmsClicked() },
onClick = { viewModel.onShowFinancialReport() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White
@@ -476,9 +516,8 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
modifier = Modifier.padding(start = 4.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Message,
contentDescription = "Bulk SMS",
modifier = Modifier.graphicsLayer(scaleX = -1f)
imageVector = Icons.Default.Assessment,
contentDescription = "گزارشات مالی"
)
}
}
@@ -1,62 +0,0 @@
@file:Suppress("unused")
package com.oqba26.monthlypaymentapp.data.remote
import com.oqba26.monthlypaymentapp.data.model.AuthRequest
import com.oqba26.monthlypaymentapp.data.model.AuthResponse
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
interface ApiService {
// ⭐️ NEW: Auth Routes
@POST("auth/register")
suspend fun register(@Body request: AuthRequest): AuthResponse
@POST("auth/login")
suspend fun login(@Body request: AuthRequest): AuthResponse
// Persons (AuthInterceptor توکن را به هدر اضافه می‌کند)
@GET("persons")
suspend fun getPersons(): List<Person>
@POST("persons")
suspend fun addPerson(@Body person: Person): Response<Unit>
// ⭐️ NEW: مسیر PUT برای ویرایش نام
@PUT("persons/{id}")
suspend fun updatePerson(@Path("id") personId: String, @Body person: Person): Response<Unit>
@PATCH("persons/{id}/archived")
suspend fun updatePersonArchivedStatus(@Path("id") personId: String, @Body isArchived: Map<String, Boolean>): Response<Unit>
@PATCH("persons/{id}/display-order")
suspend fun updatePersonDisplayOrder(@Path("id") personId: String, @Body displayOrder: Map<String, Long>): Response<Unit>
@POST("persons/rebalance")
suspend fun rebalanceDisplayOrders(): Response<Unit>
@DELETE("persons/{id}")
suspend fun deletePersonAndPayments(@Path("id") personId: String): Response<Unit>
// Payments
@GET("payments")
suspend fun getPayments(
@Query("personId") personId: String? = null
): List<PaymentRecord>
@POST("payments")
suspend fun addPayment(@Body paymentRecord: PaymentRecord): Response<Unit>
@DELETE("payments/{id}")
suspend fun deletePayment(@Path("id") paymentId: String): Response<Unit>
}
@@ -1,31 +0,0 @@
package com.oqba26.monthlypaymentapp.data.remote
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
@Suppress("unused")
class AuthInterceptor(
private val settingsRepository: SettingsRepository
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
// Get the token from DataStore, blocking the current thread.
// This is acceptable for an I/O-bound operation in an interceptor.
val authToken = runBlocking {
settingsRepository.authTokenFlow.first() // Use first() to wait for a value
}
val originalRequest = chain.request()
val requestBuilder = originalRequest.newBuilder()
// Add the Authorization header only if the token is not null or blank
if (!authToken.isNullOrBlank()) {
requestBuilder.header("Authorization", "Bearer $authToken")
}
return chain.proceed(requestBuilder.build())
}
}
@@ -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)
}
@@ -56,21 +56,46 @@ class LocalPersonRepository(
if (personPlan.upserts.isNotEmpty()) personDao.insertAll(personPlan.upserts)
if (paymentPlan.upserts.isNotEmpty()) paymentDao.insertAllPaymentRecords(paymentPlan.upserts)
// خودترمیمی: اگر رکوردهایی در دیتابیس محلی هستند که در سرور وجود ندارند و در صف هم نیستند،
// آن‌ها را دوباره برای ارسال به سرور ثبت می‌کنیم تا سرور و کلاینت همگام شوند.
// خودترمیمی: فقط رکوردهای محلی که نیاز به سینک دارند (آفلاین ایجاد شده‌اند) و در سرور نیستند ارسال شوند
val serverPersonIds = persons.map { it.id }.toSet()
val missingPersons = localPersons.filter { it.id !in serverPersonIds && it.id !in pendingIds }
for (p in missingPersons) {
personDao.insertAll(listOf(p.copy(needsSync = true)))
val offlinePersons = localPersons.filter { it.needsSync && it.id !in serverPersonIds && it.id !in pendingIds }
for (p in offlinePersons) {
personDao.insertAll(listOf(p))
syncQueueDao.insert(SyncQueue(entityId = p.id, type = SyncType.PERSON, operation = SyncOperation.INSERT))
}
// رکوردهایی که قبلاً سینک شده بودند ولی روی سرور حذف شده‌اند، از دیتابیس محلی هم حذف می‌شوند
val deletedPersonIds = localPersons.filter { !it.needsSync && it.id !in serverPersonIds && it.id !in pendingIds }.map { it.id }
if (deletedPersonIds.isNotEmpty()) {
personDao.deleteByIds(deletedPersonIds)
}
val serverPaymentIds = payments.map { it.id }.toSet()
val missingPayments = localPayments.filter { it.id !in serverPaymentIds && it.id !in pendingIds }
for (p in missingPayments) {
paymentDao.insertAllPaymentRecords(listOf(p.copy(needsSync = true)))
val offlinePayments = localPayments.filter { it.needsSync && it.id !in serverPaymentIds && it.id !in pendingIds }
for (p in offlinePayments) {
paymentDao.insertAllPaymentRecords(listOf(p))
syncQueueDao.insert(SyncQueue(entityId = p.id, type = SyncType.PAYMENT, operation = SyncOperation.INSERT))
}
val deletedPaymentIds = localPayments.filter { !it.needsSync && it.id !in serverPaymentIds && it.id !in pendingIds }.map { it.id }
if (deletedPaymentIds.isNotEmpty()) {
paymentDao.deleteByIds(deletedPaymentIds)
}
// پاک‌سازی پرداخت‌های تکراری: برای هر شخص، سال و ماه (غیر از مسجد)، حداکثر ۱ پرداخت نگه‌داری شود
val duplicatePayments = paymentDao.getAllPayments()
.filter { it.category != "mosque" }
.groupBy { Triple(it.personId, it.shamsiYear, it.shamsiMonth) }
.values
.filter { it.size > 1 }
.flatMap { group ->
val sorted = group.sortedByDescending { it.timestamp }
sorted.drop(1).map { it.id }
}
if (duplicatePayments.isNotEmpty()) {
paymentDao.deleteByIds(duplicatePayments)
for (dupId in duplicatePayments) {
syncQueueDao.insert(SyncQueue(entityId = dupId, type = SyncType.PAYMENT, operation = SyncOperation.DELETE))
}
}
}
}
@@ -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,13 +44,16 @@ class NetworkRepository(
// --- Realtime Operations ---
fun observeRealtimeChanges() {
repositoryScope.launch {
try {
pocketBase.listenRealtime(listOf("persons", "payments")).collect { event ->
Log.d("PocketBase-Realtime", "Change detected in PocketBase: $event")
refresh()
while (true) {
try {
pocketBase.listenRealtime(listOf("persons", "payments")).collect { event ->
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) {
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
@@ -3,6 +3,7 @@ package com.oqba26.monthlypaymentapp.data.repository
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
@@ -26,6 +27,8 @@ class SettingsRepository(context: Context) {
val REMINDER_DAY = stringPreferencesKey("reminder_day")
val CARD_NUMBERS = stringPreferencesKey("card_numbers")
val IGNORED_CONTACT_SUGGESTIONS = stringPreferencesKey("ignored_contact_suggestions")
val IS_OPERATOR_MODE = booleanPreferencesKey("is_operator_mode")
val ADMIN_PIN = stringPreferencesKey("admin_pin")
/** آخرین versionCodeی که با آن اجرا شده — مبنای تشخیص ارتقای نسخه برای گرفتن snapshot. */
val LAST_RUN_VERSION_CODE = intPreferencesKey("last_run_version_code")
@@ -116,4 +119,20 @@ class SettingsRepository(context: Context) {
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 }
}
}
@@ -0,0 +1,685 @@
package com.oqba26.monthlypaymentapp.ui.components
import android.Manifest
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat
import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
import com.oqba26.monthlypaymentapp.utils.toPersianDigits
import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch
import com.oqba26.monthlypaymentapp.viewmodel.ContactSuggestion
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
@Composable
fun ArchiveConfirmationDialog(personName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp)
) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(text = "انتقال به آرشیو", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(text = "آیا از انتقال $personName به آرشیو مطمئن هستید؟", style = MaterialTheme.typography.bodyMedium)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Button(onClick = onDismiss) { Text("لغو") }
Button(onClick = onConfirm, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) {
Text("آرشیو")
}
}
}
}
}
}
}
@Composable
fun ContactSuggestionDialog(
suggestion: ContactSuggestion,
onConfirm: (String) -> Unit,
onDismiss: () -> Unit
) {
var selectedNumber by remember { mutableStateOf(suggestion.matches.firstOrNull()?.phoneNumber ?: "") }
Dialog(onDismissRequest = onDismiss) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
shape = RoundedCornerShape(24.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "شماره تماس پیدا شد",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Column {
Text("برای «${suggestion.personName}» موارد زیر در مخاطبین پیدا شد:")
Spacer(Modifier.height(12.dp))
LazyColumn(
modifier = Modifier.heightIn(max = 300.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(suggestion.matches) { match ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable { selectedNumber = match.phoneNumber }
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedNumber == match.phoneNumber,
onClick = { selectedNumber = match.phoneNumber }
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = match.nameInContacts,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium
)
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Text(
text = match.phoneNumber.toPersianDigits(),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.fillMaxWidth(),
textAlign = androidx.compose.ui.text.style.TextAlign.Right
)
}
}
}
}
}
Spacer(Modifier.height(8.dp))
Text("آیا می‌خواهید شماره انتخاب شده به اطلاعات شخص اضافه شود؟")
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = { onConfirm(selectedNumber) },
enabled = selectedNumber.isNotEmpty()
) {
Text("بله، اضافه کن")
}
TextButton(onClick = onDismiss) {
Text("خیر")
}
}
}
}
}
}
}
@Composable
fun AddNewPersonDialog(
isMosqueCategory: Boolean = false,
onConfirm: (String, String, Boolean, Double, Int, Int, Double) -> Unit,
onDismiss: () -> Unit,
onSearchContact: (String) -> List<ContactMatch>
) {
var name by remember { mutableStateOf("") }
var phoneNumber by remember { mutableStateOf("") }
var isAnonymous by remember { mutableStateOf(value = false) }
var monthlyCommitment by remember { mutableStateOf("") }
var initialPaymentAmount by remember { mutableStateOf("") }
val currentShamsiYear = getCurrentShamsiYear()
val currentShamsiMonth = getCurrentShamsiMonth()
var startMonth by remember { mutableIntStateOf(if (isMosqueCategory) currentShamsiMonth else 1) }
var startYear by remember { mutableIntStateOf(currentShamsiYear) }
var similarContacts by remember { mutableStateOf<List<ContactMatch>>(emptyList()) }
val context = LocalContext.current
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
val matches = onSearchContact(name)
if (matches.isNotEmpty()) {
similarContacts = matches
} else {
Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT).show()
}
} else {
Toast.makeText(context, "دسترسی به مخاطبین داده نشد", Toast.LENGTH_SHORT).show()
}
}
Dialog(onDismissRequest = onDismiss) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
shape = RoundedCornerShape(24.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = if (isAnonymous) "ثبت کمک آنی" else "افزودن شخص جدید",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
if (isMosqueCategory) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { isAnonymous = !isAnonymous }
.padding(vertical = 4.dp)
) {
Checkbox(
checked = isAnonymous,
onCheckedChange = { isAnonymous = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text("ثبت به صورت خیر ناشناس")
}
}
if (!isAnonymous) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("نام شخص") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
OutlinedTextField(
value = phoneNumber,
onValueChange = { phoneNumber = it },
label = { Text("شماره موبایل (اختیاری)") },
singleLine = true,
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
visualTransformation = PersianDigitsTransformation(),
placeholder = {
Text(
"۰۹---------",
textAlign = androidx.compose.ui.text.style.TextAlign.Right
)
}
)
}
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = {
if (name.isBlank()) {
Toast.makeText(context, "ابتدا نام را وارد کنید", Toast.LENGTH_SHORT)
.show()
return@IconButton
}
when (PackageManager.PERMISSION_GRANTED) {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_CONTACTS
) -> {
val matches = onSearchContact(name)
if (matches.isNotEmpty()) {
similarContacts = matches
} else {
Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT)
.show()
}
}
else -> {
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
}
}
) {
Icon(Icons.Default.Search, contentDescription = "جستجو در مخاطبین")
}
}
if (similarContacts.isNotEmpty()) {
Text(
"موارد مشابه پیدا شده:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
LazyColumn(
modifier = Modifier.heightIn(max = 200.dp)
) {
items(similarContacts) { match ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
phoneNumber = match.phoneNumber
similarContacts = emptyList()
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = phoneNumber == match.phoneNumber,
onClick = {
phoneNumber = match.phoneNumber
similarContacts = emptyList()
}
)
Column {
Text(match.nameInContacts, style = MaterialTheme.typography.bodySmall)
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Text(
match.phoneNumber.toPersianDigits(),
style = MaterialTheme.typography.labelSmall,
color = Color.Gray
)
}
}
}
}
}
}
}
}
if (isMosqueCategory) {
if (isAnonymous) {
OutlinedTextField(
value = initialPaymentAmount,
onValueChange = { value ->
if (value.length <= 12) {
val digitsOnly = value.filter { it.isDigit() || (it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9') }
initialPaymentAmount = digitsOnly.map {
when (it) {
in '\u0660'..'\u0669' -> (it.code - '\u0660'.code + '0'.code).toChar()
in '\u06f0'..'\u06f9' -> (it.code - '\u06f0'.code + '0'.code).toChar()
else -> it
}
}.joinToString("")
}
},
label = { Text("مبلغ پرداختی (تومان)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
visualTransformation = PersianNumberVisualTransformation()
)
} else {
OutlinedTextField(
value = monthlyCommitment,
onValueChange = { value ->
if (value.length <= 12) {
val digitsOnly = value.filter { it.isDigit() || (it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9') }
monthlyCommitment = digitsOnly.map {
when (it) {
in '\u0660'..'\u0669' -> (it.code - '\u0660'.code + '0'.code).toChar()
in '\u06f0'..'\u06f9' -> (it.code - '\u06f0'.code + '0'.code).toChar()
else -> it
}
}.joinToString("")
}
},
label = { Text("مبلغ تعهد ماهانه (تومان) - اختیاری") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
visualTransformation = PersianNumberVisualTransformation(),
placeholder = { Text("اگر مبلغ ثابتی پرداخت می‌کند وارد کنید") }
)
}
}
if (!isAnonymous) {
// انتخاب ماه و سال شروع
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("تاریخ شروع تعهد:", style = MaterialTheme.typography.labelLarge)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// سال
Box(modifier = Modifier.weight(1f)) {
var expandedYear by remember { mutableStateOf(false) }
OutlinedButton(
onClick = { expandedYear = true },
modifier = Modifier.fillMaxWidth()
) {
Text("سال ${startYear.toString().toPersianDigits()}")
}
DropdownMenu(
expanded = expandedYear,
onDismissRequest = { expandedYear = false }) {
(currentShamsiYear - 1..currentShamsiYear + 1).forEach { y ->
DropdownMenuItem(
text = { Text(y.toString().toPersianDigits()) },
onClick = { startYear = y; expandedYear = false }
)
}
}
}
// ماه
Box(modifier = Modifier.weight(1f)) {
var expandedMonth by remember { mutableStateOf(false) }
OutlinedButton(
onClick = { expandedMonth = true },
modifier = Modifier.fillMaxWidth()
) {
Text(getPersianMonthName(startMonth))
}
DropdownMenu(
expanded = expandedMonth,
onDismissRequest = { expandedMonth = false }) {
(1..12).forEach { m ->
DropdownMenuItem(
text = { Text(getPersianMonthName(m)) },
onClick = { startMonth = m; expandedMonth = false }
)
}
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
if (isAnonymous || name.isNotBlank()) {
val commitment = monthlyCommitment.toDoubleOrNull() ?: 0.0
val initial = initialPaymentAmount.toDoubleOrNull() ?: 0.0
onConfirm(name, phoneNumber, isAnonymous, commitment, startMonth, startYear, initial)
}
},
enabled = isAnonymous || name.isNotBlank()
) {
Text(if (isAnonymous) "ثبت کمک" else "افزودن")
}
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("لغو")
}
}
}
}
}
}
}
@Composable
fun BulkSmsSelectionDialog(
debtors: List<PersonUiModel>,
onStart: (List<PersonUiModel>) -> Unit,
onDismiss: () -> Unit
) {
val selectedIds = remember { mutableStateListOf<String>().apply { addAll(debtors.map { it.id }) } }
Dialog(onDismissRequest = onDismiss) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
shape = RoundedCornerShape(24.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "انتخاب افراد برای یادآور گروهی",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
LazyColumn(modifier = Modifier.height(300.dp)) {
itemsIndexed(debtors) { _, person ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
if (selectedIds.contains(person.id)) selectedIds.remove(person.id)
else selectedIds.add(person.id)
}
.padding(vertical = 4.dp)
) {
Checkbox(
checked = selectedIds.contains(person.id),
onCheckedChange = null
)
Text(person.name, modifier = Modifier.padding(start = 8.dp))
Spacer(Modifier.weight(1f))
Text(
"${person.debtCount.toString().toPersianDigits()} بدهی",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = {
val selectedList = debtors.filter { selectedIds.contains(it.id) }
onStart(selectedList)
}) {
Text("شروع ارسال (${selectedIds.size.toString().toPersianDigits()})")
}
TextButton(onClick = onDismiss) { Text("لغو") }
}
}
}
}
}
}
@Composable
fun BulkSmsProgressDialog(
currentName: String,
currentIndex: Int,
total: Int,
onSend: () -> Unit,
onSkip: () -> Unit,
onCancel: () -> Unit
) {
Dialog(onDismissRequest = {}) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
shape = RoundedCornerShape(24.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "ارسال گروهی پیامک",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("در حال آماده‌سازی پیام برای:")
Text(
currentName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(8.dp))
Text(
"نفر ${(currentIndex + 1).toString().toPersianDigits()} از ${total.toString().toPersianDigits()}",
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(16.dp))
LinearProgressIndicator(
progress = { (currentIndex.toFloat() / total.toFloat()) },
modifier = Modifier.fillMaxWidth()
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = onSend) { Text("ارسال پیام") }
Row {
TextButton(onClick = onSkip) { Text("رد کردن") }
TextButton(onClick = onCancel, colors = ButtonDefaults.textButtonColors(contentColor = Color.Red)) {
Text("توقف")
}
}
}
}
}
}
}
}
@Composable
fun SelectCardDialog(
cardNumbers: List<String>,
onCardSelected: (String?) -> Unit,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
shape = RoundedCornerShape(24.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "انتخاب شماره کارت",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Column {
Text("کدام شماره کارت در متن پیامک درج شود؟")
Spacer(Modifier.height(16.dp))
if (cardNumbers.isEmpty()) {
Text("هیچ شماره کارتی در تنظیمات ثبت نشده است.", color = MaterialTheme.colorScheme.error)
}
cardNumbers.forEach { card ->
OutlinedButton(
onClick = { onCardSelected(card) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
) {
Text(card.toPersianDigits())
}
}
TextButton(
onClick = { onCardSelected(null) },
modifier = Modifier.fillMaxWidth()
) {
Text("ارسال بدون شماره کارت")
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) {
Text("لغو")
}
}
}
}
}
}
}
@@ -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 },
@@ -124,18 +124,20 @@ fun PersonDetailScreen(
Spacer(modifier = Modifier.height(4.dp))
val isMosque = detailState.person?.category == "mosque"
LazyColumn(modifier = Modifier.fillMaxSize().padding(bottom = 8.dp)) {
items(detailState.monthStates) { monthModel ->
MonthListItem(
month = monthModel,
startMonth = detailState.person?.startMonth ?: 1,
isMosqueCategory = isMosque,
onHeaderClick = {
expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month
},
onEditClick = {
if (monthModel.status == MonthStatus.PAID) {
editingPayment = monthModel.payment
} else if (monthModel.status == MonthStatus.AVAILABLE) {
} else {
addingPaymentMonth = monthModel.month
}
}
@@ -170,8 +172,11 @@ fun PersonDetailScreen(
val monthName = getPersianMonthName(month)
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
val isMosque = detailState.person?.category == "mosque"
val isCurrentMonth = (month == getCurrentShamsiMonth()) && (detailState.selectedYear == getCurrentShamsiYear())
val initialDesc = if (isCurrentMonth) {
val initialDesc = if (isMosque) {
"پرداخت کمک به مسجد در تاریخ $currentDateTime ثبت شد."
} else if (isCurrentMonth) {
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
} else {
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
@@ -600,6 +605,7 @@ fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
fun MonthListItem(
month: MonthUiModel,
startMonth: Int,
isMosqueCategory: Boolean = false,
onHeaderClick: () -> Unit,
onEditClick: () -> Unit
) {
@@ -607,7 +613,7 @@ fun MonthListItem(
val containerColor = when (month.status) {
MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer
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
}
@@ -616,19 +622,27 @@ fun MonthListItem(
.padding(horizontal = 16.dp, vertical = 4.dp)
.fillMaxWidth()
.clickable {
if (month.status == MonthStatus.NOT_COMMITTED) {
val monthName = getPersianMonthName(startMonth)
Toast.makeText(
context,
"ماه‌های قبل از $monthName ماه جزو تعهدات این فرد نیست",
Toast.LENGTH_SHORT
).show()
if (month.status == MonthStatus.PAID) {
onHeaderClick()
} else if (month.status == MonthStatus.AVAILABLE) {
onEditClick()
} else if (month.status == MonthStatus.NOT_COMMITTED) {
if (isMosqueCategory) {
onEditClick()
} else {
val monthName = getPersianMonthName(startMonth)
Toast.makeText(
context,
"ماه‌های قبل از $monthName ماه جزو تعهدات این فرد نیست",
Toast.LENGTH_SHORT
).show()
}
} else {
onHeaderClick()
}
},
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)) {
Row(
@@ -641,7 +655,7 @@ fun MonthListItem(
text = getPersianMonthName(month.month),
style = MaterialTheme.typography.titleMedium,
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)) {
Spacer(modifier = Modifier.width(8.dp))
File diff suppressed because it is too large. Load diff
@@ -4,23 +4,36 @@ import android.widget.Toast
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
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -33,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
@@ -131,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(
@@ -148,7 +164,15 @@ fun SettingsScreen(onLogout: () -> Unit) {
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()
ReminderDaySetting(
@@ -156,74 +180,74 @@ fun SettingsScreen(onLogout: () -> Unit) {
onSave = { viewModel.saveReminderDay(it) }
)
Spacer(Modifier.weight(1f))
val cardNumbers by viewModel.cardNumbers.collectAsState()
CardNumbersSetting(
cardNumbers = cardNumbers,
isOperatorMode = isOperatorMode,
onAdd = { viewModel.addCardNumber(it) },
onRemove = { viewModel.removeCardNumber(it) }
)
Spacer(Modifier.weight(1f))
FontSelectionDropdown(
selectedFont = selectedFont,
onFontSelected = { viewModel.onFontSelected(it) }
)
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
if (!isOperatorMode) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
"پشتیبان‌گیری و بازیابی",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
"پشتیبان‌گیری و بازیابی",
style = MaterialTheme.typography.titleMedium
)
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(
onClick = {
val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault())
val fileName = "payment_backup_${simpleDateFormat.format(Date())}.json"
backupLauncher.launch(fileName)
viewModel.loadSnapshots()
showSnapshotDialog = true
},
modifier = Modifier.weight(1f)
modifier = Modifier.fillMaxWidth()
) {
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)
Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
}
}
// تور نجات: نسخه‌هایی که خودِ برنامه قبل از عملیات پرخطر گرفته است.
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))
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) {
@@ -505,10 +529,17 @@ fun ReminderDaySetting(currentDay: Int?, onSave: (String) -> Unit) {
}
@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 newCardText by remember { mutableStateOf("") }
var cardToRemove by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
Card(
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)) {
Row(verticalAlignment = Alignment.CenterVertically) {
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(
value = newCardText,
onValueChange = { newCardText = it.filter { c -> c.isDigit() } },
@@ -534,9 +567,33 @@ fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRem
}
}
cardNumbers.forEach { card ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(card.toPersianDigits(), modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge)
IconButton(onClick = { cardToRemove = card }) { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) }
Row(
verticalAlignment = Alignment.CenterVertically,
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
fun FontSelectionDropdown(selectedFont: String, onFontSelected: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
@@ -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
}
@@ -45,7 +45,7 @@ sealed class DownloadState {
class UpdateManager(private val context: Context) {
private val updateUrl = "https://raw.githubusercontent.com/oqba26/MonthlyPaymentApp/main/update.json"
private val updateUrl = "https://git.mizbanplatform.ir/oqba26/MonthlyPaymentApp/raw/branch/main/update.json"
private val json = Json {
ignoreUnknownKeys = true
@@ -14,8 +14,10 @@ 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
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -71,6 +73,12 @@ class PersonViewModel @Inject constructor(
private val _uiState = MutableStateFlow(PersonListUiState())
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)
val showAddPersonDialog: StateFlow<Boolean> = _showAddPersonDialog.asStateFlow()
@@ -92,10 +100,11 @@ class PersonViewModel @Inject constructor(
localPersonRepository.getAllPersonsFlow(),
localPersonRepository.getAllPaymentsFlow(),
_selectedYear,
_currentCategory
_currentCategory,
) { persons, allPayments, year, category ->
val person = persons.find { it.id == personId }
val personCategory = person?.category ?: category
val isMosque = (personCategory == "mosque")
val paymentsForPersonInYear = allPayments.filter {
(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 isBeforeCommitment = if (person != null) {
year < person.startYear || (year == person.startYear && month < person.startMonth)
} else false
val isBeforeCommitment = (person != null) &&
(year < person.startYear || (year == person.startYear && month < person.startMonth))
val status = when {
payment != null -> MonthStatus.PAID
isMosque -> {
if (year == currentYear && month == currentMonth) {
MonthStatus.AVAILABLE
} else {
MonthStatus.NOT_COMMITTED
}
}
isBeforeCommitment -> MonthStatus.NOT_COMMITTED
year < currentYear -> MonthStatus.PAST_YEAR
year > currentYear -> MonthStatus.FUTURE_YEAR
@@ -133,26 +148,28 @@ class PersonViewModel @Inject constructor(
if (token != null) {
networkRepository.observeRealtimeChanges()
launch {
combine(
networkRepository.getPersonsFlow(),
networkRepository.getPaymentsFlow(),
networkRepository.hasServerDataFlow()
) { persons, payments, hasServerData ->
if (hasServerData) {
if (!snapshotTakenThisSession) {
// آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی
// ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد،
// کاربر بتواند از تنظیمات برش گرداند.
backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC)
snapshotTakenThisSession = true
coroutineScope {
launch {
combine(
networkRepository.getPersonsFlow(),
networkRepository.getPaymentsFlow(),
networkRepository.hasServerDataFlow()
) { persons, payments, hasServerData ->
if (hasServerData) {
if (!snapshotTakenThisSession) {
// آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی
// ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد،
// کاربر بتواند از تنظیمات برش گرداند.
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("اطلاعات با سرور همگام‌سازی شد")
}
}
}
@@ -168,8 +185,9 @@ class PersonViewModel @Inject constructor(
localPersonRepository.getAllPersonsFlow(),
localPersonRepository.getAllPaymentsFlow(),
_searchQuery,
_currentCategory
) { persons, allPayments, query, category ->
_currentCategory,
settingsRepository.defaultPaymentAmountFlow
) { persons, allPayments, query, category, defaultPaymentAmount ->
val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear()
val currentDay = getCurrentShamsiDay()
@@ -181,6 +199,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 }))
@@ -188,6 +208,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 {
@@ -198,8 +225,8 @@ class PersonViewModel @Inject constructor(
val unpaid = (debtStartMonth..debtEndMonth).filter { m ->
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category }
}
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 defaultPaymentAmount
Triple(unpaid.size, unpaid.size * amountPerMonth, unpaid.map { getPersianMonthName(it) } to unpaid)
}
val (debtCount, totalDebtAmount, unpaidData) = unpaidResult
@@ -224,13 +251,62 @@ 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 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 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 onQuickPayClicked(person: PersonUiModel) { _personForPaymentDialog.value = person }
fun onDismissPaymentDialog() { _personForPaymentDialog.value = null }
fun onShowFinancialReport() { _showFinancialReportDialog.value = true }
fun onDismissFinancialReport() { _showFinancialReportDialog.value = false }
fun onEvent(event: PersonScreenEvent) {
viewModelScope.launch {
@@ -291,9 +369,9 @@ class PersonViewModel @Inject constructor(
val persons = localPersonRepository.getAllPersonsFlow().first()
val finalName = if (event.isAnonymous && event.name.isBlank()) "ناشناس" else event.name
val isDuplicate = if (!event.isAnonymous) {
persons.any { it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true) }
} else false
val isDuplicate = !event.isAnonymous && persons.any {
it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true)
}
if (isDuplicate) {
_toastMessage.emit("خطا: این نام از قبل وجود دارد.")
@@ -334,12 +412,25 @@ class PersonViewModel @Inject constructor(
val currentDay = getCurrentShamsiDay()
val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear()
val targetMonth = if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1
val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear
val isMosque = _currentCategory.value == "mosque"
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 category = _currentCategory.value
val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description)
.copy(category = _currentCategory.value)
localPersonRepository.insertPaymentLocally(record)
val existing = if (!isMosque) {
localPersonRepository.getAllPayments().find {
it.personId == event.personId && it.shamsiYear == targetYear && it.shamsiMonth == targetMonth && it.category == category
}
} else null
if (existing != null) {
val updated = existing.copy(amount = event.amount, description = event.description, timestamp = System.currentTimeMillis())
localPersonRepository.insertPaymentLocally(updated)
} else {
val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description)
.copy(category = category)
localPersonRepository.insertPaymentLocally(record)
}
}
is PersonScreenEvent.SelectPerson -> {
@@ -398,9 +489,20 @@ class PersonViewModel @Inject constructor(
is PersonScreenEvent.AddPaymentForMonth -> {
val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == event.personId }
val category = person?.category ?: _currentCategory.value
val record = createPaymentRecord(event.personId, event.month, event.year, event.amount, event.description)
.copy(category = category)
localPersonRepository.insertPaymentLocally(record)
val existing = if (category != "mosque") {
localPersonRepository.getAllPayments().find {
it.personId == event.personId && it.shamsiYear == event.year && it.shamsiMonth == event.month && it.category == category
}
} else null
if (existing != null) {
val updated = existing.copy(amount = event.amount, description = event.description, timestamp = System.currentTimeMillis())
localPersonRepository.insertPaymentLocally(updated)
} else {
val record = createPaymentRecord(event.personId, event.month, event.year, event.amount, event.description)
.copy(category = category)
localPersonRepository.insertPaymentLocally(record)
}
}
is PersonScreenEvent.UpdatePayment -> {
@@ -409,7 +511,20 @@ class PersonViewModel @Inject constructor(
}
is PersonScreenEvent.DeletePayment -> {
localPersonRepository.deletePaymentLocally(event.payment.id)
val paymentsToDelete = if (event.payment.category != "mosque") {
localPersonRepository.getAllPayments().filter {
it.personId == event.payment.personId &&
it.shamsiYear == event.payment.shamsiYear &&
it.shamsiMonth == event.payment.shamsiMonth &&
it.category == event.payment.category
}.ifEmpty { listOf(event.payment) }
} else {
listOf(event.payment)
}
paymentsToDelete.forEach { payment ->
localPersonRepository.deletePaymentLocally(payment.id)
}
_toastMessage.emit("پرداخت با موفقیت حذف شد")
}
@@ -440,6 +555,7 @@ class PersonViewModel @Inject constructor(
val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear()
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
val allPayments = localPersonRepository.getAllPayments()
event.months.forEach { month ->
val monthName = getPersianMonthName(month)
@@ -450,14 +566,29 @@ class PersonViewModel @Inject constructor(
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
}
val record = createPaymentRecord(
personId = event.personId,
month = month,
year = event.year,
amount = event.amount,
description = description
).copy(category = category)
localPersonRepository.insertPaymentLocally(record)
val existing = if (category != "mosque") {
allPayments.find {
it.personId == event.personId && it.shamsiYear == event.year && it.shamsiMonth == month && it.category == category
}
} else null
if (existing != null) {
val updated = existing.copy(
amount = event.amount,
description = description,
timestamp = System.currentTimeMillis()
)
localPersonRepository.insertPaymentLocally(updated)
} else {
val record = createPaymentRecord(
personId = event.personId,
month = month,
year = event.year,
amount = event.amount,
description = description
).copy(category = category)
localPersonRepository.insertPaymentLocally(record)
}
}
} catch (_: Exception) {
_toastMessage.emit("خطا در ثبت پرداخت‌های دسته جمعی")
@@ -520,11 +651,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 {
@@ -46,9 +46,29 @@ class SettingsViewModel @Inject constructor(
val cardNumbers: StateFlow<List<String>> = settingsRepository.cardNumbersFlow
.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>()
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) {
viewModelScope.launch {
val dayInt = day.toIntOrNull()
@@ -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,5 +145,31 @@ 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
)
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()
)
+3 -3
View File
@@ -1,9 +1,9 @@
# gradle.properties
# org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx2560m -XX:+UseParallelGC -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx2048m -XX:+UseG1GC -Dfile.encoding=UTF-8
android.useAndroidX=true
android.enableJetifier=true
android.enableJetifier=false
kotlin.code.style=official
android.nonTransitiveRClass=true
android.suppressUnsupportedCompileSdk=34
@@ -20,5 +20,5 @@ ksp.incremental=true
# Fix for AGP 8.7+ "Several environment variables" error
systemProp.ANDROID_PREFS_ROOT=
systemProp.ANDROID_USER_HOME=C:\\Users\\msi\\.android
# systemProp.ANDROID_USER_HOME=C:\\Users\\msi\\.android
org.gradle.project.ANDROID_PREFS_ROOT=
+1 -1
View File
@@ -1,4 +1,4 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.10.2-bin.zip
zipStorePath=wrapper/dists
+4 -4
View File
@@ -1,7 +1,7 @@
{
"versionCode": 8,
"versionName": "1.0.7",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.7/MonthlyPaymentApp-v1.0.7.apk",
"releaseNotes": "مهاجرت به PocketBase، رفع خطاهای بیلد و بهبود پایداری همگام‌سازی",
"versionCode": 17,
"versionName": "1.1.6",
"url": "https://git.mizbanplatform.ir/oqba26/MonthlyPaymentApp/releases/download/v1.1.6/MonthlyPaymentApp-v1.1.6.apk",
"releaseNotes": "رفع باگ حذف پرداخت‌ها و پایداری همگام‌سازی",
"isForceUpdate": false
}