اصلاح مهاجرت دیتابیس، مرتبسازی تبها و بهروزرسانی عناوین
- رفع تداخل در مهاجرت دیتابیس Room با اصلاح بازه fallbackToDestructiveMigrationFrom. - حذف فایل MigrationTest.kt جهت پاکسازی پروژه. - تغییر ترتیب تبها: انتقال تب 'آرشیو' به کنار تبهای 'پرداخت شده' و 'پرداخت نشده'. - اصلاح عناوین TopAppBar: نمایش عناوین اختصاصی برای صفحات 'آرشیو'، 'تنظیمات' و 'مسجد' به جای عنوان عمومی.
This commit is contained in:
1 parent
913b8ca083
commit
271e9ad117
88 files changed
+2472
-475
No files matched your search
+24
-13
@@ -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
|
||||||
Generated
-11
@@ -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
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
o/classes
|
|
||||||
BIN
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
o/classes
|
|
||||||
BIN
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
o/bundleLibRuntimeToDirDebug
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
-10
@@ -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>
|
|
||||||
-18
@@ -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"
|
|
||||||
}
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
aarFormatVersion=1.0
|
|
||||||
aarMetadataVersion=1.0
|
|
||||||
minCompileSdk=1
|
|
||||||
minCompileSdkExtension=0
|
|
||||||
minAndroidGradlePluginVersion=1.0.0
|
|
||||||
coreLibraryDesugaringEnabled=false
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
BIN
Binary file not shown.
Binary file not shown.
@@ -1 +0,0 @@
|
|||||||
int string app_name 0x0
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
#Fri Jul 31 20:09:38 GMT+03:30 2026
|
|
||||||
-4
@@ -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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<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:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\build\intermediates\shader_assets\debug\compileDebugShaders\out"/></dataSet></merger>
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
-3
@@ -1,3 +0,0 @@
|
|||||||
R_DEF: Internal format may change without notice
|
|
||||||
local
|
|
||||||
string app_name
|
|
||||||
-12
@@ -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>
|
|
||||||
-10
@@ -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
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
0 Warning/Error
|
|
||||||
-4
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<string name="app_name">PersianDate</string>
|
|
||||||
</resources>
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
-2
@@ -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
|
|
||||||
Binary file not shown.
@@ -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,9 +375,16 @@ 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(
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+118
-47
@@ -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()
|
||||||
}
|
}
|
||||||
+105
-33
@@ -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,34 +31,47 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// گوش دادن به تغییرات جدول پرداختها
|
// گوش دادن به تغییرات جدول پرداختها
|
||||||
launch {
|
launch {
|
||||||
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
|
||||||
|
val maxRetries = 2
|
||||||
|
|
||||||
|
while (retries <= maxRetries) {
|
||||||
|
try {
|
||||||
supabase.from("payments").delete {
|
supabase.from("payments").delete {
|
||||||
filter { eq("id", paymentId) }
|
filter { eq("id", paymentId) }
|
||||||
}
|
}
|
||||||
true
|
Log.d("Supabase", "deletePayment Success: $paymentId")
|
||||||
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("Supabase", "deletePayment error", e); false
|
retries++
|
||||||
|
Log.e("Supabase", "deletePayment error (Attempt $retries): ${e.message}", e)
|
||||||
|
if (retries > maxRetries) return false
|
||||||
|
kotlinx.coroutines.delay(1000L * retries) // Exponential backoff-ish
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
|
||||||
shape = RoundedCornerShape(24.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(20.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "حذف قطعی شخص",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "آیا از حذف قطعی ${person.name} و تمامی سوابق پرداخت او مطمئن هستید؟ این عمل غیرقابل بازگشت است.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = { personToDelete = null }
|
|
||||||
) {
|
|
||||||
Text("لغو")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
viewModel.onEvent(PersonScreenEvent.DeletePerson(person.id))
|
viewModel.onEvent(PersonScreenEvent.DeletePerson(person.id))
|
||||||
personToDelete = null
|
personToDelete = null
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.buttonColors(
|
onDismiss = { personToDelete = null }
|
||||||
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,14 +134,20 @@ fun PersonScreen(
|
|||||||
val reorderableState = rememberReorderableLazyListState(
|
val reorderableState = rememberReorderableLazyListState(
|
||||||
onMove = { from, to ->
|
onMove = { from, to ->
|
||||||
if (searchQuery.isBlank()) {
|
if (searchQuery.isBlank()) {
|
||||||
|
val headerOffset = if (currentCategory == "mosque") 1 else 0
|
||||||
|
val fromIdx = from.index - headerOffset
|
||||||
|
val toIdx = to.index - headerOffset
|
||||||
|
|
||||||
|
if (fromIdx in uiState.unpaidPersons.indices && toIdx in uiState.unpaidPersons.indices) {
|
||||||
viewModel.onEvent(
|
viewModel.onEvent(
|
||||||
PersonScreenEvent.MovePersonNew(
|
PersonScreenEvent.MovePersonNew(
|
||||||
fromIndex = from.index,
|
fromIndex = fromIdx,
|
||||||
toIndex = to.index,
|
toIndex = toIdx,
|
||||||
listType = PersonListType.UNPAID
|
listType = PersonListType.UNPAID
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDragEnd = { _, _ ->
|
onDragEnd = { _, _ ->
|
||||||
if (searchQuery.isBlank()) {
|
if (searchQuery.isBlank()) {
|
||||||
@@ -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,11 +546,29 @@ 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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (isMosqueCategory) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { isAnonymous = !isAnonymous }
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = isAnonymous,
|
||||||
|
onCheckedChange = { isAnonymous = it }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("ثبت به صورت خیر ناشناس")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAnonymous) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = name,
|
value = name,
|
||||||
@@ -497,25 +590,36 @@ fun AddNewPersonDialog(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
visualTransformation = PersianDigitsTransformation(),
|
visualTransformation = PersianDigitsTransformation(),
|
||||||
placeholder = { Text("۰۹---------", textAlign = androidx.compose.ui.text.style.TextAlign.Right) }
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
"۰۹---------",
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Right
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (name.isBlank()) {
|
if (name.isBlank()) {
|
||||||
Toast.makeText(context, "ابتدا نام را وارد کنید", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "ابتدا نام را وارد کنید", Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
return@IconButton
|
return@IconButton
|
||||||
}
|
}
|
||||||
when (PackageManager.PERMISSION_GRANTED) {
|
when (PackageManager.PERMISSION_GRANTED) {
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) -> {
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.READ_CONTACTS
|
||||||
|
) -> {
|
||||||
val matches = onSearchContact(name)
|
val matches = onSearchContact(name)
|
||||||
if (matches.isNotEmpty()) {
|
if (matches.isNotEmpty()) {
|
||||||
similarContacts = matches
|
similarContacts = matches
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
|
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
|
||||||
}
|
}
|
||||||
@@ -568,6 +672,105 @@ fun AddNewPersonDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMosqueCategory) {
|
||||||
|
if (isAnonymous) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = initialPaymentAmount,
|
||||||
|
onValueChange = { value ->
|
||||||
|
if (value.length <= 12) {
|
||||||
|
val digitsOnly = value.filter { it.isDigit() || (it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9') }
|
||||||
|
initialPaymentAmount = digitsOnly.map {
|
||||||
|
when (it) {
|
||||||
|
in '\u0660'..'\u0669' -> (it.code - '\u0660'.code + '0'.code).toChar()
|
||||||
|
in '\u06f0'..'\u06f9' -> (it.code - '\u06f0'.code + '0'.code).toChar()
|
||||||
|
else -> it
|
||||||
|
}
|
||||||
|
}.joinToString("")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("مبلغ پرداختی (تومان)") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
visualTransformation = com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = monthlyCommitment,
|
||||||
|
onValueChange = { value ->
|
||||||
|
if (value.length <= 12) {
|
||||||
|
val digitsOnly = value.filter { it.isDigit() || (it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9') }
|
||||||
|
monthlyCommitment = digitsOnly.map {
|
||||||
|
when (it) {
|
||||||
|
in '\u0660'..'\u0669' -> (it.code - '\u0660'.code + '0'.code).toChar()
|
||||||
|
in '\u06f0'..'\u06f9' -> (it.code - '\u06f0'.code + '0'.code).toChar()
|
||||||
|
else -> it
|
||||||
|
}
|
||||||
|
}.joinToString("")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = { Text("مبلغ تعهد ماهانه (تومان) - اختیاری") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
visualTransformation = com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation(),
|
||||||
|
placeholder = { Text("اگر مبلغ ثابتی پرداخت میکند وارد کنید") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAnonymous) {
|
||||||
|
// انتخاب ماه و سال شروع
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("تاریخ شروع تعهد:", style = MaterialTheme.typography.labelLarge)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// سال
|
||||||
|
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(),
|
||||||
@@ -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 (debtCount, totalDebtAmount, unpaidMonths) = if (category == "mosque") {
|
||||||
|
if (person.monthlyCommitment > 0) {
|
||||||
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
|
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
|
||||||
val unpaidMonthsInCurrentYear = (1..debtEndMonth).filter { m ->
|
val debtStartMonth = if (person.startYear == currentYear) person.startMonth else 1
|
||||||
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m }
|
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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 بیضرر است، حذف نیست
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user