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 }