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

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

No files matched your search

+24 -13
View File
@@ -1,17 +1,28 @@
# Gradle files
.gradle/
build/
# IntelliJ IDEA files
.idea/
*.iml *.iml
.gradle
/local.properties # Local configuration
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties local.properties
keystore.properties keystore.properties
# Android Studio captures
captures/
# External native builds
.externalNativeBuild/
.cxx/
# Key store files
*.jks *.jks
*.keystore
# Assistant Artifacts
.artifacts/
# System files
.DS_Store
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PlanningModeManager">
<option name="approvalStates">
<map>
<entry key="15926d7b-894e-47f6-892f-6114218cde2d" value="false" />
<entry key="b6b8bd31-26e8-4cfd-8648-5c332ef07ef3" value="false" />
</map>
</option>
</component>
</project>
@@ -1 +0,0 @@
o/bundleLibRuntimeToDirDebug
@@ -1 +0,0 @@
o/classes
@@ -1 +0,0 @@
o/classes
@@ -1 +0,0 @@
o/bundleLibRuntimeToDirDebug
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.github.samanzamani.persiandate" >
<uses-sdk android:minSdkVersion="21" />
<application android:label="@string/app_name" >
</application>
</manifest>
@@ -1,18 +0,0 @@
{
"version": 3,
"artifactType": {
"type": "AAPT_FRIENDLY_MERGED_MANIFESTS",
"kind": "Directory"
},
"applicationId": "com.github.samanzamani.persiandate",
"variantName": "debug",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"outputFile": "AndroidManifest.xml"
}
],
"elementType": "File"
}
@@ -1,6 +0,0 @@
aarFormatVersion=1.0
aarMetadataVersion=1.0
minCompileSdk=1
minCompileSdkExtension=0
minAndroidGradlePluginVersion=1.0.0
coreLibraryDesugaringEnabled=false
@@ -1 +0,0 @@
int string app_name 0x0
@@ -1 +0,0 @@
#Fri Jul 31 20:09:38 GMT+03:30 2026
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PersianDate</string>
</resources>
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main" generated-set="main$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\res"><file path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\res\values\strings.xml" qualifiers=""><string name="app_name">PersianDate</string></file></source></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\debug\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug" generated-set="debug$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\debug\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\build\generated\res\resValues\debug"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated" generated-set="generated$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\build\generated\res\resValues\debug"/></dataSet><mergedItems/></merger>
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\jniLibs"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\debug\jniLibs"/></dataSet></merger>
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\shaders"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\debug\shaders"/></dataSet></merger>
@@ -1,2 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\assets"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\debug\assets"/></dataSet><dataSet config="generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\build\intermediates\shader_assets\debug\compileDebugShaders\out"/></dataSet></merger>
@@ -1,3 +0,0 @@
R_DEF: Internal format may change without notice
local
string app_name
@@ -1,12 +0,0 @@
1<?xml version="1.0" encoding="utf-8"?>
2<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3 package="com.github.samanzamani.persiandate" >
4
5 <uses-sdk android:minSdkVersion="21" />
6
7 <application android:label="@string/app_name" >
7-->E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml:5:5-7:19
7-->E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml:5:18-50
8 </application>
9
10</manifest>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.github.samanzamani.persiandate" >
<uses-sdk android:minSdkVersion="21" />
<application android:label="@string/app_name" >
</application>
</manifest>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PersianDate</string>
</resources>
@@ -1,2 +0,0 @@
com.github.samanzamani.persiandate
string app_name
@@ -1,20 +0,0 @@
-- Merging decision tree log ---
manifest
ADDED from E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml:1:1-9:12
INJECTED from E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml:1:1-9:12
package
INJECTED from E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml
xmlns:android
ADDED from E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml:1:11-69
application
ADDED from E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml:5:5-7:19
android:label
ADDED from E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml:5:18-50
uses-sdk
INJECTED from E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml reason: use-sdk injection requested
INJECTED from E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml
INJECTED from E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml
android:targetSdkVersion
INJECTED from E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml
android:minSdkVersion
INJECTED from E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml
+12
View File
@@ -84,6 +84,16 @@ android {
packaging { packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } 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 { dependencies {
@@ -143,6 +153,8 @@ dependencies {
// Testing // Testing
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.androidTest.junit.ext) androidTestImplementation(libs.androidTest.junit.ext)
androidTestImplementation(libs.androidTest.espresso.core) androidTestImplementation(libs.androidTest.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) 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.Icons
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.automirrored.filled.Message 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.Add
import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
@@ -68,6 +69,8 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight 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 androidx.compose.ui.unit.LayoutDirection
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -183,6 +186,11 @@ fun MainAppHost(viewModel: PersonViewModel) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show() 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 // UI elements for update and permissions
@@ -265,9 +273,18 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route ?: "" 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( val showBottomBar = currentRoute in listOf(
Screen.Unpaid.route, Screen.Unpaid.route,
Screen.Paid.route, Screen.Paid.route,
Screen.Mosque.route,
Screen.Archive.route, Screen.Archive.route,
Screen.Settings.route Screen.Settings.route
) )
@@ -358,10 +375,17 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
title = { title = {
val titleText = when { val titleText = when {
isSelectionMode -> "انتخاب شده‌ها" isSelectionMode -> "انتخاب شده‌ها"
currentRoute == Screen.Settings.route -> "تنظیمات" else -> {
val currentRoute = navController.currentBackStackEntry?.destination?.route
when (currentRoute) {
Screen.Mosque.route -> "مدیریت کمک‌های مسجد"
Screen.Settings.route -> "تنظیمات"
Screen.Archive.route -> "آرشیو"
else -> "مدیریت حقوق ماهانه" else -> "مدیریت حقوق ماهانه"
} }
Text(titleText) }
}
Text(titleText)
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = if (isSelectionMode) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.primary, containerColor = if (isSelectionMode) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.primary,
@@ -390,7 +414,7 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
IconButton(onClick = { showConfirmMoveDialog = true }) { IconButton(onClick = { showConfirmMoveDialog = true }) {
Icon(Icons.Default.Check, contentDescription = "Confirm Move", tint = Color.Green) Icon(Icons.Default.Check, contentDescription = "Confirm Move", tint = Color.Green)
} }
} else if (currentRoute == Screen.Unpaid.route) { } else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) {
FilledIconButton( FilledIconButton(
onClick = { viewModel.onAddPersonClicked() }, onClick = { viewModel.onAddPersonClicked() },
colors = IconButtonDefaults.filledIconButtonColors( colors = IconButtonDefaults.filledIconButtonColors(
@@ -437,6 +461,13 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
navController = navController navController = navController
) )
} }
composable(Screen.Mosque.route) {
PersonScreen(
viewModel = viewModel,
contactViewModel = contactViewModel,
navController = navController
)
}
composable(Screen.Paid.route) { composable(Screen.Paid.route) {
PaidListScreen( PaidListScreen(
viewModel = viewModel, viewModel = viewModel,
@@ -475,17 +506,19 @@ fun AppBottomNavigationBar(navController: NavController) {
Screen.Unpaid, Screen.Unpaid,
Screen.Paid, Screen.Paid,
Screen.Archive, Screen.Archive,
Screen.Mosque,
Screen.Settings Screen.Settings
) )
NavigationBar( NavigationBar(
containerColor = MaterialTheme.colorScheme.primary // Match TopAppBar containerColor = MaterialTheme.colorScheme.primary
) { ) {
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination val currentDestination = navBackStackEntry?.destination
items.forEach { screen -> items.forEach { screen ->
NavigationBarItem( NavigationBarItem(
alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(
selectedTextColor = Color.White, selectedTextColor = Color.White,
selectedIconColor = Color.White, selectedIconColor = Color.White,
@@ -494,7 +527,15 @@ fun AppBottomNavigationBar(navController: NavController) {
unselectedIconColor = Color.White.copy(alpha = 0.7f) unselectedIconColor = Color.White.copy(alpha = 0.7f)
), ),
icon = { Icon(screen.icon, contentDescription = screen.title) }, 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, selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = { onClick = {
navController.navigate(screen.route) { navController.navigate(screen.route) {
@@ -517,6 +558,7 @@ sealed class Screen(
) { ) {
data object Unpaid : Screen("unpaid_list", "پرداخت نشده", Icons.AutoMirrored.Filled.List) data object Unpaid : Screen("unpaid_list", "پرداخت نشده", Icons.AutoMirrored.Filled.List)
data object Paid : Screen("paid_list", "پرداخت شده", Icons.Default.Paid) 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 Archive : Screen("archive", "آرشیو", Icons.Default.Archive)
data object Settings : Screen("settings", "تنظیمات", Icons.Default.Settings) data object Settings : Screen("settings", "تنظیمات", Icons.Default.Settings)
} }
@@ -1,7 +1,59 @@
package com.oqba26.monthlypaymentapp.core package com.oqba26.monthlypaymentapp.core
import android.app.Application 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 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 @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 package com.oqba26.monthlypaymentapp.core.di
import android.content.Context import android.content.Context
import androidx.room.Room
import com.oqba26.monthlypaymentapp.data.dao.PaymentDao import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
import com.oqba26.monthlypaymentapp.data.dao.PersonDao import com.oqba26.monthlypaymentapp.data.dao.PersonDao
import com.oqba26.monthlypaymentapp.data.database.ALL_MIGRATIONS
import com.oqba26.monthlypaymentapp.data.database.AppDatabase import com.oqba26.monthlypaymentapp.data.database.AppDatabase
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -18,7 +20,18 @@ object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { 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 @Provides
@@ -30,4 +43,9 @@ object DatabaseModule {
fun providePaymentDao(database: AppDatabase): PaymentDao { fun providePaymentDao(database: AppDatabase): PaymentDao {
return database.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( fun provideLocalPersonRepository(
personDao: PersonDao, personDao: PersonDao,
paymentDao: PaymentDao, paymentDao: PaymentDao,
syncQueueDao: com.oqba26.monthlypaymentapp.data.dao.SyncQueueDao,
database: AppDatabase database: AppDatabase
): LocalPersonRepository { ): LocalPersonRepository {
return LocalPersonRepository(personDao, paymentDao, database) return LocalPersonRepository(personDao, paymentDao, syncQueueDao, database)
} }
@Provides @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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAllPaymentRecords(payments: List<PaymentRecord>) 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") @Query("DELETE FROM payments")
suspend fun deleteAllPaymentRecords() suspend fun deleteAllPaymentRecords()
} }
@@ -28,6 +28,13 @@ interface PersonDao {
@Query("UPDATE persons SET displayOrder = :displayOrder WHERE id = :personId") @Query("UPDATE persons SET displayOrder = :displayOrder WHERE id = :personId")
suspend fun updateDisplayOrder(personId: String, displayOrder: Int) 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") @Query("DELETE FROM persons")
suspend fun deleteAll() 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 package com.oqba26.monthlypaymentapp.data.database
import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.Transaction
import com.oqba26.monthlypaymentapp.data.dao.PaymentDao import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
import com.oqba26.monthlypaymentapp.data.dao.PersonDao 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.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person 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( @Database(
entities = [Person::class, PaymentRecord::class], entities = [Person::class, PaymentRecord::class, SyncQueue::class],
version = 13, // حتماً نسخه را بالا ببری که اسکیمای جدید اعمال شود version = 18,
exportSchema = false exportSchema = true
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun personDao(): PersonDao abstract fun personDao(): PersonDao
abstract fun paymentDao(): PaymentDao abstract fun paymentDao(): PaymentDao
abstract fun syncQueueDao(): SyncQueueDao
@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)
}
companion object { companion object {
@Volatile const val DATABASE_NAME = "monthly_payment_db"
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
}
}
} }
} }
@@ -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("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("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 { httpConfig {
install(HttpTimeout) { install(HttpTimeout) {
requestTimeoutMillis = 30000 requestTimeoutMillis = 60000
connectTimeoutMillis = 30000 connectTimeoutMillis = 60000
socketTimeoutMillis = 30000 socketTimeoutMillis = 60000
} }
} }
@@ -5,17 +5,22 @@ package com.oqba26.monthlypaymentapp.data.repository
import androidx.room.withTransaction import androidx.room.withTransaction
import com.oqba26.monthlypaymentapp.data.dao.PaymentDao import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
import com.oqba26.monthlypaymentapp.data.dao.PersonDao 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.database.AppDatabase
import com.oqba26.monthlypaymentapp.data.model.BackupData import com.oqba26.monthlypaymentapp.data.model.BackupData
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person 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.Flow
import kotlinx.coroutines.flow.first
import java.util.UUID import java.util.UUID
class LocalPersonRepository( class LocalPersonRepository(
private val personDao: PersonDao, private val personDao: PersonDao,
private val paymentDao: PaymentDao, private val paymentDao: PaymentDao,
private val syncQueueDao: SyncQueueDao,
private val database: AppDatabase private val database: AppDatabase
) { ) {
// Reads // Reads
@@ -25,53 +30,35 @@ class LocalPersonRepository(
fun getPaymentsForPersonFlow(personId: String): Flow<List<PaymentRecord>> = fun getPaymentsForPersonFlow(personId: String): Flow<List<PaymentRecord>> =
paymentDao.getPaymentsForPersonFlow(personId) paymentDao.getPaymentsForPersonFlow(personId)
suspend fun syncPersons(persons: List<Person>) { /**
val current = getAllPersonsFlow().first() * ادغام غیرمخربِ داده‌ی سرور با داده‌ی محلی.
val currentByName = current.associateBy { normalize(it.name) } *
* جانشین `syncAll` قبلی است که اول `deleteAll()` می‌زد و بعد داده‌ی سرور را درج می‌کرد؛
val safePersons = persons.map { p -> * آن رفتار هر تغییر محلیِ هنوز-ارسال-نشده را نابود می‌کرد.
val name = p.name.trim() *
val norm = normalize(name) * اینجا هیچ `deleteAll`ی وجود ندارد. تصمیمِ «چه چیزی حذف شود» را [SyncMerger] می‌گیرد
val resolvedId = when { * (که تست واحد دارد) و رکوردهای موجود در صف سینک یا دارای `needsSync` را کنار می‌گذارد.
p.id.isNotBlank() -> p.id *
currentByName.containsKey(norm) -> currentByName[norm]!!.id * فقط با داده‌ی **معتبرِ** سرور صدا زده شود — یعنی بعد از اینکه
else -> UUID.randomUUID().toString() * [com.oqba26.monthlypaymentapp.data.repository.NetworkRepository.refresh] مقدار `true` برگردانده باشد.
} */
p.copy(id = resolvedId, name = name) suspend fun mergeFromServer(persons: List<Person>, payments: List<PaymentRecord>) {
}
personDao.insertAll(safePersons)
}
suspend fun syncPayments(payments: List<PaymentRecord>) =
paymentDao.insertAllPaymentRecords(payments)
// ⭐️ تابع جدید و اصلاح شده برای همگام‌سازی کامل
suspend fun syncAll(persons: List<Person>, payments: List<PaymentRecord>) {
database.withTransaction { 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 personPlan = SyncMerger.planPersons(localPersons, persons, pendingIds)
val current = getAllPersonsFlow().first() val paymentPlan = SyncMerger.planPayments(localPayments, payments, pendingIds)
val currentByName = current.associateBy { normalize(it.name) }
val safePersons = persons.map { p -> // پرداخت‌ها اول حذف می‌شوند تا اگر کلید خارجی اضافه شد، ترتیب درست باشد.
val name = p.name.trim() if (paymentPlan.deleteIds.isNotEmpty()) paymentDao.deleteByIds(paymentPlan.deleteIds)
val norm = normalize(name) if (personPlan.deleteIds.isNotEmpty()) personDao.deleteByIds(personPlan.deleteIds)
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 (personPlan.upserts.isNotEmpty()) personDao.insertAll(personPlan.upserts)
personDao.deleteAll() if (paymentPlan.upserts.isNotEmpty()) paymentDao.insertAllPaymentRecords(paymentPlan.upserts)
paymentDao.deleteAllPaymentRecords()
// ۳. درج داده‌های جدید
personDao.insertAll(safePersons)
paymentDao.insertAllPaymentRecords(payments)
} }
} }
@@ -82,21 +69,105 @@ class LocalPersonRepository(
return BackupData(persons = persons, payments = payments) return BackupData(persons = persons, payments = payments)
} }
/**
* بازگردانی کامل از یک نسخه‌ی پشتیبان.
*
* برخلاف [mergeFromServer]، این عملیات عمداً مخرب است — کاربر صریحاً خواسته وضعیت فعلی
* با نسخه‌ی پشتیبان جایگزین شود. صدازننده باید قبلش snapshot بگیرد
* ([com.oqba26.monthlypaymentapp.core.manager.BackupManager.restoreSnapshot] این کار را می‌کند).
*
* صف سینک هم بازسازی می‌شود. قبلاً دست‌نخورده می‌ماند و دو مشکل داشت:
* - آیتم‌های قدیمی به رکوردهای پاک‌شده اشاره می‌کردند.
* - داده‌ی بازیابی‌شده هرگز به سرور نمی‌رفت، پس اولین سینکِ بعدی دوباره پاکش می‌کرد.
*/
suspend fun restoreBackup(backupData: BackupData) { suspend fun restoreBackup(backupData: BackupData) {
database.withTransaction { database.withTransaction {
personDao.deleteAll() personDao.deleteAll()
paymentDao.deleteAllPaymentRecords() paymentDao.deleteAllPaymentRecords()
syncQueueDao.deleteAll()
val safePersons = backupData.persons.map { p -> val safePersons = backupData.persons.map { p ->
val name = p.name.trim() val name = p.name.trim()
val id = p.id.ifBlank { UUID.randomUUID().toString() } 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) personDao.insertAll(safePersons)
paymentDao.insertAllPaymentRecords(backupData.payments) paymentDao.insertAllPaymentRecords(safePayments)
// همه‌ی رکوردهای بازیابی‌شده باید دوباره به سرور برسند.
safePersons.forEach {
syncQueueDao.insert(
SyncQueue(entityId = it.id, type = SyncType.PERSON, operation = SyncOperation.INSERT)
)
}
safePayments.forEach {
syncQueueDao.insert(
SyncQueue(entityId = it.id, type = SyncType.PAYMENT, operation = SyncOperation.INSERT)
)
}
} }
} }
private fun normalize(name: String): String = name.trim().lowercase() // Local-First Write Operations
suspend fun insertPersonLocally(person: Person) {
database.withTransaction {
personDao.insertAll(listOf(person.copy(needsSync = true)))
syncQueueDao.insert(SyncQueue(entityId = person.id, type = SyncType.PERSON, operation = SyncOperation.INSERT))
}
}
suspend fun updatePersonLocally(person: Person) {
database.withTransaction {
personDao.insertAll(listOf(person.copy(needsSync = true)))
syncQueueDao.insert(SyncQueue(entityId = person.id, type = SyncType.PERSON, operation = SyncOperation.UPDATE))
}
}
suspend fun archivePersonLocally(personId: String, isArchived: Boolean) {
database.withTransaction {
personDao.updateArchivedStatus(personId, isArchived)
syncQueueDao.insert(SyncQueue(entityId = personId, type = SyncType.PERSON, operation = SyncOperation.UPDATE))
}
}
suspend fun deletePersonLocally(personId: String) {
database.withTransaction {
personDao.deleteById(personId)
syncQueueDao.insert(SyncQueue(entityId = personId, type = SyncType.PERSON, operation = SyncOperation.DELETE))
}
}
suspend fun insertPaymentLocally(payment: PaymentRecord) {
database.withTransaction {
paymentDao.insertAllPaymentRecords(listOf(payment.copy(needsSync = true)))
syncQueueDao.insert(SyncQueue(entityId = payment.id, type = SyncType.PAYMENT, operation = SyncOperation.INSERT))
}
}
suspend fun deletePaymentLocally(paymentId: String) {
database.withTransaction {
paymentDao.deleteById(paymentId)
syncQueueDao.insert(SyncQueue(entityId = paymentId, type = SyncType.PAYMENT, operation = SyncOperation.DELETE))
}
}
/** خواندن یک‌باره (نه Flow) — برای استفاده داخل تراکنش و در [com.oqba26.monthlypaymentapp.data.sync.SyncManager]. */
suspend fun getAllPersons(): List<Person> = personDao.getAllPersons()
suspend fun getAllPayments(): List<PaymentRecord> = paymentDao.getAllPayments()
fun getSyncQueueFlow() = syncQueueDao.getAllPendingFlow()
suspend fun getPendingSyncItems() = syncQueueDao.getAllPending()
suspend fun removeSyncItem(item: SyncQueue) = syncQueueDao.delete(item)
/** ثبت تلاش ناموفق روی آیتم صف — آیتم نگه داشته می‌شود تا دور بعد. */
suspend fun recordSyncAttempt(itemId: String, attemptCount: Int, at: Long) =
syncQueueDao.recordAttempt(itemId, attemptCount, at)
/** حذف چند آیتم صف با هم — برای زمانی که coalesce آن‌ها را بی‌اثر تشخیص می‌دهد. */
suspend fun removeSyncItems(items: List<SyncQueue>) {
database.withTransaction { items.forEach { syncQueueDao.delete(it) } }
}
suspend fun clearSyncQueue() = syncQueueDao.deleteAll()
} }
@@ -20,6 +20,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.UUID import java.util.UUID
class NetworkRepository { class NetworkRepository {
@@ -29,20 +31,31 @@ class NetworkRepository {
private val _paymentsFlow = MutableStateFlow<List<PaymentRecord>>(emptyList()) private val _paymentsFlow = MutableStateFlow<List<PaymentRecord>>(emptyList())
private val repositoryScope = CoroutineScope(Dispatchers.IO) 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 --- // --- Realtime Operations ---
fun observeRealtimeChanges() { fun observeRealtimeChanges() {
repositoryScope.launch { repositoryScope.launch {
try { try {
// اطمینان از اتصال قبل از اشتراک در کانال
supabase.realtime.connect() supabase.realtime.connect()
val myChannel = supabase.channel("db-changes") val myChannel = supabase.channel("db-changes")
// گوش دادن به تغییرات جدول اشخاص // گوش دادن به تغییرات جدول اشخاص
launch { myChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
myChannel.postgresChangeFlow<PostgresAction>(schema = "public") { table = "persons"
table = "persons" }.collect {
}.collect { Log.d("Supabase-Realtime", "Change detected in persons table")
refresh() refresh()
}
} }
// گوش دادن به تغییرات جدول پرداخت‌ها // گوش دادن به تغییرات جدول پرداخت‌ها
@@ -50,13 +63,15 @@ class NetworkRepository {
myChannel.postgresChangeFlow<PostgresAction>(schema = "public") { myChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
table = "payments" table = "payments"
}.collect { }.collect {
Log.d("Supabase-Realtime", "Change detected in payments table")
refresh() refresh()
} }
} }
myChannel.subscribe() myChannel.subscribe()
Log.d("Supabase-Realtime", "Subscribed to all changes")
} catch (e: Exception) { } 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 --- // --- Person Operations ---
suspend fun addPerson(person: Person): Int? { /**
* ارسال شخص به سرور با upsert.
*
* upsert (و نه insert) عمداً انتخاب شده تا این عملیات idempotent باشد: اگر تلاش اول
* به سرور رسیده باشد ولی پاسخش به‌خاطر timeout به ما نرسیده باشد، تلاش دوم رکورد
* تکراری نمی‌سازد. صف سینک متکی به همین خاصیت است تا بتواند بی‌خطر retry کند.
*
* اعتبارسنجی تکراری‌بودن نام اینجا انجام نمی‌شود؛ آن کار در PersonViewModel روی
* داده‌ی محلی (که همیشه کامل است) انجام می‌گیرد. کش _personsFlow ممکن است خالی یا
* قدیمی باشد و نتیجه‌ی غلط بدهد.
*/
suspend fun addPerson(person: Person): Boolean {
return try { return try {
val trimmedName = person.name.trim() val trimmedName = person.name.trim()
if (trimmedName.isEmpty()) return 400 if (trimmedName.isEmpty()) {
Log.w("Supabase", "addPerson skipped: blank name")
return false
}
// بررسی تکراری بودن نام در لیست موجود val personToUpsert = if (person.id.isEmpty()) {
val isDuplicate = _personsFlow.value.any { it.name.trim().equals(trimmedName, ignoreCase = true) }
if (isDuplicate) return 409
val personToInsert = if (person.id.isEmpty()) {
person.copy(id = UUID.randomUUID().toString(), name = trimmedName, createdAt = System.currentTimeMillis()) person.copy(id = UUID.randomUUID().toString(), name = trimmedName, createdAt = System.currentTimeMillis())
} else { } else {
person.copy(name = trimmedName) person.copy(name = trimmedName)
} }
supabase.from("persons").insert(personToInsert) supabase.from("persons").upsert(personToUpsert)
201 true
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Supabase", "addPerson error", e) 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 { return try {
val trimmedName = name.trim() val trimmedName = name.trim()
if (trimmedName.isEmpty()) return 400 if (trimmedName.isEmpty()) return 400
// بررسی تکراری بودن نام (به جز خودِ این شخص) val currentPerson = _personsFlow.value.find { it.id == personId }
val currentCategory = currentPerson?.category ?: "salary"
// بررسی تکراری بودن نام در همان دسته‌بندی (به جز خودِ این شخص)
val isDuplicate = _personsFlow.value.any { 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 if (isDuplicate) return 409
supabase.from("persons").update( supabase.from("persons").update(
mapOf( buildJsonObject {
"name" to trimmedName, put("name", trimmedName)
"phoneNumber" to phoneNumber?.trim() put("phoneNumber", phoneNumber?.trim())
) put("monthlyCommitment", monthlyCommitment)
put("startMonth", startMonth)
put("startYear", startYear)
},
) { ) {
filter { eq("id", personId) } filter { eq("id", personId) }
} }
@@ -154,7 +194,9 @@ class NetworkRepository {
suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean { suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean {
return try { return try {
supabase.from("persons").update( supabase.from("persons").update(
mapOf("isArchived" to isArchived) buildJsonObject {
put("isArchived", isArchived)
}
) { ) {
filter { eq("id", personId) } filter { eq("id", personId) }
} }
@@ -167,7 +209,9 @@ class NetworkRepository {
suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean { suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean {
return try { return try {
supabase.from("persons").update( supabase.from("persons").update(
mapOf("displayOrder" to displayOrder) buildJsonObject {
put("displayOrder", displayOrder)
}
) { ) {
filter { eq("id", personId) } filter { eq("id", personId) }
} }
@@ -180,9 +224,14 @@ class NetworkRepository {
fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow() fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow()
// --- Payment Operations --- // --- Payment Operations ---
/**
* ارسال پرداخت به سرور با upsert — به همان دلیل idempotent بودن که در [addPerson] توضیح داده شد.
* ضمناً همین باعث می‌شود ویرایش پرداخت (SyncOperation.UPDATE) هم درست کار کند؛ قبلاً insert
* بود و روی رکورد موجود شکست می‌خورد.
*/
suspend fun addPayment(paymentRecord: PaymentRecord): Boolean { suspend fun addPayment(paymentRecord: PaymentRecord): Boolean {
return try { return try {
supabase.from("payments").insert(paymentRecord) supabase.from("payments").upsert(paymentRecord)
true true
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Supabase", "addPayment error", e); false Log.e("Supabase", "addPayment error", e); false
@@ -190,33 +239,56 @@ class NetworkRepository {
} }
suspend fun deletePayment(paymentId: String): Boolean { suspend fun deletePayment(paymentId: String): Boolean {
return try { var retries = 0
supabase.from("payments").delete { val maxRetries = 2
filter { eq("id", paymentId) }
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() 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>() val persons = supabase.from("persons").select().decodeList<Person>()
_personsFlow.value = persons
// دریافت و تبدیل خودکار لیست پرداخت‌ها
val payments = supabase.from("payments").select { val payments = supabase.from("payments").select {
order("timestamp", order = Order.DESCENDING) order("timestamp", order = Order.DESCENDING)
}.decodeList<PaymentRecord>() }.decodeList<PaymentRecord>()
// فقط بعد از موفقیت هر دو واکشی مقداردهی می‌کنیم
_personsFlow.value = persons
_paymentsFlow.value = payments _paymentsFlow.value = payments
_hasServerData.value = true
Log.d("Supabase", "Refresh Success: ${persons.size} persons, ${payments.size} payments") Log.d("Supabase", "Refresh Success: ${persons.size} persons, ${payments.size} payments")
true
} catch (e: Exception) { } 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.Preferences
import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") 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 CARD_NUMBERS = stringPreferencesKey("card_numbers")
val IGNORED_CONTACT_SUGGESTIONS = stringPreferencesKey("ignored_contact_suggestions") 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 FALLBACK_AMOUNT = 200000.0
const val DEFAULT_FONT = "Estedad" 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 { val reminderDayFlow: Flow<Int?> = dataStore.data.map {
it[REMINDER_DAY]?.toIntOrNull() 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 -> personToDelete?.let { person ->
Dialog( ConfirmDeleteDialog(
onDismissRequest = { personToDelete = null } title = "حذف قطعی شخص",
) { message = "آیا از حذف قطعی ${person.name} و تمامی سوابق پرداخت او مطمئن هستید؟ این عمل غیرقابل بازگشت است.",
CompositionLocalProvider( onConfirm = {
LocalLayoutDirection provides LayoutDirection.Rtl viewModel.onEvent(PersonScreenEvent.DeletePerson(person.id))
) { personToDelete = null
Card( },
modifier = Modifier.fillMaxWidth().padding(16.dp), onDismiss = { personToDelete = null }
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("حذف قطعی")
}
}
}
}
}
}
} }
// دیالوگ تایید بازیابی // دیالوگ تایید بازیابی
@@ -42,6 +42,13 @@ fun AuthScreen(
val authState by viewModel.authState.collectAsState() val authState by viewModel.authState.collectAsState()
val isLoading = authState is AuthState.Loading val isLoading = authState is AuthState.Loading
var isLoginMode by remember { mutableStateOf(true) } 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( Column(
modifier = Modifier modifier = Modifier
@@ -34,9 +34,12 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -47,6 +50,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
@@ -92,18 +97,21 @@ fun PersonDetailScreen(
val detailState by viewModel.personDetailState.collectAsState() val detailState by viewModel.personDetailState.collectAsState()
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0) val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
val context = LocalContext.current
var expandedMonth by remember { mutableStateOf<Int?>(null) } var expandedMonth by remember { mutableStateOf<Int?>(null) }
var editingPayment by remember { mutableStateOf<PaymentRecord?>(null) } var editingPayment by remember { mutableStateOf<PaymentRecord?>(null) }
var addingPaymentMonth by remember { mutableStateOf<Int?>(null) } var addingPaymentMonth by remember { mutableStateOf<Int?>(null) }
var editingPerson by remember { mutableStateOf(value = false) } var editingPerson by remember { mutableStateOf(value = false) }
var showBulkPaymentDialog by remember { mutableStateOf(false) } var showBulkPaymentDialog by remember { mutableStateOf(value = false) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
val displayName = detailState.person?.let { p ->
if (p.isAnonymous && (p.category == "mosque")) "خیر ناشناس (${p.name})" else p.name
} ?: "..."
PersonDetailHeader( PersonDetailHeader(
name = detailState.person?.name ?: "...", name = displayName,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() },
) { editingPerson = true } ) { editingPerson = true }
if (detailState.person == null) { if (detailState.person == null) {
@@ -113,14 +121,15 @@ fun PersonDetailScreen(
} else { } else {
YearSelector( YearSelector(
year = detailState.selectedYear, year = detailState.selectedYear,
onYearChange = { offset -> ) { offset ->
viewModel.onEvent(PersonScreenEvent.ChangeYear(offset)) viewModel.onEvent(PersonScreenEvent.ChangeYear(offset))
} }
)
val availableMonths = detailState.monthStates val availableMonths = detailState.monthStates
.asSequence()
.filter { it.status == MonthStatus.AVAILABLE } .filter { it.status == MonthStatus.AVAILABLE }
.map { it.month } .map { it.month }
.toList()
if (availableMonths.isNotEmpty()) { if (availableMonths.isNotEmpty()) {
Button( Button(
@@ -140,6 +149,8 @@ fun PersonDetailScreen(
items(detailState.monthStates) { monthModel -> items(detailState.monthStates) { monthModel ->
MonthListItem( MonthListItem(
month = monthModel, month = monthModel,
personName = detailState.person?.name ?: "",
startMonth = detailState.person?.startMonth ?: 1,
onHeaderClick = { onHeaderClick = {
expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month
}, },
@@ -190,7 +201,9 @@ fun PersonDetailScreen(
AddPaymentDialog( AddPaymentDialog(
personName = detailState.person?.name ?: "", personName = detailState.person?.name ?: "",
defaultAmount = defaultPaymentAmount, defaultAmount = if ((detailState.person?.monthlyCommitment ?: 0.0) > 0)
detailState.person!!.monthlyCommitment
else defaultPaymentAmount,
initialDescription = initialDesc, initialDescription = initialDesc,
onConfirm = { amount, description -> onConfirm = { amount, description ->
viewModel.onEvent( viewModel.onEvent(
@@ -204,18 +217,21 @@ fun PersonDetailScreen(
) )
addingPaymentMonth = null addingPaymentMonth = null
}, },
onDismiss = { onDismiss = { addingPaymentMonth = null }
addingPaymentMonth = null
}
) )
} }
if (editingPerson) { if (editingPerson) {
val currentCategory by viewModel.currentCategory.collectAsState()
UpdatePersonDialog( UpdatePersonDialog(
initialName = detailState.person?.name ?: "", initialName = detailState.person?.name ?: "",
initialPhone = detailState.person?.phoneNumber, initialPhone = detailState.person?.phoneNumber,
onConfirm = { name, phone -> initialCommitment = detailState.person?.monthlyCommitment ?: 0.0,
viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone)) 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 editingPerson = false
}, },
onDismiss = { editingPerson = false }, onDismiss = { editingPerson = false },
@@ -227,12 +243,18 @@ fun PersonDetailScreen(
if (showBulkPaymentDialog) { if (showBulkPaymentDialog) {
val availableMonths = detailState.monthStates val availableMonths = detailState.monthStates
.asSequence()
.filter { it.status == MonthStatus.AVAILABLE } .filter { it.status == MonthStatus.AVAILABLE }
.map { it.month } .map { it.month }
.toList()
val bulkDefaultAmount = if ((detailState.person?.monthlyCommitment ?: 0.0) > 0)
detailState.person!!.monthlyCommitment
else defaultPaymentAmount
BulkPaymentDialog( BulkPaymentDialog(
availableMonths = availableMonths, availableMonths = availableMonths,
defaultAmount = defaultPaymentAmount, defaultAmount = bulkDefaultAmount,
onConfirm = { selected, amount -> onConfirm = { selected, amount ->
viewModel.onEvent( viewModel.onEvent(
PersonScreenEvent.AddBulkPayments( PersonScreenEvent.AddBulkPayments(
@@ -244,8 +266,7 @@ fun PersonDetailScreen(
) )
showBulkPaymentDialog = false showBulkPaymentDialog = false
}, },
onDismiss = { showBulkPaymentDialog = false } ) { showBulkPaymentDialog = false }
)
} }
} }
@@ -254,7 +275,7 @@ fun BulkPaymentDialog(
availableMonths: List<Int>, availableMonths: List<Int>,
defaultAmount: Double, defaultAmount: Double,
onConfirm: (List<Int>, Double) -> Unit, onConfirm: (List<Int>, Double) -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit,
) { ) {
val selectedMonths = remember { mutableStateListOf<Int>().apply { addAll(availableMonths) } } val selectedMonths = remember { mutableStateListOf<Int>().apply { addAll(availableMonths) } }
var amountText by remember { mutableStateOf(defaultAmount.toInt().toString()) } var amountText by remember { mutableStateOf(defaultAmount.toInt().toString()) }
@@ -307,17 +328,28 @@ fun BulkPaymentDialog(
OutlinedTextField( OutlinedTextField(
value = amountText, 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("مبلغ برای هر ماه (تومان)") }, label = { Text("مبلغ برای هر ماه (تومان)") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
visualTransformation = PersianDigitsTransformation() visualTransformation = PersianNumberVisualTransformation()
) )
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
Button( Button(
onClick = { onClick = {
@@ -343,7 +375,7 @@ fun BulkPaymentDialog(
private fun PersonDetailHeader( private fun PersonDetailHeader(
name: String, name: String,
onBack: () -> Unit, onBack: () -> Unit,
onEdit: () -> Unit onEdit: () -> Unit,
) { ) {
Surface(color = MaterialTheme.colorScheme.primary, contentColor = Color.White) { Surface(color = MaterialTheme.colorScheme.primary, contentColor = Color.White) {
Row( Row(
@@ -371,12 +403,22 @@ private fun PersonDetailHeader(
fun UpdatePersonDialog( fun UpdatePersonDialog(
initialName: String, initialName: String,
initialPhone: 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, onDismiss: () -> Unit,
onSearchContact: (String) -> List<ContactMatch> onSearchContact: (String) -> List<ContactMatch>,
) { ) {
var name by remember { mutableStateOf(initialName) } var name by remember { mutableStateOf(initialName) }
var phoneNumber by remember { mutableStateOf(initialPhone ?: "") } 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()) } var similarContacts by remember { mutableStateOf<List<ContactMatch>>(emptyList()) }
val context = LocalContext.current 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()) { if (similarContacts.isNotEmpty()) {
Text( 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
Button( Button(
onClick = { onClick = {
if (name.isNotBlank()) { if (name.isNotBlank()) {
onConfirm(name, phoneNumber) val commitment = monthlyCommitment.toDoubleOrNull() ?: 0.0
onConfirm(name, phoneNumber, commitment, startMonth, startYear)
} }
}, },
enabled = name.isNotBlank() enabled = name.isNotBlank()
@@ -573,19 +685,37 @@ fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
@Composable @Composable
fun MonthListItem( fun MonthListItem(
month: MonthUiModel, month: MonthUiModel,
personName: String,
startMonth: Int,
onHeaderClick: () -> Unit, onHeaderClick: () -> Unit,
onEditClick: () -> Unit onEditClick: () -> Unit
) { ) {
val context = LocalContext.current
val containerColor = when (month.status) { val containerColor = when (month.status) {
MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer
MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.surface MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.surface
MonthStatus.NOT_COMMITTED -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
else -> MaterialTheme.colorScheme.surfaceVariant else -> MaterialTheme.colorScheme.surfaceVariant
} }
Card( 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), 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)) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
Row( Row(
@@ -594,13 +724,18 @@ fun MonthListItem(
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = getPersianMonthName(month.month), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text(
if (month.status == MonthStatus.PAID && month.payment != null) { 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)) Spacer(modifier = Modifier.width(8.dp))
Text(text = "${formatNumberAsPersian(month.payment.amount)} ت", style = MaterialTheme.typography.bodyMedium) 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( Button(
onClick = onEditClick, onClick = onEditClick,
colors = ButtonDefaults.buttonColors( 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 ?: "" val desc = month.payment.description ?: ""
if (desc.isNotBlank()) { if (desc.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp)) 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.automirrored.filled.Message
import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Payments import androidx.compose.material.icons.filled.Payments
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
@@ -41,6 +42,9 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
@@ -56,6 +60,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -97,7 +102,7 @@ import org.burnoutcrew.reorderable.reorderable
fun PersonScreen( fun PersonScreen(
viewModel: PersonViewModel, viewModel: PersonViewModel,
contactViewModel: ContactViewModel = hiltViewModel(), contactViewModel: ContactViewModel = hiltViewModel(),
navController: NavController navController: NavController,
) { ) {
val searchQuery by viewModel.searchQuery.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState()
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
@@ -114,7 +119,7 @@ fun PersonScreen(
val isRefreshing by viewModel.isRefreshing.collectAsState() val isRefreshing by viewModel.isRefreshing.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState() val isSelectionMode by viewModel.isSelectionMode.collectAsState()
val selectedIds by viewModel.selectedIds.collectAsState() val selectedIds by viewModel.selectedIds.collectAsState()
val context = LocalContext.current val currentCategory by viewModel.currentCategory.collectAsState()
LaunchedEffect(uiState.unpaidPersons, uiState.paidPersons) { LaunchedEffect(uiState.unpaidPersons, uiState.paidPersons) {
val allPersons = uiState.unpaidPersons + uiState.paidPersons val allPersons = uiState.unpaidPersons + uiState.paidPersons
@@ -129,13 +134,19 @@ fun PersonScreen(
val reorderableState = rememberReorderableLazyListState( val reorderableState = rememberReorderableLazyListState(
onMove = { from, to -> onMove = { from, to ->
if (searchQuery.isBlank()) { if (searchQuery.isBlank()) {
viewModel.onEvent( val headerOffset = if (currentCategory == "mosque") 1 else 0
PersonScreenEvent.MovePersonNew( val fromIdx = from.index - headerOffset
fromIndex = from.index, val toIdx = to.index - headerOffset
toIndex = to.index,
listType = PersonListType.UNPAID if (fromIdx in uiState.unpaidPersons.indices && toIdx in uiState.unpaidPersons.indices) {
viewModel.onEvent(
PersonScreenEvent.MovePersonNew(
fromIndex = fromIdx,
toIndex = toIdx,
listType = PersonListType.UNPAID
)
) )
) }
} }
}, },
onDragEnd = { _, _ -> onDragEnd = { _, _ ->
@@ -181,6 +192,17 @@ fun PersonScreen(
.fillMaxSize() .fillMaxSize()
.reorderable(reorderableState) .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 -> itemsIndexed(uiState.unpaidPersons, key = { _, person -> person.id }) { index, person ->
ReorderableItem(reorderableState, key = person.id) { isDragging -> ReorderableItem(reorderableState, key = person.id) { isDragging ->
val elevation = animateFloatAsState(if (isDragging) 8f else 0f, label = "").value 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)) } item { Spacer(modifier = Modifier.height(60.dp)) }
} }
} }
@@ -229,7 +291,7 @@ fun PersonScreen(
personForPaymentDialog?.let { person -> personForPaymentDialog?.let { person ->
AddPaymentDialog( AddPaymentDialog(
personName = person.name, personName = person.name,
defaultAmount = defaultPaymentAmount, defaultAmount = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount,
onConfirm = { amount, description -> onConfirm = { amount, description ->
viewModel.onEvent(PersonScreenEvent.AddQuickPayment(person.id, amount, description)) viewModel.onEvent(PersonScreenEvent.AddQuickPayment(person.id, amount, description))
viewModel.onDismissPaymentDialog() viewModel.onDismissPaymentDialog()
@@ -261,9 +323,11 @@ fun PersonScreen(
} }
if (isAddPersonDialogShown) { if (isAddPersonDialogShown) {
val currentCategory by viewModel.currentCategory.collectAsState()
AddNewPersonDialog( AddNewPersonDialog(
onConfirm = { name, phone -> isMosqueCategory = currentCategory == "mosque",
viewModel.onEvent(PersonScreenEvent.AddPerson(name, phone, context)) onConfirm = { name, phone, isAnon, commitment, month, year, initialPayment ->
viewModel.onEvent(PersonScreenEvent.AddPerson(name, phone, isAnon, commitment, month, year, initialPayment))
viewModel.onDismissAddPersonDialog() viewModel.onDismissAddPersonDialog()
}, },
onDismiss = { viewModel.onDismissAddPersonDialog() }, onDismiss = { viewModel.onDismissAddPersonDialog() },
@@ -436,12 +500,23 @@ fun ContactSuggestionDialog(
@Composable @Composable
fun AddNewPersonDialog( fun AddNewPersonDialog(
onConfirm: (String, String) -> Unit, isMosqueCategory: Boolean = false,
onConfirm: (String, String, Boolean, Double, Int, Int, Double) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSearchContact: (String) -> List<ContactMatch> onSearchContact: (String) -> List<ContactMatch>
) { ) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
var phoneNumber 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()) } var similarContacts by remember { mutableStateOf<List<ContactMatch>>(emptyList()) }
val context = LocalContext.current val context = LocalContext.current
@@ -471,97 +546,225 @@ fun AddNewPersonDialog(
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Text(
text = "افزودن شخص جدید", text = if (isAnonymous) "ثبت کمک آنی" else "افزودن شخص جدید",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (isMosqueCategory) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("نام شخص") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row( 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) { Checkbox(
OutlinedTextField( checked = isAnonymous,
value = phoneNumber, onCheckedChange = { isAnonymous = it }
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)) Spacer(modifier = Modifier.width(8.dp))
IconButton( Text("ثبت به صورت خیر ناشناس")
onClick = { }
if (name.isBlank()) { }
Toast.makeText(context, "ابتدا نام را وارد کنید", Toast.LENGTH_SHORT).show()
return@IconButton if (!isAnonymous) {
} Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
when (PackageManager.PERMISSION_GRANTED) { OutlinedTextField(
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) -> { value = name,
val matches = onSearchContact(name) onValueChange = { name = it },
if (matches.isNotEmpty()) { label = { Text("نام شخص") },
similarContacts = matches singleLine = true,
} else { modifier = Modifier.fillMaxWidth()
Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT).show() )
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()) { if (isMosqueCategory) {
Text( if (isAnonymous) {
"موارد مشابه پیدا شده:", OutlinedTextField(
style = MaterialTheme.typography.labelMedium, value = initialPaymentAmount,
color = MaterialTheme.colorScheme.primary 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( } else {
modifier = Modifier.heightIn(max = 200.dp) 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( Box(modifier = Modifier.weight(1f)) {
modifier = Modifier var expandedYear by remember { mutableStateOf(false) }
.fillMaxWidth() OutlinedButton(
.clickable { onClick = { expandedYear = true },
phoneNumber = match.phoneNumber modifier = Modifier.fillMaxWidth()
similarContacts = emptyList()
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
RadioButton( Text("سال ${startYear.toString().toPersianDigits()}")
selected = phoneNumber == match.phoneNumber, }
onClick = { DropdownMenu(
phoneNumber = match.phoneNumber expanded = expandedYear,
similarContacts = emptyList() onDismissRequest = { expandedYear = false }) {
} (currentShamsiYear - 1..currentShamsiYear + 1).forEach { y ->
) DropdownMenuItem(
Column { text = { Text(y.toString().toPersianDigits()) },
Text(match.nameInContacts, style = MaterialTheme.typography.bodySmall) onClick = { startYear = y; expandedYear = false }
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { )
Text( }
match.phoneNumber.toPersianDigits(), }
style = MaterialTheme.typography.labelSmall, }
color = Color.Gray
) // ماه
} 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( Button(
onClick = { onClick = {
if (name.isNotBlank()) { if (isAnonymous || name.isNotBlank()) {
onConfirm(name, phoneNumber) 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( Button(
onClick = onDismiss, 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)) Spacer(Modifier.width(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(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.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel 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.PersianDigitsTransformation
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
import com.oqba26.monthlypaymentapp.utils.toPersianDigits import com.oqba26.monthlypaymentapp.utils.toPersianDigits
@@ -80,6 +83,9 @@ fun SettingsScreen(onLogout: () -> Unit) {
val selectedFont by viewModel.selectedFont.collectAsState() val selectedFont by viewModel.selectedFont.collectAsState()
var backupJsonToRestore by remember { mutableStateOf<String?>(null) } 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) { LaunchedEffect(Unit) {
viewModel.toastMessage.collect { message -> 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)) Spacer(Modifier.weight(1f))
Button( 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 -> backupJsonToRestore?.let { json ->
Dialog(onDismissRequest = { backupJsonToRestore = null }) { Dialog(onDismissRequest = { backupJsonToRestore = null }) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { 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 @Composable
fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) { fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
var isEditing by remember { mutableStateOf(false) } var isEditing by remember { mutableStateOf(false) }
@@ -267,7 +427,16 @@ fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
OutlinedTextField( OutlinedTextField(
value = amountText, 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(), modifier = Modifier.fillMaxWidth(),
label = { Text("مبلغ پیش‌فرض (تومان)") }, label = { Text("مبلغ پیش‌فرض (تومان)") },
visualTransformation = PersianNumberVisualTransformation(), visualTransformation = PersianNumberVisualTransformation(),
@@ -339,6 +508,7 @@ fun ReminderDaySetting(currentDay: Int?, onSave: (String) -> Unit) {
fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) { fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
var isAdding by remember { mutableStateOf(false) } var isAdding by remember { mutableStateOf(false) }
var newCardText by remember { mutableStateOf("") } var newCardText by remember { mutableStateOf("") }
var cardToRemove by remember { mutableStateOf<String?>(null) }
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -366,11 +536,23 @@ fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRem
cardNumbers.forEach { card -> cardNumbers.forEach { card ->
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text(card.toPersianDigits(), modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge) 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 @Composable
@@ -5,6 +5,7 @@ package com.oqba26.monthlypaymentapp.ui.screens
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -47,6 +48,7 @@ fun EditPaymentDialog(
) { ) {
var amountText by remember { mutableStateOf(paymentRecord.amount.toLong().toString()) } var amountText by remember { mutableStateOf(paymentRecord.amount.toLong().toString()) }
var description by remember { mutableStateOf(paymentRecord.description ?: "") } var description by remember { mutableStateOf(paymentRecord.description ?: "") }
var showDeleteConfirm by remember { mutableStateOf(false) }
Dialog(onDismissRequest = onDismiss) { Dialog(onDismissRequest = onDismiss) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
@@ -125,15 +127,90 @@ fun EditPaymentDialog(
) { ) {
Text("لغو") Text("لغو")
} }
}
IconButton( Button(
onClick = onDelete, onClick = { showDeleteConfirm = true },
) { modifier = Modifier.fillMaxWidth(),
Icon( colors = ButtonDefaults.buttonColors(
imageVector = androidx.compose.material.icons.Icons.Default.Delete, containerColor = MaterialTheme.colorScheme.errorContainer,
contentDescription = "حذف", contentColor = MaterialTheme.colorScheme.error
tint = 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 rawAmount by remember { mutableStateOf(defaultAmount.toLong().toString()) }
var description by remember { mutableStateOf(initialDescription) } var description by remember { mutableStateOf(initialDescription) }
var showDeleteConfirm by remember { mutableStateOf(false) }
val title = val title =
if (onDelete != null) "ویرایش پرداخت برای $personName" else "ثبت پرداخت برای $personName" if (onDelete != null) "ویرایش پرداخت برای $personName" else "ثبت پرداخت برای $personName"
@@ -220,7 +298,7 @@ fun AddPaymentDialog(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (onDelete != null) { if (onDelete != null) {
Button( Button(
onClick = onDelete, onClick = { showDeleteConfirm = true },
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error 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 selectedCardForBulk: String? = null,
val showBulkSmsDialog: Boolean = false, val showBulkSmsDialog: Boolean = false,
val personForSmsDialog: PersonUiModel? = null, val personForSmsDialog: PersonUiModel? = null,
val bulkSmsQueueForDialog: List<PersonUiModel> = emptyList() val bulkSmsQueueForDialog: List<PersonUiModel> = emptyList(),
) )
@HiltViewModel @HiltViewModel
class ContactViewModel @Inject constructor( class ContactViewModel @Inject constructor(
@ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val networkRepository: NetworkRepository, private val networkRepository: NetworkRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val smsManager: SmsManager private val smsManager: SmsManager
@@ -148,7 +148,7 @@ class ContactViewModel @Inject constructor(
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
if (nameIndex != -1 && numberIndex != -1) { if ((nameIndex != -1) && (numberIndex != -1)) {
val contactName = cursor.getString(nameIndex) ?: "" val contactName = cursor.getString(nameIndex) ?: ""
val rawNumber = cursor.getString(numberIndex) ?: "" val rawNumber = cursor.getString(numberIndex) ?: ""
@@ -163,7 +163,20 @@ class ContactViewModel @Inject constructor(
fun confirmContactSuggestion(personId: String, personName: String, phoneNumber: String) { fun confirmContactSuggestion(personId: String, personName: String, phoneNumber: String) {
viewModelScope.launch { 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) { if (statusCode == 200) {
networkRepository.refresh() networkRepository.refresh()
_uiState.update { state -> _uiState.update { state ->
@@ -1,15 +1,14 @@
package com.oqba26.monthlypaymentapp.viewmodel package com.oqba26.monthlypaymentapp.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.oqba26.monthlypaymentapp.core.manager.BackupManager
import com.oqba26.monthlypaymentapp.core.manager.ExportManager import com.oqba26.monthlypaymentapp.core.manager.ExportManager
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person import com.oqba26.monthlypaymentapp.data.model.Person
import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
@@ -38,9 +37,13 @@ class PersonViewModel @Inject constructor(
private val networkRepository: NetworkRepository, private val networkRepository: NetworkRepository,
private val localPersonRepository: LocalPersonRepository, private val localPersonRepository: LocalPersonRepository,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val exportManager: ExportManager private val exportManager: ExportManager,
private val backupManager: BackupManager,
) : ViewModel() { ) : ViewModel() {
/** فقط یک بار در هر اجرا snapshot قبل از ادغام گرفته می‌شود. */
private var snapshotTakenThisSession = false
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow() val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
@@ -49,6 +52,8 @@ class PersonViewModel @Inject constructor(
private val _selectedPersonId = MutableStateFlow<String?>(null) private val _selectedPersonId = MutableStateFlow<String?>(null)
private val _selectedYear = MutableStateFlow(getCurrentShamsiYear()) private val _selectedYear = MutableStateFlow(getCurrentShamsiYear())
private val _currentCategory = MutableStateFlow("salary")
val currentCategory: StateFlow<String> = _currentCategory.asStateFlow()
private val _dashboardData = MutableStateFlow(DashboardUiModel()) private val _dashboardData = MutableStateFlow(DashboardUiModel())
val dashboardData: StateFlow<DashboardUiModel> = _dashboardData.asStateFlow() val dashboardData: StateFlow<DashboardUiModel> = _dashboardData.asStateFlow()
@@ -85,22 +90,31 @@ class PersonViewModel @Inject constructor(
combine( combine(
localPersonRepository.getAllPersonsFlow(), localPersonRepository.getAllPersonsFlow(),
localPersonRepository.getAllPaymentsFlow(), localPersonRepository.getAllPaymentsFlow(),
_selectedYear _selectedYear,
) { persons, allPayments, year -> _currentCategory
) { persons, allPayments, year, category ->
val person = persons.find { it.id == personId } val person = persons.find { it.id == personId }
val personCategory = person?.category ?: category
val paymentsForPersonInYear = allPayments.filter { val paymentsForPersonInYear = allPayments.filter {
it.personId == personId && it.shamsiYear == year (it.personId == personId) && (it.shamsiYear == year) && (it.category == personCategory)
} }
val currentYear = getCurrentShamsiYear() val currentYear = getCurrentShamsiYear()
val currentMonth = getCurrentShamsiMonth() val currentMonth = getCurrentShamsiMonth()
val currentDay = getCurrentShamsiDay() val currentDay = getCurrentShamsiDay()
val monthStates = (1..12).map { month -> val monthStates = (1..12).map { month ->
val payment = paymentsForPersonInYear.find { it.shamsiMonth == 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 { val status = when {
payment != null -> MonthStatus.PAID
isBeforeCommitment -> MonthStatus.NOT_COMMITTED
year < currentYear -> MonthStatus.PAST_YEAR year < currentYear -> MonthStatus.PAST_YEAR
year > currentYear -> MonthStatus.FUTURE_YEAR year > currentYear -> MonthStatus.FUTURE_YEAR
month > currentMonth || (month == currentMonth && currentDay < 20) -> MonthStatus.FUTURE_MONTH month > currentMonth || (month == currentMonth && currentDay < 20) -> MonthStatus.FUTURE_MONTH
payment != null -> MonthStatus.PAID
else -> MonthStatus.AVAILABLE else -> MonthStatus.AVAILABLE
} }
MonthUiModel(month, payment, status) MonthUiModel(month, payment, status)
@@ -119,12 +133,24 @@ class PersonViewModel @Inject constructor(
networkRepository.observeRealtimeChanges() networkRepository.observeRealtimeChanges()
networkRepository.refresh() networkRepository.refresh()
// شرط قبلی `persons.isNotEmpty()` بود که دو مشکل داشت: هم لیست خالیِ
// معتبر سرور را نادیده می‌گرفت، و هم اگر واکشی شکست می‌خورد (چون refresh
// خطا را می‌بلعید) روی داده‌ی قدیمی/ناقص merge می‌کرد.
// حالا فقط بعد از یک refresh موفق merge می‌کنیم و خودِ merge هم غیرمخرب است.
combine( combine(
networkRepository.getPersonsFlow(), networkRepository.getPersonsFlow(),
networkRepository.getPaymentsFlow() networkRepository.getPaymentsFlow(),
) { persons, payments -> networkRepository.hasServerDataFlow()
if (persons.isNotEmpty()) { ) { persons, payments, hasServerData ->
localPersonRepository.syncAll(persons, payments) if (hasServerData) {
if (!snapshotTakenThisSession) {
// آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی
// ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد،
// کاربر بتواند از تنظیمات برش گرداند.
backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC)
snapshotTakenThisSession = true
}
localPersonRepository.mergeFromServer(persons, payments)
true true
} else false } else false
}.collect { isUpdated -> }.collect { isUpdated ->
@@ -141,8 +167,9 @@ class PersonViewModel @Inject constructor(
combine( combine(
localPersonRepository.getAllPersonsFlow(), localPersonRepository.getAllPersonsFlow(),
localPersonRepository.getAllPaymentsFlow(), localPersonRepository.getAllPaymentsFlow(),
_searchQuery _searchQuery,
) { persons, allPayments, query -> _currentCategory
) { persons, allPayments, query, category ->
val currentMonth = getCurrentShamsiMonth() val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear() val currentYear = getCurrentShamsiYear()
val currentDay = getCurrentShamsiDay() val currentDay = getCurrentShamsiDay()
@@ -151,30 +178,59 @@ class PersonViewModel @Inject constructor(
val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear
val relevantPayments = allPayments.filter { val relevantPayments = allPayments.filter {
it.shamsiMonth == targetMonth && it.shamsiYear == targetYear it.shamsiMonth == targetMonth && it.shamsiYear == targetYear && it.category == category
} }
val activePersons = persons val activePersons = persons
.filter { !it.isArchived } .filter { !it.isArchived && it.category == category }
.sortedWith(compareBy({ it.displayOrder }, { it.createdAt })) .sortedWith(compareBy({ it.displayOrder }, { it.createdAt }))
val uiModels = activePersons.map { person -> val uiModels = activePersons.map { person ->
val hasPaid = relevantPayments.any { it.personId == person.id } val hasPaid = relevantPayments.any { it.personId == person.id }
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
val unpaidMonthsInCurrentYear = (1..debtEndMonth).filter { m -> val (debtCount, totalDebtAmount, unpaidMonths) = if (category == "mosque") {
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m } 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( PersonUiModel(
id = person.id, id = person.id,
name = person.name, name = displayName,
hasPaidThisMonth = hasPaid, hasPaidThisMonth = hasPaid,
displayOrder = person.displayOrder, displayOrder = person.displayOrder,
createdAt = person.createdAt, createdAt = person.createdAt,
debtCount = unpaidMonthsInCurrentYear.size, debtCount = debtCount,
totalDebtAmount = unpaidMonthsInCurrentYear.size * (settingsRepository.defaultPaymentAmountFlow.first()), totalDebtAmount = totalDebtAmount,
phoneNumber = person.phoneNumber, 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 filteredPaid = if (query.isBlank()) paidModels else paidModels.filter { it.name.contains(query, ignoreCase = true) }
val filteredUnpaid = if (query.isBlank()) unpaidModels else unpaidModels.filter { it.name.contains(query, ignoreCase = true) } val filteredUnpaid = if (query.isBlank()) unpaidModels else unpaidModels.filter { it.name.contains(query, ignoreCase = true) }
val archivedPersons = persons.filter { it.isArchived }.map { val archivedPersons = persons.filter { it.isArchived && it.category == category }.map { person ->
PersonUiModel(it.id, it.name, false, it.displayOrder, it.createdAt) 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( PersonListUiState(
@@ -224,12 +293,46 @@ class PersonViewModel @Inject constructor(
} }
is PersonScreenEvent.AddPerson -> { is PersonScreenEvent.AddPerson -> {
if (event.name.isNotBlank()) { if (event.isAnonymous || event.name.isNotBlank()) {
val person = Person(name = event.name, phoneNumber = event.phoneNumber, displayOrder = 0) val persons = localPersonRepository.getAllPersonsFlow().first()
val statusCode = networkRepository.addPerson(person) val finalName = if (event.isAnonymous && event.name.isBlank()) "ناشناس" else event.name
if (statusCode == 409) _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
else if (statusCode != 200 && statusCode != 201) _toastMessage.emit("خطا در افزودن شخص.") val isDuplicate = if (!event.isAnonymous) {
else networkRepository.refresh() 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 targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear
val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description) val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description)
if (networkRepository.addPayment(record)) networkRepository.refresh() .copy(category = _currentCategory.value)
else _toastMessage.emit("خطا در ثبت پرداخت.") localPersonRepository.insertPaymentLocally(record)
} }
is PersonScreenEvent.SelectPerson -> { is PersonScreenEvent.SelectPerson -> {
@@ -251,28 +354,38 @@ class PersonViewModel @Inject constructor(
} }
is PersonScreenEvent.DeletePerson -> { is PersonScreenEvent.DeletePerson -> {
if (networkRepository.deletePersonAndPayments(event.personId)) networkRepository.refresh() localPersonRepository.deletePersonLocally(event.personId)
} }
is PersonScreenEvent.ArchivePerson -> { is PersonScreenEvent.ArchivePerson -> {
if (networkRepository.updatePersonArchivedStatus(event.personId, true)) { localPersonRepository.archivePersonLocally(event.personId, true)
_toastMessage.emit("شخص به آرشیو انتقال یافت.") _toastMessage.emit("شخص به آرشیو انتقال یافت.")
networkRepository.refresh()
}
} }
is PersonScreenEvent.RestorePerson -> { is PersonScreenEvent.RestorePerson -> {
if (networkRepository.updatePersonArchivedStatus(event.personId, false)) { localPersonRepository.archivePersonLocally(event.personId, false)
_toastMessage.emit("شخص از آرشیو بازیابی شد.") _toastMessage.emit("شخص از آرشیو بازیابی شد.")
networkRepository.refresh()
}
} }
is PersonScreenEvent.UpdatePerson -> { is PersonScreenEvent.UpdatePerson -> {
val statusCode = networkRepository.updatePerson(event.personId, event.name, event.phoneNumber) val persons = localPersonRepository.getAllPersonsFlow().first()
if (statusCode == 409) _toastMessage.emit("خطا: این نام از قبل وجود دارد.") val isDuplicate = persons.any { it.id != event.personId && it.category == _currentCategory.value && it.name.trim().equals(event.name.trim(), ignoreCase = true) }
else if (statusCode == 200) networkRepository.refresh()
else _toastMessage.emit("خطا در ویرایش اطلاعات.") 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 -> { is PersonScreenEvent.ChangeYear -> {
@@ -286,20 +399,21 @@ class PersonViewModel @Inject constructor(
} }
is PersonScreenEvent.AddPaymentForMonth -> { 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) val record = createPaymentRecord(event.personId, event.month, event.year, event.amount, event.description)
if (networkRepository.addPayment(record)) networkRepository.refresh() .copy(category = category)
else _toastMessage.emit("خطا در ثبت پرداخت") localPersonRepository.insertPaymentLocally(record)
} }
is PersonScreenEvent.UpdatePayment -> { is PersonScreenEvent.UpdatePayment -> {
val updatedRecord = event.payment.copy(amount = event.newAmount, description = event.newDescription, timestamp = System.currentTimeMillis()) val updatedRecord = event.payment.copy(amount = event.newAmount, description = event.newDescription, timestamp = System.currentTimeMillis())
if (networkRepository.addPayment(updatedRecord)) networkRepository.refresh() localPersonRepository.insertPaymentLocally(updatedRecord)
else _toastMessage.emit("خطا در ویرایش پرداخت")
} }
is PersonScreenEvent.DeletePayment -> { is PersonScreenEvent.DeletePayment -> {
if (networkRepository.deletePayment(event.payment.id)) networkRepository.refresh() localPersonRepository.deletePaymentLocally(event.payment.id)
else _toastMessage.emit("خطا در حذف پرداخت") _toastMessage.emit("پرداخت با موفقیت حذف شد")
} }
is PersonScreenEvent.ToggleSelection -> { is PersonScreenEvent.ToggleSelection -> {
@@ -320,22 +434,48 @@ class PersonViewModel @Inject constructor(
_isSelectionMode.value = false _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 -> { is PersonScreenEvent.CommitReorder -> {
val list = if (event.listType == PersonListType.UNPAID) _uiState.value.unpaidPersons else _uiState.value.paidPersons val list = if (event.listType == PersonListType.UNPAID) _uiState.value.unpaidPersons else _uiState.value.paidPersons
if (list.size < 2) return@launch if (list.size < 2) return@launch
try { try {
list.forEachIndexed { index, person -> list.forEachIndexed { index, personUi ->
val newOrder = (index + 1) * 10_000L val newOrder = (index + 1) * 10_000L
if (person.displayOrder != newOrder) { if (personUi.displayOrder != newOrder) {
networkRepository.updatePersonDisplayOrder(person.id, newOrder) val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == personUi.id }
person?.let {
localPersonRepository.updatePersonLocally(it.copy(displayOrder = newOrder))
}
} }
} }
networkRepository.refresh()
} catch (_: Exception) { } catch (_: Exception) {
_toastMessage.emit("خطا در ذخیره ترتیب") _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 { viewModelScope.launch {
val persons = localPersonRepository.getAllPersonsFlow().first() val persons = localPersonRepository.getAllPersonsFlow().first()
val payments = localPersonRepository.getAllPaymentsFlow().first().filter { it.shamsiYear == year } 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.ViewModel
import androidx.lifecycle.viewModelScope 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.model.BackupData
import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -19,7 +23,8 @@ import kotlinx.serialization.json.Json
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val localPersonRepository: LocalPersonRepository private val localPersonRepository: LocalPersonRepository,
private val backupManager: BackupManager
) : ViewModel() { ) : ViewModel() {
private val json = Json { private val json = Json {
@@ -98,10 +103,32 @@ class SettingsViewModel @Inject constructor(
return json.encodeToString(backupData) 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) { fun restoreFromBackupJson(jsonString: String) {
viewModelScope.launch { viewModelScope.launch {
try { try {
val backupData = json.decodeFromString<BackupData>(jsonString) val backupData = json.decodeFromString<BackupData>(jsonString)
// قبل از جایگزینی، وضعیت فعلی نگه داشته می‌شود تا راه برگشت بسته نشود.
backupManager.createSnapshot(BackupManager.REASON_BEFORE_RESTORE)
localPersonRepository.restoreBackup(backupData) localPersonRepository.restoreBackup(backupData)
_toastMessage.emit("اطلاعات با موفقیت بازیابی شد! لطفاً برنامه را مجدد اجرا کنید.") _toastMessage.emit("اطلاعات با موفقیت بازیابی شد! لطفاً برنامه را مجدد اجرا کنید.")
} catch (_: Exception) { } catch (_: Exception) {
@@ -1,6 +1,5 @@
package com.oqba26.monthlypaymentapp.viewmodel package com.oqba26.monthlypaymentapp.viewmodel
import android.content.Context
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person import com.oqba26.monthlypaymentapp.data.model.Person
@@ -13,7 +12,10 @@ data class PersonUiModel(
val debtCount: Int = 0, val debtCount: Int = 0,
val totalDebtAmount: Double = 0.0, val totalDebtAmount: Double = 0.0,
val phoneNumber: String? = null, 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( data class PersonListUiState(
@@ -55,7 +57,8 @@ enum class MonthStatus {
AVAILABLE, AVAILABLE,
FUTURE_MONTH, FUTURE_MONTH,
PAST_YEAR, PAST_YEAR,
FUTURE_YEAR FUTURE_YEAR,
NOT_COMMITTED
} }
enum class PersonListType { enum class PersonListType {
@@ -65,8 +68,23 @@ enum class PersonListType {
sealed class PersonScreenEvent { sealed class PersonScreenEvent {
data object RefreshData : PersonScreenEvent() data object RefreshData : PersonScreenEvent()
data class AddPerson(val name: String, val phoneNumber: String?, val context: Context) : PersonScreenEvent() data class AddPerson(
data class UpdatePerson(val personId: String, val name: String, val phoneNumber: String?) : PersonScreenEvent() 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 DeletePerson(val personId: String) : PersonScreenEvent()
data class ArchivePerson(val personId: String) : PersonScreenEvent() data class ArchivePerson(val personId: String) : PersonScreenEvent()
data class RestorePerson(val personId: String) : PersonScreenEvent() data class RestorePerson(val personId: String) : PersonScreenEvent()
@@ -110,14 +128,13 @@ sealed class PersonScreenEvent {
val year: Int, val year: Int,
val amount: Double val amount: Double
) : PersonScreenEvent() ) : 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 ToggleSelection(val personId: String) : PersonScreenEvent()
data class MoveSelected(val direction: Int, val listType: PersonListType) : PersonScreenEvent() data class MoveSelected(val direction: Int, val listType: PersonListType) : PersonScreenEvent()
data object ClearSelection : PersonScreenEvent() data object ClearSelection : PersonScreenEvent()
data class ConfirmContactSuggestion(val personId: String, val personName: String, val phoneNumber: String) : PersonScreenEvent() data class SetCategory(val category: String) : PersonScreenEvent()
data class DismissContactSuggestion(val suggestion: ContactSuggestion) : PersonScreenEvent()
} }
data class DashboardUiModel( data class DashboardUiModel(
+13
View File
@@ -21,6 +21,19 @@
<string name="backup_error">خطا در ایجاد فایل پشتیبان</string> <string name="backup_error">خطا در ایجاد فایل پشتیبان</string>
<string name="restore_warning">هشدار بازیابی</string> <string name="restore_warning">هشدار بازیابی</string>
<string name="restore_warning_text">با بازیابی، تمام داده‌های فعلی حذف و اطلاعات فایل جایگزین می‌شود. مطمئن هستید؟</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="logout">خروج از حساب کاربری</string>
<string name="default_payment_amount">مبلغ پیش‌فرض پرداخت</string> <string name="default_payment_amount">مبلغ پیش‌فرض پرداخت</string>
<string name="reminder_day">روز یادآور ماهانه</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 بی‌ضرر است، حذف نیست
}
}
+2
View File
@@ -59,9 +59,11 @@ google-gson = { group = "com.google.code.gson", name = "gson", version.ref = "gs
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+1 -1
View File
@@ -3,5 +3,5 @@
"versionName": "1.0.0", "versionName": "1.0.0",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.0/MonthlyPaymentApp.apk", "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.0/MonthlyPaymentApp.apk",
"releaseNotes": "نسخه اولیه برنامه مدیریت حقوق ماهانه:\n- مدیریت لیست افراد\n- تفکیک وضعیت پرداخت\n- سیستم آپدیت خودکار\n- مدیریت دسترسی‌های اجباری", "releaseNotes": "نسخه اولیه برنامه مدیریت حقوق ماهانه:\n- مدیریت لیست افراد\n- تفکیک وضعیت پرداخت\n- سیستم آپدیت خودکار\n- مدیریت دسترسی‌های اجباری",
"isForceUpdate": false "isForceUpdate": true
} }