diff --git a/.gitignore b/.gitignore
index 0b7c68d..7c9bb02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,17 +1,28 @@
+# Gradle files
+.gradle/
+build/
+
+# IntelliJ IDEA files
+.idea/
*.iml
-.gradle
-/local.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
-.DS_Store
-/build
-/captures
-.externalNativeBuild
-.cxx
+
+# Local configuration
local.properties
keystore.properties
+
+# Android Studio captures
+captures/
+
+# External native builds
+.externalNativeBuild/
+.cxx/
+
+# Key store files
*.jks
+*.keystore
+
+# Assistant Artifacts
+.artifacts/
+
+# System files
+.DS_Store
diff --git a/.idea/planningMode.xml b/.idea/planningMode.xml
deleted file mode 100644
index 9192952..0000000
--- a/.idea/planningMode.xml
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/results.bin b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/results.bin
deleted file mode 100644
index 7ed749e..0000000
--- a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/results.bin
+++ /dev/null
@@ -1 +0,0 @@
-o/bundleLibRuntimeToDirDebug
diff --git a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate$Dialect.dex b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate$Dialect.dex
deleted file mode 100644
index 462def9..0000000
Binary files a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate$Dialect.dex and /dev/null differ
diff --git a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate.dex b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate.dex
deleted file mode 100644
index 5a596c6..0000000
Binary files a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate.dex and /dev/null differ
diff --git a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$1.dex b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$1.dex
deleted file mode 100644
index e1a20de..0000000
Binary files a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$1.dex and /dev/null differ
diff --git a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.dex b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.dex
deleted file mode 100644
index ffa3b13..0000000
Binary files a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.dex and /dev/null differ
diff --git a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat.dex b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat.dex
deleted file mode 100644
index f1e24b0..0000000
Binary files a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat.dex and /dev/null differ
diff --git a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin
deleted file mode 100644
index 6b26a44..0000000
Binary files a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin and /dev/null differ
diff --git a/PersianDate/build/.transforms/91dd66fd99e1bf96d3a5dda255469897/results.bin b/PersianDate/build/.transforms/91dd66fd99e1bf96d3a5dda255469897/results.bin
deleted file mode 100644
index 0d259dd..0000000
--- a/PersianDate/build/.transforms/91dd66fd99e1bf96d3a5dda255469897/results.bin
+++ /dev/null
@@ -1 +0,0 @@
-o/classes
diff --git a/PersianDate/build/.transforms/91dd66fd99e1bf96d3a5dda255469897/transformed/classes/classes_dex/classes.dex b/PersianDate/build/.transforms/91dd66fd99e1bf96d3a5dda255469897/transformed/classes/classes_dex/classes.dex
deleted file mode 100644
index 537a134..0000000
Binary files a/PersianDate/build/.transforms/91dd66fd99e1bf96d3a5dda255469897/transformed/classes/classes_dex/classes.dex and /dev/null differ
diff --git a/PersianDate/build/.transforms/99c70c53220c636dd34a74b4e5874025/results.bin b/PersianDate/build/.transforms/99c70c53220c636dd34a74b4e5874025/results.bin
deleted file mode 100644
index 0d259dd..0000000
--- a/PersianDate/build/.transforms/99c70c53220c636dd34a74b4e5874025/results.bin
+++ /dev/null
@@ -1 +0,0 @@
-o/classes
diff --git a/PersianDate/build/.transforms/99c70c53220c636dd34a74b4e5874025/transformed/classes/classes_dex/classes.dex b/PersianDate/build/.transforms/99c70c53220c636dd34a74b4e5874025/transformed/classes/classes_dex/classes.dex
deleted file mode 100644
index 934e7e5..0000000
Binary files a/PersianDate/build/.transforms/99c70c53220c636dd34a74b4e5874025/transformed/classes/classes_dex/classes.dex and /dev/null differ
diff --git a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/results.bin b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/results.bin
deleted file mode 100644
index 7ed749e..0000000
--- a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/results.bin
+++ /dev/null
@@ -1 +0,0 @@
-o/bundleLibRuntimeToDirDebug
diff --git a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate$Dialect.dex b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate$Dialect.dex
deleted file mode 100644
index 72147c9..0000000
Binary files a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate$Dialect.dex and /dev/null differ
diff --git a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate.dex b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate.dex
deleted file mode 100644
index e3ddc4f..0000000
Binary files a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate.dex and /dev/null differ
diff --git a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$1.dex b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$1.dex
deleted file mode 100644
index 72ab1d0..0000000
Binary files a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$1.dex and /dev/null differ
diff --git a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.dex b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.dex
deleted file mode 100644
index 3b4e993..0000000
Binary files a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.dex and /dev/null differ
diff --git a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat.dex b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat.dex
deleted file mode 100644
index b4e9905..0000000
Binary files a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat.dex and /dev/null differ
diff --git a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin
deleted file mode 100644
index 56ac563..0000000
Binary files a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin and /dev/null differ
diff --git a/PersianDate/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml b/PersianDate/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml
deleted file mode 100644
index f6518bc..0000000
--- a/PersianDate/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json b/PersianDate/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json
deleted file mode 100644
index 67bd344..0000000
--- a/PersianDate/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json
+++ /dev/null
@@ -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"
-}
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties b/PersianDate/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties
deleted file mode 100644
index 1211b1e..0000000
--- a/PersianDate/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties
+++ /dev/null
@@ -1,6 +0,0 @@
-aarFormatVersion=1.0
-aarMetadataVersion=1.0
-minCompileSdk=1
-minCompileSdkExtension=0
-minAndroidGradlePluginVersion=1.0.0
-coreLibraryDesugaringEnabled=false
diff --git a/PersianDate/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json b/PersianDate/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json
deleted file mode 100644
index 9e26dfe..0000000
--- a/PersianDate/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar b/PersianDate/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar
deleted file mode 100644
index d4775dc..0000000
Binary files a/PersianDate/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar and /dev/null differ
diff --git a/PersianDate/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar b/PersianDate/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar
deleted file mode 100644
index 2c5da86..0000000
Binary files a/PersianDate/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar and /dev/null differ
diff --git a/PersianDate/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt b/PersianDate/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt
deleted file mode 100644
index a9551e5..0000000
--- a/PersianDate/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt
+++ /dev/null
@@ -1 +0,0 @@
-int string app_name 0x0
diff --git a/PersianDate/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties b/PersianDate/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties
deleted file mode 100644
index 6e9f359..0000000
--- a/PersianDate/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties
+++ /dev/null
@@ -1 +0,0 @@
-#Fri Jul 31 20:09:38 GMT+03:30 2026
diff --git a/PersianDate/build/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml b/PersianDate/build/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml
deleted file mode 100644
index d81740a..0000000
--- a/PersianDate/build/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- PersianDate
-
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/incremental/debug/packageDebugResources/merger.xml b/PersianDate/build/intermediates/incremental/debug/packageDebugResources/merger.xml
deleted file mode 100644
index d0fc3d4..0000000
--- a/PersianDate/build/intermediates/incremental/debug/packageDebugResources/merger.xml
+++ /dev/null
@@ -1,2 +0,0 @@
-
-PersianDate
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml b/PersianDate/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml
deleted file mode 100644
index 5ed22d7..0000000
--- a/PersianDate/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/incremental/mergeDebugShaders/merger.xml b/PersianDate/build/intermediates/incremental/mergeDebugShaders/merger.xml
deleted file mode 100644
index 103c790..0000000
--- a/PersianDate/build/intermediates/incremental/mergeDebugShaders/merger.xml
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/incremental/packageDebugAssets/merger.xml b/PersianDate/build/intermediates/incremental/packageDebugAssets/merger.xml
deleted file mode 100644
index 1777a64..0000000
--- a/PersianDate/build/intermediates/incremental/packageDebugAssets/merger.xml
+++ /dev/null
@@ -1,2 +0,0 @@
-
-
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDate$Dialect.class b/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDate$Dialect.class
deleted file mode 100644
index 1928020..0000000
Binary files a/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDate$Dialect.class and /dev/null differ
diff --git a/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDate.class b/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDate.class
deleted file mode 100644
index d0ff723..0000000
Binary files a/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDate.class and /dev/null differ
diff --git a/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDateFormat$1.class b/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDateFormat$1.class
deleted file mode 100644
index 355066e..0000000
Binary files a/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDateFormat$1.class and /dev/null differ
diff --git a/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.class b/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.class
deleted file mode 100644
index 58e1d5a..0000000
Binary files a/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.class and /dev/null differ
diff --git a/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDateFormat.class b/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDateFormat.class
deleted file mode 100644
index 91997ae..0000000
Binary files a/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDateFormat.class and /dev/null differ
diff --git a/PersianDate/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt b/PersianDate/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt
deleted file mode 100644
index 34ca0ec..0000000
--- a/PersianDate/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-R_DEF: Internal format may change without notice
-local
-string app_name
diff --git a/PersianDate/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt b/PersianDate/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt
deleted file mode 100644
index 8a229b9..0000000
--- a/PersianDate/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt
+++ /dev/null
@@ -1,12 +0,0 @@
-1
-2
-4
-5
-6
-7
-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
-9
-10
diff --git a/PersianDate/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml b/PersianDate/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml
deleted file mode 100644
index f6518bc..0000000
--- a/PersianDate/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json b/PersianDate/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json
deleted file mode 100644
index 0637a08..0000000
--- a/PersianDate/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json
+++ /dev/null
@@ -1 +0,0 @@
-[]
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt b/PersianDate/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt
deleted file mode 100644
index 08f4ebe..0000000
--- a/PersianDate/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt
+++ /dev/null
@@ -1 +0,0 @@
-0 Warning/Error
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/packaged_res/debug/packageDebugResources/values/values.xml b/PersianDate/build/intermediates/packaged_res/debug/packageDebugResources/values/values.xml
deleted file mode 100644
index d81740a..0000000
--- a/PersianDate/build/intermediates/packaged_res/debug/packageDebugResources/values/values.xml
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
- PersianDate
-
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDate$Dialect.class b/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDate$Dialect.class
deleted file mode 100644
index 1928020..0000000
Binary files a/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDate$Dialect.class and /dev/null differ
diff --git a/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDate.class b/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDate.class
deleted file mode 100644
index d0ff723..0000000
Binary files a/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDate.class and /dev/null differ
diff --git a/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDateFormat$1.class b/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDateFormat$1.class
deleted file mode 100644
index 355066e..0000000
Binary files a/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDateFormat$1.class and /dev/null differ
diff --git a/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.class b/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.class
deleted file mode 100644
index 58e1d5a..0000000
Binary files a/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.class and /dev/null differ
diff --git a/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDateFormat.class b/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDateFormat.class
deleted file mode 100644
index 91997ae..0000000
Binary files a/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDateFormat.class and /dev/null differ
diff --git a/PersianDate/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar b/PersianDate/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar
deleted file mode 100644
index 8c6c9f2..0000000
Binary files a/PersianDate/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar and /dev/null differ
diff --git a/PersianDate/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt b/PersianDate/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt
deleted file mode 100644
index 7a3ee7d..0000000
--- a/PersianDate/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-com.github.samanzamani.persiandate
-string app_name
diff --git a/PersianDate/build/outputs/logs/manifest-merger-debug-report.txt b/PersianDate/build/outputs/logs/manifest-merger-debug-report.txt
deleted file mode 100644
index 65e6e6d..0000000
--- a/PersianDate/build/outputs/logs/manifest-merger-debug-report.txt
+++ /dev/null
@@ -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
diff --git a/PersianDate/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin b/PersianDate/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin
deleted file mode 100644
index fe6f04d..0000000
Binary files a/PersianDate/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin and /dev/null differ
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index cdf8aee..5b55056 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -84,6 +84,16 @@ android {
packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
}
+
+ // اسکیمای تولیدشده در app/schemas/ ذخیره و کامیت میشود.
+ // MigrationTest برای بازسازی دیتابیس نسخهی قبل به همین فایلها نیاز دارد.
+ sourceSets.getByName("androidTest") {
+ assets.srcDirs("$projectDir/schemas")
+ }
+}
+
+ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
@@ -143,6 +153,8 @@ dependencies {
// Testing
testImplementation(libs.test.junit)
+ testImplementation(libs.kotlinx.coroutines.test)
+ androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.androidTest.junit.ext)
androidTestImplementation(libs.androidTest.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
diff --git a/app/schemas/com.oqba26.monthlypaymentapp.data.database.AppDatabase/18.json b/app/schemas/com.oqba26.monthlypaymentapp.data.database.AppDatabase/18.json
new file mode 100644
index 0000000..198973d
--- /dev/null
+++ b/app/schemas/com.oqba26.monthlypaymentapp.data.database.AppDatabase/18.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt
index e58ca49..3b1edc3 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt
@@ -24,6 +24,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.automirrored.filled.Message
+import androidx.compose.material.icons.filled.AccountBalance
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.ArrowDownward
@@ -68,6 +69,8 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import dagger.hilt.android.AndroidEntryPoint
import androidx.hilt.navigation.compose.hiltViewModel
@@ -183,6 +186,11 @@ fun MainAppHost(viewModel: PersonViewModel) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
+ launch {
+ contactViewModel.toastMessage.collect { message ->
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+ }
}
// UI elements for update and permissions
@@ -265,9 +273,18 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route ?: ""
+ LaunchedEffect(currentRoute) {
+ if (currentRoute == Screen.Mosque.route) {
+ viewModel.onEvent(PersonScreenEvent.SetCategory("mosque"))
+ } else if (currentRoute in listOf(Screen.Unpaid.route, Screen.Paid.route)) {
+ viewModel.onEvent(PersonScreenEvent.SetCategory("salary"))
+ }
+ }
+
val showBottomBar = currentRoute in listOf(
Screen.Unpaid.route,
Screen.Paid.route,
+ Screen.Mosque.route,
Screen.Archive.route,
Screen.Settings.route
)
@@ -358,10 +375,17 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
title = {
val titleText = when {
isSelectionMode -> "انتخاب شدهها"
- currentRoute == Screen.Settings.route -> "تنظیمات"
+ else -> {
+ val currentRoute = navController.currentBackStackEntry?.destination?.route
+ when (currentRoute) {
+ Screen.Mosque.route -> "مدیریت کمکهای مسجد"
+ Screen.Settings.route -> "تنظیمات"
+ Screen.Archive.route -> "آرشیو"
else -> "مدیریت حقوق ماهانه"
}
- Text(titleText)
+ }
+ }
+ Text(titleText)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = if (isSelectionMode) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.primary,
@@ -390,7 +414,7 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
IconButton(onClick = { showConfirmMoveDialog = true }) {
Icon(Icons.Default.Check, contentDescription = "Confirm Move", tint = Color.Green)
}
- } else if (currentRoute == Screen.Unpaid.route) {
+ } else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) {
FilledIconButton(
onClick = { viewModel.onAddPersonClicked() },
colors = IconButtonDefaults.filledIconButtonColors(
@@ -437,6 +461,13 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
navController = navController
)
}
+ composable(Screen.Mosque.route) {
+ PersonScreen(
+ viewModel = viewModel,
+ contactViewModel = contactViewModel,
+ navController = navController
+ )
+ }
composable(Screen.Paid.route) {
PaidListScreen(
viewModel = viewModel,
@@ -475,17 +506,19 @@ fun AppBottomNavigationBar(navController: NavController) {
Screen.Unpaid,
Screen.Paid,
Screen.Archive,
+ Screen.Mosque,
Screen.Settings
)
NavigationBar(
- containerColor = MaterialTheme.colorScheme.primary // Match TopAppBar
+ containerColor = MaterialTheme.colorScheme.primary
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
NavigationBarItem(
+ alwaysShowLabel = true,
colors = NavigationBarItemDefaults.colors(
selectedTextColor = Color.White,
selectedIconColor = Color.White,
@@ -494,7 +527,15 @@ fun AppBottomNavigationBar(navController: NavController) {
unselectedIconColor = Color.White.copy(alpha = 0.7f)
),
icon = { Icon(screen.icon, contentDescription = screen.title) },
- label = { Text(screen.title) },
+ label = {
+ Text(
+ text = screen.title,
+ textAlign = TextAlign.Center,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.labelSmall
+ )
+ },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
@@ -517,6 +558,7 @@ sealed class Screen(
) {
data object Unpaid : Screen("unpaid_list", "پرداخت نشده", Icons.AutoMirrored.Filled.List)
data object Paid : Screen("paid_list", "پرداخت شده", Icons.Default.Paid)
+ data object Mosque : Screen("mosque_list", "مسجد", Icons.Default.AccountBalance)
data object Archive : Screen("archive", "آرشیو", Icons.Default.Archive)
data object Settings : Screen("settings", "تنظیمات", Icons.Default.Settings)
}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/core/PaymentApplication.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/core/PaymentApplication.kt
index 05b6eec..59974e7 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/core/PaymentApplication.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/core/PaymentApplication.kt
@@ -1,7 +1,59 @@
package com.oqba26.monthlypaymentapp.core
import android.app.Application
+import android.util.Log
+import com.oqba26.monthlypaymentapp.BuildConfig
+import com.oqba26.monthlypaymentapp.core.manager.BackupManager
+import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
+import com.oqba26.monthlypaymentapp.data.sync.SyncManager
import dagger.hilt.android.HiltAndroidApp
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import javax.inject.Inject
@HiltAndroidApp
-class PaymentApplication : Application()
+class PaymentApplication : Application() {
+ @Inject
+ lateinit var syncManager: SyncManager
+
+ @Inject
+ lateinit var backupManager: BackupManager
+
+ @Inject
+ lateinit var settingsRepository: SettingsRepository
+
+ private val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
+ override fun onCreate() {
+ super.onCreate()
+ syncManager.startSync()
+ snapshotOnUpgrade()
+ }
+
+ /**
+ * اولین اجرا بعد از هر ارتقای نسخه، یک نسخهی پشتیبان میگیرد.
+ *
+ * ارتقای نسخه همان لحظهای است که مهاجرت دیتابیس اجرا میشود؛ اگر آنجا چیزی خراب شد،
+ * این فایل تنها راه برگشت دادهی کاربر است.
+ */
+ private fun snapshotOnUpgrade() {
+ appScope.launch {
+ try {
+ val current = BuildConfig.VERSION_CODE
+ val last = settingsRepository.getLastRunVersionCode()
+ if (last != current) {
+ if (last != null) {
+ // اجرای اول بعد از نصب، دادهای برای پشتیبانگیری ندارد؛ فقط
+ // ارتقای واقعی (last != null) ارزش snapshot دارد.
+ backupManager.createSnapshot(BackupManager.REASON_APP_UPGRADE)
+ }
+ settingsRepository.saveLastRunVersionCode(current)
+ }
+ } catch (e: Exception) {
+ Log.e("PaymentApplication", "snapshot ارتقای نسخه ناموفق بود", e)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/DatabaseModule.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/DatabaseModule.kt
index 98b0797..81e1ed0 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/DatabaseModule.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/DatabaseModule.kt
@@ -1,8 +1,10 @@
package com.oqba26.monthlypaymentapp.core.di
import android.content.Context
+import androidx.room.Room
import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
import com.oqba26.monthlypaymentapp.data.dao.PersonDao
+import com.oqba26.monthlypaymentapp.data.database.ALL_MIGRATIONS
import com.oqba26.monthlypaymentapp.data.database.AppDatabase
import dagger.Module
import dagger.Provides
@@ -18,7 +20,18 @@ object DatabaseModule {
@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
- return AppDatabase.getInstance(context)
+ return Room.databaseBuilder(
+ context.applicationContext,
+ AppDatabase::class.java,
+ AppDatabase.DATABASE_NAME
+ )
+ .addMigrations(*ALL_MIGRATIONS)
+ // فقط نسخههای ۱ تا ۱۷ اجازهی پاک شدن دارند — آنها قبل از فعال شدن
+ // exportSchema ساخته شدهاند و تاریخچهی اسکیمایشان را نداریم.
+ // از ۱۸ به بعد مهاجرت اجباری است: تغییر اسکیمای بدون مهاجرت باعث خطا
+ // میشود، نه پاک شدن بیصدای دادهی کاربر.
+ .fallbackToDestructiveMigrationFrom(*(1..16).toList().toIntArray())
+ .build()
}
@Provides
@@ -30,4 +43,9 @@ object DatabaseModule {
fun providePaymentDao(database: AppDatabase): PaymentDao {
return database.paymentDao()
}
+
+ @Provides
+ fun provideSyncQueueDao(database: AppDatabase): com.oqba26.monthlypaymentapp.data.dao.SyncQueueDao {
+ return database.syncQueueDao()
+ }
}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/RepositoryModule.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/RepositoryModule.kt
index d843c5f..64a80d2 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/RepositoryModule.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/RepositoryModule.kt
@@ -29,9 +29,10 @@ object RepositoryModule {
fun provideLocalPersonRepository(
personDao: PersonDao,
paymentDao: PaymentDao,
+ syncQueueDao: com.oqba26.monthlypaymentapp.data.dao.SyncQueueDao,
database: AppDatabase
): LocalPersonRepository {
- return LocalPersonRepository(personDao, paymentDao, database)
+ return LocalPersonRepository(personDao, paymentDao, syncQueueDao, database)
}
@Provides
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/core/manager/BackupManager.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/core/manager/BackupManager.kt
new file mode 100644
index 0000000..b01813b
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/core/manager/BackupManager.kt
@@ -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 = withContext(Dispatchers.IO) {
+ snapshotFiles().mapNotNull { file ->
+ try {
+ val data = json.decodeFromString(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(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 =
+ 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"
+ }
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PaymentDao.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PaymentDao.kt
index f505f0b..739b745 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PaymentDao.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PaymentDao.kt
@@ -22,6 +22,13 @@ interface PaymentDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAllPaymentRecords(payments: List)
+ @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)
+
@Query("DELETE FROM payments")
suspend fun deleteAllPaymentRecords()
}
\ No newline at end of file
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PersonDao.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PersonDao.kt
index fb7652b..a9e471a 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PersonDao.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PersonDao.kt
@@ -28,6 +28,13 @@ interface PersonDao {
@Query("UPDATE persons SET displayOrder = :displayOrder WHERE id = :personId")
suspend fun updateDisplayOrder(personId: String, displayOrder: Int)
+ @Query("DELETE FROM persons WHERE id = :personId")
+ suspend fun deleteById(personId: String)
+
+ /** حذف گروهی — برای اعمال [com.oqba26.monthlypaymentapp.data.sync.MergePlan] در یک کوئری. */
+ @Query("DELETE FROM persons WHERE id IN (:ids)")
+ suspend fun deleteByIds(ids: List)
+
@Query("DELETE FROM persons")
suspend fun deleteAll()
}
\ No newline at end of file
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/SyncQueueDao.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/SyncQueueDao.kt
new file mode 100644
index 0000000..cdbf881
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/SyncQueueDao.kt
@@ -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>
+
+ @Query("SELECT * FROM sync_queue ORDER BY createdAt ASC")
+ suspend fun getAllPending(): List
+
+ @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()
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/database/AppDatabase.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/database/AppDatabase.kt
index cf154dc..355a402 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/database/AppDatabase.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/database/AppDatabase.kt
@@ -1,61 +1,36 @@
-@file:Suppress("unused")
-
package com.oqba26.monthlypaymentapp.data.database
-import android.content.Context
import androidx.room.Database
-import androidx.room.Room
import androidx.room.RoomDatabase
-import androidx.room.Transaction
import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
import com.oqba26.monthlypaymentapp.data.dao.PersonDao
-import com.oqba26.monthlypaymentapp.data.model.BackupData
+import com.oqba26.monthlypaymentapp.data.dao.SyncQueueDao
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
-import java.util.UUID
+import com.oqba26.monthlypaymentapp.data.model.SyncQueue
+/**
+ * دیتابیس محلی اپ.
+ *
+ * ساخت instance اینجا انجام نمیشود؛ تنها مسیر ساخت،
+ * [com.oqba26.monthlypaymentapp.core.di.DatabaseModule] است. قبلاً علاوه بر هیلت یک
+ * `INSTANCE` دستی هم اینجا بود — دو مسیر ساخت یعنی ریسک دو اتصال جدا به یک فایل دیتابیس.
+ *
+ * از نسخه ۱۸، `exportSchema = true` است و اسکیما در `app/schemas/` ذخیره میشود.
+ * مهاجرتها در [Migrations.kt] هستند.
+ */
@Database(
- entities = [Person::class, PaymentRecord::class],
- version = 13, // حتماً نسخه را بالا ببری که اسکیمای جدید اعمال شود
- exportSchema = false
+ entities = [Person::class, PaymentRecord::class, SyncQueue::class],
+ version = 18,
+ exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun personDao(): PersonDao
abstract fun paymentDao(): PaymentDao
-
- @Transaction
- open suspend fun performRestore(backupData: BackupData) {
- // تمیزکاری قبل از درج بکاپ
- val safePersons = backupData.persons.map { p ->
- val name = p.name.trim()
- val id = p.id.ifBlank { UUID.randomUUID().toString() }
- Person(id = id, name = name)
- }
- personDao().deleteAll()
- paymentDao().deleteAllPaymentRecords()
- personDao().insertAll(safePersons)
- paymentDao().insertAllPaymentRecords(backupData.payments)
- }
+ abstract fun syncQueueDao(): SyncQueueDao
companion object {
- @Volatile
- private var INSTANCE: AppDatabase? = null
-
- fun getInstance(context: Context): AppDatabase = getDatabase(context)
-
- private fun getDatabase(context: Context): AppDatabase {
- return INSTANCE ?: synchronized(this) {
- val instance = Room.databaseBuilder(
- context.applicationContext,
- AppDatabase::class.java,
- "monthly_payment_db"
- )
- .fallbackToDestructiveMigration()
- .build()
- INSTANCE = instance
- instance
- }
- }
+ const val DATABASE_NAME = "monthly_payment_db"
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/database/Migrations.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/database/Migrations.kt
new file mode 100644
index 0000000..2679c7c
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/database/Migrations.kt
@@ -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)
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt
index 9c347b2..04e7cb3 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt
@@ -26,5 +26,10 @@ data class PaymentRecord(
@SerialName("description") val description: String? = "", // ⭐️ تغییر به String? برای پذیرش مقادیر null
- @SerialName("createdAt") val createdAt: Long? = null
+ @SerialName("createdAt") val createdAt: Long? = null,
+
+ @SerialName("category") val category: String = "salary",
+
+ @kotlinx.serialization.Transient
+ val needsSync: Boolean = false
)
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt
index 26b0685..32f5501 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt
@@ -21,5 +21,18 @@ data class Person(
@SerialName("createdAt") val createdAt: Long? = 0L,
- @SerialName("phoneNumber") val phoneNumber: String? = ""
+ @SerialName("phoneNumber") val phoneNumber: String? = "",
+
+ @SerialName("category") val category: String = "salary",
+
+ @SerialName("isAnonymous") val isAnonymous: Boolean = false,
+
+ @SerialName("monthlyCommitment") val monthlyCommitment: Double = 0.0,
+
+ @SerialName("startMonth") val startMonth: Int = 1,
+
+ @SerialName("startYear") val startYear: Int = 1403,
+
+ @kotlinx.serialization.Transient
+ val needsSync: Boolean = false
)
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/SyncQueue.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/SyncQueue.kt
new file mode 100644
index 0000000..87795e0
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/SyncQueue.kt
@@ -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
+)
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt
index 210e84f..6adf906 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt
@@ -25,9 +25,9 @@ object ApiClient {
httpConfig {
install(HttpTimeout) {
- requestTimeoutMillis = 30000
- connectTimeoutMillis = 30000
- socketTimeoutMillis = 30000
+ requestTimeoutMillis = 60000
+ connectTimeoutMillis = 60000
+ socketTimeoutMillis = 60000
}
}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt
index 281f931..184649f 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt
@@ -5,17 +5,22 @@ package com.oqba26.monthlypaymentapp.data.repository
import androidx.room.withTransaction
import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
import com.oqba26.monthlypaymentapp.data.dao.PersonDao
+import com.oqba26.monthlypaymentapp.data.dao.SyncQueueDao
import com.oqba26.monthlypaymentapp.data.database.AppDatabase
import com.oqba26.monthlypaymentapp.data.model.BackupData
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
+import com.oqba26.monthlypaymentapp.data.model.SyncQueue
+import com.oqba26.monthlypaymentapp.data.model.SyncOperation
+import com.oqba26.monthlypaymentapp.data.model.SyncType
+import com.oqba26.monthlypaymentapp.data.sync.SyncMerger
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.first
import java.util.UUID
class LocalPersonRepository(
private val personDao: PersonDao,
private val paymentDao: PaymentDao,
+ private val syncQueueDao: SyncQueueDao,
private val database: AppDatabase
) {
// Reads
@@ -25,53 +30,35 @@ class LocalPersonRepository(
fun getPaymentsForPersonFlow(personId: String): Flow> =
paymentDao.getPaymentsForPersonFlow(personId)
- suspend fun syncPersons(persons: List) {
- val current = getAllPersonsFlow().first()
- val currentByName = current.associateBy { normalize(it.name) }
-
- val safePersons = persons.map { p ->
- val name = p.name.trim()
- val norm = normalize(name)
- val resolvedId = when {
- p.id.isNotBlank() -> p.id
- currentByName.containsKey(norm) -> currentByName[norm]!!.id
- else -> UUID.randomUUID().toString()
- }
- p.copy(id = resolvedId, name = name)
- }
-
- personDao.insertAll(safePersons)
- }
-
- suspend fun syncPayments(payments: List) =
- paymentDao.insertAllPaymentRecords(payments)
-
- // ⭐️ تابع جدید و اصلاح شده برای همگامسازی کامل
- suspend fun syncAll(persons: List, payments: List) {
+ /**
+ * ادغام غیرمخربِ دادهی سرور با دادهی محلی.
+ *
+ * جانشین `syncAll` قبلی است که اول `deleteAll()` میزد و بعد دادهی سرور را درج میکرد؛
+ * آن رفتار هر تغییر محلیِ هنوز-ارسال-نشده را نابود میکرد.
+ *
+ * اینجا هیچ `deleteAll`ی وجود ندارد. تصمیمِ «چه چیزی حذف شود» را [SyncMerger] میگیرد
+ * (که تست واحد دارد) و رکوردهای موجود در صف سینک یا دارای `needsSync` را کنار میگذارد.
+ *
+ * فقط با دادهی **معتبرِ** سرور صدا زده شود — یعنی بعد از اینکه
+ * [com.oqba26.monthlypaymentapp.data.repository.NetworkRepository.refresh] مقدار `true` برگردانده باشد.
+ */
+ suspend fun mergeFromServer(persons: List, payments: List) {
database.withTransaction {
+ // خواندن با تابع suspend و نه collect کردن Flow:
+ // collect کردن Flow روم داخل تراکنش خطر deadlock دارد.
+ val localPersons = personDao.getAllPersons()
+ val localPayments = paymentDao.getAllPayments()
+ val pendingIds = syncQueueDao.getAllPending().map { it.entityId }.toSet()
- // ۱. اجرای منطق حل شناسه (ID Resolution)
- val current = getAllPersonsFlow().first()
- val currentByName = current.associateBy { normalize(it.name) }
+ val personPlan = SyncMerger.planPersons(localPersons, persons, pendingIds)
+ val paymentPlan = SyncMerger.planPayments(localPayments, payments, pendingIds)
- val safePersons = persons.map { p ->
- val name = p.name.trim()
- val norm = normalize(name)
- val resolvedId = when {
- p.id.isNotBlank() -> p.id
- currentByName.containsKey(norm) -> currentByName[norm]!!.id
- else -> UUID.randomUUID().toString()
- }
- p.copy(id = resolvedId, name = name)
- }
+ // پرداختها اول حذف میشوند تا اگر کلید خارجی اضافه شد، ترتیب درست باشد.
+ if (paymentPlan.deleteIds.isNotEmpty()) paymentDao.deleteByIds(paymentPlan.deleteIds)
+ if (personPlan.deleteIds.isNotEmpty()) personDao.deleteByIds(personPlan.deleteIds)
- // ۲. حذف کامل تمام دادههای محلی
- personDao.deleteAll()
- paymentDao.deleteAllPaymentRecords()
-
- // ۳. درج دادههای جدید
- personDao.insertAll(safePersons)
- paymentDao.insertAllPaymentRecords(payments)
+ if (personPlan.upserts.isNotEmpty()) personDao.insertAll(personPlan.upserts)
+ if (paymentPlan.upserts.isNotEmpty()) paymentDao.insertAllPaymentRecords(paymentPlan.upserts)
}
}
@@ -82,21 +69,105 @@ class LocalPersonRepository(
return BackupData(persons = persons, payments = payments)
}
+ /**
+ * بازگردانی کامل از یک نسخهی پشتیبان.
+ *
+ * برخلاف [mergeFromServer]، این عملیات عمداً مخرب است — کاربر صریحاً خواسته وضعیت فعلی
+ * با نسخهی پشتیبان جایگزین شود. صدازننده باید قبلش snapshot بگیرد
+ * ([com.oqba26.monthlypaymentapp.core.manager.BackupManager.restoreSnapshot] این کار را میکند).
+ *
+ * صف سینک هم بازسازی میشود. قبلاً دستنخورده میماند و دو مشکل داشت:
+ * - آیتمهای قدیمی به رکوردهای پاکشده اشاره میکردند.
+ * - دادهی بازیابیشده هرگز به سرور نمیرفت، پس اولین سینکِ بعدی دوباره پاکش میکرد.
+ */
suspend fun restoreBackup(backupData: BackupData) {
database.withTransaction {
personDao.deleteAll()
paymentDao.deleteAllPaymentRecords()
+ syncQueueDao.deleteAll()
val safePersons = backupData.persons.map { p ->
val name = p.name.trim()
val id = p.id.ifBlank { UUID.randomUUID().toString() }
- p.copy(id = id, name = name)
+ p.copy(id = id, name = name, needsSync = true)
}
+ val safePayments = backupData.payments.map { it.copy(needsSync = true) }
personDao.insertAll(safePersons)
- paymentDao.insertAllPaymentRecords(backupData.payments)
+ paymentDao.insertAllPaymentRecords(safePayments)
+
+ // همهی رکوردهای بازیابیشده باید دوباره به سرور برسند.
+ safePersons.forEach {
+ syncQueueDao.insert(
+ SyncQueue(entityId = it.id, type = SyncType.PERSON, operation = SyncOperation.INSERT)
+ )
+ }
+ safePayments.forEach {
+ syncQueueDao.insert(
+ SyncQueue(entityId = it.id, type = SyncType.PAYMENT, operation = SyncOperation.INSERT)
+ )
+ }
}
}
- private fun normalize(name: String): String = name.trim().lowercase()
+ // Local-First Write Operations
+ suspend fun insertPersonLocally(person: Person) {
+ database.withTransaction {
+ personDao.insertAll(listOf(person.copy(needsSync = true)))
+ syncQueueDao.insert(SyncQueue(entityId = person.id, type = SyncType.PERSON, operation = SyncOperation.INSERT))
+ }
+ }
+
+ suspend fun updatePersonLocally(person: Person) {
+ database.withTransaction {
+ personDao.insertAll(listOf(person.copy(needsSync = true)))
+ syncQueueDao.insert(SyncQueue(entityId = person.id, type = SyncType.PERSON, operation = SyncOperation.UPDATE))
+ }
+ }
+
+ suspend fun archivePersonLocally(personId: String, isArchived: Boolean) {
+ database.withTransaction {
+ personDao.updateArchivedStatus(personId, isArchived)
+ syncQueueDao.insert(SyncQueue(entityId = personId, type = SyncType.PERSON, operation = SyncOperation.UPDATE))
+ }
+ }
+
+ suspend fun deletePersonLocally(personId: String) {
+ database.withTransaction {
+ personDao.deleteById(personId)
+ syncQueueDao.insert(SyncQueue(entityId = personId, type = SyncType.PERSON, operation = SyncOperation.DELETE))
+ }
+ }
+
+ suspend fun insertPaymentLocally(payment: PaymentRecord) {
+ database.withTransaction {
+ paymentDao.insertAllPaymentRecords(listOf(payment.copy(needsSync = true)))
+ syncQueueDao.insert(SyncQueue(entityId = payment.id, type = SyncType.PAYMENT, operation = SyncOperation.INSERT))
+ }
+ }
+
+ suspend fun deletePaymentLocally(paymentId: String) {
+ database.withTransaction {
+ paymentDao.deleteById(paymentId)
+ syncQueueDao.insert(SyncQueue(entityId = paymentId, type = SyncType.PAYMENT, operation = SyncOperation.DELETE))
+ }
+ }
+
+ /** خواندن یکباره (نه Flow) — برای استفاده داخل تراکنش و در [com.oqba26.monthlypaymentapp.data.sync.SyncManager]. */
+ suspend fun getAllPersons(): List = personDao.getAllPersons()
+ suspend fun getAllPayments(): List = 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) {
+ database.withTransaction { items.forEach { syncQueueDao.delete(it) } }
+ }
+ suspend fun clearSyncQueue() = syncQueueDao.deleteAll()
}
\ No newline at end of file
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt
index 4671669..a203280 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt
@@ -20,6 +20,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
import java.util.UUID
class NetworkRepository {
@@ -29,20 +31,31 @@ class NetworkRepository {
private val _paymentsFlow = MutableStateFlow>(emptyList())
private val repositoryScope = CoroutineScope(Dispatchers.IO)
+ /**
+ * تا وقتی حداقل یک [refresh] موفق انجام نشده باشد `false` است.
+ *
+ * چرا لازم است: مقدار اولیهی `_personsFlow`/`_paymentsFlow` لیست خالی است و از لیست خالیِ
+ * واقعی (کاربری که هنوز دادهای ثبت نکرده) قابل تشخیص نیست. merge کردن روی آن لیست خالیِ
+ * قالبی، همهی دادهی محلی را پاک میکند. مصرفکننده باید منتظر `true` بماند.
+ */
+ private val _hasServerData = MutableStateFlow(false)
+ fun hasServerDataFlow(): Flow = _hasServerData.asStateFlow()
+
// --- Realtime Operations ---
fun observeRealtimeChanges() {
repositoryScope.launch {
try {
+ // اطمینان از اتصال قبل از اشتراک در کانال
supabase.realtime.connect()
+
val myChannel = supabase.channel("db-changes")
// گوش دادن به تغییرات جدول اشخاص
- launch {
- myChannel.postgresChangeFlow(schema = "public") {
- table = "persons"
- }.collect {
- refresh()
- }
+ myChannel.postgresChangeFlow(schema = "public") {
+ table = "persons"
+ }.collect {
+ Log.d("Supabase-Realtime", "Change detected in persons table")
+ refresh()
}
// گوش دادن به تغییرات جدول پرداختها
@@ -50,13 +63,15 @@ class NetworkRepository {
myChannel.postgresChangeFlow(schema = "public") {
table = "payments"
}.collect {
+ Log.d("Supabase-Realtime", "Change detected in payments table")
refresh()
}
}
myChannel.subscribe()
+ Log.d("Supabase-Realtime", "Subscribed to all changes")
} catch (e: Exception) {
- Log.e("Supabase", "Realtime connection error", e)
+ Log.e("Supabase-Realtime", "Realtime connection error, retrying...", e)
}
}
}
@@ -91,26 +106,36 @@ class NetworkRepository {
}
// --- Person Operations ---
- suspend fun addPerson(person: Person): Int? {
+ /**
+ * ارسال شخص به سرور با upsert.
+ *
+ * upsert (و نه insert) عمداً انتخاب شده تا این عملیات idempotent باشد: اگر تلاش اول
+ * به سرور رسیده باشد ولی پاسخش بهخاطر timeout به ما نرسیده باشد، تلاش دوم رکورد
+ * تکراری نمیسازد. صف سینک متکی به همین خاصیت است تا بتواند بیخطر retry کند.
+ *
+ * اعتبارسنجی تکراریبودن نام اینجا انجام نمیشود؛ آن کار در PersonViewModel روی
+ * دادهی محلی (که همیشه کامل است) انجام میگیرد. کش _personsFlow ممکن است خالی یا
+ * قدیمی باشد و نتیجهی غلط بدهد.
+ */
+ suspend fun addPerson(person: Person): Boolean {
return try {
val trimmedName = person.name.trim()
- if (trimmedName.isEmpty()) return 400
+ if (trimmedName.isEmpty()) {
+ Log.w("Supabase", "addPerson skipped: blank name")
+ return false
+ }
- // بررسی تکراری بودن نام در لیست موجود
- val isDuplicate = _personsFlow.value.any { it.name.trim().equals(trimmedName, ignoreCase = true) }
- if (isDuplicate) return 409
-
- val personToInsert = if (person.id.isEmpty()) {
+ val personToUpsert = if (person.id.isEmpty()) {
person.copy(id = UUID.randomUUID().toString(), name = trimmedName, createdAt = System.currentTimeMillis())
} else {
person.copy(name = trimmedName)
}
-
- supabase.from("persons").insert(personToInsert)
- 201
+
+ supabase.from("persons").upsert(personToUpsert)
+ true
} catch (e: Exception) {
Log.e("Supabase", "addPerson error", e)
- null
+ false
}
}
@@ -125,22 +150,37 @@ class NetworkRepository {
}
}
- suspend fun updatePerson(personId: String, name: String, phoneNumber: String?): Int {
+ suspend fun updatePerson(
+ personId: String,
+ name: String,
+ phoneNumber: String?,
+ monthlyCommitment: Double,
+ startMonth: Int,
+ startYear: Int
+ ): Int {
return try {
val trimmedName = name.trim()
if (trimmedName.isEmpty()) return 400
- // بررسی تکراری بودن نام (به جز خودِ این شخص)
+ val currentPerson = _personsFlow.value.find { it.id == personId }
+ val currentCategory = currentPerson?.category ?: "salary"
+
+ // بررسی تکراری بودن نام در همان دستهبندی (به جز خودِ این شخص)
val isDuplicate = _personsFlow.value.any {
- it.id != personId && it.name.trim().equals(trimmedName, ignoreCase = true)
+ it.id != personId &&
+ it.category == currentCategory &&
+ it.name.trim().equals(trimmedName, ignoreCase = true)
}
if (isDuplicate) return 409
supabase.from("persons").update(
- mapOf(
- "name" to trimmedName,
- "phoneNumber" to phoneNumber?.trim()
- )
+ buildJsonObject {
+ put("name", trimmedName)
+ put("phoneNumber", phoneNumber?.trim())
+ put("monthlyCommitment", monthlyCommitment)
+ put("startMonth", startMonth)
+ put("startYear", startYear)
+ },
) {
filter { eq("id", personId) }
}
@@ -154,7 +194,9 @@ class NetworkRepository {
suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean {
return try {
supabase.from("persons").update(
- mapOf("isArchived" to isArchived)
+ buildJsonObject {
+ put("isArchived", isArchived)
+ }
) {
filter { eq("id", personId) }
}
@@ -167,7 +209,9 @@ class NetworkRepository {
suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean {
return try {
supabase.from("persons").update(
- mapOf("displayOrder" to displayOrder)
+ buildJsonObject {
+ put("displayOrder", displayOrder)
+ }
) {
filter { eq("id", personId) }
}
@@ -180,9 +224,14 @@ class NetworkRepository {
fun getPersonsFlow(): Flow> = _personsFlow.asStateFlow()
// --- Payment Operations ---
+ /**
+ * ارسال پرداخت به سرور با upsert — به همان دلیل idempotent بودن که در [addPerson] توضیح داده شد.
+ * ضمناً همین باعث میشود ویرایش پرداخت (SyncOperation.UPDATE) هم درست کار کند؛ قبلاً insert
+ * بود و روی رکورد موجود شکست میخورد.
+ */
suspend fun addPayment(paymentRecord: PaymentRecord): Boolean {
return try {
- supabase.from("payments").insert(paymentRecord)
+ supabase.from("payments").upsert(paymentRecord)
true
} catch (e: Exception) {
Log.e("Supabase", "addPayment error", e); false
@@ -190,33 +239,56 @@ class NetworkRepository {
}
suspend fun deletePayment(paymentId: String): Boolean {
- return try {
- supabase.from("payments").delete {
- filter { eq("id", paymentId) }
+ var retries = 0
+ val maxRetries = 2
+
+ while (retries <= maxRetries) {
+ try {
+ supabase.from("payments").delete {
+ filter { eq("id", paymentId) }
+ }
+ Log.d("Supabase", "deletePayment Success: $paymentId")
+ return true
+ } catch (e: Exception) {
+ retries++
+ Log.e("Supabase", "deletePayment error (Attempt $retries): ${e.message}", e)
+ if (retries > maxRetries) return false
+ kotlinx.coroutines.delay(1000L * retries) // Exponential backoff-ish
}
- true
- } catch (e: Exception) {
- Log.e("Supabase", "deletePayment error", e); false
}
+ return false
}
fun getPaymentsFlow(): Flow> = _paymentsFlow.asStateFlow()
- suspend fun refresh() {
- try {
- // دریافت و تبدیل خودکار لیست اشخاص
+ /**
+ * واکشی کامل دادهها از سرور.
+ *
+ * @return true فقط اگر **هر دو** واکشی موفق بوده باشند.
+ *
+ * دو تضمین مهم که فراخوانها به آن تکیه میکنند:
+ * ۱. در صورت شکست، flowها **دستنخورده** میمانند. قبلاً خطا بلعیده میشد و صدازننده
+ * نمیفهمید واکشی شکست خورده؛ نتیجهاش merge روی دادهی ناقص و پاک شدن دادهی محلی بود.
+ * ۲. مقداردهی flowها اتمیک است — یا هر دو با هم بهروز میشوند یا هیچکدام. قبلاً اگر
+ * واکشی پرداختها شکست میخورد، لیست اشخاص جدید کنار لیست پرداختهای قدیمی مینشست.
+ */
+ suspend fun refresh(): Boolean {
+ return try {
val persons = supabase.from("persons").select().decodeList()
- _personsFlow.value = persons
-
- // دریافت و تبدیل خودکار لیست پرداختها
val payments = supabase.from("payments").select {
order("timestamp", order = Order.DESCENDING)
}.decodeList()
+
+ // فقط بعد از موفقیت هر دو واکشی مقداردهی میکنیم
+ _personsFlow.value = persons
_paymentsFlow.value = payments
-
+ _hasServerData.value = true
+
Log.d("Supabase", "Refresh Success: ${persons.size} persons, ${payments.size} payments")
+ true
} catch (e: Exception) {
- Log.e("Supabase", "refresh error", e)
+ Log.e("Supabase", "refresh error — دادههای محلی دستنخورده باقی میمانند", e)
+ false
}
}
}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt
index 722688b..635e823 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt
@@ -5,9 +5,11 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore by preferencesDataStore(name = "settings")
@@ -25,10 +27,19 @@ class SettingsRepository(context: Context) {
val CARD_NUMBERS = stringPreferencesKey("card_numbers")
val IGNORED_CONTACT_SUGGESTIONS = stringPreferencesKey("ignored_contact_suggestions")
+ /** آخرین versionCodeی که با آن اجرا شده — مبنای تشخیص ارتقای نسخه برای گرفتن snapshot. */
+ val LAST_RUN_VERSION_CODE = intPreferencesKey("last_run_version_code")
+
const val FALLBACK_AMOUNT = 200000.0
const val DEFAULT_FONT = "Estedad"
}
+ suspend fun getLastRunVersionCode(): Int? = dataStore.data.first()[LAST_RUN_VERSION_CODE]
+
+ suspend fun saveLastRunVersionCode(versionCode: Int) {
+ dataStore.edit { it[LAST_RUN_VERSION_CODE] = versionCode }
+ }
+
val reminderDayFlow: Flow = dataStore.data.map {
it[REMINDER_DAY]?.toIntOrNull()
}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/sync/SyncManager.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/sync/SyncManager.kt
new file mode 100644
index 0000000..c171915
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/sync/SyncManager.kt
@@ -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(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): Pair, List> {
+ val effective = mutableListOf()
+ val obsolete = mutableListOf()
+
+ 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
+ }
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/sync/SyncMerger.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/sync/SyncMerger.kt
new file mode 100644
index 0000000..df8d911
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/sync/SyncMerger.kt
@@ -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(
+ val upserts: List,
+ val deleteIds: List
+) {
+ 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, pendingIds: Set): Set =
+ pendingIds + local.filter { it.needsSync }.map { it.id }
+
+ private fun protectedPaymentIds(local: List, pendingIds: Set): Set =
+ pendingIds + local.filter { it.needsSync }.map { it.id }
+
+ /**
+ * @param idGenerator تولیدکنندهٔ شناسه؛ فقط برای تستپذیری قابل تزریق است.
+ */
+ fun planPersons(
+ local: List,
+ server: List,
+ pendingIds: Set,
+ idGenerator: () -> String = { UUID.randomUUID().toString() }
+ ): MergePlan {
+ 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,
+ server: List,
+ pendingIds: Set
+ ): MergePlan {
+ 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 }
+ )
+ }
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/ArchiveScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/ArchiveScreen.kt
index 1ae9a14..b6fc5f5 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/ArchiveScreen.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/ArchiveScreen.kt
@@ -77,58 +77,15 @@ fun ArchiveScreen(viewModel: PersonViewModel) {
// دیالوگ تایید حذف قطعی
personToDelete?.let { person ->
- Dialog(
- onDismissRequest = { personToDelete = null }
- ) {
- CompositionLocalProvider(
- LocalLayoutDirection provides LayoutDirection.Rtl
- ) {
- Card(
- modifier = Modifier.fillMaxWidth().padding(16.dp),
- shape = RoundedCornerShape(24.dp)
- ) {
- Column(
- modifier = Modifier.padding(20.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- text = "حذف قطعی شخص",
- style = MaterialTheme.typography.headlineSmall,
- fontWeight = FontWeight.Bold
- )
-
- Text(
- text = "آیا از حذف قطعی ${person.name} و تمامی سوابق پرداخت او مطمئن هستید؟ این عمل غیرقابل بازگشت است.",
- style = MaterialTheme.typography.bodyMedium
- )
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Button(
- onClick = { personToDelete = null }
- ) {
- Text("لغو")
- }
-
- Button(
- onClick = {
- viewModel.onEvent(PersonScreenEvent.DeletePerson(person.id))
- personToDelete = null
- },
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error
- )
- ) {
- Text("حذف قطعی")
- }
- }
- }
- }
- }
- }
+ ConfirmDeleteDialog(
+ title = "حذف قطعی شخص",
+ message = "آیا از حذف قطعی ${person.name} و تمامی سوابق پرداخت او مطمئن هستید؟ این عمل غیرقابل بازگشت است.",
+ onConfirm = {
+ viewModel.onEvent(PersonScreenEvent.DeletePerson(person.id))
+ personToDelete = null
+ },
+ onDismiss = { personToDelete = null }
+ )
}
// دیالوگ تایید بازیابی
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/AuthScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/AuthScreen.kt
index 2d44c00..da6b82a 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/AuthScreen.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/AuthScreen.kt
@@ -42,6 +42,13 @@ fun AuthScreen(
val authState by viewModel.authState.collectAsState()
val isLoading = authState is AuthState.Loading
var isLoginMode by remember { mutableStateOf(true) }
+ val context = androidx.compose.ui.platform.LocalContext.current
+
+ androidx.compose.runtime.LaunchedEffect(Unit) {
+ viewModel.toastMessage.collect { message ->
+ android.widget.Toast.makeText(context, message, android.widget.Toast.LENGTH_SHORT).show()
+ }
+ }
Column(
modifier = Modifier
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt
index 79c87fb..4bbc066 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt
@@ -34,9 +34,12 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
@@ -47,6 +50,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -58,6 +62,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
+import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -92,18 +97,21 @@ fun PersonDetailScreen(
val detailState by viewModel.personDetailState.collectAsState()
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
- val context = LocalContext.current
var expandedMonth by remember { mutableStateOf(null) }
var editingPayment by remember { mutableStateOf(null) }
var addingPaymentMonth by remember { mutableStateOf(null) }
var editingPerson by remember { mutableStateOf(value = false) }
- var showBulkPaymentDialog by remember { mutableStateOf(false) }
+ var showBulkPaymentDialog by remember { mutableStateOf(value = false) }
Column(modifier = Modifier.fillMaxSize()) {
+ val displayName = detailState.person?.let { p ->
+ if (p.isAnonymous && (p.category == "mosque")) "خیر ناشناس (${p.name})" else p.name
+ } ?: "..."
+
PersonDetailHeader(
- name = detailState.person?.name ?: "...",
- onBack = { navController.popBackStack() }
+ name = displayName,
+ onBack = { navController.popBackStack() },
) { editingPerson = true }
if (detailState.person == null) {
@@ -113,14 +121,15 @@ fun PersonDetailScreen(
} else {
YearSelector(
year = detailState.selectedYear,
- onYearChange = { offset ->
- viewModel.onEvent(PersonScreenEvent.ChangeYear(offset))
- }
- )
+ ) { offset ->
+ viewModel.onEvent(PersonScreenEvent.ChangeYear(offset))
+ }
val availableMonths = detailState.monthStates
+ .asSequence()
.filter { it.status == MonthStatus.AVAILABLE }
.map { it.month }
+ .toList()
if (availableMonths.isNotEmpty()) {
Button(
@@ -140,6 +149,8 @@ fun PersonDetailScreen(
items(detailState.monthStates) { monthModel ->
MonthListItem(
month = monthModel,
+ personName = detailState.person?.name ?: "",
+ startMonth = detailState.person?.startMonth ?: 1,
onHeaderClick = {
expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month
},
@@ -190,7 +201,9 @@ fun PersonDetailScreen(
AddPaymentDialog(
personName = detailState.person?.name ?: "",
- defaultAmount = defaultPaymentAmount,
+ defaultAmount = if ((detailState.person?.monthlyCommitment ?: 0.0) > 0)
+ detailState.person!!.monthlyCommitment
+ else defaultPaymentAmount,
initialDescription = initialDesc,
onConfirm = { amount, description ->
viewModel.onEvent(
@@ -204,18 +217,21 @@ fun PersonDetailScreen(
)
addingPaymentMonth = null
},
- onDismiss = {
- addingPaymentMonth = null
- }
+ onDismiss = { addingPaymentMonth = null }
)
}
if (editingPerson) {
+ val currentCategory by viewModel.currentCategory.collectAsState()
UpdatePersonDialog(
initialName = detailState.person?.name ?: "",
initialPhone = detailState.person?.phoneNumber,
- onConfirm = { name, phone ->
- viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone))
+ initialCommitment = detailState.person?.monthlyCommitment ?: 0.0,
+ initialStartMonth = detailState.person?.startMonth ?: 1,
+ initialStartYear = detailState.person?.startYear ?: com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear(),
+ isMosqueCategory = currentCategory == "mosque",
+ onConfirm = { name, phone, commitment, month, year ->
+ viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone, commitment, month, year))
editingPerson = false
},
onDismiss = { editingPerson = false },
@@ -227,12 +243,18 @@ fun PersonDetailScreen(
if (showBulkPaymentDialog) {
val availableMonths = detailState.monthStates
+ .asSequence()
.filter { it.status == MonthStatus.AVAILABLE }
.map { it.month }
+ .toList()
+ val bulkDefaultAmount = if ((detailState.person?.monthlyCommitment ?: 0.0) > 0)
+ detailState.person!!.monthlyCommitment
+ else defaultPaymentAmount
+
BulkPaymentDialog(
availableMonths = availableMonths,
- defaultAmount = defaultPaymentAmount,
+ defaultAmount = bulkDefaultAmount,
onConfirm = { selected, amount ->
viewModel.onEvent(
PersonScreenEvent.AddBulkPayments(
@@ -244,8 +266,7 @@ fun PersonDetailScreen(
)
showBulkPaymentDialog = false
},
- onDismiss = { showBulkPaymentDialog = false }
- )
+ ) { showBulkPaymentDialog = false }
}
}
@@ -254,7 +275,7 @@ fun BulkPaymentDialog(
availableMonths: List,
defaultAmount: Double,
onConfirm: (List, Double) -> Unit,
- onDismiss: () -> Unit
+ onDismiss: () -> Unit,
) {
val selectedMonths = remember { mutableStateListOf().apply { addAll(availableMonths) } }
var amountText by remember { mutableStateOf(defaultAmount.toInt().toString()) }
@@ -307,17 +328,28 @@ fun BulkPaymentDialog(
OutlinedTextField(
value = amountText,
- onValueChange = { amountText = it },
+ onValueChange = { value ->
+ // اجازه ورود فقط به اعداد (چه انگلیسی چه فارسی)
+ val digitsOnly = value.filter { it.isDigit() || (((it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9'))) }
+ // تبدیل اعداد فارسی به انگلیسی برای ذخیره در استیت
+ amountText = digitsOnly.map {
+ when (it) {
+ in '\u0660'..'\u0669' -> ((it.code - '\u0660'.code) + '0'.code).toChar()
+ in '\u06f0'..'\u06f9' -> ((it.code - '\u06f0'.code) + '0'.code).toChar()
+ else -> it
+ }
+ }.joinToString("")
+ },
label = { Text("مبلغ برای هر ماه (تومان)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
- visualTransformation = PersianDigitsTransformation()
+ visualTransformation = PersianNumberVisualTransformation()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
@@ -343,7 +375,7 @@ fun BulkPaymentDialog(
private fun PersonDetailHeader(
name: String,
onBack: () -> Unit,
- onEdit: () -> Unit
+ onEdit: () -> Unit,
) {
Surface(color = MaterialTheme.colorScheme.primary, contentColor = Color.White) {
Row(
@@ -371,12 +403,22 @@ private fun PersonDetailHeader(
fun UpdatePersonDialog(
initialName: String,
initialPhone: String?,
- onConfirm: (String, String) -> Unit,
+ initialCommitment: Double = 0.0,
+ initialStartMonth: Int = 1,
+ initialStartYear: Int = 1403,
+ isMosqueCategory: Boolean = false,
+ onConfirm: (String, String, Double, Int, Int) -> Unit,
onDismiss: () -> Unit,
- onSearchContact: (String) -> List
+ onSearchContact: (String) -> List,
) {
var name by remember { mutableStateOf(initialName) }
var phoneNumber by remember { mutableStateOf(initialPhone ?: "") }
+ var monthlyCommitment by remember { mutableStateOf(if (initialCommitment > 0) initialCommitment.toInt().toString() else "") }
+
+ val currentShamsiYear = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear()
+ var startMonth by remember { mutableIntStateOf(initialStartMonth) }
+ var startYear by remember { mutableIntStateOf(initialStartYear) }
+
var similarContacts by remember { mutableStateOf>(emptyList()) }
val context = LocalContext.current
@@ -469,6 +511,28 @@ fun UpdatePersonDialog(
}
}
+ if (isMosqueCategory) {
+ OutlinedTextField(
+ value = monthlyCommitment,
+ onValueChange = { value ->
+ if (value.length <= 12) {
+ val digitsOnly = value.filter { it.isDigit() || (((it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9'))) }
+ monthlyCommitment = digitsOnly.map {
+ when (it) {
+ in '\u0660'..'\u0669' -> ((it.code - '\u0660'.code) + '0'.code).toChar()
+ in '\u06f0'..'\u06f9' -> ((it.code - '\u06f0'.code) + '0'.code).toChar()
+ else -> it
+ }
+ }.joinToString("")
+ }
+ },
+ label = { Text("مبلغ تعهد ماهانه (تومان)") },
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ visualTransformation = PersianNumberVisualTransformation()
+ )
+ }
+
if (similarContacts.isNotEmpty()) {
Text(
"موارد مشابه پیدا شده:",
@@ -511,15 +575,63 @@ fun UpdatePersonDialog(
}
}
+ // انتخاب ماه و سال شروع
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("تاریخ شروع تعهد:", style = MaterialTheme.typography.labelLarge)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // سال
+ Box(modifier = Modifier.weight(1f)) {
+ var expandedYear by remember { mutableStateOf(false) }
+ OutlinedButton(
+ onClick = { expandedYear = true },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("سال ${startYear.toString().toPersianDigits()}")
+ }
+ DropdownMenu(expanded = expandedYear, onDismissRequest = { expandedYear = false }) {
+ (currentShamsiYear - 1..currentShamsiYear + 1).forEach { y ->
+ DropdownMenuItem(
+ text = { Text(y.toString().toPersianDigits()) },
+ onClick = { startYear = y; expandedYear = false }
+ )
+ }
+ }
+ }
+
+ // ماه
+ Box(modifier = Modifier.weight(1f)) {
+ var expandedMonth by remember { mutableStateOf(false) }
+ OutlinedButton(
+ onClick = { expandedMonth = true },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(startMonth))
+ }
+ DropdownMenu(expanded = expandedMonth, onDismissRequest = { expandedMonth = false }) {
+ (1..12).forEach { m ->
+ DropdownMenuItem(
+ text = { Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(m)) },
+ onClick = { startMonth = m; expandedMonth = false }
+ )
+ }
+ }
+ }
+ }
+ }
+
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
if (name.isNotBlank()) {
- onConfirm(name, phoneNumber)
+ val commitment = monthlyCommitment.toDoubleOrNull() ?: 0.0
+ onConfirm(name, phoneNumber, commitment, startMonth, startYear)
}
},
enabled = name.isNotBlank()
@@ -573,19 +685,37 @@ fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
@Composable
fun MonthListItem(
month: MonthUiModel,
+ personName: String,
+ startMonth: Int,
onHeaderClick: () -> Unit,
onEditClick: () -> Unit
) {
+ val context = LocalContext.current
val containerColor = when (month.status) {
MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer
MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.surface
+ MonthStatus.NOT_COMMITTED -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
else -> MaterialTheme.colorScheme.surfaceVariant
}
Card(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp).fillMaxWidth().clickable(onClick = onHeaderClick),
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .fillMaxWidth()
+ .clickable {
+ if (month.status == MonthStatus.NOT_COMMITTED) {
+ val monthName = getPersianMonthName(startMonth)
+ Toast.makeText(
+ context,
+ "ماههای قبل از $monthName ماه جزو تعهدات این فرد نیست",
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ onHeaderClick()
+ }
+ },
colors = CardDefaults.cardColors(containerColor = containerColor),
- elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
+ elevation = CardDefaults.cardElevation(defaultElevation = if (month.status == MonthStatus.NOT_COMMITTED) 0.dp else 1.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
Row(
@@ -594,13 +724,18 @@ fun MonthListItem(
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
- Text(text = getPersianMonthName(month.month), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
- if (month.status == MonthStatus.PAID && month.payment != null) {
+ Text(
+ text = getPersianMonthName(month.month),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = if (month.status == MonthStatus.NOT_COMMITTED) Color.Gray else Color.Unspecified
+ )
+ if ((month.status == MonthStatus.PAID) && (month.payment != null)) {
Spacer(modifier = Modifier.width(8.dp))
Text(text = "${formatNumberAsPersian(month.payment.amount)} ت", style = MaterialTheme.typography.bodyMedium)
}
}
- if (month.status == MonthStatus.PAID || month.status == MonthStatus.AVAILABLE) {
+ if ((month.status == MonthStatus.PAID) || (month.status == MonthStatus.AVAILABLE)) {
Button(
onClick = onEditClick,
colors = ButtonDefaults.buttonColors(
@@ -611,7 +746,7 @@ fun MonthListItem(
}
}
}
- if (month.status == MonthStatus.PAID && month.payment != null) {
+ if ((month.status == MonthStatus.PAID) && (month.payment != null)) {
val desc = month.payment.description ?: ""
if (desc.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt
index 6d4eb0b..66fa805 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Payments
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.pullrefresh.PullRefreshIndicator
@@ -41,6 +42,9 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
@@ -56,6 +60,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -97,7 +102,7 @@ import org.burnoutcrew.reorderable.reorderable
fun PersonScreen(
viewModel: PersonViewModel,
contactViewModel: ContactViewModel = hiltViewModel(),
- navController: NavController
+ navController: NavController,
) {
val searchQuery by viewModel.searchQuery.collectAsState()
val uiState by viewModel.uiState.collectAsState()
@@ -114,7 +119,7 @@ fun PersonScreen(
val isRefreshing by viewModel.isRefreshing.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
val selectedIds by viewModel.selectedIds.collectAsState()
- val context = LocalContext.current
+ val currentCategory by viewModel.currentCategory.collectAsState()
LaunchedEffect(uiState.unpaidPersons, uiState.paidPersons) {
val allPersons = uiState.unpaidPersons + uiState.paidPersons
@@ -129,13 +134,19 @@ fun PersonScreen(
val reorderableState = rememberReorderableLazyListState(
onMove = { from, to ->
if (searchQuery.isBlank()) {
- viewModel.onEvent(
- PersonScreenEvent.MovePersonNew(
- fromIndex = from.index,
- toIndex = to.index,
- listType = PersonListType.UNPAID
+ val headerOffset = if (currentCategory == "mosque") 1 else 0
+ val fromIdx = from.index - headerOffset
+ val toIdx = to.index - headerOffset
+
+ if (fromIdx in uiState.unpaidPersons.indices && toIdx in uiState.unpaidPersons.indices) {
+ viewModel.onEvent(
+ PersonScreenEvent.MovePersonNew(
+ fromIndex = fromIdx,
+ toIndex = toIdx,
+ listType = PersonListType.UNPAID
+ )
)
- )
+ }
}
},
onDragEnd = { _, _ ->
@@ -181,6 +192,17 @@ fun PersonScreen(
.fillMaxSize()
.reorderable(reorderableState)
) {
+ if (currentCategory == "mosque" && uiState.unpaidPersons.isNotEmpty()) {
+ item {
+ Text(
+ text = "لیست خیرین (بدهکار)",
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+
itemsIndexed(uiState.unpaidPersons, key = { _, person -> person.id }) { index, person ->
ReorderableItem(reorderableState, key = person.id) { isDragging ->
val elevation = animateFloatAsState(if (isDragging) 8f else 0f, label = "").value
@@ -214,6 +236,46 @@ fun PersonScreen(
)
}
}
+
+ if (currentCategory == "mosque" && uiState.paidPersons.isNotEmpty()) {
+ item {
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), thickness = 0.5.dp)
+ Text(
+ text = "لیست تسویه شدگان",
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ itemsIndexed(uiState.paidPersons, key = { _, person -> person.id }) { index, person ->
+ PersonListItem(
+ person = person,
+ index = index + 1 + uiState.unpaidPersons.size,
+ isSelectionMode = isSelectionMode,
+ isSelected = selectedIds.contains(person.id),
+ onPersonClick = {
+ if (isSelectionMode) {
+ viewModel.onEvent(PersonScreenEvent.ToggleSelection(person.id))
+ } else {
+ navController.navigate("person_detail/${person.id}")
+ }
+ },
+ onLongClick = {
+ if (!isSelectionMode) {
+ viewModel.onEvent(PersonScreenEvent.ToggleSelection(person.id))
+ }
+ },
+ onQuickPayClick = { },
+ onArchiveClick = { selectedPerson ->
+ personToArchive = selectedPerson
+ },
+ onSmsClick = { selectedPerson ->
+ contactViewModel.onSmsClicked(selectedPerson)
+ }
+ )
+ }
+ }
+
item { Spacer(modifier = Modifier.height(60.dp)) }
}
}
@@ -229,7 +291,7 @@ fun PersonScreen(
personForPaymentDialog?.let { person ->
AddPaymentDialog(
personName = person.name,
- defaultAmount = defaultPaymentAmount,
+ defaultAmount = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount,
onConfirm = { amount, description ->
viewModel.onEvent(PersonScreenEvent.AddQuickPayment(person.id, amount, description))
viewModel.onDismissPaymentDialog()
@@ -261,9 +323,11 @@ fun PersonScreen(
}
if (isAddPersonDialogShown) {
+ val currentCategory by viewModel.currentCategory.collectAsState()
AddNewPersonDialog(
- onConfirm = { name, phone ->
- viewModel.onEvent(PersonScreenEvent.AddPerson(name, phone, context))
+ isMosqueCategory = currentCategory == "mosque",
+ onConfirm = { name, phone, isAnon, commitment, month, year, initialPayment ->
+ viewModel.onEvent(PersonScreenEvent.AddPerson(name, phone, isAnon, commitment, month, year, initialPayment))
viewModel.onDismissAddPersonDialog()
},
onDismiss = { viewModel.onDismissAddPersonDialog() },
@@ -436,12 +500,23 @@ fun ContactSuggestionDialog(
@Composable
fun AddNewPersonDialog(
- onConfirm: (String, String) -> Unit,
+ isMosqueCategory: Boolean = false,
+ onConfirm: (String, String, Boolean, Double, Int, Int, Double) -> Unit,
onDismiss: () -> Unit,
onSearchContact: (String) -> List
) {
var name by remember { mutableStateOf("") }
var phoneNumber by remember { mutableStateOf("") }
+ var isAnonymous by remember { mutableStateOf(value = false) }
+ var monthlyCommitment by remember { mutableStateOf("") }
+ var initialPaymentAmount by remember { mutableStateOf("") }
+
+ val currentShamsiYear = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear()
+ val currentShamsiMonth = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth()
+
+ var startMonth by remember { mutableIntStateOf(currentShamsiMonth) }
+ var startYear by remember { mutableIntStateOf(currentShamsiYear) }
+
var similarContacts by remember { mutableStateOf>(emptyList()) }
val context = LocalContext.current
@@ -471,97 +546,225 @@ fun AddNewPersonDialog(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
- text = "افزودن شخص جدید",
+ text = if (isAnonymous) "ثبت کمک آنی" else "افزودن شخص جدید",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
- Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
- OutlinedTextField(
- value = name,
- onValueChange = { name = it },
- label = { Text("نام شخص") },
- singleLine = true,
- modifier = Modifier.fillMaxWidth()
- )
+ if (isMosqueCategory) {
Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { isAnonymous = !isAnonymous }
+ .padding(vertical = 4.dp)
) {
- CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
- OutlinedTextField(
- value = phoneNumber,
- onValueChange = { phoneNumber = it },
- label = { Text("شماره موبایل (اختیاری)") },
- singleLine = true,
- modifier = Modifier.weight(1f),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
- visualTransformation = PersianDigitsTransformation(),
- placeholder = { Text("۰۹---------", textAlign = androidx.compose.ui.text.style.TextAlign.Right) }
- )
- }
+ Checkbox(
+ checked = isAnonymous,
+ onCheckedChange = { isAnonymous = it }
+ )
Spacer(modifier = Modifier.width(8.dp))
- IconButton(
- onClick = {
- if (name.isBlank()) {
- Toast.makeText(context, "ابتدا نام را وارد کنید", Toast.LENGTH_SHORT).show()
- return@IconButton
- }
- when (PackageManager.PERMISSION_GRANTED) {
- ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) -> {
- val matches = onSearchContact(name)
- if (matches.isNotEmpty()) {
- similarContacts = matches
- } else {
- Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT).show()
+ Text("ثبت به صورت خیر ناشناس")
+ }
+ }
+
+ if (!isAnonymous) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ label = { Text("نام شخص") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
+ OutlinedTextField(
+ value = phoneNumber,
+ onValueChange = { phoneNumber = it },
+ label = { Text("شماره موبایل (اختیاری)") },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
+ visualTransformation = PersianDigitsTransformation(),
+ placeholder = {
+ Text(
+ "۰۹---------",
+ textAlign = androidx.compose.ui.text.style.TextAlign.Right
+ )
+ }
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ IconButton(
+ onClick = {
+ if (name.isBlank()) {
+ Toast.makeText(context, "ابتدا نام را وارد کنید", Toast.LENGTH_SHORT)
+ .show()
+ return@IconButton
+ }
+ when (PackageManager.PERMISSION_GRANTED) {
+ ContextCompat.checkSelfPermission(
+ context,
+ Manifest.permission.READ_CONTACTS
+ ) -> {
+ val matches = onSearchContact(name)
+ if (matches.isNotEmpty()) {
+ similarContacts = matches
+ } else {
+ Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT)
+ .show()
+ }
+ }
+
+ else -> {
+ permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
}
- else -> {
- permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
+ }
+ ) {
+ Icon(Icons.Default.Search, contentDescription = "جستجو در مخاطبین")
+ }
+ }
+
+ if (similarContacts.isNotEmpty()) {
+ Text(
+ "موارد مشابه پیدا شده:",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ LazyColumn(
+ modifier = Modifier.heightIn(max = 200.dp)
+ ) {
+ items(similarContacts) { match ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ phoneNumber = match.phoneNumber
+ similarContacts = emptyList()
+ }
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = phoneNumber == match.phoneNumber,
+ onClick = {
+ phoneNumber = match.phoneNumber
+ similarContacts = emptyList()
+ }
+ )
+ Column {
+ Text(match.nameInContacts, style = MaterialTheme.typography.bodySmall)
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
+ Text(
+ match.phoneNumber.toPersianDigits(),
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.Gray
+ )
+ }
+ }
}
}
}
- ) {
- Icon(Icons.Default.Search, contentDescription = "جستجو در مخاطبین")
}
}
+ }
- if (similarContacts.isNotEmpty()) {
- Text(
- "موارد مشابه پیدا شده:",
- style = MaterialTheme.typography.labelMedium,
- color = MaterialTheme.colorScheme.primary
+ if (isMosqueCategory) {
+ if (isAnonymous) {
+ OutlinedTextField(
+ value = initialPaymentAmount,
+ onValueChange = { value ->
+ if (value.length <= 12) {
+ val digitsOnly = value.filter { it.isDigit() || (it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9') }
+ initialPaymentAmount = digitsOnly.map {
+ when (it) {
+ in '\u0660'..'\u0669' -> (it.code - '\u0660'.code + '0'.code).toChar()
+ in '\u06f0'..'\u06f9' -> (it.code - '\u06f0'.code + '0'.code).toChar()
+ else -> it
+ }
+ }.joinToString("")
+ }
+ },
+ label = { Text("مبلغ پرداختی (تومان)") },
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ visualTransformation = com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation()
)
- LazyColumn(
- modifier = Modifier.heightIn(max = 200.dp)
+ } else {
+ OutlinedTextField(
+ value = monthlyCommitment,
+ onValueChange = { value ->
+ if (value.length <= 12) {
+ val digitsOnly = value.filter { it.isDigit() || (it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9') }
+ monthlyCommitment = digitsOnly.map {
+ when (it) {
+ in '\u0660'..'\u0669' -> (it.code - '\u0660'.code + '0'.code).toChar()
+ in '\u06f0'..'\u06f9' -> (it.code - '\u06f0'.code + '0'.code).toChar()
+ else -> it
+ }
+ }.joinToString("")
+ }
+ },
+ label = { Text("مبلغ تعهد ماهانه (تومان) - اختیاری") },
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ visualTransformation = com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation(),
+ placeholder = { Text("اگر مبلغ ثابتی پرداخت میکند وارد کنید") }
+ )
+ }
+ }
+
+ if (!isAnonymous) {
+ // انتخاب ماه و سال شروع
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("تاریخ شروع تعهد:", style = MaterialTheme.typography.labelLarge)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- items(similarContacts) { match ->
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable {
- phoneNumber = match.phoneNumber
- similarContacts = emptyList()
- }
- .padding(vertical = 4.dp),
- verticalAlignment = Alignment.CenterVertically
+ // سال
+ Box(modifier = Modifier.weight(1f)) {
+ var expandedYear by remember { mutableStateOf(false) }
+ OutlinedButton(
+ onClick = { expandedYear = true },
+ modifier = Modifier.fillMaxWidth()
) {
- RadioButton(
- selected = phoneNumber == match.phoneNumber,
- onClick = {
- phoneNumber = match.phoneNumber
- similarContacts = emptyList()
- }
- )
- Column {
- Text(match.nameInContacts, style = MaterialTheme.typography.bodySmall)
- CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
- Text(
- match.phoneNumber.toPersianDigits(),
- style = MaterialTheme.typography.labelSmall,
- color = Color.Gray
- )
- }
+ Text("سال ${startYear.toString().toPersianDigits()}")
+ }
+ DropdownMenu(
+ expanded = expandedYear,
+ onDismissRequest = { expandedYear = false }) {
+ (currentShamsiYear - 1..currentShamsiYear + 1).forEach { y ->
+ DropdownMenuItem(
+ text = { Text(y.toString().toPersianDigits()) },
+ onClick = { startYear = y; expandedYear = false }
+ )
+ }
+ }
+ }
+
+ // ماه
+ Box(modifier = Modifier.weight(1f)) {
+ var expandedMonth by remember { mutableStateOf(false) }
+ OutlinedButton(
+ onClick = { expandedMonth = true },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(startMonth))
+ }
+ DropdownMenu(
+ expanded = expandedMonth,
+ onDismissRequest = { expandedMonth = false }) {
+ (1..12).forEach { m ->
+ DropdownMenuItem(
+ text = { Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(m)) },
+ onClick = { startMonth = m; expandedMonth = false }
+ )
}
}
}
@@ -576,13 +779,15 @@ fun AddNewPersonDialog(
) {
Button(
onClick = {
- if (name.isNotBlank()) {
- onConfirm(name, phoneNumber)
+ if (isAnonymous || name.isNotBlank()) {
+ val commitment = monthlyCommitment.toDoubleOrNull() ?: 0.0
+ val initial = initialPaymentAmount.toDoubleOrNull() ?: 0.0
+ onConfirm(name, phoneNumber, isAnonymous, commitment, startMonth, startYear, initial)
}
},
- enabled = name.isNotBlank()
+ enabled = isAnonymous || name.isNotBlank()
) {
- Text("افزودن")
+ Text(if (isAnonymous) "ثبت کمک" else "افزودن")
}
Button(
onClick = onDismiss,
@@ -913,6 +1118,15 @@ fun PersonListItem(
}
}
+ if (person.needsSync) {
+ Icon(
+ imageVector = Icons.Default.Sync,
+ contentDescription = "Pending Sync",
+ tint = MaterialTheme.colorScheme.outline,
+ modifier = Modifier.size(16.dp).padding(horizontal = 2.dp)
+ )
+ }
+
Spacer(Modifier.width(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
@@ -943,40 +1157,3 @@ fun PersonListItem(
}
}
-@Composable
-fun AddPaymentDialog(
- personName: String,
- defaultAmount: Double,
- onConfirm: (Double, String) -> Unit,
- onDismiss: () -> Unit
-) {
- var amount by remember { mutableStateOf(defaultAmount.toString()) }
- var description by remember { mutableStateOf("") }
-
- Dialog(onDismissRequest = onDismiss) {
- CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
- Card(modifier = Modifier.fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(24.dp)) {
- Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
- Text(text = "ثبت پرداخت سریع برای $personName", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
- OutlinedTextField(
- value = amount,
- onValueChange = { amount = it },
- label = { Text("مبلغ") },
- modifier = Modifier.fillMaxWidth(),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
- )
- OutlinedTextField(
- value = description,
- onValueChange = { description = it },
- label = { Text("توضیحات") },
- modifier = Modifier.fillMaxWidth()
- )
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- Button(onClick = onDismiss) { Text("لغو") }
- Button(onClick = { onConfirm(amount.toDoubleOrNull() ?: 0.0, description) }) { Text("ثبت") }
- }
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt
index f181ec3..f5da1ef 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt
@@ -55,7 +55,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.hilt.navigation.compose.hiltViewModel
+import com.oqba26.monthlypaymentapp.core.manager.BackupManager
+import com.oqba26.monthlypaymentapp.core.manager.SnapshotInfo
import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation
+import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
import com.oqba26.monthlypaymentapp.utils.toPersianDigits
@@ -80,6 +83,9 @@ fun SettingsScreen(onLogout: () -> Unit) {
val selectedFont by viewModel.selectedFont.collectAsState()
var backupJsonToRestore by remember { mutableStateOf(null) }
+ var showSnapshotDialog by remember { mutableStateOf(false) }
+ var snapshotToRestore by remember { mutableStateOf(null) }
+ val snapshots by viewModel.snapshots.collectAsState()
LaunchedEffect(Unit) {
viewModel.toastMessage.collect { message ->
@@ -195,6 +201,17 @@ fun SettingsScreen(onLogout: () -> Unit) {
}
}
+ // تور نجات: نسخههایی که خودِ برنامه قبل از عملیات پرخطر گرفته است.
+ OutlinedButton(
+ onClick = {
+ viewModel.loadSnapshots()
+ showSnapshotDialog = true
+ },
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
+ ) {
+ Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
+ }
+
Spacer(Modifier.weight(1f))
Button(
@@ -209,6 +226,63 @@ fun SettingsScreen(onLogout: () -> Unit) {
}
}
+ if (showSnapshotDialog) {
+ SnapshotListDialog(
+ snapshots = snapshots,
+ onSelect = { snapshotToRestore = it },
+ onDismiss = { showSnapshotDialog = false }
+ )
+ }
+
+ snapshotToRestore?.let { snapshot ->
+ Dialog(onDismissRequest = { snapshotToRestore = null }) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ shape = RoundedCornerShape(24.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.restore_warning),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = formatTimestampToPersianDateTime(snapshot.timestamp),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = stringResource(R.string.auto_backup_restore_confirm),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = {
+ viewModel.restoreSnapshot(snapshot)
+ snapshotToRestore = null
+ showSnapshotDialog = false
+ },
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
+ ) { Text(stringResource(R.string.yes)) }
+
+ Button(onClick = { snapshotToRestore = null }) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
backupJsonToRestore?.let { json ->
Dialog(onDismissRequest = { backupJsonToRestore = null }) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
@@ -253,6 +327,92 @@ fun SettingsScreen(onLogout: () -> Unit) {
}
}
+/**
+ * لیست نسخههای پشتیبان خودکار.
+ *
+ * تاریخها شمسی و اعداد فارسی نمایش داده میشوند تا با بقیهی اپ یکدست باشد.
+ */
+@Composable
+private fun SnapshotListDialog(
+ snapshots: List,
+ onSelect: (SnapshotInfo) -> Unit,
+ onDismiss: () -> Unit
+) {
+ Dialog(onDismissRequest = onDismiss) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ shape = RoundedCornerShape(24.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.auto_backup_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = stringResource(R.string.auto_backup_description),
+ style = MaterialTheme.typography.bodySmall
+ )
+
+ if (snapshots.isEmpty()) {
+ Text(
+ text = stringResource(R.string.auto_backup_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+ } else {
+ snapshots.forEach { snapshot ->
+ OutlinedButton(
+ onClick = { onSelect(snapshot) },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = formatTimestampToPersianDateTime(snapshot.timestamp),
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = stringResource(
+ R.string.auto_backup_item_summary,
+ snapshot.personCount.toString().toPersianDigits(),
+ snapshot.paymentCount.toString().toPersianDigits()
+ ),
+ style = MaterialTheme.typography.bodySmall
+ )
+ Text(
+ text = stringResource(snapshotReasonLabel(snapshot.reason)),
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) }
+ }
+ }
+ }
+ }
+ }
+}
+
+/** ترجمهی علتِ ذخیرهشده در نام فایل به متن قابل فهم کاربر. */
+private fun snapshotReasonLabel(reason: String): Int = when (reason) {
+ BackupManager.REASON_BEFORE_SYNC -> R.string.auto_backup_reason_before_sync
+ BackupManager.REASON_BEFORE_RESTORE -> R.string.auto_backup_reason_before_restore
+ BackupManager.REASON_APP_UPGRADE -> R.string.auto_backup_reason_app_upgrade
+ else -> R.string.auto_backup_reason_unknown
+}
+
@Composable
fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
var isEditing by remember { mutableStateOf(false) }
@@ -267,7 +427,16 @@ fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
Column(modifier = Modifier.padding(12.dp)) {
OutlinedTextField(
value = amountText,
- onValueChange = { amountText = it.filter { c -> c.isDigit() } },
+ onValueChange = { value ->
+ val digitsOnly = value.filter { it.isDigit() || it in '\u0660'..'\u0669' || it in '\u06f0'..'\u06f9' }
+ amountText = digitsOnly.map {
+ when (it) {
+ in '\u0660'..'\u0669' -> (it.code - '\u0660'.code + '0'.code).toChar()
+ in '\u06f0'..'\u06f9' -> (it.code - '\u06f0'.code + '0'.code).toChar()
+ else -> it
+ }
+ }.joinToString("")
+ },
modifier = Modifier.fillMaxWidth(),
label = { Text("مبلغ پیشفرض (تومان)") },
visualTransformation = PersianNumberVisualTransformation(),
@@ -339,6 +508,7 @@ fun ReminderDaySetting(currentDay: Int?, onSave: (String) -> Unit) {
fun CardNumbersSetting(cardNumbers: List, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
var isAdding by remember { mutableStateOf(false) }
var newCardText by remember { mutableStateOf("") }
+ var cardToRemove by remember { mutableStateOf(null) }
Card(
modifier = Modifier.fillMaxWidth(),
@@ -366,11 +536,23 @@ fun CardNumbersSetting(cardNumbers: List, onAdd: (String) -> Unit, onRem
cardNumbers.forEach { card ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(card.toPersianDigits(), modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge)
- IconButton(onClick = { onRemove(card) }) { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) }
+ IconButton(onClick = { cardToRemove = card }) { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) }
}
}
}
}
+
+ cardToRemove?.let { card ->
+ ConfirmDeleteDialog(
+ title = "حذف شماره کارت",
+ message = "آیا از حذف شماره کارت ${card.toPersianDigits()} مطمئن هستید؟",
+ onConfirm = {
+ onRemove(card)
+ cardToRemove = null
+ },
+ onDismiss = { cardToRemove = null }
+ )
+ }
}
@Composable
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SharedComponents.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SharedComponents.kt
index 2ad275c..699c0ba 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SharedComponents.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SharedComponents.kt
@@ -5,6 +5,7 @@ package com.oqba26.monthlypaymentapp.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -47,6 +48,7 @@ fun EditPaymentDialog(
) {
var amountText by remember { mutableStateOf(paymentRecord.amount.toLong().toString()) }
var description by remember { mutableStateOf(paymentRecord.description ?: "") }
+ var showDeleteConfirm by remember { mutableStateOf(false) }
Dialog(onDismissRequest = onDismiss) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
@@ -125,15 +127,90 @@ fun EditPaymentDialog(
) {
Text("لغو")
}
+ }
- IconButton(
- onClick = onDelete,
- ) {
- Icon(
- imageVector = androidx.compose.material.icons.Icons.Default.Delete,
- contentDescription = "حذف",
- tint = MaterialTheme.colorScheme.error
+ Button(
+ onClick = { showDeleteConfirm = true },
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Icon(androidx.compose.material.icons.Icons.Default.Delete, contentDescription = null)
+ Spacer(Modifier.padding(horizontal = 4.dp))
+ Text("حذف این پرداخت")
+ }
+ }
+ }
+ }
+ }
+
+ if (showDeleteConfirm) {
+ ConfirmDeleteDialog(
+ title = "حذف پرداخت",
+ message = "آیا از حذف این رکورد پرداخت مطمئن هستید؟",
+ onConfirm = {
+ onDelete()
+ showDeleteConfirm = false
+ },
+ onDismiss = { showDeleteConfirm = false }
+ )
+ }
+}
+
+@Composable
+fun ConfirmDeleteDialog(
+ title: String,
+ message: String,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ Dialog(onDismissRequest = onDismiss) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ shape = RoundedCornerShape(24.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.error
+ )
+
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = onDismiss,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondary
)
+ ) {
+ Text("لغو")
+ }
+
+ Button(
+ onClick = onConfirm,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text("حذف")
}
}
}
@@ -153,6 +230,7 @@ fun AddPaymentDialog(
) {
var rawAmount by remember { mutableStateOf(defaultAmount.toLong().toString()) }
var description by remember { mutableStateOf(initialDescription) }
+ var showDeleteConfirm by remember { mutableStateOf(false) }
val title =
if (onDelete != null) "ویرایش پرداخت برای $personName" else "ثبت پرداخت برای $personName"
@@ -220,7 +298,7 @@ fun AddPaymentDialog(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (onDelete != null) {
Button(
- onClick = onDelete,
+ onClick = { showDeleteConfirm = true },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
@@ -242,4 +320,16 @@ fun AddPaymentDialog(
}
}
}
+
+ if (showDeleteConfirm && onDelete != null) {
+ ConfirmDeleteDialog(
+ title = "حذف پرداخت",
+ message = "آیا از حذف این رکورد پرداخت برای $personName مطمئن هستید؟",
+ onConfirm = {
+ onDelete()
+ showDeleteConfirm = false
+ },
+ onDismiss = { showDeleteConfirm = false }
+ )
+ }
}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/ContactViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/ContactViewModel.kt
index f65ecd9..86ecf36 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/ContactViewModel.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/ContactViewModel.kt
@@ -29,12 +29,12 @@ data class ContactUiState(
val selectedCardForBulk: String? = null,
val showBulkSmsDialog: Boolean = false,
val personForSmsDialog: PersonUiModel? = null,
- val bulkSmsQueueForDialog: List = emptyList()
+ val bulkSmsQueueForDialog: List = emptyList(),
)
@HiltViewModel
class ContactViewModel @Inject constructor(
- @ApplicationContext private val context: Context,
+ @param:ApplicationContext private val context: Context,
private val networkRepository: NetworkRepository,
private val settingsRepository: SettingsRepository,
private val smsManager: SmsManager
@@ -148,7 +148,7 @@ class ContactViewModel @Inject constructor(
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
while (cursor.moveToNext()) {
- if (nameIndex != -1 && numberIndex != -1) {
+ if ((nameIndex != -1) && (numberIndex != -1)) {
val contactName = cursor.getString(nameIndex) ?: ""
val rawNumber = cursor.getString(numberIndex) ?: ""
@@ -163,7 +163,20 @@ class ContactViewModel @Inject constructor(
fun confirmContactSuggestion(personId: String, personName: String, phoneNumber: String) {
viewModelScope.launch {
- val statusCode = networkRepository.updatePerson(personId, personName, phoneNumber)
+ // دریافت اطلاعات فعلی شخص برای حفظ مقادیر دیگر
+ val currentPerson = networkRepository.getPersonsFlow().first().find { it.id == personId }
+ val commitment = currentPerson?.monthlyCommitment ?: 0.0
+ val startMonth = currentPerson?.startMonth ?: 1
+ val startYear = currentPerson?.startYear ?: 1403
+
+ val statusCode = networkRepository.updatePerson(
+ personId,
+ personName,
+ phoneNumber,
+ commitment,
+ startMonth,
+ startYear
+ )
if (statusCode == 200) {
networkRepository.refresh()
_uiState.update { state ->
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt
index cb9fdf9..f3425be 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt
@@ -1,15 +1,14 @@
package com.oqba26.monthlypaymentapp.viewmodel
-import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.oqba26.monthlypaymentapp.core.manager.BackupManager
import com.oqba26.monthlypaymentapp.core.manager.ExportManager
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
-import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
@@ -38,9 +37,13 @@ class PersonViewModel @Inject constructor(
private val networkRepository: NetworkRepository,
private val localPersonRepository: LocalPersonRepository,
private val settingsRepository: SettingsRepository,
- private val exportManager: ExportManager
+ private val exportManager: ExportManager,
+ private val backupManager: BackupManager,
) : ViewModel() {
+ /** فقط یک بار در هر اجرا snapshot قبل از ادغام گرفته میشود. */
+ private var snapshotTakenThisSession = false
+
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow = _searchQuery.asStateFlow()
@@ -49,6 +52,8 @@ class PersonViewModel @Inject constructor(
private val _selectedPersonId = MutableStateFlow(null)
private val _selectedYear = MutableStateFlow(getCurrentShamsiYear())
+ private val _currentCategory = MutableStateFlow("salary")
+ val currentCategory: StateFlow = _currentCategory.asStateFlow()
private val _dashboardData = MutableStateFlow(DashboardUiModel())
val dashboardData: StateFlow = _dashboardData.asStateFlow()
@@ -85,22 +90,31 @@ class PersonViewModel @Inject constructor(
combine(
localPersonRepository.getAllPersonsFlow(),
localPersonRepository.getAllPaymentsFlow(),
- _selectedYear
- ) { persons, allPayments, year ->
+ _selectedYear,
+ _currentCategory
+ ) { persons, allPayments, year, category ->
val person = persons.find { it.id == personId }
+ val personCategory = person?.category ?: category
val paymentsForPersonInYear = allPayments.filter {
- it.personId == personId && it.shamsiYear == year
+ (it.personId == personId) && (it.shamsiYear == year) && (it.category == personCategory)
}
val currentYear = getCurrentShamsiYear()
val currentMonth = getCurrentShamsiMonth()
val currentDay = getCurrentShamsiDay()
val monthStates = (1..12).map { month ->
val payment = paymentsForPersonInYear.find { it.shamsiMonth == month }
+
+ // بررسی شروع تعهد
+ val isBeforeCommitment = if (person != null) {
+ year < person.startYear || (year == person.startYear && month < person.startMonth)
+ } else false
+
val status = when {
+ payment != null -> MonthStatus.PAID
+ isBeforeCommitment -> MonthStatus.NOT_COMMITTED
year < currentYear -> MonthStatus.PAST_YEAR
year > currentYear -> MonthStatus.FUTURE_YEAR
month > currentMonth || (month == currentMonth && currentDay < 20) -> MonthStatus.FUTURE_MONTH
- payment != null -> MonthStatus.PAID
else -> MonthStatus.AVAILABLE
}
MonthUiModel(month, payment, status)
@@ -119,12 +133,24 @@ class PersonViewModel @Inject constructor(
networkRepository.observeRealtimeChanges()
networkRepository.refresh()
+ // شرط قبلی `persons.isNotEmpty()` بود که دو مشکل داشت: هم لیست خالیِ
+ // معتبر سرور را نادیده میگرفت، و هم اگر واکشی شکست میخورد (چون refresh
+ // خطا را میبلعید) روی دادهی قدیمی/ناقص merge میکرد.
+ // حالا فقط بعد از یک refresh موفق merge میکنیم و خودِ merge هم غیرمخرب است.
combine(
networkRepository.getPersonsFlow(),
- networkRepository.getPaymentsFlow()
- ) { persons, payments ->
- if (persons.isNotEmpty()) {
- localPersonRepository.syncAll(persons, payments)
+ networkRepository.getPaymentsFlow(),
+ networkRepository.hasServerDataFlow()
+ ) { persons, payments, hasServerData ->
+ if (hasServerData) {
+ if (!snapshotTakenThisSession) {
+ // آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی
+ // ذخیره میشود تا اگر با وجود همهی محافظتها چیزی خراب شد،
+ // کاربر بتواند از تنظیمات برش گرداند.
+ backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC)
+ snapshotTakenThisSession = true
+ }
+ localPersonRepository.mergeFromServer(persons, payments)
true
} else false
}.collect { isUpdated ->
@@ -141,8 +167,9 @@ class PersonViewModel @Inject constructor(
combine(
localPersonRepository.getAllPersonsFlow(),
localPersonRepository.getAllPaymentsFlow(),
- _searchQuery
- ) { persons, allPayments, query ->
+ _searchQuery,
+ _currentCategory
+ ) { persons, allPayments, query, category ->
val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear()
val currentDay = getCurrentShamsiDay()
@@ -151,30 +178,59 @@ class PersonViewModel @Inject constructor(
val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear
val relevantPayments = allPayments.filter {
- it.shamsiMonth == targetMonth && it.shamsiYear == targetYear
+ it.shamsiMonth == targetMonth && it.shamsiYear == targetYear && it.category == category
}
val activePersons = persons
- .filter { !it.isArchived }
+ .filter { !it.isArchived && it.category == category }
.sortedWith(compareBy({ it.displayOrder }, { it.createdAt }))
val uiModels = activePersons.map { person ->
val hasPaid = relevantPayments.any { it.personId == person.id }
- val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
- val unpaidMonthsInCurrentYear = (1..debtEndMonth).filter { m ->
- allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m }
- }
+ val (debtCount, totalDebtAmount, unpaidMonths) = if (category == "mosque") {
+ if (person.monthlyCommitment > 0) {
+ val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
+ val debtStartMonth = if (person.startYear == currentYear) person.startMonth else 1
+
+ val unpaid = (debtStartMonth..debtEndMonth).filter { m ->
+ allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category }
+ }
+ Triple(unpaid.size, unpaid.size * person.monthlyCommitment, unpaid.map { getPersianMonthName(it) })
+ } else {
+ Triple(0, 0.0, emptyList())
+ }
+ } else {
+ // Salary logic
+ val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
+ val debtStartMonth = if (person.startYear == currentYear) person.startMonth else 1
+
+ val unpaid = (debtStartMonth..debtEndMonth).filter { m ->
+ allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category }
+ }
+ val defaultAmount = settingsRepository.defaultPaymentAmountFlow.first()
+ Triple(unpaid.size, unpaid.size * defaultAmount, unpaid.map { getPersianMonthName(it) })
+ }
+
+ val displayName = if (person.isAnonymous && category == "mosque") {
+ "خیر ناشناس (${person.name})"
+ } else {
+ person.name
+ }
+
PersonUiModel(
id = person.id,
- name = person.name,
+ name = displayName,
hasPaidThisMonth = hasPaid,
displayOrder = person.displayOrder,
createdAt = person.createdAt,
- debtCount = unpaidMonthsInCurrentYear.size,
- totalDebtAmount = unpaidMonthsInCurrentYear.size * (settingsRepository.defaultPaymentAmountFlow.first()),
+ debtCount = debtCount,
+ totalDebtAmount = totalDebtAmount,
phoneNumber = person.phoneNumber,
- unpaidMonthsNames = unpaidMonthsInCurrentYear.map { getPersianMonthName(it) }
+ isAnonymous = person.isAnonymous,
+ monthlyCommitment = person.monthlyCommitment,
+ unpaidMonthsNames = unpaidMonths,
+ needsSync = person.needsSync
)
}
@@ -185,8 +241,21 @@ class PersonViewModel @Inject constructor(
val filteredPaid = if (query.isBlank()) paidModels else paidModels.filter { it.name.contains(query, ignoreCase = true) }
val filteredUnpaid = if (query.isBlank()) unpaidModels else unpaidModels.filter { it.name.contains(query, ignoreCase = true) }
- val archivedPersons = persons.filter { it.isArchived }.map {
- PersonUiModel(it.id, it.name, false, it.displayOrder, it.createdAt)
+ val archivedPersons = persons.filter { it.isArchived && it.category == category }.map { person ->
+ val displayName = if (person.isAnonymous && category == "mosque") {
+ "خیر ناشناس (${person.name})"
+ } else {
+ person.name
+ }
+ PersonUiModel(
+ id = person.id,
+ name = displayName,
+ hasPaidThisMonth = false,
+ displayOrder = person.displayOrder,
+ createdAt = person.createdAt,
+ isAnonymous = person.isAnonymous,
+ needsSync = person.needsSync
+ )
}
PersonListUiState(
@@ -224,12 +293,46 @@ class PersonViewModel @Inject constructor(
}
is PersonScreenEvent.AddPerson -> {
- if (event.name.isNotBlank()) {
- val person = Person(name = event.name, phoneNumber = event.phoneNumber, displayOrder = 0)
- val statusCode = networkRepository.addPerson(person)
- if (statusCode == 409) _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
- else if (statusCode != 200 && statusCode != 201) _toastMessage.emit("خطا در افزودن شخص.")
- else networkRepository.refresh()
+ if (event.isAnonymous || event.name.isNotBlank()) {
+ val persons = localPersonRepository.getAllPersonsFlow().first()
+ val finalName = if (event.isAnonymous && event.name.isBlank()) "ناشناس" else event.name
+
+ val isDuplicate = if (!event.isAnonymous) {
+ persons.any { it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true) }
+ } else false
+
+ if (isDuplicate) {
+ _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
+ } else {
+ val personId = java.util.UUID.randomUUID().toString()
+ val person = Person(
+ id = personId,
+ name = finalName,
+ phoneNumber = event.phoneNumber,
+ displayOrder = 0,
+ category = _currentCategory.value,
+ isAnonymous = event.isAnonymous,
+ monthlyCommitment = if (event.isAnonymous) 0.0 else event.monthlyCommitment,
+ startMonth = event.startMonth,
+ startYear = event.startYear,
+ createdAt = System.currentTimeMillis()
+ )
+ localPersonRepository.insertPersonLocally(person)
+
+ // اگر مبلغ اولیه وارد شده باشد (مخصوصا برای ناشناس)
+ if (event.initialPaymentAmount > 0) {
+ val currentMonth = getCurrentShamsiMonth()
+ val currentYear = getCurrentShamsiYear()
+ val record = createPaymentRecord(
+ personId = personId,
+ month = currentMonth,
+ year = currentYear,
+ amount = event.initialPaymentAmount,
+ description = if (event.isAnonymous) "کمک آنی خیر ناشناس" else "پرداخت اولیه"
+ ).copy(category = _currentCategory.value)
+ localPersonRepository.insertPaymentLocally(record)
+ }
+ }
}
}
@@ -241,8 +344,8 @@ class PersonViewModel @Inject constructor(
val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear
val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description)
- if (networkRepository.addPayment(record)) networkRepository.refresh()
- else _toastMessage.emit("خطا در ثبت پرداخت.")
+ .copy(category = _currentCategory.value)
+ localPersonRepository.insertPaymentLocally(record)
}
is PersonScreenEvent.SelectPerson -> {
@@ -251,28 +354,38 @@ class PersonViewModel @Inject constructor(
}
is PersonScreenEvent.DeletePerson -> {
- if (networkRepository.deletePersonAndPayments(event.personId)) networkRepository.refresh()
+ localPersonRepository.deletePersonLocally(event.personId)
}
is PersonScreenEvent.ArchivePerson -> {
- if (networkRepository.updatePersonArchivedStatus(event.personId, true)) {
- _toastMessage.emit("شخص به آرشیو انتقال یافت.")
- networkRepository.refresh()
- }
+ localPersonRepository.archivePersonLocally(event.personId, true)
+ _toastMessage.emit("شخص به آرشیو انتقال یافت.")
}
is PersonScreenEvent.RestorePerson -> {
- if (networkRepository.updatePersonArchivedStatus(event.personId, false)) {
- _toastMessage.emit("شخص از آرشیو بازیابی شد.")
- networkRepository.refresh()
- }
+ localPersonRepository.archivePersonLocally(event.personId, false)
+ _toastMessage.emit("شخص از آرشیو بازیابی شد.")
}
is PersonScreenEvent.UpdatePerson -> {
- val statusCode = networkRepository.updatePerson(event.personId, event.name, event.phoneNumber)
- if (statusCode == 409) _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
- else if (statusCode == 200) networkRepository.refresh()
- else _toastMessage.emit("خطا در ویرایش اطلاعات.")
+ val persons = localPersonRepository.getAllPersonsFlow().first()
+ val isDuplicate = persons.any { it.id != event.personId && it.category == _currentCategory.value && it.name.trim().equals(event.name.trim(), ignoreCase = true) }
+
+ if (isDuplicate) {
+ _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
+ } else {
+ val currentPerson = persons.find { it.id == event.personId }
+ if (currentPerson != null) {
+ val updatedPerson = currentPerson.copy(
+ name = event.name,
+ phoneNumber = event.phoneNumber,
+ monthlyCommitment = event.monthlyCommitment,
+ startMonth = event.startMonth,
+ startYear = event.startYear
+ )
+ localPersonRepository.updatePersonLocally(updatedPerson)
+ }
+ }
}
is PersonScreenEvent.ChangeYear -> {
@@ -286,20 +399,21 @@ class PersonViewModel @Inject constructor(
}
is PersonScreenEvent.AddPaymentForMonth -> {
+ val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == event.personId }
+ val category = person?.category ?: _currentCategory.value
val record = createPaymentRecord(event.personId, event.month, event.year, event.amount, event.description)
- if (networkRepository.addPayment(record)) networkRepository.refresh()
- else _toastMessage.emit("خطا در ثبت پرداخت")
+ .copy(category = category)
+ localPersonRepository.insertPaymentLocally(record)
}
is PersonScreenEvent.UpdatePayment -> {
val updatedRecord = event.payment.copy(amount = event.newAmount, description = event.newDescription, timestamp = System.currentTimeMillis())
- if (networkRepository.addPayment(updatedRecord)) networkRepository.refresh()
- else _toastMessage.emit("خطا در ویرایش پرداخت")
+ localPersonRepository.insertPaymentLocally(updatedRecord)
}
is PersonScreenEvent.DeletePayment -> {
- if (networkRepository.deletePayment(event.payment.id)) networkRepository.refresh()
- else _toastMessage.emit("خطا در حذف پرداخت")
+ localPersonRepository.deletePaymentLocally(event.payment.id)
+ _toastMessage.emit("پرداخت با موفقیت حذف شد")
}
is PersonScreenEvent.ToggleSelection -> {
@@ -320,22 +434,48 @@ class PersonViewModel @Inject constructor(
_isSelectionMode.value = false
}
- is PersonScreenEvent.ExportYearlyReport -> exportYearlyReport(event.year, event.context)
+ is PersonScreenEvent.ExportYearlyReport -> exportYearlyReport(event.year)
+
+ is PersonScreenEvent.AddBulkPayments -> {
+ try {
+ val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == event.personId }
+ val category = person?.category ?: _currentCategory.value
+ event.months.forEach { month ->
+ val record = createPaymentRecord(
+ personId = event.personId,
+ month = month,
+ year = event.year,
+ amount = event.amount,
+ description = "پرداخت دسته جمعی"
+ ).copy(category = category)
+ localPersonRepository.insertPaymentLocally(record)
+ }
+ } catch (_: Exception) {
+ _toastMessage.emit("خطا در ثبت پرداختهای دسته جمعی")
+ }
+ }
+
+ is PersonScreenEvent.SetCategory -> {
+ _currentCategory.value = event.category
+ _selectedIds.value = emptySet()
+ _isSelectionMode.value = false
+ }
is PersonScreenEvent.CommitReorder -> {
val list = if (event.listType == PersonListType.UNPAID) _uiState.value.unpaidPersons else _uiState.value.paidPersons
if (list.size < 2) return@launch
try {
- list.forEachIndexed { index, person ->
+ list.forEachIndexed { index, personUi ->
val newOrder = (index + 1) * 10_000L
- if (person.displayOrder != newOrder) {
- networkRepository.updatePersonDisplayOrder(person.id, newOrder)
+ if (personUi.displayOrder != newOrder) {
+ val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == personUi.id }
+ person?.let {
+ localPersonRepository.updatePersonLocally(it.copy(displayOrder = newOrder))
+ }
}
}
- networkRepository.refresh()
} catch (_: Exception) {
_toastMessage.emit("خطا در ذخیره ترتیب")
- networkRepository.refresh()
}
}
@@ -349,7 +489,7 @@ class PersonViewModel @Inject constructor(
}
}
- private fun exportYearlyReport(year: Int, context: Context) {
+ private fun exportYearlyReport(year: Int) {
viewModelScope.launch {
val persons = localPersonRepository.getAllPersonsFlow().first()
val payments = localPersonRepository.getAllPaymentsFlow().first().filter { it.shamsiYear == year }
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/SettingsViewModel.kt
index 51f9c36..e3fd341 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/SettingsViewModel.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/SettingsViewModel.kt
@@ -2,15 +2,19 @@ package com.oqba26.monthlypaymentapp.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.oqba26.monthlypaymentapp.core.manager.BackupManager
+import com.oqba26.monthlypaymentapp.core.manager.SnapshotInfo
import com.oqba26.monthlypaymentapp.data.model.BackupData
import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
@@ -19,7 +23,8 @@ import kotlinx.serialization.json.Json
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
- private val localPersonRepository: LocalPersonRepository
+ private val localPersonRepository: LocalPersonRepository,
+ private val backupManager: BackupManager
) : ViewModel() {
private val json = Json {
@@ -98,10 +103,32 @@ class SettingsViewModel @Inject constructor(
return json.encodeToString(backupData)
}
+ // ---------------------------------------------- نسخههای پشتیبان خودکار (تور نجات)
+
+ private val _snapshots = MutableStateFlow>(emptyList())
+ val snapshots: StateFlow> = _snapshots.asStateFlow()
+
+ fun loadSnapshots() {
+ viewModelScope.launch { _snapshots.value = backupManager.listSnapshots() }
+ }
+
+ fun restoreSnapshot(info: SnapshotInfo) {
+ viewModelScope.launch {
+ val ok = backupManager.restoreSnapshot(info.file)
+ _toastMessage.emit(
+ if (ok) "اطلاعات از نسخهی خودکار بازگردانی شد. لطفاً برنامه را مجدد اجرا کنید."
+ else "بازگردانی ناموفق بود؛ فایل پشتیبان سالم نیست."
+ )
+ loadSnapshots()
+ }
+ }
+
fun restoreFromBackupJson(jsonString: String) {
viewModelScope.launch {
try {
val backupData = json.decodeFromString(jsonString)
+ // قبل از جایگزینی، وضعیت فعلی نگه داشته میشود تا راه برگشت بسته نشود.
+ backupManager.createSnapshot(BackupManager.REASON_BEFORE_RESTORE)
localPersonRepository.restoreBackup(backupData)
_toastMessage.emit("اطلاعات با موفقیت بازیابی شد! لطفاً برنامه را مجدد اجرا کنید.")
} catch (_: Exception) {
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt
index 6f22665..410656d 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt
@@ -1,6 +1,5 @@
package com.oqba26.monthlypaymentapp.viewmodel
-import android.content.Context
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
@@ -13,7 +12,10 @@ data class PersonUiModel(
val debtCount: Int = 0,
val totalDebtAmount: Double = 0.0,
val phoneNumber: String? = null,
- val unpaidMonthsNames: List = emptyList()
+ val isAnonymous: Boolean = false,
+ val monthlyCommitment: Double = 0.0,
+ val unpaidMonthsNames: List = emptyList(),
+ val needsSync: Boolean = false,
)
data class PersonListUiState(
@@ -55,7 +57,8 @@ enum class MonthStatus {
AVAILABLE,
FUTURE_MONTH,
PAST_YEAR,
- FUTURE_YEAR
+ FUTURE_YEAR,
+ NOT_COMMITTED
}
enum class PersonListType {
@@ -65,8 +68,23 @@ enum class PersonListType {
sealed class PersonScreenEvent {
data object RefreshData : PersonScreenEvent()
- data class AddPerson(val name: String, val phoneNumber: String?, val context: Context) : PersonScreenEvent()
- data class UpdatePerson(val personId: String, val name: String, val phoneNumber: String?) : PersonScreenEvent()
+ data class AddPerson(
+ val name: String,
+ val phoneNumber: String?,
+ val isAnonymous: Boolean = false,
+ val monthlyCommitment: Double = 0.0,
+ val startMonth: Int = 1,
+ val startYear: Int = 1403,
+ val initialPaymentAmount: Double = 0.0
+ ) : PersonScreenEvent()
+ data class UpdatePerson(
+ val personId: String,
+ val name: String,
+ val phoneNumber: String?,
+ val monthlyCommitment: Double = 0.0,
+ val startMonth: Int = 1,
+ val startYear: Int = 1403
+ ) : PersonScreenEvent()
data class DeletePerson(val personId: String) : PersonScreenEvent()
data class ArchivePerson(val personId: String) : PersonScreenEvent()
data class RestorePerson(val personId: String) : PersonScreenEvent()
@@ -110,14 +128,13 @@ sealed class PersonScreenEvent {
val year: Int,
val amount: Double
) : PersonScreenEvent()
- data class ExportYearlyReport(val year: Int, val context: Context) : PersonScreenEvent()
+ data class ExportYearlyReport(val year: Int) : PersonScreenEvent()
data class ToggleSelection(val personId: String) : PersonScreenEvent()
data class MoveSelected(val direction: Int, val listType: PersonListType) : PersonScreenEvent()
data object ClearSelection : PersonScreenEvent()
- data class ConfirmContactSuggestion(val personId: String, val personName: String, val phoneNumber: String) : PersonScreenEvent()
- data class DismissContactSuggestion(val suggestion: ContactSuggestion) : PersonScreenEvent()
+ data class SetCategory(val category: String) : PersonScreenEvent()
}
data class DashboardUiModel(
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e7dd9e6..8d6c2aa 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -21,6 +21,19 @@
خطا در ایجاد فایل پشتیبان
هشدار بازیابی
با بازیابی، تمام دادههای فعلی حذف و اطلاعات فایل جایگزین میشود. مطمئن هستید؟
+
+
+ نسخههای خودکار
+ بازگردانی از نسخهی خودکار
+ برنامه قبل از هر عملیات پرخطر، خودش یک نسخه از اطلاعات شما ذخیره میکند. اگر دادهای گم شد، میتوانید از اینجا برش گردانید.
+ هنوز نسخهی خودکاری ذخیره نشده است.
+ %1$s نفر، %2$s پرداخت
+ با بازگردانی این نسخه، اطلاعات فعلی جایگزین میشود. مطمئن هستید؟
+ قبل از همگامسازی با سرور
+ قبل از بازیابی نسخهی پشتیبان
+ بعد از بهروزرسانی برنامه
+ نسخهی خودکار
+ بستن
خروج از حساب کاربری
مبلغ پیشفرض پرداخت
روز یادآور ماهانه
diff --git a/app/src/test/java/com/oqba26/monthlypaymentapp/data/sync/SyncMergerTest.kt b/app/src/test/java/com/oqba26/monthlypaymentapp/data/sync/SyncMergerTest.kt
new file mode 100644
index 0000000..00e8980
--- /dev/null
+++ b/app/src/test/java/com/oqba26/monthlypaymentapp/data/sync/SyncMergerTest.kt
@@ -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(), 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(), 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(), 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(), 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(), plan.deleteIds)
+ assertEquals(2, plan.upserts.size) // upsert بیضرر است، حذف نیست
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a0573ea..c8a3abc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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-ktx = { group = "androidx.room", name = "room-ktx", 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-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" }
diff --git a/update.json b/update.json
index 49e7283..9907632 100644
--- a/update.json
+++ b/update.json
@@ -3,5 +3,5 @@
"versionName": "1.0.0",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.0/MonthlyPaymentApp.apk",
"releaseNotes": "نسخه اولیه برنامه مدیریت حقوق ماهانه:\n- مدیریت لیست افراد\n- تفکیک وضعیت پرداخت\n- سیستم آپدیت خودکار\n- مدیریت دسترسیهای اجباری",
- "isForceUpdate": false
+ "isForceUpdate": true
}