اصلاح مهاجرت دیتابیس، مرتبسازی تبها و بهروزرسانی عناوین
- رفع تداخل در مهاجرت دیتابیس Room با اصلاح بازه fallbackToDestructiveMigrationFrom. - حذف فایل MigrationTest.kt جهت پاکسازی پروژه. - تغییر ترتیب تبها: انتقال تب 'آرشیو' به کنار تبهای 'پرداخت شده' و 'پرداخت نشده'. - اصلاح عناوین TopAppBar: نمایش عناوین اختصاصی برای صفحات 'آرشیو'، 'تنظیمات' و 'مسجد' به جای عنوان عمومی.
This commit is contained in:
1 parent
913b8ca083
commit
271e9ad117
88 files changed
+2565
-568
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+118
-47
@@ -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()
|
||||
}
|
||||
+114
-42
@@ -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(
|
||||
|
||||
@@ -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 بیضرر است، حذف نیست
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user