اصلاح مهاجرت دیتابیس، مرتب‌سازی تب‌ها و به‌روزرسانی عناوین

- رفع تداخل در مهاجرت دیتابیس Room با اصلاح بازه fallbackToDestructiveMigrationFrom.
- حذف فایل MigrationTest.kt جهت پاک‌سازی پروژه.
- تغییر ترتیب تب‌ها: انتقال تب 'آرشیو' به کنار تب‌های 'پرداخت شده' و 'پرداخت نشده'.
- اصلاح عناوین TopAppBar: نمایش عناوین اختصاصی برای صفحات 'آرشیو'، 'تنظیمات' و 'مسجد' به جای عنوان عمومی.
This commit is contained in:
oqba26 committed 2026-08-02 10:36:39 +03:30
1 parent 913b8ca083
commit 271e9ad117
88 files changed
+2565 -568

No files matched your search

+12
View File
@@ -84,6 +84,16 @@ android {
packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
}
// اسکیمای تولیدشده در app/schemas/ ذخیره و کامیت می‌شود.
// MigrationTest برای بازسازی دیتابیس نسخه‌ی قبل به همین فایل‌ها نیاز دارد.
sourceSets.getByName("androidTest") {
assets.srcDirs("$projectDir/schemas")
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
@@ -143,6 +153,8 @@ dependencies {
// Testing
testImplementation(libs.test.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.androidTest.junit.ext)
androidTestImplementation(libs.androidTest.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
@@ -0,0 +1,230 @@
{
"formatVersion": 1,
"database": {
"version": 18,
"identityHash": "89e44721be742ed1937b2d1583bde701",
"entities": [
{
"tableName": "persons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `isArchived` INTEGER NOT NULL, `displayOrder` INTEGER, `createdAt` INTEGER, `phoneNumber` TEXT, `category` TEXT NOT NULL, `isAnonymous` INTEGER NOT NULL, `monthlyCommitment` REAL NOT NULL, `startMonth` INTEGER NOT NULL, `startYear` INTEGER NOT NULL, `needsSync` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isArchived",
"columnName": "isArchived",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayOrder",
"columnName": "displayOrder",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "phoneNumber",
"columnName": "phoneNumber",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isAnonymous",
"columnName": "isAnonymous",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "monthlyCommitment",
"columnName": "monthlyCommitment",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "startMonth",
"columnName": "startMonth",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "startYear",
"columnName": "startYear",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "needsSync",
"columnName": "needsSync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "payments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `personId` TEXT NOT NULL, `amount` REAL NOT NULL, `shamsiYear` INTEGER NOT NULL, `shamsiMonth` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `description` TEXT, `createdAt` INTEGER, `category` TEXT NOT NULL, `needsSync` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "personId",
"columnName": "personId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "amount",
"columnName": "amount",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "shamsiYear",
"columnName": "shamsiYear",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "shamsiMonth",
"columnName": "shamsiMonth",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "category",
"columnName": "category",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "needsSync",
"columnName": "needsSync",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "sync_queue",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `entityId` TEXT NOT NULL, `type` TEXT NOT NULL, `operation` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastAttemptAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "operation",
"columnName": "operation",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "createdAt",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "attemptCount",
"columnName": "attemptCount",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastAttemptAt",
"columnName": "lastAttemptAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '89e44721be742ed1937b2d1583bde701')"
]
}
}
@@ -24,6 +24,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.filled.AccountBalance
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.ArrowDownward
@@ -68,6 +69,8 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import dagger.hilt.android.AndroidEntryPoint
import androidx.hilt.navigation.compose.hiltViewModel
@@ -183,6 +186,11 @@ fun MainAppHost(viewModel: PersonViewModel) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
launch {
contactViewModel.toastMessage.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
}
// UI elements for update and permissions
@@ -265,9 +273,18 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route ?: ""
LaunchedEffect(currentRoute) {
if (currentRoute == Screen.Mosque.route) {
viewModel.onEvent(PersonScreenEvent.SetCategory("mosque"))
} else if (currentRoute in listOf(Screen.Unpaid.route, Screen.Paid.route)) {
viewModel.onEvent(PersonScreenEvent.SetCategory("salary"))
}
}
val showBottomBar = currentRoute in listOf(
Screen.Unpaid.route,
Screen.Paid.route,
Screen.Mosque.route,
Screen.Archive.route,
Screen.Settings.route
)
@@ -358,10 +375,17 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
title = {
val titleText = when {
isSelectionMode -> "انتخاب شده‌ها"
currentRoute == Screen.Settings.route -> "تنظیمات"
else -> {
val currentRoute = navController.currentBackStackEntry?.destination?.route
when (currentRoute) {
Screen.Mosque.route -> "مدیریت کمک‌های مسجد"
Screen.Settings.route -> "تنظیمات"
Screen.Archive.route -> "آرشیو"
else -> "مدیریت حقوق ماهانه"
}
Text(titleText)
}
}
Text(titleText)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = if (isSelectionMode) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.primary,
@@ -390,7 +414,7 @@ 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) {
} else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) {
FilledIconButton(
onClick = { viewModel.onAddPersonClicked() },
colors = IconButtonDefaults.filledIconButtonColors(
@@ -437,6 +461,13 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
navController = navController
)
}
composable(Screen.Mosque.route) {
PersonScreen(
viewModel = viewModel,
contactViewModel = contactViewModel,
navController = navController
)
}
composable(Screen.Paid.route) {
PaidListScreen(
viewModel = viewModel,
@@ -475,17 +506,19 @@ fun AppBottomNavigationBar(navController: NavController) {
Screen.Unpaid,
Screen.Paid,
Screen.Archive,
Screen.Mosque,
Screen.Settings
)
NavigationBar(
containerColor = MaterialTheme.colorScheme.primary // Match TopAppBar
containerColor = MaterialTheme.colorScheme.primary
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
NavigationBarItem(
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
selectedTextColor = Color.White,
selectedIconColor = Color.White,
@@ -494,7 +527,15 @@ fun AppBottomNavigationBar(navController: NavController) {
unselectedIconColor = Color.White.copy(alpha = 0.7f)
),
icon = { Icon(screen.icon, contentDescription = screen.title) },
label = { Text(screen.title) },
label = {
Text(
text = screen.title,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.labelSmall
)
},
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
@@ -517,6 +558,7 @@ sealed class Screen(
) {
data object Unpaid : Screen("unpaid_list", "پرداخت نشده", Icons.AutoMirrored.Filled.List)
data object Paid : Screen("paid_list", "پرداخت شده", Icons.Default.Paid)
data object Mosque : Screen("mosque_list", "مسجد", Icons.Default.AccountBalance)
data object Archive : Screen("archive", "آرشیو", Icons.Default.Archive)
data object Settings : Screen("settings", "تنظیمات", Icons.Default.Settings)
}
@@ -1,7 +1,59 @@
package com.oqba26.monthlypaymentapp.core
import android.app.Application
import android.util.Log
import com.oqba26.monthlypaymentapp.BuildConfig
import com.oqba26.monthlypaymentapp.core.manager.BackupManager
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
import com.oqba26.monthlypaymentapp.data.sync.SyncManager
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltAndroidApp
class PaymentApplication : Application()
class PaymentApplication : Application() {
@Inject
lateinit var syncManager: SyncManager
@Inject
lateinit var backupManager: BackupManager
@Inject
lateinit var settingsRepository: SettingsRepository
private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onCreate() {
super.onCreate()
syncManager.startSync()
snapshotOnUpgrade()
}
/**
* اولین اجرا بعد از هر ارتقای نسخه، یک نسخه‌ی پشتیبان می‌گیرد.
*
* ارتقای نسخه همان لحظه‌ای است که مهاجرت دیتابیس اجرا می‌شود؛ اگر آنجا چیزی خراب شد،
* این فایل تنها راه برگشت داده‌ی کاربر است.
*/
private fun snapshotOnUpgrade() {
appScope.launch {
try {
val current = BuildConfig.VERSION_CODE
val last = settingsRepository.getLastRunVersionCode()
if (last != current) {
if (last != null) {
// اجرای اول بعد از نصب، داده‌ای برای پشتیبان‌گیری ندارد؛ فقط
// ارتقای واقعی (last != null) ارزش snapshot دارد.
backupManager.createSnapshot(BackupManager.REASON_APP_UPGRADE)
}
settingsRepository.saveLastRunVersionCode(current)
}
} catch (e: Exception) {
Log.e("PaymentApplication", "snapshot ارتقای نسخه ناموفق بود", e)
}
}
}
}
@@ -1,8 +1,10 @@
package com.oqba26.monthlypaymentapp.core.di
import android.content.Context
import androidx.room.Room
import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
import com.oqba26.monthlypaymentapp.data.dao.PersonDao
import com.oqba26.monthlypaymentapp.data.database.ALL_MIGRATIONS
import com.oqba26.monthlypaymentapp.data.database.AppDatabase
import dagger.Module
import dagger.Provides
@@ -18,7 +20,18 @@ object DatabaseModule {
@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return AppDatabase.getInstance(context)
return Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
AppDatabase.DATABASE_NAME
)
.addMigrations(*ALL_MIGRATIONS)
// فقط نسخه‌های ۱ تا ۱۷ اجازه‌ی پاک شدن دارند — آن‌ها قبل از فعال شدن
// exportSchema ساخته شده‌اند و تاریخچه‌ی اسکیمایشان را نداریم.
// از ۱۸ به بعد مهاجرت اجباری است: تغییر اسکیمای بدون مهاجرت باعث خطا
// می‌شود، نه پاک شدن بی‌صدای داده‌ی کاربر.
.fallbackToDestructiveMigrationFrom(*(1..16).toList().toIntArray())
.build()
}
@Provides
@@ -30,4 +43,9 @@ object DatabaseModule {
fun providePaymentDao(database: AppDatabase): PaymentDao {
return database.paymentDao()
}
@Provides
fun provideSyncQueueDao(database: AppDatabase): com.oqba26.monthlypaymentapp.data.dao.SyncQueueDao {
return database.syncQueueDao()
}
}
@@ -29,9 +29,10 @@ object RepositoryModule {
fun provideLocalPersonRepository(
personDao: PersonDao,
paymentDao: PaymentDao,
syncQueueDao: com.oqba26.monthlypaymentapp.data.dao.SyncQueueDao,
database: AppDatabase
): LocalPersonRepository {
return LocalPersonRepository(personDao, paymentDao, database)
return LocalPersonRepository(personDao, paymentDao, syncQueueDao, database)
}
@Provides
@@ -0,0 +1,147 @@
package com.oqba26.monthlypaymentapp.core.manager
import android.content.Context
import android.util.Log
import com.oqba26.monthlypaymentapp.data.model.BackupData
import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* اطلاعات یک نسخه‌ی پشتیبان خودکار، برای نمایش در تنظیمات.
*/
data class SnapshotInfo(
val file: File,
val reason: String,
val timestamp: Long,
val personCount: Int,
val paymentCount: Int
)
/**
* تور نجات: قبل از هر عملیات خطرناک، یک نسخه‌ی کامل از داده‌ی محلی روی حافظه‌ی خود گوشی
* ذخیره می‌کند تا اگر با وجود همه‌ی محافظت‌های دیگر داده‌ای از بین رفت، کاربر بتواند برش گرداند.
*
* فایل‌ها در `filesDir/snapshots` ذخیره می‌شوند — حافظه‌ی خصوصی اپ، پس نیازی به مجوز
* دسترسی به حافظه ندارد و اپ‌های دیگر نمی‌توانند بخوانندش.
*
* فقط [MAX_SNAPSHOTS] نسخه‌ی جدیدتر نگه داشته می‌شود؛ قدیمی‌ترها خودکار پاک می‌شوند تا
* حافظه‌ی گوشی پر نشود.
*/
@Singleton
class BackupManager @Inject constructor(
@ApplicationContext private val context: Context,
private val localPersonRepository: LocalPersonRepository
) {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val snapshotDir: File
get() = File(context.filesDir, DIR_NAME).apply { if (!exists()) mkdirs() }
/**
* گرفتن یک نسخه‌ی پشتیبان.
*
* اگر داده‌ای برای پشتیبان‌گیری نباشد کاری نمی‌کند — snapshot خالی هم بی‌فایده است و هم
* جای snapshot مفیدِ قبلی را در سقف سه‌تایی می‌گیرد.
*
* این تابع عمداً هیچ استثنایی به بیرون نمی‌دهد: شکستِ گرفتن پشتیبان نباید عملیات اصلی
* (سینک، بازیابی، ارتقای نسخه) را متوقف کند.
*
* @param reason شناسه‌ی کوتاه انگلیسی برای علت — بخشی از نام فایل می‌شود.
* @return فایل ساخته‌شده یا `null` اگر ساخته نشد.
*/
suspend fun createSnapshot(reason: String): File? = withContext(Dispatchers.IO) {
try {
val data = localPersonRepository.getDataForBackup()
if (data.persons.isEmpty() && data.payments.isEmpty()) {
Log.d(TAG, "snapshot گرفته نشد: داده‌ای وجود ندارد")
return@withContext null
}
val safeReason = reason.filter { it.isLetterOrDigit() || it == '-' || it == '_' }
.ifBlank { "manual" }
val file = File(snapshotDir, "$safeReason-${System.currentTimeMillis()}.json")
file.writeText(json.encodeToString(data))
pruneOldSnapshots()
Log.d(TAG, "snapshot ساخته شد: ${file.name} (${data.persons.size} شخص، ${data.payments.size} پرداخت)")
file
} catch (e: Exception) {
Log.e(TAG, "ساخت snapshot ناموفق بود — عملیات اصلی ادامه می‌یابد", e)
null
}
}
/** لیست نسخه‌های موجود، جدیدترین اول. فایل‌های خراب نادیده گرفته می‌شوند. */
suspend fun listSnapshots(): List<SnapshotInfo> = withContext(Dispatchers.IO) {
snapshotFiles().mapNotNull { file ->
try {
val data = json.decodeFromString<BackupData>(file.readText())
SnapshotInfo(
file = file,
reason = file.nameWithoutExtension.substringBeforeLast('-'),
timestamp = file.nameWithoutExtension.substringAfterLast('-').toLongOrNull()
?: file.lastModified(),
personCount = data.persons.size,
paymentCount = data.payments.size
)
} catch (e: Exception) {
Log.w(TAG, "snapshot خراب نادیده گرفته شد: ${file.name}", e)
null
}
}
}
/**
* بازگرداندن یک نسخه.
*
* قبل از بازگردانی، خودش یک snapshot از وضعیت فعلی می‌گیرد — تا اگر کاربر اشتباهی
* نسخه‌ی غلط را انتخاب کرد، راه برگشت داشته باشد.
*/
suspend fun restoreSnapshot(file: File): Boolean = withContext(Dispatchers.IO) {
try {
val data = json.decodeFromString<BackupData>(file.readText())
createSnapshot(REASON_BEFORE_RESTORE)
localPersonRepository.restoreBackup(data)
true
} catch (e: Exception) {
Log.e(TAG, "بازگردانی snapshot ناموفق بود: ${file.name}", e)
false
}
}
private fun snapshotFiles(): List<File> =
snapshotDir.listFiles { f -> f.isFile && f.extension == "json" }
?.sortedByDescending { it.lastModified() }
?: emptyList()
private fun pruneOldSnapshots() {
snapshotFiles().drop(MAX_SNAPSHOTS).forEach { old ->
if (old.delete()) Log.d(TAG, "snapshot قدیمی حذف شد: ${old.name}")
}
}
companion object {
private const val TAG = "BackupManager"
private const val DIR_NAME = "snapshots"
private const val MAX_SNAPSHOTS = 3
/** قبل از اولین ادغام داده‌ی سرور در هر اجرا. */
const val REASON_BEFORE_SYNC = "before-sync"
/** قبل از بازگردانی نسخه‌ی پشتیبان (دستی یا خودکار). */
const val REASON_BEFORE_RESTORE = "before-restore"
/** بعد از ارتقای نسخه‌ی اپ. */
const val REASON_APP_UPGRADE = "app-upgrade"
}
}
@@ -22,6 +22,13 @@ interface PaymentDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAllPaymentRecords(payments: List<PaymentRecord>)
@Query("DELETE FROM payments WHERE id = :paymentId")
suspend fun deleteById(paymentId: String)
/** حذف گروهی — برای اعمال [com.oqba26.monthlypaymentapp.data.sync.MergePlan] در یک کوئری. */
@Query("DELETE FROM payments WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
@Query("DELETE FROM payments")
suspend fun deleteAllPaymentRecords()
}
@@ -28,6 +28,13 @@ interface PersonDao {
@Query("UPDATE persons SET displayOrder = :displayOrder WHERE id = :personId")
suspend fun updateDisplayOrder(personId: String, displayOrder: Int)
@Query("DELETE FROM persons WHERE id = :personId")
suspend fun deleteById(personId: String)
/** حذف گروهی — برای اعمال [com.oqba26.monthlypaymentapp.data.sync.MergePlan] در یک کوئری. */
@Query("DELETE FROM persons WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
@Query("DELETE FROM persons")
suspend fun deleteAll()
}
@@ -0,0 +1,36 @@
package com.oqba26.monthlypaymentapp.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.oqba26.monthlypaymentapp.data.model.SyncQueue
import kotlinx.coroutines.flow.Flow
@Dao
interface SyncQueueDao {
@Query("SELECT * FROM sync_queue ORDER BY createdAt ASC")
fun getAllPendingFlow(): Flow<List<SyncQueue>>
@Query("SELECT * FROM sync_queue ORDER BY createdAt ASC")
suspend fun getAllPending(): List<SyncQueue>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(item: SyncQueue)
@Delete
suspend fun delete(item: SyncQueue)
/**
* ثبت یک تلاش ناموفق. آیتم **حذف نمی‌شود** — فقط عقب می‌افتد تا دور بعد.
*/
@Query("UPDATE sync_queue SET attemptCount = :count, lastAttemptAt = :at WHERE id = :id")
suspend fun recordAttempt(id: String, count: Int, at: Long)
@Query("DELETE FROM sync_queue WHERE entityId = :entityId")
suspend fun deleteByEntityId(entityId: String)
@Query("DELETE FROM sync_queue")
suspend fun deleteAll()
}
@@ -1,61 +1,36 @@
@file:Suppress("unused")
package com.oqba26.monthlypaymentapp.data.database
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Transaction
import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
import com.oqba26.monthlypaymentapp.data.dao.PersonDao
import com.oqba26.monthlypaymentapp.data.model.BackupData
import com.oqba26.monthlypaymentapp.data.dao.SyncQueueDao
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
import java.util.UUID
import com.oqba26.monthlypaymentapp.data.model.SyncQueue
/**
* دیتابیس محلی اپ.
*
* ساخت instance اینجا انجام نمی‌شود؛ تنها مسیر ساخت،
* [com.oqba26.monthlypaymentapp.core.di.DatabaseModule] است. قبلاً علاوه بر هیلت یک
* `INSTANCE` دستی هم اینجا بود — دو مسیر ساخت یعنی ریسک دو اتصال جدا به یک فایل دیتابیس.
*
* از نسخه ۱۸، `exportSchema = true` است و اسکیما در `app/schemas/` ذخیره می‌شود.
* مهاجرت‌ها در [Migrations.kt] هستند.
*/
@Database(
entities = [Person::class, PaymentRecord::class],
version = 13, // حتماً نسخه را بالا ببری که اسکیمای جدید اعمال شود
exportSchema = false
entities = [Person::class, PaymentRecord::class, SyncQueue::class],
version = 18,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun personDao(): PersonDao
abstract fun paymentDao(): PaymentDao
@Transaction
open suspend fun performRestore(backupData: BackupData) {
// تمیزکاری قبل از درج بکاپ
val safePersons = backupData.persons.map { p ->
val name = p.name.trim()
val id = p.id.ifBlank { UUID.randomUUID().toString() }
Person(id = id, name = name)
}
personDao().deleteAll()
paymentDao().deleteAllPaymentRecords()
personDao().insertAll(safePersons)
paymentDao().insertAllPaymentRecords(backupData.payments)
}
abstract fun syncQueueDao(): SyncQueueDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase = getDatabase(context)
private fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"monthly_payment_db"
)
.fallbackToDestructiveMigration()
.build()
INSTANCE = instance
instance
}
}
const val DATABASE_NAME = "monthly_payment_db"
}
}
}
@@ -0,0 +1,33 @@
package com.oqba26.monthlypaymentapp.data.database
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
/**
* مهاجرت‌های اسکیمای دیتابیس.
*
* تا نسخه ۱۷، اپ از `fallbackToDestructiveMigration()` استفاده می‌کرد؛ یعنی هر تغییر اسکیما
* کل داده‌ی کاربر را پاک می‌کرد. از نسخه ۱۸ به بعد مهاجرت اجباری است: اگر اسکیما تغییر کند
* و مهاجرتش نوشته نشده باشد، اپ در زمان بیلد/تست خطا می‌دهد — نه اینکه بی‌صدا داده را پاک کند.
*
* هنگام اضافه کردن نسخه‌ی جدید:
* ۱. `version` را در [AppDatabase] یکی زیاد کن.
* ۲. یک `MIGRATION_<قبلی>_<جدید>` اینجا بنویس و به `addMigrations` در `DatabaseModule` اضافه کن.
* ۳. فایل JSON تولیدشده در پوشه‌ی `app/schemas` را کامیت کن (مبنای تست مهاجرت است).
*/
/**
* ۱۷ → ۱۸: دو ستون برای retry با backoff به صف سینک اضافه می‌شود.
*
* جدول‌های `persons` و `payments` دست‌نخورده‌اند، پس هیچ داده‌ی کاربری در این مهاجرت
* جابه‌جا یا بازنویسی نمی‌شود.
*/
val MIGRATION_17_18 = object : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE sync_queue ADD COLUMN attemptCount INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE sync_queue ADD COLUMN lastAttemptAt INTEGER NOT NULL DEFAULT 0")
}
}
/** همه‌ی مهاجرت‌های شناخته‌شده — به `Room.databaseBuilder(...).addMigrations(*ALL_MIGRATIONS)` داده می‌شود. */
val ALL_MIGRATIONS = arrayOf(MIGRATION_17_18)
@@ -26,5 +26,10 @@ data class PaymentRecord(
@SerialName("description") val description: String? = "", // ⭐️ تغییر به String? برای پذیرش مقادیر null
@SerialName("createdAt") val createdAt: Long? = null
@SerialName("createdAt") val createdAt: Long? = null,
@SerialName("category") val category: String = "salary",
@kotlinx.serialization.Transient
val needsSync: Boolean = false
)
@@ -21,5 +21,18 @@ data class Person(
@SerialName("createdAt") val createdAt: Long? = 0L,
@SerialName("phoneNumber") val phoneNumber: String? = ""
@SerialName("phoneNumber") val phoneNumber: String? = "",
@SerialName("category") val category: String = "salary",
@SerialName("isAnonymous") val isAnonymous: Boolean = false,
@SerialName("monthlyCommitment") val monthlyCommitment: Double = 0.0,
@SerialName("startMonth") val startMonth: Int = 1,
@SerialName("startYear") val startYear: Int = 1403,
@kotlinx.serialization.Transient
val needsSync: Boolean = false
)
@@ -0,0 +1,28 @@
package com.oqba26.monthlypaymentapp.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
enum class SyncOperation {
INSERT, UPDATE, DELETE
}
enum class SyncType {
PERSON, PAYMENT
}
@Entity(tableName = "sync_queue")
data class SyncQueue(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val entityId: String,
val type: SyncType,
val operation: SyncOperation,
val createdAt: Long = System.currentTimeMillis(),
/** تعداد تلاش‌های ناموفق. مبنای محاسبه‌ی فاصله‌ی تلاش بعدی (backoff) است. */
val attemptCount: Int = 0,
/** زمان آخرین تلاش (میلی‌ثانیه). صفر یعنی هنوز تلاشی نشده. */
val lastAttemptAt: Long = 0L
)
@@ -25,9 +25,9 @@ object ApiClient {
httpConfig {
install(HttpTimeout) {
requestTimeoutMillis = 30000
connectTimeoutMillis = 30000
socketTimeoutMillis = 30000
requestTimeoutMillis = 60000
connectTimeoutMillis = 60000
socketTimeoutMillis = 60000
}
}
@@ -5,17 +5,22 @@ package com.oqba26.monthlypaymentapp.data.repository
import androidx.room.withTransaction
import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
import com.oqba26.monthlypaymentapp.data.dao.PersonDao
import com.oqba26.monthlypaymentapp.data.dao.SyncQueueDao
import com.oqba26.monthlypaymentapp.data.database.AppDatabase
import com.oqba26.monthlypaymentapp.data.model.BackupData
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
import com.oqba26.monthlypaymentapp.data.model.SyncQueue
import com.oqba26.monthlypaymentapp.data.model.SyncOperation
import com.oqba26.monthlypaymentapp.data.model.SyncType
import com.oqba26.monthlypaymentapp.data.sync.SyncMerger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import java.util.UUID
class LocalPersonRepository(
private val personDao: PersonDao,
private val paymentDao: PaymentDao,
private val syncQueueDao: SyncQueueDao,
private val database: AppDatabase
) {
// Reads
@@ -25,53 +30,35 @@ class LocalPersonRepository(
fun getPaymentsForPersonFlow(personId: String): Flow<List<PaymentRecord>> =
paymentDao.getPaymentsForPersonFlow(personId)
suspend fun syncPersons(persons: List<Person>) {
val current = getAllPersonsFlow().first()
val currentByName = current.associateBy { normalize(it.name) }
val safePersons = persons.map { p ->
val name = p.name.trim()
val norm = normalize(name)
val resolvedId = when {
p.id.isNotBlank() -> p.id
currentByName.containsKey(norm) -> currentByName[norm]!!.id
else -> UUID.randomUUID().toString()
}
p.copy(id = resolvedId, name = name)
}
personDao.insertAll(safePersons)
}
suspend fun syncPayments(payments: List<PaymentRecord>) =
paymentDao.insertAllPaymentRecords(payments)
// ⭐️ تابع جدید و اصلاح شده برای همگام‌سازی کامل
suspend fun syncAll(persons: List<Person>, payments: List<PaymentRecord>) {
/**
* ادغام غیرمخربِ داده‌ی سرور با داده‌ی محلی.
*
* جانشین `syncAll` قبلی است که اول `deleteAll()` می‌زد و بعد داده‌ی سرور را درج می‌کرد؛
* آن رفتار هر تغییر محلیِ هنوز-ارسال-نشده را نابود می‌کرد.
*
* اینجا هیچ `deleteAll`ی وجود ندارد. تصمیمِ «چه چیزی حذف شود» را [SyncMerger] می‌گیرد
* (که تست واحد دارد) و رکوردهای موجود در صف سینک یا دارای `needsSync` را کنار می‌گذارد.
*
* فقط با داده‌ی **معتبرِ** سرور صدا زده شود — یعنی بعد از اینکه
* [com.oqba26.monthlypaymentapp.data.repository.NetworkRepository.refresh] مقدار `true` برگردانده باشد.
*/
suspend fun mergeFromServer(persons: List<Person>, payments: List<PaymentRecord>) {
database.withTransaction {
// خواندن با تابع suspend و نه collect کردن Flow:
// collect کردن Flow روم داخل تراکنش خطر deadlock دارد.
val localPersons = personDao.getAllPersons()
val localPayments = paymentDao.getAllPayments()
val pendingIds = syncQueueDao.getAllPending().map { it.entityId }.toSet()
// ۱. اجرای منطق حل شناسه (ID Resolution)
val current = getAllPersonsFlow().first()
val currentByName = current.associateBy { normalize(it.name) }
val personPlan = SyncMerger.planPersons(localPersons, persons, pendingIds)
val paymentPlan = SyncMerger.planPayments(localPayments, payments, pendingIds)
val safePersons = persons.map { p ->
val name = p.name.trim()
val norm = normalize(name)
val resolvedId = when {
p.id.isNotBlank() -> p.id
currentByName.containsKey(norm) -> currentByName[norm]!!.id
else -> UUID.randomUUID().toString()
}
p.copy(id = resolvedId, name = name)
}
// پرداخت‌ها اول حذف می‌شوند تا اگر کلید خارجی اضافه شد، ترتیب درست باشد.
if (paymentPlan.deleteIds.isNotEmpty()) paymentDao.deleteByIds(paymentPlan.deleteIds)
if (personPlan.deleteIds.isNotEmpty()) personDao.deleteByIds(personPlan.deleteIds)
// ۲. حذف کامل تمام داده‌های محلی
personDao.deleteAll()
paymentDao.deleteAllPaymentRecords()
// ۳. درج داده‌های جدید
personDao.insertAll(safePersons)
paymentDao.insertAllPaymentRecords(payments)
if (personPlan.upserts.isNotEmpty()) personDao.insertAll(personPlan.upserts)
if (paymentPlan.upserts.isNotEmpty()) paymentDao.insertAllPaymentRecords(paymentPlan.upserts)
}
}
@@ -82,21 +69,105 @@ class LocalPersonRepository(
return BackupData(persons = persons, payments = payments)
}
/**
* بازگردانی کامل از یک نسخه‌ی پشتیبان.
*
* برخلاف [mergeFromServer]، این عملیات عمداً مخرب است — کاربر صریحاً خواسته وضعیت فعلی
* با نسخه‌ی پشتیبان جایگزین شود. صدازننده باید قبلش snapshot بگیرد
* ([com.oqba26.monthlypaymentapp.core.manager.BackupManager.restoreSnapshot] این کار را می‌کند).
*
* صف سینک هم بازسازی می‌شود. قبلاً دست‌نخورده می‌ماند و دو مشکل داشت:
* - آیتم‌های قدیمی به رکوردهای پاک‌شده اشاره می‌کردند.
* - داده‌ی بازیابی‌شده هرگز به سرور نمی‌رفت، پس اولین سینکِ بعدی دوباره پاکش می‌کرد.
*/
suspend fun restoreBackup(backupData: BackupData) {
database.withTransaction {
personDao.deleteAll()
paymentDao.deleteAllPaymentRecords()
syncQueueDao.deleteAll()
val safePersons = backupData.persons.map { p ->
val name = p.name.trim()
val id = p.id.ifBlank { UUID.randomUUID().toString() }
p.copy(id = id, name = name)
p.copy(id = id, name = name, needsSync = true)
}
val safePayments = backupData.payments.map { it.copy(needsSync = true) }
personDao.insertAll(safePersons)
paymentDao.insertAllPaymentRecords(backupData.payments)
paymentDao.insertAllPaymentRecords(safePayments)
// همه‌ی رکوردهای بازیابی‌شده باید دوباره به سرور برسند.
safePersons.forEach {
syncQueueDao.insert(
SyncQueue(entityId = it.id, type = SyncType.PERSON, operation = SyncOperation.INSERT)
)
}
safePayments.forEach {
syncQueueDao.insert(
SyncQueue(entityId = it.id, type = SyncType.PAYMENT, operation = SyncOperation.INSERT)
)
}
}
}
private fun normalize(name: String): String = name.trim().lowercase()
// Local-First Write Operations
suspend fun insertPersonLocally(person: Person) {
database.withTransaction {
personDao.insertAll(listOf(person.copy(needsSync = true)))
syncQueueDao.insert(SyncQueue(entityId = person.id, type = SyncType.PERSON, operation = SyncOperation.INSERT))
}
}
suspend fun updatePersonLocally(person: Person) {
database.withTransaction {
personDao.insertAll(listOf(person.copy(needsSync = true)))
syncQueueDao.insert(SyncQueue(entityId = person.id, type = SyncType.PERSON, operation = SyncOperation.UPDATE))
}
}
suspend fun archivePersonLocally(personId: String, isArchived: Boolean) {
database.withTransaction {
personDao.updateArchivedStatus(personId, isArchived)
syncQueueDao.insert(SyncQueue(entityId = personId, type = SyncType.PERSON, operation = SyncOperation.UPDATE))
}
}
suspend fun deletePersonLocally(personId: String) {
database.withTransaction {
personDao.deleteById(personId)
syncQueueDao.insert(SyncQueue(entityId = personId, type = SyncType.PERSON, operation = SyncOperation.DELETE))
}
}
suspend fun insertPaymentLocally(payment: PaymentRecord) {
database.withTransaction {
paymentDao.insertAllPaymentRecords(listOf(payment.copy(needsSync = true)))
syncQueueDao.insert(SyncQueue(entityId = payment.id, type = SyncType.PAYMENT, operation = SyncOperation.INSERT))
}
}
suspend fun deletePaymentLocally(paymentId: String) {
database.withTransaction {
paymentDao.deleteById(paymentId)
syncQueueDao.insert(SyncQueue(entityId = paymentId, type = SyncType.PAYMENT, operation = SyncOperation.DELETE))
}
}
/** خواندن یک‌باره (نه Flow) — برای استفاده داخل تراکنش و در [com.oqba26.monthlypaymentapp.data.sync.SyncManager]. */
suspend fun getAllPersons(): List<Person> = personDao.getAllPersons()
suspend fun getAllPayments(): List<PaymentRecord> = paymentDao.getAllPayments()
fun getSyncQueueFlow() = syncQueueDao.getAllPendingFlow()
suspend fun getPendingSyncItems() = syncQueueDao.getAllPending()
suspend fun removeSyncItem(item: SyncQueue) = syncQueueDao.delete(item)
/** ثبت تلاش ناموفق روی آیتم صف — آیتم نگه داشته می‌شود تا دور بعد. */
suspend fun recordSyncAttempt(itemId: String, attemptCount: Int, at: Long) =
syncQueueDao.recordAttempt(itemId, attemptCount, at)
/** حذف چند آیتم صف با هم — برای زمانی که coalesce آن‌ها را بی‌اثر تشخیص می‌دهد. */
suspend fun removeSyncItems(items: List<SyncQueue>) {
database.withTransaction { items.forEach { syncQueueDao.delete(it) } }
}
suspend fun clearSyncQueue() = syncQueueDao.deleteAll()
}
@@ -20,6 +20,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.UUID
class NetworkRepository {
@@ -29,20 +31,31 @@ class NetworkRepository {
private val _paymentsFlow = MutableStateFlow<List<PaymentRecord>>(emptyList())
private val repositoryScope = CoroutineScope(Dispatchers.IO)
/**
* تا وقتی حداقل یک [refresh] موفق انجام نشده باشد `false` است.
*
* چرا لازم است: مقدار اولیه‌ی `_personsFlow`/`_paymentsFlow` لیست خالی است و از لیست خالیِ
* واقعی (کاربری که هنوز داده‌ای ثبت نکرده) قابل تشخیص نیست. merge کردن روی آن لیست خالیِ
* قالبی، همه‌ی داده‌ی محلی را پاک می‌کند. مصرف‌کننده باید منتظر `true` بماند.
*/
private val _hasServerData = MutableStateFlow(false)
fun hasServerDataFlow(): Flow<Boolean> = _hasServerData.asStateFlow()
// --- Realtime Operations ---
fun observeRealtimeChanges() {
repositoryScope.launch {
try {
// اطمینان از اتصال قبل از اشتراک در کانال
supabase.realtime.connect()
val myChannel = supabase.channel("db-changes")
// گوش دادن به تغییرات جدول اشخاص
launch {
myChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
table = "persons"
}.collect {
refresh()
}
myChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
table = "persons"
}.collect {
Log.d("Supabase-Realtime", "Change detected in persons table")
refresh()
}
// گوش دادن به تغییرات جدول پرداخت‌ها
@@ -50,13 +63,15 @@ class NetworkRepository {
myChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
table = "payments"
}.collect {
Log.d("Supabase-Realtime", "Change detected in payments table")
refresh()
}
}
myChannel.subscribe()
Log.d("Supabase-Realtime", "Subscribed to all changes")
} catch (e: Exception) {
Log.e("Supabase", "Realtime connection error", e)
Log.e("Supabase-Realtime", "Realtime connection error, retrying...", e)
}
}
}
@@ -91,26 +106,36 @@ class NetworkRepository {
}
// --- Person Operations ---
suspend fun addPerson(person: Person): Int? {
/**
* ارسال شخص به سرور با upsert.
*
* upsert (و نه insert) عمداً انتخاب شده تا این عملیات idempotent باشد: اگر تلاش اول
* به سرور رسیده باشد ولی پاسخش به‌خاطر timeout به ما نرسیده باشد، تلاش دوم رکورد
* تکراری نمی‌سازد. صف سینک متکی به همین خاصیت است تا بتواند بی‌خطر retry کند.
*
* اعتبارسنجی تکراری‌بودن نام اینجا انجام نمی‌شود؛ آن کار در PersonViewModel روی
* داده‌ی محلی (که همیشه کامل است) انجام می‌گیرد. کش _personsFlow ممکن است خالی یا
* قدیمی باشد و نتیجه‌ی غلط بدهد.
*/
suspend fun addPerson(person: Person): Boolean {
return try {
val trimmedName = person.name.trim()
if (trimmedName.isEmpty()) return 400
if (trimmedName.isEmpty()) {
Log.w("Supabase", "addPerson skipped: blank name")
return false
}
// بررسی تکراری بودن نام در لیست موجود
val isDuplicate = _personsFlow.value.any { it.name.trim().equals(trimmedName, ignoreCase = true) }
if (isDuplicate) return 409
val personToInsert = if (person.id.isEmpty()) {
val personToUpsert = if (person.id.isEmpty()) {
person.copy(id = UUID.randomUUID().toString(), name = trimmedName, createdAt = System.currentTimeMillis())
} else {
person.copy(name = trimmedName)
}
supabase.from("persons").insert(personToInsert)
201
supabase.from("persons").upsert(personToUpsert)
true
} catch (e: Exception) {
Log.e("Supabase", "addPerson error", e)
null
false
}
}
@@ -125,22 +150,37 @@ class NetworkRepository {
}
}
suspend fun updatePerson(personId: String, name: String, phoneNumber: String?): Int {
suspend fun updatePerson(
personId: String,
name: String,
phoneNumber: String?,
monthlyCommitment: Double,
startMonth: Int,
startYear: Int
): Int {
return try {
val trimmedName = name.trim()
if (trimmedName.isEmpty()) return 400
// بررسی تکراری بودن نام (به جز خودِ این شخص)
val currentPerson = _personsFlow.value.find { it.id == personId }
val currentCategory = currentPerson?.category ?: "salary"
// بررسی تکراری بودن نام در همان دسته‌بندی (به جز خودِ این شخص)
val isDuplicate = _personsFlow.value.any {
it.id != personId && it.name.trim().equals(trimmedName, ignoreCase = true)
it.id != personId &&
it.category == currentCategory &&
it.name.trim().equals(trimmedName, ignoreCase = true)
}
if (isDuplicate) return 409
supabase.from("persons").update(
mapOf(
"name" to trimmedName,
"phoneNumber" to phoneNumber?.trim()
)
buildJsonObject {
put("name", trimmedName)
put("phoneNumber", phoneNumber?.trim())
put("monthlyCommitment", monthlyCommitment)
put("startMonth", startMonth)
put("startYear", startYear)
},
) {
filter { eq("id", personId) }
}
@@ -154,7 +194,9 @@ class NetworkRepository {
suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean {
return try {
supabase.from("persons").update(
mapOf("isArchived" to isArchived)
buildJsonObject {
put("isArchived", isArchived)
}
) {
filter { eq("id", personId) }
}
@@ -167,7 +209,9 @@ class NetworkRepository {
suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean {
return try {
supabase.from("persons").update(
mapOf("displayOrder" to displayOrder)
buildJsonObject {
put("displayOrder", displayOrder)
}
) {
filter { eq("id", personId) }
}
@@ -180,9 +224,14 @@ class NetworkRepository {
fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow()
// --- Payment Operations ---
/**
* ارسال پرداخت به سرور با upsert — به همان دلیل idempotent بودن که در [addPerson] توضیح داده شد.
* ضمناً همین باعث می‌شود ویرایش پرداخت (SyncOperation.UPDATE) هم درست کار کند؛ قبلاً insert
* بود و روی رکورد موجود شکست می‌خورد.
*/
suspend fun addPayment(paymentRecord: PaymentRecord): Boolean {
return try {
supabase.from("payments").insert(paymentRecord)
supabase.from("payments").upsert(paymentRecord)
true
} catch (e: Exception) {
Log.e("Supabase", "addPayment error", e); false
@@ -190,33 +239,56 @@ class NetworkRepository {
}
suspend fun deletePayment(paymentId: String): Boolean {
return try {
supabase.from("payments").delete {
filter { eq("id", paymentId) }
var retries = 0
val maxRetries = 2
while (retries <= maxRetries) {
try {
supabase.from("payments").delete {
filter { eq("id", paymentId) }
}
Log.d("Supabase", "deletePayment Success: $paymentId")
return true
} catch (e: Exception) {
retries++
Log.e("Supabase", "deletePayment error (Attempt $retries): ${e.message}", e)
if (retries > maxRetries) return false
kotlinx.coroutines.delay(1000L * retries) // Exponential backoff-ish
}
true
} catch (e: Exception) {
Log.e("Supabase", "deletePayment error", e); false
}
return false
}
fun getPaymentsFlow(): Flow<List<PaymentRecord>> = _paymentsFlow.asStateFlow()
suspend fun refresh() {
try {
// دریافت و تبدیل خودکار لیست اشخاص
/**
* واکشی کامل داده‌ها از سرور.
*
* @return true فقط اگر **هر دو** واکشی موفق بوده باشند.
*
* دو تضمین مهم که فراخوان‌ها به آن تکیه می‌کنند:
* ۱. در صورت شکست، flow‌ها **دست‌نخورده** می‌مانند. قبلاً خطا بلعیده می‌شد و صدازننده
* نمی‌فهمید واکشی شکست خورده؛ نتیجه‌اش merge روی داده‌ی ناقص و پاک شدن داده‌ی محلی بود.
* ۲. مقداردهی flow‌ها اتمیک است — یا هر دو با هم به‌روز می‌شوند یا هیچ‌کدام. قبلاً اگر
* واکشی پرداخت‌ها شکست می‌خورد، لیست اشخاص جدید کنار لیست پرداخت‌های قدیمی می‌نشست.
*/
suspend fun refresh(): Boolean {
return try {
val persons = supabase.from("persons").select().decodeList<Person>()
_personsFlow.value = persons
// دریافت و تبدیل خودکار لیست پرداخت‌ها
val payments = supabase.from("payments").select {
order("timestamp", order = Order.DESCENDING)
}.decodeList<PaymentRecord>()
// فقط بعد از موفقیت هر دو واکشی مقداردهی می‌کنیم
_personsFlow.value = persons
_paymentsFlow.value = payments
_hasServerData.value = true
Log.d("Supabase", "Refresh Success: ${persons.size} persons, ${payments.size} payments")
true
} catch (e: Exception) {
Log.e("Supabase", "refresh error", e)
Log.e("Supabase", "refresh error — داده‌های محلی دست‌نخورده باقی می‌مانند", e)
false
}
}
}
@@ -5,9 +5,11 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
@@ -25,10 +27,19 @@ class SettingsRepository(context: Context) {
val CARD_NUMBERS = stringPreferencesKey("card_numbers")
val IGNORED_CONTACT_SUGGESTIONS = stringPreferencesKey("ignored_contact_suggestions")
/** آخرین versionCodeی که با آن اجرا شده — مبنای تشخیص ارتقای نسخه برای گرفتن snapshot. */
val LAST_RUN_VERSION_CODE = intPreferencesKey("last_run_version_code")
const val FALLBACK_AMOUNT = 200000.0
const val DEFAULT_FONT = "Estedad"
}
suspend fun getLastRunVersionCode(): Int? = dataStore.data.first()[LAST_RUN_VERSION_CODE]
suspend fun saveLastRunVersionCode(versionCode: Int) {
dataStore.edit { it[LAST_RUN_VERSION_CODE] = versionCode }
}
val reminderDayFlow: Flow<Int?> = dataStore.data.map {
it[REMINDER_DAY]?.toIntOrNull()
}
@@ -0,0 +1,276 @@
package com.oqba26.monthlypaymentapp.data.sync
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.util.Log
import com.oqba26.monthlypaymentapp.data.model.SyncOperation
import com.oqba26.monthlypaymentapp.data.model.SyncQueue
import com.oqba26.monthlypaymentapp.data.model.SyncType
import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
/**
* ارسال تغییرات محلی به سرور.
*
* ## چه چیزی نسبت به نسخه‌ی قبل عوض شد
*
* ۱. **حلقه دیگر خودش را cancel نمی‌کند.** نسخه‌ی قبلی `collectLatest` روی Flow صف بود، ولی
* پردازش صف خودش صف را تغییر می‌دهد → Flow دوباره emit می‌کند → `collectLatest` پردازشِ
* در جریان را لغو می‌کند. اینجا Flow فقط نقش «زنگ» را دارد و پردازش در یک حلقه‌ی
* تک‌مصرف‌کننده با [Mutex] انجام می‌شود.
*
* ۲. **هیچ آیتمی بدون رسیدن به سرور حذف نمی‌شود.** قبلاً اگر رکورد محلی پیدا نمی‌شد،
* `else true` برمی‌گشت و آیتم به‌عنوان «موفق» از صف پاک می‌شد — یعنی داده‌ی کاربر بی‌صدا
* گم می‌شد. حالا آیتم نگه داشته می‌شود و تلاشش ثبت می‌گردد.
*
* ۳. **retry با backoff.** قبلاً اولین شکست `break` می‌زد و تا trigger بعدی هیچ کاری نمی‌شد؛
* آفلاین که بودی، هیچ‌وقت خودش دوباره تلاش نمی‌کرد. حالا هر آیتم شکست‌خورده عقب می‌افتد
* ولی حلقه بقیه را ادامه می‌دهد، و برگشتن شبکه خودش یک trigger است.
*
* ۴. **coalesce.** چند عمل روی یک رکورد به یک عمل نهایی تبدیل می‌شود؛ این همان چیزی است که
* حالت متناقض «آیتم صف هست ولی رکورد محلی نیست» را از بین می‌برد.
*/
@Singleton
class SyncManager @Inject constructor(
private val localRepository: LocalPersonRepository,
private val networkRepository: NetworkRepository,
@ApplicationContext private val context: Context
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
/**
* زنگ شروع پردازش. `CONFLATED` است چون چند زنگ پشت‌سرهم معنایی جز «یک بار دیگر بررسی کن»
* ندارند؛ صف‌کردنشان فقط کار تکراری می‌سازد.
*/
private val trigger = Channel<Unit>(Channel.CONFLATED)
/** تضمین می‌کند فقط یک پردازش همزمان در جریان باشد. */
private val mutex = Mutex()
@Volatile
private var started = false
fun startSync() {
if (started) return
started = true
// مصرف‌کننده‌ی تنها
scope.launch {
for (ignored in trigger) {
mutex.withLock {
try {
processQueue()
} catch (e: Exception) {
// حلقه هرگز نباید بمیرد؛ وگرنه سینک تا ری‌استارت اپ متوقف می‌ماند.
Log.e(TAG, "خطای غیرمنتظره در پردازش صف", e)
}
}
}
}
// زنگ ۱: هر تغییر در صف
scope.launch {
localRepository.getSyncQueueFlow().collect { queue ->
if (queue.isNotEmpty()) trigger.trySend(Unit)
}
}
// زنگ ۲: برگشتن اینترنت
registerNetworkCallback()
// زنگ ۳: خودِ راه‌اندازی (آیتم‌های باقی‌مانده از اجرای قبلی)
trigger.trySend(Unit)
}
private fun registerNetworkCallback() {
try {
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.registerNetworkCallback(request, object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
Log.d(TAG, "شبکه برگشت — تلاش دوباره برای سینک")
trigger.trySend(Unit)
}
})
} catch (e: Exception) {
// نبود این زنگ فقط باعث می‌شود سینک دیرتر (با زنگ‌های دیگر) اتفاق بیفتد.
Log.e(TAG, "ثبت NetworkCallback ناموفق بود", e)
}
}
private suspend fun processQueue() {
val raw = localRepository.getPendingSyncItems()
if (raw.isEmpty()) return
val (effective, obsolete) = coalesce(raw)
// آیتم‌هایی که coalesce بی‌اثر تشخیص داد (مثلاً INSERT بعدش DELETE، یا UPDATEهای
// قدیمی‌ترِ یک رکورد) هرگز به سرور نمی‌رسند چون لازم نیست برسند.
if (obsolete.isNotEmpty()) {
localRepository.removeSyncItems(obsolete)
Log.d(TAG, "${obsolete.size} آیتم تکراری/بی‌اثر از صف حذف شد")
}
val now = System.currentTimeMillis()
val persons = localRepository.getAllPersons().associateBy { it.id }
val payments = localRepository.getAllPayments().associateBy { it.id }
var failed = false
for (item in effective) {
if (!isDue(item, now)) continue
val success = try {
when (item.type) {
SyncType.PERSON -> syncPerson(item, persons[item.entityId])
SyncType.PAYMENT -> syncPayment(item, payments[item.entityId])
}
} catch (e: Exception) {
Log.e(TAG, "خطا در سینک آیتم ${item.id}", e)
false
}
if (success) {
localRepository.removeSyncItem(item)
} else {
// آیتم نگه داشته می‌شود. `break` نمی‌زنیم: شکست یک رکورد نباید بقیه را
// معطل کند (مثلاً یک رکورد خرابِ همیشه-ناموفق کل صف را قفل می‌کرد).
localRepository.recordSyncAttempt(item.id, item.attemptCount + 1, now)
Log.w(TAG, "سینک آیتم ${item.id} ناموفق (تلاش ${item.attemptCount + 1}) — در صف می‌ماند")
failed = true
}
}
// اگر چیزی شکست خورد، خودمان زنگ بعدی را کوک می‌کنیم. بدون این، آیتمِ شکست‌خورده
// تا تغییر بعدیِ صف یا برگشتن شبکه معطل می‌ماند — و در حالتی که شبکه وصل است ولی
// سرور خطا می‌دهد، هیچ‌کدام از آن دو رخ نمی‌دهد.
if (failed) scheduleRetry()
}
/** یک زنگ تأخیری برای آیتم‌های شکست‌خورده. */
private fun scheduleRetry() {
scope.launch {
delay(RETRY_TICK_MS)
trigger.trySend(Unit)
}
}
/**
* آیا زمان تلاش بعدیِ این آیتم رسیده؟
*
* آیتم تازه (`lastAttemptAt == 0`) همیشه due است. آیتم شکست‌خورده به اندازه‌ی
* [backoffMillis] صبر می‌کند تا در حالت آفلاین، لاگ و مصرف باتری منفجر نشود.
*/
private fun isDue(item: SyncQueue, now: Long): Boolean =
item.lastAttemptAt == 0L || now - item.lastAttemptAt >= backoffMillis(item.attemptCount)
/** ۲ ثانیه، ۴، ۸، ... تا سقف ۵ دقیقه. */
private fun backoffMillis(attemptCount: Int): Long {
if (attemptCount <= 0) return 0L
val exponent = attemptCount.coerceAtMost(8) // جلوگیری از سرریز
val delay = BASE_BACKOFF_MS shl (exponent - 1)
return delay.coerceAtMost(MAX_BACKOFF_MS)
}
/**
* چند عمل روی یک رکورد را به یک عمل نهایی تبدیل می‌کند.
*
* قواعد (به ترتیب زمانی روی هر `entityId`):
* - `INSERT` سپس `DELETE` → **هیچ‌کدام**. رکورد هرگز به سرور نرفته، پس چیزی برای حذف نیست.
* - `INSERT` سپس `UPDATE` → `INSERT` (که با upsert همان حالت نهایی را می‌فرستد).
* - چند `UPDATE` → آخری. حالت فعلیِ رکورد فرستاده می‌شود، پس میانی‌ها بی‌معنی‌اند.
* - `DELETE` آخرین عمل باشد → `DELETE` و همه‌ی عمل‌های قبلی بی‌اثر می‌شوند.
*
* @return جفتِ (آیتم‌هایی که باید پردازش شوند، آیتم‌هایی که باید بی‌پردازش حذف شوند)
*/
private fun coalesce(items: List<SyncQueue>): Pair<List<SyncQueue>, List<SyncQueue>> {
val effective = mutableListOf<SyncQueue>()
val obsolete = mutableListOf<SyncQueue>()
items.groupBy { it.entityId to it.type }.forEach { (_, group) ->
val ordered = group.sortedBy { it.createdAt }
val last = ordered.last()
val hadInsert = ordered.any { it.operation == SyncOperation.INSERT }
when {
// ساخته و بعد پاک شد، بدون اینکه هرگز به سرور برسد → کل ماجرا بی‌اثر است.
last.operation == SyncOperation.DELETE && hadInsert -> obsolete += ordered
// ساخته شد (و شاید بعد ویرایش) → یک INSERT کافی است؛ upsert حالت فعلی را می‌فرستد.
hadInsert -> {
val insert = ordered.first { it.operation == SyncOperation.INSERT }
effective += insert
obsolete += ordered.filter { it.id != insert.id }
}
// فقط UPDATE/DELETE → آخری حرف آخر را می‌زند.
else -> {
effective += last
obsolete += ordered.filter { it.id != last.id }
}
}
}
return effective.sortedBy { it.createdAt } to obsolete
}
private suspend fun syncPerson(
item: SyncQueue,
person: com.oqba26.monthlypaymentapp.data.model.Person?
): Boolean = when (item.operation) {
SyncOperation.DELETE -> networkRepository.deletePersonAndPayments(item.entityId)
// INSERT و UPDATE هر دو upsert می‌شوند: idempotent است، پس retry بی‌خطر است و
// خطای ۴۰۹ (تکراری بودن) هم دیگر رخ نمی‌دهد.
SyncOperation.INSERT, SyncOperation.UPDATE -> {
if (person == null) {
// حالت متناقض. قبلاً اینجا `true` برمی‌گشت و آیتم پاک می‌شد؛ نتیجه‌اش
// گم شدن دائمی داده بود. حالا آیتم می‌ماند تا وضعیت قابل بررسی باشد.
Log.e(TAG, "رکورد محلی شخص ${item.entityId} پیدا نشد — آیتم در صف نگه داشته شد")
false
} else {
networkRepository.addPerson(person)
}
}
}
private suspend fun syncPayment(
item: SyncQueue,
payment: com.oqba26.monthlypaymentapp.data.model.PaymentRecord?
): Boolean = when (item.operation) {
SyncOperation.DELETE -> networkRepository.deletePayment(item.entityId)
SyncOperation.INSERT, SyncOperation.UPDATE -> {
if (payment == null) {
Log.e(TAG, "رکورد محلی پرداخت ${item.entityId} پیدا نشد — آیتم در صف نگه داشته شد")
false
} else {
// upsert است، پس ویرایش پرداخت هم درست کار می‌کند؛ قبلاً insert بود و
// روی رکورد موجود شکست می‌خورد.
networkRepository.addPayment(payment)
}
}
}
private companion object {
const val TAG = "SyncManager"
const val BASE_BACKOFF_MS = 2_000L
const val MAX_BACKOFF_MS = 5 * 60 * 1000L
const val RETRY_TICK_MS = 15_000L
}
}
@@ -0,0 +1,105 @@
package com.oqba26.monthlypaymentapp.data.sync
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
import java.util.UUID
/**
* نتیجه‌ی تصمیم‌گیری برای همگام‌سازی: چه رکوردهایی نوشته/به‌روز شوند و چه شناسه‌هایی حذف شوند.
*/
data class MergePlan<T>(
val upserts: List<T>,
val deleteIds: List<String>
) {
val isEmpty: Boolean get() = upserts.isEmpty() && deleteIds.isEmpty()
}
/**
* منطق خالصِ ادغام داده‌ی سرور با داده‌ی محلی.
*
* این کلاس عمداً هیچ وابستگی‌ای به Room، Context یا شبکه ندارد تا کاملاً unit-testable باشد؛
* تصمیم «چه چیزی حذف شود» حساس‌ترین بخش اپ است و باید تست داشته باشد.
*
* ## چرا این کلاس وجود دارد
* پیاده‌سازی قبلی (`LocalPersonRepository.syncAll`) اول `deleteAll()` می‌زد و بعد داده‌ی سرور را
* درج می‌کرد. نتیجه‌اش این بود که هر تغییر محلیِ هنوز-سینک-نشده‌ای نابود می‌شد — کاربر آفلاین
* پرداختی ثبت می‌کرد و آن پرداخت بی‌صدا از بین می‌رفت.
*
* ## قاعده‌ی حاکم
* **سرور منبع حقیقت است، مگر برای رکوردهایی که تغییر محلیِ ارسال‌نشده دارند.**
* آن رکوردها دست‌نخورده می‌مانند تا [SyncManager] موفق شود آن‌ها را بفرستد و از صف حذفشان کند؛
* از آن لحظه به بعد سرور دربارهٔ آن‌ها هم حرف آخر را می‌زند.
*/
object SyncMerger {
/** نرمال‌سازی نام برای تطبیق رکوردهای سرور که شناسه ندارند. */
fun normalizeName(name: String): String = name.trim().lowercase()
/**
* مجموعهٔ شناسه‌هایی که **نباید** لمس شوند.
*
* دو منبع دارد:
* - `pendingIds`: آیتم‌های موجود در صف سینک.
* - پرچم `needsSync` روی خودِ رکورد محلی.
*
* منبع دوم صرفاً تکرار اولی نیست: اگر صف به هر دلیلی از دست رفته باشد (مثلاً به‌خاطر باگ
* قبلی که آیتم‌ها را بی‌اینکه واقعاً ارسال شوند حذف می‌کرد)، پرچم `needsSync` هنوز روی رکورد
* هست و همان جلوی پاک شدنش را می‌گیرد.
*/
private fun protectedPersonIds(local: List<Person>, pendingIds: Set<String>): Set<String> =
pendingIds + local.filter { it.needsSync }.map { it.id }
private fun protectedPaymentIds(local: List<PaymentRecord>, pendingIds: Set<String>): Set<String> =
pendingIds + local.filter { it.needsSync }.map { it.id }
/**
* @param idGenerator تولیدکنندهٔ شناسه؛ فقط برای تست‌پذیری قابل تزریق است.
*/
fun planPersons(
local: List<Person>,
server: List<Person>,
pendingIds: Set<String>,
idGenerator: () -> String = { UUID.randomUUID().toString() }
): MergePlan<Person> {
val protectedIds = protectedPersonIds(local, pendingIds)
val localByName = local.associateBy { normalizeName(it.name) }
// حل شناسه: رکورد سرور که id ندارد، اگر همنامِ محلی داشت شناسهٔ او را می‌گیرد.
// (این منطق از پیاده‌سازی قبلی حفظ شده و رفتار سرورهای قدیمی را پوشش می‌دهد.)
val resolved = server.map { person ->
val name = person.name.trim()
val resolvedId = when {
person.id.isNotBlank() -> person.id
else -> localByName[normalizeName(name)]?.id ?: idGenerator()
}
person.copy(id = resolvedId, name = name, needsSync = false)
}
val serverIds = resolved.map { it.id }.toSet()
return MergePlan(
upserts = resolved.filterNot { it.id in protectedIds },
deleteIds = local
.map { it.id }
.filterNot { it in serverIds || it in protectedIds }
)
}
fun planPayments(
local: List<PaymentRecord>,
server: List<PaymentRecord>,
pendingIds: Set<String>
): MergePlan<PaymentRecord> {
val protectedIds = protectedPaymentIds(local, pendingIds)
val serverIds = server.map { it.id }.toSet()
return MergePlan(
upserts = server
.filterNot { it.id in protectedIds }
.map { it.copy(needsSync = false) },
deleteIds = local
.map { it.id }
.filterNot { it in serverIds || it in protectedIds }
)
}
}
@@ -77,58 +77,15 @@ fun ArchiveScreen(viewModel: PersonViewModel) {
// دیالوگ تایید حذف قطعی
personToDelete?.let { person ->
Dialog(
onDismissRequest = { personToDelete = null }
) {
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
)
Text(
text = "آیا از حذف قطعی ${person.name} و تمامی سوابق پرداخت او مطمئن هستید؟ این عمل غیرقابل بازگشت است.",
style = MaterialTheme.typography.bodyMedium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = { personToDelete = null }
) {
Text("لغو")
}
Button(
onClick = {
viewModel.onEvent(PersonScreenEvent.DeletePerson(person.id))
personToDelete = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("حذف قطعی")
}
}
}
}
}
}
ConfirmDeleteDialog(
title = "حذف قطعی شخص",
message = "آیا از حذف قطعی ${person.name} و تمامی سوابق پرداخت او مطمئن هستید؟ این عمل غیرقابل بازگشت است.",
onConfirm = {
viewModel.onEvent(PersonScreenEvent.DeletePerson(person.id))
personToDelete = null
},
onDismiss = { personToDelete = null }
)
}
// دیالوگ تایید بازیابی
@@ -42,6 +42,13 @@ fun AuthScreen(
val authState by viewModel.authState.collectAsState()
val isLoading = authState is AuthState.Loading
var isLoginMode by remember { mutableStateOf(true) }
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.runtime.LaunchedEffect(Unit) {
viewModel.toastMessage.collect { message ->
android.widget.Toast.makeText(context, message, android.widget.Toast.LENGTH_SHORT).show()
}
}
Column(
modifier = Modifier
@@ -34,9 +34,12 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
@@ -47,6 +50,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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
@@ -58,6 +62,7 @@ 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 com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -92,18 +97,21 @@ fun PersonDetailScreen(
val detailState by viewModel.personDetailState.collectAsState()
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
val context = LocalContext.current
var expandedMonth by remember { mutableStateOf<Int?>(null) }
var editingPayment by remember { mutableStateOf<PaymentRecord?>(null) }
var addingPaymentMonth by remember { mutableStateOf<Int?>(null) }
var editingPerson by remember { mutableStateOf(value = false) }
var showBulkPaymentDialog by remember { mutableStateOf(false) }
var showBulkPaymentDialog by remember { mutableStateOf(value = false) }
Column(modifier = Modifier.fillMaxSize()) {
val displayName = detailState.person?.let { p ->
if (p.isAnonymous && (p.category == "mosque")) "خیر ناشناس (${p.name})" else p.name
} ?: "..."
PersonDetailHeader(
name = detailState.person?.name ?: "...",
onBack = { navController.popBackStack() }
name = displayName,
onBack = { navController.popBackStack() },
) { editingPerson = true }
if (detailState.person == null) {
@@ -113,14 +121,15 @@ fun PersonDetailScreen(
} else {
YearSelector(
year = detailState.selectedYear,
onYearChange = { offset ->
viewModel.onEvent(PersonScreenEvent.ChangeYear(offset))
}
)
) { offset ->
viewModel.onEvent(PersonScreenEvent.ChangeYear(offset))
}
val availableMonths = detailState.monthStates
.asSequence()
.filter { it.status == MonthStatus.AVAILABLE }
.map { it.month }
.toList()
if (availableMonths.isNotEmpty()) {
Button(
@@ -140,6 +149,8 @@ fun PersonDetailScreen(
items(detailState.monthStates) { monthModel ->
MonthListItem(
month = monthModel,
personName = detailState.person?.name ?: "",
startMonth = detailState.person?.startMonth ?: 1,
onHeaderClick = {
expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month
},
@@ -190,7 +201,9 @@ fun PersonDetailScreen(
AddPaymentDialog(
personName = detailState.person?.name ?: "",
defaultAmount = defaultPaymentAmount,
defaultAmount = if ((detailState.person?.monthlyCommitment ?: 0.0) > 0)
detailState.person!!.monthlyCommitment
else defaultPaymentAmount,
initialDescription = initialDesc,
onConfirm = { amount, description ->
viewModel.onEvent(
@@ -204,18 +217,21 @@ fun PersonDetailScreen(
)
addingPaymentMonth = null
},
onDismiss = {
addingPaymentMonth = null
}
onDismiss = { addingPaymentMonth = null }
)
}
if (editingPerson) {
val currentCategory by viewModel.currentCategory.collectAsState()
UpdatePersonDialog(
initialName = detailState.person?.name ?: "",
initialPhone = detailState.person?.phoneNumber,
onConfirm = { name, phone ->
viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone))
initialCommitment = detailState.person?.monthlyCommitment ?: 0.0,
initialStartMonth = detailState.person?.startMonth ?: 1,
initialStartYear = detailState.person?.startYear ?: com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear(),
isMosqueCategory = currentCategory == "mosque",
onConfirm = { name, phone, commitment, month, year ->
viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone, commitment, month, year))
editingPerson = false
},
onDismiss = { editingPerson = false },
@@ -227,12 +243,18 @@ fun PersonDetailScreen(
if (showBulkPaymentDialog) {
val availableMonths = detailState.monthStates
.asSequence()
.filter { it.status == MonthStatus.AVAILABLE }
.map { it.month }
.toList()
val bulkDefaultAmount = if ((detailState.person?.monthlyCommitment ?: 0.0) > 0)
detailState.person!!.monthlyCommitment
else defaultPaymentAmount
BulkPaymentDialog(
availableMonths = availableMonths,
defaultAmount = defaultPaymentAmount,
defaultAmount = bulkDefaultAmount,
onConfirm = { selected, amount ->
viewModel.onEvent(
PersonScreenEvent.AddBulkPayments(
@@ -244,8 +266,7 @@ fun PersonDetailScreen(
)
showBulkPaymentDialog = false
},
onDismiss = { showBulkPaymentDialog = false }
)
) { showBulkPaymentDialog = false }
}
}
@@ -254,7 +275,7 @@ fun BulkPaymentDialog(
availableMonths: List<Int>,
defaultAmount: Double,
onConfirm: (List<Int>, Double) -> Unit,
onDismiss: () -> Unit
onDismiss: () -> Unit,
) {
val selectedMonths = remember { mutableStateListOf<Int>().apply { addAll(availableMonths) } }
var amountText by remember { mutableStateOf(defaultAmount.toInt().toString()) }
@@ -307,17 +328,28 @@ fun BulkPaymentDialog(
OutlinedTextField(
value = amountText,
onValueChange = { amountText = it },
onValueChange = { value ->
// اجازه ورود فقط به اعداد (چه انگلیسی چه فارسی)
val digitsOnly = value.filter { it.isDigit() || (((it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9'))) }
// تبدیل اعداد فارسی به انگلیسی برای ذخیره در استیت
amountText = digitsOnly.map {
when (it) {
in '\u0660'..'\u0669' -> ((it.code - '\u0660'.code) + '0'.code).toChar()
in '\u06f0'..'\u06f9' -> ((it.code - '\u06f0'.code) + '0'.code).toChar()
else -> it
}
}.joinToString("")
},
label = { Text("مبلغ برای هر ماه (تومان)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
visualTransformation = PersianDigitsTransformation()
visualTransformation = PersianNumberVisualTransformation()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
@@ -343,7 +375,7 @@ fun BulkPaymentDialog(
private fun PersonDetailHeader(
name: String,
onBack: () -> Unit,
onEdit: () -> Unit
onEdit: () -> Unit,
) {
Surface(color = MaterialTheme.colorScheme.primary, contentColor = Color.White) {
Row(
@@ -371,12 +403,22 @@ private fun PersonDetailHeader(
fun UpdatePersonDialog(
initialName: String,
initialPhone: String?,
onConfirm: (String, String) -> Unit,
initialCommitment: Double = 0.0,
initialStartMonth: Int = 1,
initialStartYear: Int = 1403,
isMosqueCategory: Boolean = false,
onConfirm: (String, String, Double, Int, Int) -> Unit,
onDismiss: () -> Unit,
onSearchContact: (String) -> List<ContactMatch>
onSearchContact: (String) -> List<ContactMatch>,
) {
var name by remember { mutableStateOf(initialName) }
var phoneNumber by remember { mutableStateOf(initialPhone ?: "") }
var monthlyCommitment by remember { mutableStateOf(if (initialCommitment > 0) initialCommitment.toInt().toString() else "") }
val currentShamsiYear = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear()
var startMonth by remember { mutableIntStateOf(initialStartMonth) }
var startYear by remember { mutableIntStateOf(initialStartYear) }
var similarContacts by remember { mutableStateOf<List<ContactMatch>>(emptyList()) }
val context = LocalContext.current
@@ -469,6 +511,28 @@ fun UpdatePersonDialog(
}
}
if (isMosqueCategory) {
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()
)
}
if (similarContacts.isNotEmpty()) {
Text(
"موارد مشابه پیدا شده:",
@@ -511,15 +575,63 @@ fun UpdatePersonDialog(
}
}
// انتخاب ماه و سال شروع
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(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(startMonth))
}
DropdownMenu(expanded = expandedMonth, onDismissRequest = { expandedMonth = false }) {
(1..12).forEach { m ->
DropdownMenuItem(
text = { Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(m)) },
onClick = { startMonth = m; expandedMonth = false }
)
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
if (name.isNotBlank()) {
onConfirm(name, phoneNumber)
val commitment = monthlyCommitment.toDoubleOrNull() ?: 0.0
onConfirm(name, phoneNumber, commitment, startMonth, startYear)
}
},
enabled = name.isNotBlank()
@@ -573,19 +685,37 @@ fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
@Composable
fun MonthListItem(
month: MonthUiModel,
personName: String,
startMonth: Int,
onHeaderClick: () -> Unit,
onEditClick: () -> Unit
) {
val context = LocalContext.current
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)
else -> MaterialTheme.colorScheme.surfaceVariant
}
Card(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp).fillMaxWidth().clickable(onClick = onHeaderClick),
modifier = Modifier
.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()
} else {
onHeaderClick()
}
},
colors = CardDefaults.cardColors(containerColor = containerColor),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
elevation = CardDefaults.cardElevation(defaultElevation = if (month.status == MonthStatus.NOT_COMMITTED) 0.dp else 1.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
Row(
@@ -594,13 +724,18 @@ fun MonthListItem(
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = getPersianMonthName(month.month), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (month.status == MonthStatus.PAID && month.payment != null) {
Text(
text = getPersianMonthName(month.month),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (month.status == MonthStatus.NOT_COMMITTED) Color.Gray else Color.Unspecified
)
if ((month.status == MonthStatus.PAID) && (month.payment != null)) {
Spacer(modifier = Modifier.width(8.dp))
Text(text = "${formatNumberAsPersian(month.payment.amount)} ت", style = MaterialTheme.typography.bodyMedium)
}
}
if (month.status == MonthStatus.PAID || month.status == MonthStatus.AVAILABLE) {
if ((month.status == MonthStatus.PAID) || (month.status == MonthStatus.AVAILABLE)) {
Button(
onClick = onEditClick,
colors = ButtonDefaults.buttonColors(
@@ -611,7 +746,7 @@ fun MonthListItem(
}
}
}
if (month.status == MonthStatus.PAID && month.payment != null) {
if ((month.status == MonthStatus.PAID) && (month.payment != null)) {
val desc = month.payment.description ?: ""
if (desc.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
@@ -31,6 +31,7 @@ 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.Check
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Payments
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.pullrefresh.PullRefreshIndicator
@@ -41,6 +42,9 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
@@ -56,6 +60,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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
@@ -97,7 +102,7 @@ import org.burnoutcrew.reorderable.reorderable
fun PersonScreen(
viewModel: PersonViewModel,
contactViewModel: ContactViewModel = hiltViewModel(),
navController: NavController
navController: NavController,
) {
val searchQuery by viewModel.searchQuery.collectAsState()
val uiState by viewModel.uiState.collectAsState()
@@ -114,7 +119,7 @@ fun PersonScreen(
val isRefreshing by viewModel.isRefreshing.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
val selectedIds by viewModel.selectedIds.collectAsState()
val context = LocalContext.current
val currentCategory by viewModel.currentCategory.collectAsState()
LaunchedEffect(uiState.unpaidPersons, uiState.paidPersons) {
val allPersons = uiState.unpaidPersons + uiState.paidPersons
@@ -129,13 +134,19 @@ fun PersonScreen(
val reorderableState = rememberReorderableLazyListState(
onMove = { from, to ->
if (searchQuery.isBlank()) {
viewModel.onEvent(
PersonScreenEvent.MovePersonNew(
fromIndex = from.index,
toIndex = to.index,
listType = PersonListType.UNPAID
val headerOffset = if (currentCategory == "mosque") 1 else 0
val fromIdx = from.index - headerOffset
val toIdx = to.index - headerOffset
if (fromIdx in uiState.unpaidPersons.indices && toIdx in uiState.unpaidPersons.indices) {
viewModel.onEvent(
PersonScreenEvent.MovePersonNew(
fromIndex = fromIdx,
toIndex = toIdx,
listType = PersonListType.UNPAID
)
)
)
}
}
},
onDragEnd = { _, _ ->
@@ -181,6 +192,17 @@ fun PersonScreen(
.fillMaxSize()
.reorderable(reorderableState)
) {
if (currentCategory == "mosque" && uiState.unpaidPersons.isNotEmpty()) {
item {
Text(
text = "لیست خیرین (بدهکار)",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
)
}
}
itemsIndexed(uiState.unpaidPersons, key = { _, person -> person.id }) { index, person ->
ReorderableItem(reorderableState, key = person.id) { isDragging ->
val elevation = animateFloatAsState(if (isDragging) 8f else 0f, label = "").value
@@ -214,6 +236,46 @@ fun PersonScreen(
)
}
}
if (currentCategory == "mosque" && uiState.paidPersons.isNotEmpty()) {
item {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), thickness = 0.5.dp)
Text(
text = "لیست تسویه شدگان",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
itemsIndexed(uiState.paidPersons, key = { _, person -> person.id }) { index, person ->
PersonListItem(
person = person,
index = index + 1 + uiState.unpaidPersons.size,
isSelectionMode = isSelectionMode,
isSelected = selectedIds.contains(person.id),
onPersonClick = {
if (isSelectionMode) {
viewModel.onEvent(PersonScreenEvent.ToggleSelection(person.id))
} else {
navController.navigate("person_detail/${person.id}")
}
},
onLongClick = {
if (!isSelectionMode) {
viewModel.onEvent(PersonScreenEvent.ToggleSelection(person.id))
}
},
onQuickPayClick = { },
onArchiveClick = { selectedPerson ->
personToArchive = selectedPerson
},
onSmsClick = { selectedPerson ->
contactViewModel.onSmsClicked(selectedPerson)
}
)
}
}
item { Spacer(modifier = Modifier.height(60.dp)) }
}
}
@@ -229,7 +291,7 @@ fun PersonScreen(
personForPaymentDialog?.let { person ->
AddPaymentDialog(
personName = person.name,
defaultAmount = defaultPaymentAmount,
defaultAmount = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount,
onConfirm = { amount, description ->
viewModel.onEvent(PersonScreenEvent.AddQuickPayment(person.id, amount, description))
viewModel.onDismissPaymentDialog()
@@ -261,9 +323,11 @@ fun PersonScreen(
}
if (isAddPersonDialogShown) {
val currentCategory by viewModel.currentCategory.collectAsState()
AddNewPersonDialog(
onConfirm = { name, phone ->
viewModel.onEvent(PersonScreenEvent.AddPerson(name, phone, context))
isMosqueCategory = currentCategory == "mosque",
onConfirm = { name, phone, isAnon, commitment, month, year, initialPayment ->
viewModel.onEvent(PersonScreenEvent.AddPerson(name, phone, isAnon, commitment, month, year, initialPayment))
viewModel.onDismissAddPersonDialog()
},
onDismiss = { viewModel.onDismissAddPersonDialog() },
@@ -436,12 +500,23 @@ fun ContactSuggestionDialog(
@Composable
fun AddNewPersonDialog(
onConfirm: (String, String) -> Unit,
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 = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear()
val currentShamsiMonth = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth()
var startMonth by remember { mutableIntStateOf(currentShamsiMonth) }
var startYear by remember { mutableIntStateOf(currentShamsiYear) }
var similarContacts by remember { mutableStateOf<List<ContactMatch>>(emptyList()) }
val context = LocalContext.current
@@ -471,97 +546,225 @@ fun AddNewPersonDialog(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "افزودن شخص جدید",
text = if (isAnonymous) "ثبت کمک آنی" else "افزودن شخص جدید",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("نام شخص") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
if (isMosqueCategory) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { isAnonymous = !isAnonymous }
.padding(vertical = 4.dp)
) {
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) }
)
}
Checkbox(
checked = isAnonymous,
onCheckedChange = { isAnonymous = it }
)
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()
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)
}
}
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
)
}
}
}
}
}
) {
Icon(Icons.Default.Search, contentDescription = "جستجو در مخاطبین")
}
}
}
if (similarContacts.isNotEmpty()) {
Text(
"موارد مشابه پیدا شده:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
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 = com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation()
)
LazyColumn(
modifier = Modifier.heightIn(max = 200.dp)
} 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 = com.oqba26.monthlypaymentapp.utils.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)
) {
items(similarContacts) { match ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
phoneNumber = match.phoneNumber
similarContacts = emptyList()
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
// سال
Box(modifier = Modifier.weight(1f)) {
var expandedYear by remember { mutableStateOf(false) }
OutlinedButton(
onClick = { expandedYear = true },
modifier = Modifier.fillMaxWidth()
) {
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
)
}
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(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(startMonth))
}
DropdownMenu(
expanded = expandedMonth,
onDismissRequest = { expandedMonth = false }) {
(1..12).forEach { m ->
DropdownMenuItem(
text = { Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(m)) },
onClick = { startMonth = m; expandedMonth = false }
)
}
}
}
@@ -576,13 +779,15 @@ fun AddNewPersonDialog(
) {
Button(
onClick = {
if (name.isNotBlank()) {
onConfirm(name, phoneNumber)
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 = name.isNotBlank()
enabled = isAnonymous || name.isNotBlank()
) {
Text("افزودن")
Text(if (isAnonymous) "ثبت کمک" else "افزودن")
}
Button(
onClick = onDismiss,
@@ -913,6 +1118,15 @@ fun PersonListItem(
}
}
if (person.needsSync) {
Icon(
imageVector = Icons.Default.Sync,
contentDescription = "Pending Sync",
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(16.dp).padding(horizontal = 2.dp)
)
}
Spacer(Modifier.width(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
@@ -943,40 +1157,3 @@ fun PersonListItem(
}
}
@Composable
fun AddPaymentDialog(
personName: String,
defaultAmount: Double,
onConfirm: (Double, String) -> Unit,
onDismiss: () -> Unit
) {
var amount by remember { mutableStateOf(defaultAmount.toString()) }
var description by remember { mutableStateOf("") }
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 = "ثبت پرداخت سریع برای $personName", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
OutlinedTextField(
value = amount,
onValueChange = { amount = it },
label = { Text("مبلغ") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("توضیحات") },
modifier = Modifier.fillMaxWidth()
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Button(onClick = onDismiss) { Text("لغو") }
Button(onClick = { onConfirm(amount.toDoubleOrNull() ?: 0.0, description) }) { Text("ثبت") }
}
}
}
}
}
}
@@ -55,7 +55,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel
import com.oqba26.monthlypaymentapp.core.manager.BackupManager
import com.oqba26.monthlypaymentapp.core.manager.SnapshotInfo
import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
import com.oqba26.monthlypaymentapp.utils.toPersianDigits
@@ -80,6 +83,9 @@ fun SettingsScreen(onLogout: () -> Unit) {
val selectedFont by viewModel.selectedFont.collectAsState()
var backupJsonToRestore by remember { mutableStateOf<String?>(null) }
var showSnapshotDialog by remember { mutableStateOf(false) }
var snapshotToRestore by remember { mutableStateOf<SnapshotInfo?>(null) }
val snapshots by viewModel.snapshots.collectAsState()
LaunchedEffect(Unit) {
viewModel.toastMessage.collect { message ->
@@ -195,6 +201,17 @@ fun SettingsScreen(onLogout: () -> Unit) {
}
}
// تور نجات: نسخه‌هایی که خودِ برنامه قبل از عملیات پرخطر گرفته است.
OutlinedButton(
onClick = {
viewModel.loadSnapshots()
showSnapshotDialog = true
},
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
) {
Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
}
Spacer(Modifier.weight(1f))
Button(
@@ -209,6 +226,63 @@ fun SettingsScreen(onLogout: () -> Unit) {
}
}
if (showSnapshotDialog) {
SnapshotListDialog(
snapshots = snapshots,
onSelect = { snapshotToRestore = it },
onDismiss = { showSnapshotDialog = false }
)
}
snapshotToRestore?.let { snapshot ->
Dialog(onDismissRequest = { snapshotToRestore = null }) {
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 = stringResource(R.string.restore_warning),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = formatTimestampToPersianDateTime(snapshot.timestamp),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(R.string.auto_backup_restore_confirm),
style = MaterialTheme.typography.bodyMedium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
viewModel.restoreSnapshot(snapshot)
snapshotToRestore = null
showSnapshotDialog = false
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) { Text(stringResource(R.string.yes)) }
Button(onClick = { snapshotToRestore = null }) {
Text(stringResource(R.string.cancel))
}
}
}
}
}
}
}
backupJsonToRestore?.let { json ->
Dialog(onDismissRequest = { backupJsonToRestore = null }) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
@@ -253,6 +327,92 @@ fun SettingsScreen(onLogout: () -> Unit) {
}
}
/**
* لیست نسخه‌های پشتیبان خودکار.
*
* تاریخ‌ها شمسی و اعداد فارسی نمایش داده می‌شوند تا با بقیه‌ی اپ یکدست باشد.
*/
@Composable
private fun SnapshotListDialog(
snapshots: List<SnapshotInfo>,
onSelect: (SnapshotInfo) -> 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(12.dp)
) {
Text(
text = stringResource(R.string.auto_backup_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(R.string.auto_backup_description),
style = MaterialTheme.typography.bodySmall
)
if (snapshots.isEmpty()) {
Text(
text = stringResource(R.string.auto_backup_empty),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
} else {
snapshots.forEach { snapshot ->
OutlinedButton(
onClick = { onSelect(snapshot) },
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = formatTimestampToPersianDateTime(snapshot.timestamp),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(
R.string.auto_backup_item_summary,
snapshot.personCount.toString().toPersianDigits(),
snapshot.paymentCount.toString().toPersianDigits()
),
style = MaterialTheme.typography.bodySmall
)
Text(
text = stringResource(snapshotReasonLabel(snapshot.reason)),
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) }
}
}
}
}
}
}
/** ترجمه‌ی علتِ ذخیره‌شده در نام فایل به متن قابل فهم کاربر. */
private fun snapshotReasonLabel(reason: String): Int = when (reason) {
BackupManager.REASON_BEFORE_SYNC -> R.string.auto_backup_reason_before_sync
BackupManager.REASON_BEFORE_RESTORE -> R.string.auto_backup_reason_before_restore
BackupManager.REASON_APP_UPGRADE -> R.string.auto_backup_reason_app_upgrade
else -> R.string.auto_backup_reason_unknown
}
@Composable
fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
var isEditing by remember { mutableStateOf(false) }
@@ -267,7 +427,16 @@ fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
Column(modifier = Modifier.padding(12.dp)) {
OutlinedTextField(
value = amountText,
onValueChange = { amountText = it.filter { c -> c.isDigit() } },
onValueChange = { value ->
val digitsOnly = value.filter { it.isDigit() || it in '\u0660'..'\u0669' || it in '\u06f0'..'\u06f9' }
amountText = digitsOnly.map {
when (it) {
in '\u0660'..'\u0669' -> (it.code - '\u0660'.code + '0'.code).toChar()
in '\u06f0'..'\u06f9' -> (it.code - '\u06f0'.code + '0'.code).toChar()
else -> it
}
}.joinToString("")
},
modifier = Modifier.fillMaxWidth(),
label = { Text("مبلغ پیش‌فرض (تومان)") },
visualTransformation = PersianNumberVisualTransformation(),
@@ -339,6 +508,7 @@ fun ReminderDaySetting(currentDay: Int?, onSave: (String) -> Unit) {
fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
var isAdding by remember { mutableStateOf(false) }
var newCardText by remember { mutableStateOf("") }
var cardToRemove by remember { mutableStateOf<String?>(null) }
Card(
modifier = Modifier.fillMaxWidth(),
@@ -366,11 +536,23 @@ 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 = { onRemove(card) }) { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) }
IconButton(onClick = { cardToRemove = card }) { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) }
}
}
}
}
cardToRemove?.let { card ->
ConfirmDeleteDialog(
title = "حذف شماره کارت",
message = "آیا از حذف شماره کارت ${card.toPersianDigits()} مطمئن هستید؟",
onConfirm = {
onRemove(card)
cardToRemove = null
},
onDismiss = { cardToRemove = null }
)
}
}
@Composable
@@ -5,6 +5,7 @@ package com.oqba26.monthlypaymentapp.ui.screens
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -47,6 +48,7 @@ fun EditPaymentDialog(
) {
var amountText by remember { mutableStateOf(paymentRecord.amount.toLong().toString()) }
var description by remember { mutableStateOf(paymentRecord.description ?: "") }
var showDeleteConfirm by remember { mutableStateOf(false) }
Dialog(onDismissRequest = onDismiss) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
@@ -125,15 +127,90 @@ fun EditPaymentDialog(
) {
Text("لغو")
}
}
IconButton(
onClick = onDelete,
) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Default.Delete,
contentDescription = "حذف",
tint = MaterialTheme.colorScheme.error
Button(
onClick = { showDeleteConfirm = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(androidx.compose.material.icons.Icons.Default.Delete, contentDescription = null)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("حذف این پرداخت")
}
}
}
}
}
if (showDeleteConfirm) {
ConfirmDeleteDialog(
title = "حذف پرداخت",
message = "آیا از حذف این رکورد پرداخت مطمئن هستید؟",
onConfirm = {
onDelete()
showDeleteConfirm = false
},
onDismiss = { showDeleteConfirm = false }
)
}
}
@Composable
fun ConfirmDeleteDialog(
title: String,
message: String,
onConfirm: () -> 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 = title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onDismiss,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Text("لغو")
}
Button(
onClick = onConfirm,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("حذف")
}
}
}
@@ -153,6 +230,7 @@ fun AddPaymentDialog(
) {
var rawAmount by remember { mutableStateOf(defaultAmount.toLong().toString()) }
var description by remember { mutableStateOf(initialDescription) }
var showDeleteConfirm by remember { mutableStateOf(false) }
val title =
if (onDelete != null) "ویرایش پرداخت برای $personName" else "ثبت پرداخت برای $personName"
@@ -220,7 +298,7 @@ fun AddPaymentDialog(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (onDelete != null) {
Button(
onClick = onDelete,
onClick = { showDeleteConfirm = true },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
@@ -242,4 +320,16 @@ fun AddPaymentDialog(
}
}
}
if (showDeleteConfirm && onDelete != null) {
ConfirmDeleteDialog(
title = "حذف پرداخت",
message = "آیا از حذف این رکورد پرداخت برای $personName مطمئن هستید؟",
onConfirm = {
onDelete()
showDeleteConfirm = false
},
onDismiss = { showDeleteConfirm = false }
)
}
}
@@ -29,12 +29,12 @@ data class ContactUiState(
val selectedCardForBulk: String? = null,
val showBulkSmsDialog: Boolean = false,
val personForSmsDialog: PersonUiModel? = null,
val bulkSmsQueueForDialog: List<PersonUiModel> = emptyList()
val bulkSmsQueueForDialog: List<PersonUiModel> = emptyList(),
)
@HiltViewModel
class ContactViewModel @Inject constructor(
@ApplicationContext private val context: Context,
@param:ApplicationContext private val context: Context,
private val networkRepository: NetworkRepository,
private val settingsRepository: SettingsRepository,
private val smsManager: SmsManager
@@ -148,7 +148,7 @@ class ContactViewModel @Inject constructor(
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (cursor.moveToNext()) {
if (nameIndex != -1 && numberIndex != -1) {
if ((nameIndex != -1) && (numberIndex != -1)) {
val contactName = cursor.getString(nameIndex) ?: ""
val rawNumber = cursor.getString(numberIndex) ?: ""
@@ -163,7 +163,20 @@ class ContactViewModel @Inject constructor(
fun confirmContactSuggestion(personId: String, personName: String, phoneNumber: String) {
viewModelScope.launch {
val statusCode = networkRepository.updatePerson(personId, personName, phoneNumber)
// دریافت اطلاعات فعلی شخص برای حفظ مقادیر دیگر
val currentPerson = networkRepository.getPersonsFlow().first().find { it.id == personId }
val commitment = currentPerson?.monthlyCommitment ?: 0.0
val startMonth = currentPerson?.startMonth ?: 1
val startYear = currentPerson?.startYear ?: 1403
val statusCode = networkRepository.updatePerson(
personId,
personName,
phoneNumber,
commitment,
startMonth,
startYear
)
if (statusCode == 200) {
networkRepository.refresh()
_uiState.update { state ->
@@ -1,15 +1,14 @@
package com.oqba26.monthlypaymentapp.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.oqba26.monthlypaymentapp.core.manager.BackupManager
import com.oqba26.monthlypaymentapp.core.manager.ExportManager
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
@@ -38,9 +37,13 @@ class PersonViewModel @Inject constructor(
private val networkRepository: NetworkRepository,
private val localPersonRepository: LocalPersonRepository,
private val settingsRepository: SettingsRepository,
private val exportManager: ExportManager
private val exportManager: ExportManager,
private val backupManager: BackupManager,
) : ViewModel() {
/** فقط یک بار در هر اجرا snapshot قبل از ادغام گرفته می‌شود. */
private var snapshotTakenThisSession = false
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
@@ -49,6 +52,8 @@ class PersonViewModel @Inject constructor(
private val _selectedPersonId = MutableStateFlow<String?>(null)
private val _selectedYear = MutableStateFlow(getCurrentShamsiYear())
private val _currentCategory = MutableStateFlow("salary")
val currentCategory: StateFlow<String> = _currentCategory.asStateFlow()
private val _dashboardData = MutableStateFlow(DashboardUiModel())
val dashboardData: StateFlow<DashboardUiModel> = _dashboardData.asStateFlow()
@@ -85,22 +90,31 @@ class PersonViewModel @Inject constructor(
combine(
localPersonRepository.getAllPersonsFlow(),
localPersonRepository.getAllPaymentsFlow(),
_selectedYear
) { persons, allPayments, year ->
_selectedYear,
_currentCategory
) { persons, allPayments, year, category ->
val person = persons.find { it.id == personId }
val personCategory = person?.category ?: category
val paymentsForPersonInYear = allPayments.filter {
it.personId == personId && it.shamsiYear == year
(it.personId == personId) && (it.shamsiYear == year) && (it.category == personCategory)
}
val currentYear = getCurrentShamsiYear()
val currentMonth = getCurrentShamsiMonth()
val currentDay = getCurrentShamsiDay()
val monthStates = (1..12).map { month ->
val payment = paymentsForPersonInYear.find { it.shamsiMonth == month }
// بررسی شروع تعهد
val isBeforeCommitment = if (person != null) {
year < person.startYear || (year == person.startYear && month < person.startMonth)
} else false
val status = when {
payment != null -> MonthStatus.PAID
isBeforeCommitment -> MonthStatus.NOT_COMMITTED
year < currentYear -> MonthStatus.PAST_YEAR
year > currentYear -> MonthStatus.FUTURE_YEAR
month > currentMonth || (month == currentMonth && currentDay < 20) -> MonthStatus.FUTURE_MONTH
payment != null -> MonthStatus.PAID
else -> MonthStatus.AVAILABLE
}
MonthUiModel(month, payment, status)
@@ -119,12 +133,24 @@ class PersonViewModel @Inject constructor(
networkRepository.observeRealtimeChanges()
networkRepository.refresh()
// شرط قبلی `persons.isNotEmpty()` بود که دو مشکل داشت: هم لیست خالیِ
// معتبر سرور را نادیده می‌گرفت، و هم اگر واکشی شکست می‌خورد (چون refresh
// خطا را می‌بلعید) روی داده‌ی قدیمی/ناقص merge می‌کرد.
// حالا فقط بعد از یک refresh موفق merge می‌کنیم و خودِ merge هم غیرمخرب است.
combine(
networkRepository.getPersonsFlow(),
networkRepository.getPaymentsFlow()
) { persons, payments ->
if (persons.isNotEmpty()) {
localPersonRepository.syncAll(persons, payments)
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 ->
@@ -141,8 +167,9 @@ class PersonViewModel @Inject constructor(
combine(
localPersonRepository.getAllPersonsFlow(),
localPersonRepository.getAllPaymentsFlow(),
_searchQuery
) { persons, allPayments, query ->
_searchQuery,
_currentCategory
) { persons, allPayments, query, category ->
val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear()
val currentDay = getCurrentShamsiDay()
@@ -151,30 +178,59 @@ class PersonViewModel @Inject constructor(
val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear
val relevantPayments = allPayments.filter {
it.shamsiMonth == targetMonth && it.shamsiYear == targetYear
it.shamsiMonth == targetMonth && it.shamsiYear == targetYear && it.category == category
}
val activePersons = persons
.filter { !it.isArchived }
.filter { !it.isArchived && it.category == category }
.sortedWith(compareBy({ it.displayOrder }, { it.createdAt }))
val uiModels = activePersons.map { person ->
val hasPaid = relevantPayments.any { it.personId == person.id }
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
val unpaidMonthsInCurrentYear = (1..debtEndMonth).filter { m ->
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m }
}
val (debtCount, totalDebtAmount, unpaidMonths) = if (category == "mosque") {
if (person.monthlyCommitment > 0) {
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
val debtStartMonth = if (person.startYear == currentYear) person.startMonth else 1
val unpaid = (debtStartMonth..debtEndMonth).filter { m ->
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category }
}
Triple(unpaid.size, unpaid.size * person.monthlyCommitment, unpaid.map { getPersianMonthName(it) })
} else {
Triple(0, 0.0, emptyList())
}
} else {
// Salary logic
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
val debtStartMonth = if (person.startYear == currentYear) person.startMonth else 1
val unpaid = (debtStartMonth..debtEndMonth).filter { m ->
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category }
}
val defaultAmount = settingsRepository.defaultPaymentAmountFlow.first()
Triple(unpaid.size, unpaid.size * defaultAmount, unpaid.map { getPersianMonthName(it) })
}
val displayName = if (person.isAnonymous && category == "mosque") {
"خیر ناشناس (${person.name})"
} else {
person.name
}
PersonUiModel(
id = person.id,
name = person.name,
name = displayName,
hasPaidThisMonth = hasPaid,
displayOrder = person.displayOrder,
createdAt = person.createdAt,
debtCount = unpaidMonthsInCurrentYear.size,
totalDebtAmount = unpaidMonthsInCurrentYear.size * (settingsRepository.defaultPaymentAmountFlow.first()),
debtCount = debtCount,
totalDebtAmount = totalDebtAmount,
phoneNumber = person.phoneNumber,
unpaidMonthsNames = unpaidMonthsInCurrentYear.map { getPersianMonthName(it) }
isAnonymous = person.isAnonymous,
monthlyCommitment = person.monthlyCommitment,
unpaidMonthsNames = unpaidMonths,
needsSync = person.needsSync
)
}
@@ -185,8 +241,21 @@ class PersonViewModel @Inject constructor(
val filteredPaid = if (query.isBlank()) paidModels else paidModels.filter { it.name.contains(query, ignoreCase = true) }
val filteredUnpaid = if (query.isBlank()) unpaidModels else unpaidModels.filter { it.name.contains(query, ignoreCase = true) }
val archivedPersons = persons.filter { it.isArchived }.map {
PersonUiModel(it.id, it.name, false, it.displayOrder, it.createdAt)
val archivedPersons = persons.filter { it.isArchived && it.category == category }.map { person ->
val displayName = if (person.isAnonymous && category == "mosque") {
"خیر ناشناس (${person.name})"
} else {
person.name
}
PersonUiModel(
id = person.id,
name = displayName,
hasPaidThisMonth = false,
displayOrder = person.displayOrder,
createdAt = person.createdAt,
isAnonymous = person.isAnonymous,
needsSync = person.needsSync
)
}
PersonListUiState(
@@ -224,12 +293,46 @@ class PersonViewModel @Inject constructor(
}
is PersonScreenEvent.AddPerson -> {
if (event.name.isNotBlank()) {
val person = Person(name = event.name, phoneNumber = event.phoneNumber, displayOrder = 0)
val statusCode = networkRepository.addPerson(person)
if (statusCode == 409) _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
else if (statusCode != 200 && statusCode != 201) _toastMessage.emit("خطا در افزودن شخص.")
else networkRepository.refresh()
if (event.isAnonymous || event.name.isNotBlank()) {
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
if (isDuplicate) {
_toastMessage.emit("خطا: این نام از قبل وجود دارد.")
} else {
val personId = java.util.UUID.randomUUID().toString()
val person = Person(
id = personId,
name = finalName,
phoneNumber = event.phoneNumber,
displayOrder = 0,
category = _currentCategory.value,
isAnonymous = event.isAnonymous,
monthlyCommitment = if (event.isAnonymous) 0.0 else event.monthlyCommitment,
startMonth = event.startMonth,
startYear = event.startYear,
createdAt = System.currentTimeMillis()
)
localPersonRepository.insertPersonLocally(person)
// اگر مبلغ اولیه وارد شده باشد (مخصوصا برای ناشناس)
if (event.initialPaymentAmount > 0) {
val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear()
val record = createPaymentRecord(
personId = personId,
month = currentMonth,
year = currentYear,
amount = event.initialPaymentAmount,
description = if (event.isAnonymous) "کمک آنی خیر ناشناس" else "پرداخت اولیه"
).copy(category = _currentCategory.value)
localPersonRepository.insertPaymentLocally(record)
}
}
}
}
@@ -241,8 +344,8 @@ class PersonViewModel @Inject constructor(
val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear
val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description)
if (networkRepository.addPayment(record)) networkRepository.refresh()
else _toastMessage.emit("خطا در ثبت پرداخت.")
.copy(category = _currentCategory.value)
localPersonRepository.insertPaymentLocally(record)
}
is PersonScreenEvent.SelectPerson -> {
@@ -251,28 +354,38 @@ class PersonViewModel @Inject constructor(
}
is PersonScreenEvent.DeletePerson -> {
if (networkRepository.deletePersonAndPayments(event.personId)) networkRepository.refresh()
localPersonRepository.deletePersonLocally(event.personId)
}
is PersonScreenEvent.ArchivePerson -> {
if (networkRepository.updatePersonArchivedStatus(event.personId, true)) {
_toastMessage.emit("شخص به آرشیو انتقال یافت.")
networkRepository.refresh()
}
localPersonRepository.archivePersonLocally(event.personId, true)
_toastMessage.emit("شخص به آرشیو انتقال یافت.")
}
is PersonScreenEvent.RestorePerson -> {
if (networkRepository.updatePersonArchivedStatus(event.personId, false)) {
_toastMessage.emit("شخص از آرشیو بازیابی شد.")
networkRepository.refresh()
}
localPersonRepository.archivePersonLocally(event.personId, false)
_toastMessage.emit("شخص از آرشیو بازیابی شد.")
}
is PersonScreenEvent.UpdatePerson -> {
val statusCode = networkRepository.updatePerson(event.personId, event.name, event.phoneNumber)
if (statusCode == 409) _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
else if (statusCode == 200) networkRepository.refresh()
else _toastMessage.emit("خطا در ویرایش اطلاعات.")
val persons = localPersonRepository.getAllPersonsFlow().first()
val isDuplicate = persons.any { it.id != event.personId && it.category == _currentCategory.value && it.name.trim().equals(event.name.trim(), ignoreCase = true) }
if (isDuplicate) {
_toastMessage.emit("خطا: این نام از قبل وجود دارد.")
} else {
val currentPerson = persons.find { it.id == event.personId }
if (currentPerson != null) {
val updatedPerson = currentPerson.copy(
name = event.name,
phoneNumber = event.phoneNumber,
monthlyCommitment = event.monthlyCommitment,
startMonth = event.startMonth,
startYear = event.startYear
)
localPersonRepository.updatePersonLocally(updatedPerson)
}
}
}
is PersonScreenEvent.ChangeYear -> {
@@ -286,20 +399,21 @@ 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)
if (networkRepository.addPayment(record)) networkRepository.refresh()
else _toastMessage.emit("خطا در ثبت پرداخت")
.copy(category = category)
localPersonRepository.insertPaymentLocally(record)
}
is PersonScreenEvent.UpdatePayment -> {
val updatedRecord = event.payment.copy(amount = event.newAmount, description = event.newDescription, timestamp = System.currentTimeMillis())
if (networkRepository.addPayment(updatedRecord)) networkRepository.refresh()
else _toastMessage.emit("خطا در ویرایش پرداخت")
localPersonRepository.insertPaymentLocally(updatedRecord)
}
is PersonScreenEvent.DeletePayment -> {
if (networkRepository.deletePayment(event.payment.id)) networkRepository.refresh()
else _toastMessage.emit("خطا در حذف پرداخت")
localPersonRepository.deletePaymentLocally(event.payment.id)
_toastMessage.emit("پرداخت با موفقیت حذف شد")
}
is PersonScreenEvent.ToggleSelection -> {
@@ -320,22 +434,48 @@ class PersonViewModel @Inject constructor(
_isSelectionMode.value = false
}
is PersonScreenEvent.ExportYearlyReport -> exportYearlyReport(event.year, event.context)
is PersonScreenEvent.ExportYearlyReport -> exportYearlyReport(event.year)
is PersonScreenEvent.AddBulkPayments -> {
try {
val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == event.personId }
val category = person?.category ?: _currentCategory.value
event.months.forEach { month ->
val record = createPaymentRecord(
personId = event.personId,
month = month,
year = event.year,
amount = event.amount,
description = "پرداخت دسته جمعی"
).copy(category = category)
localPersonRepository.insertPaymentLocally(record)
}
} catch (_: Exception) {
_toastMessage.emit("خطا در ثبت پرداخت‌های دسته جمعی")
}
}
is PersonScreenEvent.SetCategory -> {
_currentCategory.value = event.category
_selectedIds.value = emptySet()
_isSelectionMode.value = false
}
is PersonScreenEvent.CommitReorder -> {
val list = if (event.listType == PersonListType.UNPAID) _uiState.value.unpaidPersons else _uiState.value.paidPersons
if (list.size < 2) return@launch
try {
list.forEachIndexed { index, person ->
list.forEachIndexed { index, personUi ->
val newOrder = (index + 1) * 10_000L
if (person.displayOrder != newOrder) {
networkRepository.updatePersonDisplayOrder(person.id, newOrder)
if (personUi.displayOrder != newOrder) {
val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == personUi.id }
person?.let {
localPersonRepository.updatePersonLocally(it.copy(displayOrder = newOrder))
}
}
}
networkRepository.refresh()
} catch (_: Exception) {
_toastMessage.emit("خطا در ذخیره ترتیب")
networkRepository.refresh()
}
}
@@ -349,7 +489,7 @@ class PersonViewModel @Inject constructor(
}
}
private fun exportYearlyReport(year: Int, context: Context) {
private fun exportYearlyReport(year: Int) {
viewModelScope.launch {
val persons = localPersonRepository.getAllPersonsFlow().first()
val payments = localPersonRepository.getAllPaymentsFlow().first().filter { it.shamsiYear == year }
@@ -2,15 +2,19 @@ package com.oqba26.monthlypaymentapp.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.oqba26.monthlypaymentapp.core.manager.BackupManager
import com.oqba26.monthlypaymentapp.core.manager.SnapshotInfo
import com.oqba26.monthlypaymentapp.data.model.BackupData
import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
@@ -19,7 +23,8 @@ import kotlinx.serialization.json.Json
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val localPersonRepository: LocalPersonRepository
private val localPersonRepository: LocalPersonRepository,
private val backupManager: BackupManager
) : ViewModel() {
private val json = Json {
@@ -98,10 +103,32 @@ class SettingsViewModel @Inject constructor(
return json.encodeToString(backupData)
}
// ---------------------------------------------- نسخه‌های پشتیبان خودکار (تور نجات)
private val _snapshots = MutableStateFlow<List<SnapshotInfo>>(emptyList())
val snapshots: StateFlow<List<SnapshotInfo>> = _snapshots.asStateFlow()
fun loadSnapshots() {
viewModelScope.launch { _snapshots.value = backupManager.listSnapshots() }
}
fun restoreSnapshot(info: SnapshotInfo) {
viewModelScope.launch {
val ok = backupManager.restoreSnapshot(info.file)
_toastMessage.emit(
if (ok) "اطلاعات از نسخه‌ی خودکار بازگردانی شد. لطفاً برنامه را مجدد اجرا کنید."
else "بازگردانی ناموفق بود؛ فایل پشتیبان سالم نیست."
)
loadSnapshots()
}
}
fun restoreFromBackupJson(jsonString: String) {
viewModelScope.launch {
try {
val backupData = json.decodeFromString<BackupData>(jsonString)
// قبل از جایگزینی، وضعیت فعلی نگه داشته می‌شود تا راه برگشت بسته نشود.
backupManager.createSnapshot(BackupManager.REASON_BEFORE_RESTORE)
localPersonRepository.restoreBackup(backupData)
_toastMessage.emit("اطلاعات با موفقیت بازیابی شد! لطفاً برنامه را مجدد اجرا کنید.")
} catch (_: Exception) {
@@ -1,6 +1,5 @@
package com.oqba26.monthlypaymentapp.viewmodel
import android.content.Context
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
@@ -13,7 +12,10 @@ data class PersonUiModel(
val debtCount: Int = 0,
val totalDebtAmount: Double = 0.0,
val phoneNumber: String? = null,
val unpaidMonthsNames: List<String> = emptyList()
val isAnonymous: Boolean = false,
val monthlyCommitment: Double = 0.0,
val unpaidMonthsNames: List<String> = emptyList(),
val needsSync: Boolean = false,
)
data class PersonListUiState(
@@ -55,7 +57,8 @@ enum class MonthStatus {
AVAILABLE,
FUTURE_MONTH,
PAST_YEAR,
FUTURE_YEAR
FUTURE_YEAR,
NOT_COMMITTED
}
enum class PersonListType {
@@ -65,8 +68,23 @@ enum class PersonListType {
sealed class PersonScreenEvent {
data object RefreshData : PersonScreenEvent()
data class AddPerson(val name: String, val phoneNumber: String?, val context: Context) : PersonScreenEvent()
data class UpdatePerson(val personId: String, val name: String, val phoneNumber: String?) : PersonScreenEvent()
data class AddPerson(
val name: String,
val phoneNumber: String?,
val isAnonymous: Boolean = false,
val monthlyCommitment: Double = 0.0,
val startMonth: Int = 1,
val startYear: Int = 1403,
val initialPaymentAmount: Double = 0.0
) : PersonScreenEvent()
data class UpdatePerson(
val personId: String,
val name: String,
val phoneNumber: String?,
val monthlyCommitment: Double = 0.0,
val startMonth: Int = 1,
val startYear: Int = 1403
) : PersonScreenEvent()
data class DeletePerson(val personId: String) : PersonScreenEvent()
data class ArchivePerson(val personId: String) : PersonScreenEvent()
data class RestorePerson(val personId: String) : PersonScreenEvent()
@@ -110,14 +128,13 @@ sealed class PersonScreenEvent {
val year: Int,
val amount: Double
) : PersonScreenEvent()
data class ExportYearlyReport(val year: Int, val context: Context) : PersonScreenEvent()
data class ExportYearlyReport(val year: Int) : PersonScreenEvent()
data class ToggleSelection(val personId: String) : PersonScreenEvent()
data class MoveSelected(val direction: Int, val listType: PersonListType) : PersonScreenEvent()
data object ClearSelection : PersonScreenEvent()
data class ConfirmContactSuggestion(val personId: String, val personName: String, val phoneNumber: String) : PersonScreenEvent()
data class DismissContactSuggestion(val suggestion: ContactSuggestion) : PersonScreenEvent()
data class SetCategory(val category: String) : PersonScreenEvent()
}
data class DashboardUiModel(
+13
View File
@@ -21,6 +21,19 @@
<string name="backup_error">خطا در ایجاد فایل پشتیبان</string>
<string name="restore_warning">هشدار بازیابی</string>
<string name="restore_warning_text">با بازیابی، تمام داده‌های فعلی حذف و اطلاعات فایل جایگزین می‌شود. مطمئن هستید؟</string>
<!-- نسخه‌های پشتیبان خودکار (تور نجات) -->
<string name="auto_backup_button">نسخه‌های خودکار</string>
<string name="auto_backup_title">بازگردانی از نسخه‌ی خودکار</string>
<string name="auto_backup_description">برنامه قبل از هر عملیات پرخطر، خودش یک نسخه از اطلاعات شما ذخیره می‌کند. اگر داده‌ای گم شد، می‌توانید از اینجا برش گردانید.</string>
<string name="auto_backup_empty">هنوز نسخه‌ی خودکاری ذخیره نشده است.</string>
<string name="auto_backup_item_summary">%1$s نفر، %2$s پرداخت</string>
<string name="auto_backup_restore_confirm">با بازگردانی این نسخه، اطلاعات فعلی جایگزین می‌شود. مطمئن هستید؟</string>
<string name="auto_backup_reason_before_sync">قبل از همگام‌سازی با سرور</string>
<string name="auto_backup_reason_before_restore">قبل از بازیابی نسخه‌ی پشتیبان</string>
<string name="auto_backup_reason_app_upgrade">بعد از به‌روزرسانی برنامه</string>
<string name="auto_backup_reason_unknown">نسخه‌ی خودکار</string>
<string name="close">بستن</string>
<string name="logout">خروج از حساب کاربری</string>
<string name="default_payment_amount">مبلغ پیش‌فرض پرداخت</string>
<string name="reminder_day">روز یادآور ماهانه</string>
@@ -0,0 +1,202 @@
package com.oqba26.monthlypaymentapp.data.sync
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* تست‌های [SyncMerger].
*
* تمرکز این تست‌ها روی یک چیز است: **هیچ مسیری نباید داده‌ی سینک‌نشده را پاک کند.**
* این همان باگی است که در نسخهٔ قبلی بی‌صدا پرداخت‌های کاربر را از بین می‌برد.
*/
class SyncMergerTest {
private fun person(
id: String,
name: String = "شخص $id",
needsSync: Boolean = false
) = Person(id = id, name = name, needsSync = needsSync)
private fun payment(
id: String,
personId: String = "p1",
amount: Double = 1000.0,
needsSync: Boolean = false
) = PaymentRecord(
id = id,
personId = personId,
amount = amount,
shamsiYear = 1404,
shamsiMonth = 5,
needsSync = needsSync
)
// ---------------------------------------------------------------- اشخاص
@Test
fun `شخص در صف سینک حذف نمی شود حتی اگر در سرور نباشد`() {
val local = listOf(person("a"), person("b"))
val server = listOf(person("a"))
val plan = SyncMerger.planPersons(local, server, pendingIds = setOf("b"))
assertFalse("شخص b در صف است و نباید حذف شود", plan.deleteIds.contains("b"))
assertEquals(emptyList<String>(), plan.deleteIds)
}
@Test
fun `شخص با needsSync حذف نمی شود حتی اگر صف خالی باشد`() {
// سناریوی بازماندهٔ باگ قبلی: صف از دست رفته ولی پرچم روی رکورد هست.
val local = listOf(person("a"), person("b", needsSync = true))
val server = listOf(person("a"))
val plan = SyncMerger.planPersons(local, server, pendingIds = emptySet())
assertEquals(emptyList<String>(), plan.deleteIds)
}
@Test
fun `داده سرور روی شخص در صف بازنویسی نمی شود`() {
val local = listOf(person("a", name = "نام محلی جدید", needsSync = true))
val server = listOf(person("a", name = "نام قدیمی سرور"))
val plan = SyncMerger.planPersons(local, server, pendingIds = setOf("a"))
assertTrue("رکورد در صف نباید upsert شود", plan.upserts.isEmpty())
}
@Test
fun `شخص محلی غیر pending که در سرور نیست حذف می شود`() {
val local = listOf(person("a"), person("b"))
val server = listOf(person("a"))
val plan = SyncMerger.planPersons(local, server, pendingIds = emptySet())
assertEquals(listOf("b"), plan.deleteIds)
}
@Test
fun `سرور خالی هیچ رکورد محافظت شده ای را پاک نمی کند`() {
val local = listOf(
person("a", needsSync = true),
person("b"),
person("c")
)
val plan = SyncMerger.planPersons(local, server = emptyList(), pendingIds = setOf("b"))
// a با needsSync و b از طریق صف محافظت شده‌اند؛ فقط c حذف می‌شود.
assertEquals(listOf("c"), plan.deleteIds)
assertTrue(plan.upserts.isEmpty())
}
@Test
fun `upsert پرچم needsSync را پاک می کند`() {
val plan = SyncMerger.planPersons(
local = listOf(person("a", needsSync = true)),
server = listOf(person("a")),
pendingIds = emptySet()
)
// needsSync محلی محافظت می‌کند، پس upsert خالی است.
assertTrue(plan.upserts.isEmpty())
// ولی وقتی محافظتی نباشد، رکورد سرور باید needsSync = false داشته باشد.
val clean = SyncMerger.planPersons(
local = listOf(person("a")),
server = listOf(person("a", needsSync = true)),
pendingIds = emptySet()
)
assertEquals(1, clean.upserts.size)
assertFalse(clean.upserts.first().needsSync)
}
@Test
fun `شخص سرور بدون شناسه به همنام محلی وصل می شود`() {
val local = listOf(person("local-id", name = "علی رضایی"))
val server = listOf(person("", name = " علی رضایی "))
val plan = SyncMerger.planPersons(
local, server, pendingIds = emptySet(),
idGenerator = { "شناسه-جدید-نباید-استفاده-شود" }
)
assertEquals(listOf("local-id"), plan.upserts.map { it.id })
assertEquals("علی رضایی", plan.upserts.first().name)
// چون شناسه به رکورد محلی نگاشت شد، نباید حذفی رخ دهد.
assertEquals(emptyList<String>(), plan.deleteIds)
}
@Test
fun `شخص سرور بدون شناسه و بدون همنام محلی شناسه جدید می گیرد`() {
val plan = SyncMerger.planPersons(
local = emptyList(),
server = listOf(person("", name = "شخص تازه")),
pendingIds = emptySet(),
idGenerator = { "شناسه-تولیدی" }
)
assertEquals(listOf("شناسه-تولیدی"), plan.upserts.map { it.id })
}
// -------------------------------------------------------------- پرداخت‌ها
@Test
fun `پرداخت آفلاین کاربر با merge از بین نمی رود`() {
// این دقیقاً همان سناریویی است که قبلاً داده را نابود می‌کرد:
// کاربر آفلاین پرداخت ثبت می‌کند، سرور هنوز خبر ندارد، merge اجرا می‌شود.
val offlinePayment = payment("جدید-آفلاین", needsSync = true)
val local = listOf(payment("قدیمی-سینک-شده"), offlinePayment)
val server = listOf(payment("قدیمی-سینک-شده"))
val plan = SyncMerger.planPayments(local, server, pendingIds = setOf("جدید-آفلاین"))
assertEquals(emptyList<String>(), plan.deleteIds)
assertFalse(plan.upserts.any { it.id == "جدید-آفلاین" })
}
@Test
fun `مبلغ ویرایش شده محلی با داده قدیمی سرور بازنویسی نمی شود`() {
val local = listOf(payment("x", amount = 500_000.0, needsSync = true))
val server = listOf(payment("x", amount = 200_000.0))
val plan = SyncMerger.planPayments(local, server, pendingIds = setOf("x"))
assertTrue(plan.upserts.isEmpty())
}
@Test
fun `پرداخت حذف شده در سرور از گوشی هم حذف می شود`() {
val local = listOf(payment("x"), payment("y"))
val server = listOf(payment("x"))
val plan = SyncMerger.planPayments(local, server, pendingIds = emptySet())
assertEquals(listOf("y"), plan.deleteIds)
}
@Test
fun `سرور خالی پرداخت های در صف را نگه می دارد`() {
val local = listOf(
payment("pending", needsSync = true),
payment("synced")
)
val plan = SyncMerger.planPayments(local, server = emptyList(), pendingIds = emptySet())
assertEquals(listOf("synced"), plan.deleteIds)
}
@Test
fun `merge بدون تغییر نقشه خالی می دهد`() {
val rows = listOf(payment("x"), payment("y"))
val plan = SyncMerger.planPayments(rows, rows, pendingIds = emptySet())
assertEquals(emptyList<String>(), plan.deleteIds)
assertEquals(2, plan.upserts.size) // upsert بی‌ضرر است، حذف نیست
}
}