diff --git a/.artifacts/15926d7b-894e-47f6-892f-6114218cde2d/analysis_results.artifact.md b/.artifacts/15926d7b-894e-47f6-892f-6114218cde2d/analysis_results.artifact.md
new file mode 100644
index 0000000..69bcff6
--- /dev/null
+++ b/.artifacts/15926d7b-894e-47f6-892f-6114218cde2d/analysis_results.artifact.md
@@ -0,0 +1,68 @@
+# گزارش تحلیل و پیشنهادات بهبود پروژه MonthlyPaymentApp
+
+این گزارش شامل بررسی فنی ساختار پروژه و ارائه راهکارهای عملی برای بهبود کیفیت کد، نگهداری آسانتر و رعایت استانداردهای توسعه اندروید است.
+
+## ۱. معماری و مدیریت وابستگیها (DI)
+
+### وضعیت فعلی
+پروژه از DI دستی در کلاس `PaymentApplication` استفاده میکند. با رشد پروژه، مدیریت کارخانههای ViewModel (Factories) دشوار و مستعد خطا خواهد بود.
+
+### پیشنهاد
+استفاده از **Hilt (Dagger)** برای مدیریت وابستگیها.
+- **مزیت:** حذف کدهای تکراری در `Application` و `Activity` و سادهسازی فرآیند تست واحد.
+- **اقدام:** افزودن کتابخانههای Hilt به `build.gradle.kts` و استفاده از آنوتیشنهای `@HiltAndroidApp` و `@HiltViewModel`.
+
+---
+
+## ۲. بازنگری در ViewModels (اصل Single Responsibility)
+
+### وضعیت فعلی
+فایل [PersonViewModel.kt](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt) با حدود ۱۰۰۰ خط کد، تبدیل به یک **God Object** شده است. این کلاس همزمان وظایف زیر را انجام میدهد:
+- مدیریت احراز هویت (Auth)
+- مدیریت لیست افراد و پرداختها
+- ارسال پیامکهای گروهی و تکی
+- خروجی اکسل (Excel Export)
+- جستجو در مخاطبین گوشی
+
+### پیشنهاد
+تقسیم `PersonViewModel` به کلاسهای تخصصیتر:
+- `AuthViewModel`: برای لاگین و ثبتنام.
+- `PersonListViewModel`: برای نمایش و فیلتر لیست افراد.
+- `SmsService` یا `SmsHelper`: برای منطق ارسال پیامک.
+- `ExportManager`: برای تولید فایلهای گزارش.
+
+---
+
+## ۳. جداسازی Context از لایه منطق
+
+### وضعیت فعلی
+در متدهایی مانند `processNextBulkSms(Context)` یا `exportYearlyReport(..., Context)` در ViewModel، از `Context` استفاده شده است.
+- **مشکل:** این کار باعث نشت حافظه (Memory Leak) احتمالی میشود و تستنویسی (Unit Testing) را غیرممکن میکند.
+
+### پیشنهاد
+ViewModel نباید هیچ دانشی از `Context` داشته باشد.
+- **راهکار:** برای مواردی مثل نمایش Toast یا شروع Intent، از یک `SharedFlow` برای ارسال **UI Events** به Activity استفاده کنید.
+
+---
+
+## ۴. بهبود لایه UI و Compose
+
+### وضعیت فعلی
+فایلهایی مثل `PersonScreen.kt` و `MainActivity.kt` بسیار طولانی هستند. کامپوننتهای UI و منطق ناوبری در هم تنیده شدهاند.
+
+### پیشنهاد
+- **شکستن کامپوننتها:** بخشهای مختلف صفحه (مانند SearchBar، PersonItem، Dialogs) را به فایلهای جداگانه در پکیج `ui.components` منتقل کنید.
+- **String Resources:** تمام رشتههای فارسی هاردکد شده (مانند "آیا از حذف قطعی... مطمئن هستید؟") را به فایل `res/values/strings.xml` منتقل کنید.
+
+---
+
+## ۵. پیشنهادات فنی تکمیلی
+
+- **نوسازی پکیجها:** پکیجهای `util` و `utils` را ادغام کنید تا ساختار پروژه تمیزتر شود.
+- **Supabase Realtime:** با توجه به استفاده از Supabase، مطمئن شوید که از قابلیت Realtime برای همگامسازی آنی دادهها بین دستگاههای مختلف استفاده میکنید.
+- **Room Migrations:** برای دیتابیس محلی، استراتژی مهاجرت (Migration) را مشخص کنید تا در آپدیتهای بعدی، دادههای کاربر پاک نشود.
+
+---
+
+> [!TIP]
+> اگر مایل باشید، میتوانیم با **راهاندازی Hilt** یا **انتقال متون به strings.xml** شروع کنیم که تغییراتی کمریسک اما بسیار موثر در تمیزی پروژه هستند.
diff --git a/.artifacts/15926d7b-894e-47f6-892f-6114218cde2d/implementation_plan.artifact.md b/.artifacts/15926d7b-894e-47f6-892f-6114218cde2d/implementation_plan.artifact.md
new file mode 100644
index 0000000..07574bd
--- /dev/null
+++ b/.artifacts/15926d7b-894e-47f6-892f-6114218cde2d/implementation_plan.artifact.md
@@ -0,0 +1,57 @@
+# برنامه اجرایی: مدرنسازی و استانداردسازی پروژه (فاز ۱)
+
+این برنامه روی دو محور اصلی تمرکز دارد: پیادهسازی **Hilt** برای مدیریت وابستگیها و **استانداردسازی متون** برنامه با استفاده از `strings.xml`.
+
+## Proposed Changes
+
+### ۱. زیرساخت و مدیریت وابستگیها (Hilt)
+
+#### [MODIFY] [libs.versions.toml](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/gradle/libs.versions.toml)
+- افزودن نسخهها و کتابخانههای مورد نیاز برای Hilt.
+- افزودن پلاگین Hilt.
+
+#### [MODIFY] [build.gradle.kts (Project)](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/build.gradle.kts)
+- اعمال پلاگین Hilt.
+
+#### [MODIFY] [build.gradle.kts (App)](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/build.gradle.kts)
+- اعمال پلاگین `dagger.hilt.android.plugin`.
+- افزودن وابستگیهای `hilt-android` و `hilt-navigation-compose`.
+- تنظیم KSP برای کامپایلر Hilt.
+
+#### [MODIFY] [PaymentApplication.kt](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/java/com/oqba26/monthlypaymentapp/core/PaymentApplication.kt)
+- افزودن آنوتیشن `@HiltAndroidApp`.
+- حذف مدیریت دستی Repositoryها و Factoryها.
+
+#### [NEW] [DatabaseModule.kt](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/DatabaseModule.kt)
+- ایجاد ماژول Hilt برای ارائه (Provide) دیتابیس Room و DAOها.
+
+#### [NEW] [RepositoryModule.kt](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/RepositoryModule.kt)
+- ایجاد ماژول Hilt برای ارائه Repositoryها.
+
+#### [MODIFY] [ViewModels](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/)
+- افزودن `@HiltViewModel` به `PersonViewModel` و `SettingsViewModel`.
+- استفاده از `@Inject constructor` برای تزریق وابستگیها.
+
+#### [MODIFY] [MainActivity.kt](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt)
+- افزودن آنوتیشن `@AndroidEntryPoint`.
+- استفاده از `hiltViewModel()` به جای دستی ساختن ViewModelها.
+
+---
+
+### ۲. استانداردسازی متون (String Resources)
+
+#### [MODIFY] [strings.xml](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/res/values/strings.xml)
+- استخراج و تعریف متون فارسی از UI به فایل XML.
+
+#### [MODIFY] [Compose Screens](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/)
+- جایگزینی متون هاردکد شده با `stringResource(R.string.id)`.
+
+## Verification Plan
+
+### Automated Tests
+- اجرای `gradle build` برای اطمینان از صحت پیکربندی Hilt.
+- بررسی عدم وجود خطای کامپایل در کدهای تولید شده توسط KSP.
+
+### Manual Verification
+- اجرای برنامه و اطمینان از لود شدن صحیح دادهها از دیتابیس و شبکه (تایید کارکرد صحیح DI).
+- بررسی نمایش صحیح متون در صفحات مختلف.
diff --git a/.artifacts/15926d7b-894e-47f6-892f-6114218cde2d/walkthrough.artifact.md b/.artifacts/15926d7b-894e-47f6-892f-6114218cde2d/walkthrough.artifact.md
new file mode 100644
index 0000000..bce8804
--- /dev/null
+++ b/.artifacts/15926d7b-894e-47f6-892f-6114218cde2d/walkthrough.artifact.md
@@ -0,0 +1,30 @@
+# گزارش نهایی: بازنگری معماری و بهبود زیرساخت (فاز ۲)
+
+در این فاز، معماری پروژه از حالت یکپارچه و سنگین به حالتی ماژولار و تفکیکشده تغییر یافت. فایل `PersonViewModel` که حدود ۱۰۰۰ خط بود به چندین بخش تخصصی تقسیم شد.
+
+## تغییرات کلیدی
+
+### ۱. تفکیک وظایف (ViewModel Splitting)
+- **AuthViewModel:** مسئولیت کامل احراز هویت (Login/Register/Logout) را بر عهده گرفت.
+- **ContactViewModel:** مدیریت جستجوی مخاطبین گوشی، پیشنهادات شماره تماس و ارسال پیامک (SMS) به این کلاس منتقل شد.
+- **PersonViewModel:** اکنون فقط بر روی مدیریت دادههای افراد، پرداختها و داشبورد تمرکز دارد (حجم کد حدود ۵۰٪ کاهش یافت).
+
+### ۲. جداسازی منطق از پلتفرم (Android Context Cleanup)
+- **SmsManager:** منطق ایجاد Intentهای پیامک و مدیریت صف ارسال گروهی (Bulk SMS) به این کلاس کمکی منتقل شد.
+- **ExportManager:** فرآیند تولید گزارش اکسل (CSV) و اشتراکگذاری رسیدها مستقل شد.
+- **نتیجه:** ViewModelها اکنون مستقل از `Context` اندروید هستند که تستنویسی آنها را بسیار آسان میکند.
+
+### ۳. یکپارچهسازی و نظمدهی (Cleanup)
+- **ادغام پکیجها:** پکیجهای `util` و `utils` در یک پکیج واحد ادغام شدند.
+- **مدلهای متمرکز:** تمام `Events` و `UiStates` به فایل `UiModels.kt` منتقل شدند تا دسترسی به آنها یکپارچه شود.
+- **رفع خطاهای UI:** تمام صفحات برنامه (`AuthScreen`, `PersonScreen`, `PaidListScreen`, `PersonDetailScreen`) برای انطباق با ViewModelهای جدید بازنویسی شدند.
+
+## وضعیت نهایی پروژه
+- **Build Status:** موفقیتآمیز (تایید شده با `assembleDebug`).
+- **Dependency Injection:** استفاده کامل از Hilt در تمام لایهها.
+- **Readability:** افزایش چشمگیر خوانایی کد و سهولت در عیبیابی.
+
+---
+
+> [!TIP]
+> با این ساختار جدید، افزودن ویژگیهای جدید (مثل ارسال یادآور از طریق پیامرسانهای دیگر یا افزودن فیلترهای جدید به لیست) بسیار سریعتر و با ریسک کمتر انجام خواهد شد.
diff --git a/.artifacts/b6b8bd31-26e8-4cfd-8648-5c332ef07ef3/implementation_plan.artifact.md b/.artifacts/b6b8bd31-26e8-4cfd-8648-5c332ef07ef3/implementation_plan.artifact.md
new file mode 100644
index 0000000..8c4afa9
--- /dev/null
+++ b/.artifacts/b6b8bd31-26e8-4cfd-8648-5c332ef07ef3/implementation_plan.artifact.md
@@ -0,0 +1,55 @@
+# سیستم آپدیت اجباری و مدیریت دسترسیها
+
+این طرح شامل پیادهسازی مکانیزم بررسی نسخه جدید اپلیکیشن از طریق فایل JSON در گیتهاب و همچنین الزامی کردن دسترسیهای مورد نیاز برنامه (مشابه پروژه ابزار فروش) در بدو ورود کاربر است.
+
+## User Review Required
+
+- **لینک فایل آپدیت:** فعلاً از لینک `https://raw.githubusercontent.com/oqba26/MonthlyPaymentApp/main/update.json` استفاده شده است. لطفاً مطمئن شوید این فایل در مخزن شما وجود دارد.
+- **دسترسیهای اجباری:** دسترسیهای Contacts، Camera و Storage (برای نسخههای قدیمی) به صورت پیشفرض اجباری در نظر گرفته شدهاند.
+
+## Proposed Changes
+
+### [Dependencies]
+
+#### [MODIFY] [libs.versions.toml](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/gradle/libs.versions.toml)
+افزودن کتابخانههای Ktor برای Content Negotiation و Serialization جهت پردازش فایل JSON آپدیت.
+
+#### [MODIFY] [build.gradle.kts](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/build.gradle.kts)
+اضافه کردن وابستگیهای جدید به ماژول اپلیکیشن.
+
+---
+
+### [Update System]
+
+#### [NEW] [UpdateManager.kt](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/java/com/oqba26/monthlypaymentapp/util/UpdateManager.kt)
+کلاسی برای چک کردن نسخه جدید، دانلود APK و اجرای فرآیند نصب.
+
+#### [NEW] [UpdateDialog.kt](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/UpdateDialog.kt)
+رابط کاربری نمایش وجود نسخه جدید و گزارش پیشرفت دانلود.
+
+---
+
+### [Permissions & Manifest]
+
+#### [NEW] [PermissionDialogs.kt](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/PermissionDialogs.kt)
+دیالوگهای نمایش دسترسیهای الزامی و اخطار عدم دسترسی.
+
+#### [MODIFY] [AndroidManifest.xml](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/AndroidManifest.xml)
+افزودن مجوز `REQUEST_INSTALL_PACKAGES` و تعریف `FileProvider` برای نصب خودکار فایل APK.
+
+#### [NEW] [file_paths.xml](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/res/xml/file_paths.xml)
+تعریف مسیرهای مجاز برای `FileProvider`.
+
+---
+
+### [Integration]
+
+#### [MODIFY] [MainActivity.kt](file:///E:/AndroidStudioProjects/MonthlyPaymentApp/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt)
+یکپارچهسازی فرآیند بررسی آپدیت و چک کردن دسترسیها در زمان اجرای برنامه.
+
+## Verification Plan
+
+### Manual Verification
+1. اجرای برنامه و مشاهده دیالوگ دسترسیها (در صورت عدم تایید قبلی).
+2. شبیهسازی وجود آپدیت جدید با تغییر دستی `versionCode` در کد و بررسی نمایش دیالوگ آپدیت.
+3. تست فرآیند دانلود و باز شدن صفحه نصب اندروید.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..a8c0049
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,45 @@
+name: Android Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: gradle
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Decode Keystore
+ run: |
+ echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -di > app/release-key.jks
+
+ - name: Build Release APK
+ env:
+ RELEASE_STORE_FILE: release-key.jks
+ RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
+ RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
+ RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
+ run: ./gradlew assembleRelease
+
+ - name: Rename APK
+ run: |
+ mv app/build/outputs/apk/release/app-release.apk app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index aa724b7..0b7c68d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,5 @@
.externalNativeBuild
.cxx
local.properties
+keystore.properties
+*.jks
diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
deleted file mode 100644
index eca3fe9..0000000
--- a/.idea/appInsightsSettings.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
index defe597..ca16a99 100644
--- a/.idea/deploymentTargetSelector.xml
+++ b/.idea/deploymentTargetSelector.xml
@@ -4,13 +4,6 @@
-
-
-
-
-
-
-
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 0f2d2fa..9180c21 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -6,7 +6,6 @@
-
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index 103e00c..6384ea8 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -1,13 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
@@ -16,17 +40,16 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
deleted file mode 100644
index 148fdd2..0000000
--- a/.idea/kotlinc.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/markdown.xml b/.idea/markdown.xml
new file mode 100644
index 0000000..c61ea33
--- /dev/null
+++ b/.idea/markdown.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
index b2c751a..ea3fc8b 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,8 +1,7 @@
+
-
-
-
+
diff --git a/.idea/planningMode.xml b/.idea/planningMode.xml
new file mode 100644
index 0000000..9192952
--- /dev/null
+++ b/.idea/planningMode.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 94a25f7..35eb1dd 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/PersianDate/build.gradle.kts b/PersianDate/build.gradle.kts
index 266caca..f194672 100644
--- a/PersianDate/build.gradle.kts
+++ b/PersianDate/build.gradle.kts
@@ -1,7 +1,7 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
alias(libs.plugins.android.library)
- alias(libs.plugins.jetbrains.kotlin.android)
+ alias(libs.plugins.kotlin.android)
}
android {
@@ -19,11 +19,11 @@ android {
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_17
- targetCompatibility = JavaVersion.VERSION_17
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
- jvmTarget = "17"
+ jvmTarget = "21"
}
}
diff --git a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/results.bin b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/results.bin
new file mode 100644
index 0000000..7ed749e
--- /dev/null
+++ b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/results.bin
@@ -0,0 +1 @@
+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
new file mode 100644
index 0000000..462def9
Binary files /dev/null and b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate$Dialect.dex 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
new file mode 100644
index 0000000..5a596c6
Binary files /dev/null and b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate.dex 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
new file mode 100644
index 0000000..e1a20de
Binary files /dev/null and b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$1.dex 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
new file mode 100644
index 0000000..ffa3b13
Binary files /dev/null and b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.dex 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
new file mode 100644
index 0000000..f1e24b0
Binary files /dev/null and b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat.dex differ
diff --git a/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin
new file mode 100644
index 0000000..6b26a44
Binary files /dev/null and b/PersianDate/build/.transforms/804ae9461bc36181bb932ccf21e80d26/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin differ
diff --git a/PersianDate/build/.transforms/91dd66fd99e1bf96d3a5dda255469897/results.bin b/PersianDate/build/.transforms/91dd66fd99e1bf96d3a5dda255469897/results.bin
new file mode 100644
index 0000000..0d259dd
--- /dev/null
+++ b/PersianDate/build/.transforms/91dd66fd99e1bf96d3a5dda255469897/results.bin
@@ -0,0 +1 @@
+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
new file mode 100644
index 0000000..537a134
Binary files /dev/null and b/PersianDate/build/.transforms/91dd66fd99e1bf96d3a5dda255469897/transformed/classes/classes_dex/classes.dex differ
diff --git a/PersianDate/build/.transforms/99c70c53220c636dd34a74b4e5874025/results.bin b/PersianDate/build/.transforms/99c70c53220c636dd34a74b4e5874025/results.bin
new file mode 100644
index 0000000..0d259dd
--- /dev/null
+++ b/PersianDate/build/.transforms/99c70c53220c636dd34a74b4e5874025/results.bin
@@ -0,0 +1 @@
+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
new file mode 100644
index 0000000..934e7e5
Binary files /dev/null and b/PersianDate/build/.transforms/99c70c53220c636dd34a74b4e5874025/transformed/classes/classes_dex/classes.dex differ
diff --git a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/results.bin b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/results.bin
new file mode 100644
index 0000000..7ed749e
--- /dev/null
+++ b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/results.bin
@@ -0,0 +1 @@
+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
new file mode 100644
index 0000000..72147c9
Binary files /dev/null and b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate$Dialect.dex 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
new file mode 100644
index 0000000..e3ddc4f
Binary files /dev/null and b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDate.dex 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
new file mode 100644
index 0000000..72ab1d0
Binary files /dev/null and b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$1.dex 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
new file mode 100644
index 0000000..3b4e993
Binary files /dev/null and b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.dex 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
new file mode 100644
index 0000000..b4e9905
Binary files /dev/null and b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/bundleLibRuntimeToDirDebug_dex/saman/zamani/persiandate/PersianDateFormat.dex differ
diff --git a/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin
new file mode 100644
index 0000000..56ac563
Binary files /dev/null and b/PersianDate/build/.transforms/f7fb0ddd5afd91b957e9024aed94e90b/transformed/bundleLibRuntimeToDirDebug/desugar_graph.bin 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
new file mode 100644
index 0000000..f6518bc
--- /dev/null
+++ b/PersianDate/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
\ 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
new file mode 100644
index 0000000..67bd344
--- /dev/null
+++ b/PersianDate/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json
@@ -0,0 +1,18 @@
+{
+ "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
new file mode 100644
index 0000000..1211b1e
--- /dev/null
+++ b/PersianDate/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties
@@ -0,0 +1,6 @@
+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
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/PersianDate/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json
@@ -0,0 +1 @@
+{}
\ 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
new file mode 100644
index 0000000..d4775dc
Binary files /dev/null and b/PersianDate/build/intermediates/compile_library_classes_jar/debug/bundleLibCompileToJarDebug/classes.jar 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
new file mode 100644
index 0000000..2c5da86
Binary files /dev/null and b/PersianDate/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar 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
new file mode 100644
index 0000000..a9551e5
--- /dev/null
+++ b/PersianDate/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt
@@ -0,0 +1 @@
+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
new file mode 100644
index 0000000..6e9f359
--- /dev/null
+++ b/PersianDate/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties
@@ -0,0 +1 @@
+#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
new file mode 100644
index 0000000..d81740a
--- /dev/null
+++ b/PersianDate/build/intermediates/incremental/debug/packageDebugResources/merged.dir/values/values.xml
@@ -0,0 +1,4 @@
+
+
+ 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
new file mode 100644
index 0000000..d0fc3d4
--- /dev/null
+++ b/PersianDate/build/intermediates/incremental/debug/packageDebugResources/merger.xml
@@ -0,0 +1,2 @@
+
+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
new file mode 100644
index 0000000..5ed22d7
--- /dev/null
+++ b/PersianDate/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/incremental/mergeDebugShaders/merger.xml b/PersianDate/build/intermediates/incremental/mergeDebugShaders/merger.xml
new file mode 100644
index 0000000..103c790
--- /dev/null
+++ b/PersianDate/build/intermediates/incremental/mergeDebugShaders/merger.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/PersianDate/build/intermediates/incremental/packageDebugAssets/merger.xml b/PersianDate/build/intermediates/incremental/packageDebugAssets/merger.xml
new file mode 100644
index 0000000..1777a64
--- /dev/null
+++ b/PersianDate/build/intermediates/incremental/packageDebugAssets/merger.xml
@@ -0,0 +1,2 @@
+
+
\ 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
new file mode 100644
index 0000000..1928020
Binary files /dev/null and b/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDate$Dialect.class 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
new file mode 100644
index 0000000..d0ff723
Binary files /dev/null and b/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDate.class 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
new file mode 100644
index 0000000..355066e
Binary files /dev/null and b/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDateFormat$1.class 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
new file mode 100644
index 0000000..58e1d5a
Binary files /dev/null and b/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.class 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
new file mode 100644
index 0000000..91997ae
Binary files /dev/null and b/PersianDate/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/saman/zamani/persiandate/PersianDateFormat.class 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
new file mode 100644
index 0000000..34ca0ec
--- /dev/null
+++ b/PersianDate/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt
@@ -0,0 +1,3 @@
+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
new file mode 100644
index 0000000..8a229b9
--- /dev/null
+++ b/PersianDate/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt
@@ -0,0 +1,12 @@
+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
new file mode 100644
index 0000000..f6518bc
--- /dev/null
+++ b/PersianDate/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
\ 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
new file mode 100644
index 0000000..0637a08
--- /dev/null
+++ b/PersianDate/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json
@@ -0,0 +1 @@
+[]
\ 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
new file mode 100644
index 0000000..08f4ebe
--- /dev/null
+++ b/PersianDate/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt
@@ -0,0 +1 @@
+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
new file mode 100644
index 0000000..d81740a
--- /dev/null
+++ b/PersianDate/build/intermediates/packaged_res/debug/packageDebugResources/values/values.xml
@@ -0,0 +1,4 @@
+
+
+ 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
new file mode 100644
index 0000000..1928020
Binary files /dev/null and b/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDate$Dialect.class 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
new file mode 100644
index 0000000..d0ff723
Binary files /dev/null and b/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDate.class 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
new file mode 100644
index 0000000..355066e
Binary files /dev/null and b/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDateFormat$1.class 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
new file mode 100644
index 0000000..58e1d5a
Binary files /dev/null and b/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDateFormat$PersianDateNumberCharacter.class 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
new file mode 100644
index 0000000..91997ae
Binary files /dev/null and b/PersianDate/build/intermediates/runtime_library_classes_dir/debug/bundleLibRuntimeToDirDebug/saman/zamani/persiandate/PersianDateFormat.class 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
new file mode 100644
index 0000000..8c6c9f2
Binary files /dev/null and b/PersianDate/build/intermediates/runtime_library_classes_jar/debug/bundleLibRuntimeToJarDebug/classes.jar 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
new file mode 100644
index 0000000..7a3ee7d
--- /dev/null
+++ b/PersianDate/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt
@@ -0,0 +1,2 @@
+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
new file mode 100644
index 0000000..65e6e6d
--- /dev/null
+++ b/PersianDate/build/outputs/logs/manifest-merger-debug-report.txt
@@ -0,0 +1,20 @@
+-- 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
new file mode 100644
index 0000000..fe6f04d
Binary files /dev/null and b/PersianDate/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin differ
diff --git a/PersianDate/src/main/AndroidManifest.xml b/PersianDate/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..45ba9d7
--- /dev/null
+++ b/PersianDate/src/main/AndroidManifest.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/PersianDate/src/main/java/saman/zamani/persiandate/PersianDate.java b/PersianDate/src/main/java/saman/zamani/persiandate/PersianDate.java
new file mode 100644
index 0000000..2e64842
--- /dev/null
+++ b/PersianDate/src/main/java/saman/zamani/persiandate/PersianDate.java
@@ -0,0 +1,1550 @@
+package saman.zamani.persiandate;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import java.util.Objects;
+
+/**
+ * Created by Saman Zamani(saman.zamani1@gmail.com) on 3/31/2017 AD.
+ * Last update on Friday, July 15, 2022
+ */
+public class PersianDate {
+
+ /*----- Define Variable ---*/
+ private Long timeInMilliSecond;
+ public static final int FARVARDIN = 1;
+ public static final int ORDIBEHEST = 2;
+ public static final int KHORDAD = 3;
+ public static final int TIR = 4;
+ public static final int MORDAD = 5;
+ public static final int SHAHRIVAR = 6;
+ public static final int MEHR = 7;
+ public static final int ABAN = 8;
+ public static final int AZAR = 9;
+ public static final int DAY = 10;
+ public static final int BAHMAN = 11;
+ public static final int ESFAND = 12;
+ public static final int AM = 1;
+ public static final int PM = 2;
+ public static final String AM_SHORT_NAME = "ق.ظ";
+ public static final String PM_SHORT_NAME = "ب.ظ";
+ public static final String AM_NAME = "قبل از ظهر";
+ public static final String PM_NAME = "بعد از ظهر";
+ private int shYear;
+ private int shMonth;
+ private int shDay;
+ private int grgYear;
+ private int grgMonth;
+ private int grgDay;
+ private int hour;
+ private int minute;
+ private int second;
+ private Locale locale = Locale.getDefault();
+
+ private Dialect dialect = Dialect.IRANIAN;
+
+ public enum Dialect {
+ FINGLISH,
+ AFGHAN,
+ IRANIAN,
+ KURDISH,
+ PASHTO
+ }
+
+ /**
+ * Contractor
+ */
+ public PersianDate() {
+ this.timeInMilliSecond = new Date().getTime();
+ this.init();
+ }
+
+ /**
+ * Contractor
+ */
+ public PersianDate(Long timeInMilliSecond) {
+ this.timeInMilliSecond = timeInMilliSecond;
+ this.init();
+ }
+
+ /**
+ * Contractor
+ */
+ public PersianDate(Date date) {
+ this.timeInMilliSecond = date.getTime();
+ this.init();
+ }
+
+ @Override
+ public String toString() {
+ return PersianDateFormat.format(this, null);
+ }
+
+ //region CONST
+ private final String[] dayNames = {"شنبه", "یکشنبه", "دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه",
+ "جمعه"};
+ private final String[] dayFinglishNames = {"Shanbe", "Yekshanbe", "Doshanbe", "Seshanbe",
+ "Chaharshanbe", "Panjshanbe",
+ "Jom'e"};
+ private final String[] dayEnglishNames = {"Saturday", "Sunday", "Monday", "Tuesday", "Wednesday",
+ "Thursday",
+ "Friday"};
+ private final String[] monthNames = {"فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
+ "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند"};
+ private final String[] FinglishMonthNames = {"Farvardin", "Ordibehesht", "Khordad", "Tir",
+ "Mordad", "Shahrivar", "Mehr",
+ "Aban", "Azar", "Day", "Bahman", "Esfand"};
+ private final String[] AfghanMonthNames = {"حمل", "ثور", "جوزا", "سرطان", "اسد", "سنبله", "میزان",
+ "عقرب", "قوس", "جدی", "دلو", "حوت"};
+ private final String[] KurdishMonthNames = {"جیژنان", "گولان", "زه ردان", "په رپه ر", "گه لاویژ",
+ "نوخشان", "به ران", "خه زان", "ساران", "بفران", "به ندان", "رمشان"};
+ private final String[] PashtoMonthNames = {"وری", "غويی", "غبرګولی", "چنګاښ", "زمری", "وږی",
+ "تله", "لړم", "ليندۍ", "مرغومی", "سلواغه", "كب"};
+ private final String[] GrgMonthNames = {"January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December"};
+
+ //endregion
+ //region Setter & Getters
+ /*---- Setter And getter ----*/
+ public Locale getLocale() {
+ return locale;
+ }
+
+ public PersianDate setLocal(Locale locale) {
+ this.locale = locale;
+ return this;
+ }
+
+ public Dialect getDialect() {
+ return dialect;
+ }
+
+ public PersianDate setDialect(Dialect dialect) {
+ this.dialect = dialect;
+ return this;
+ }
+
+ public int getShYear() {
+ return shYear;
+ }
+
+ /**
+ * Set Shamsi Year
+ *
+ * @param shYear Shamsi Year as a Integer value grate than 0
+ * @return PersianDate
+ * @throws IllegalArgumentException Return Exception if Year is less than 1
+ */
+ public PersianDate setShYear(int shYear) throws IllegalArgumentException {
+ if (shYear < 1) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Year must be greater than 0");
+ }
+ this.shYear = shYear;
+ this.changeTime(true);
+ return this;
+ }
+
+ public int getShMonth() {
+ return shMonth;
+ }
+
+ /**
+ * Set Shamsi Month
+ *
+ * @param shMonth Shamsi Month as a Integer value between 1 and 12
+ * @return PersianDate
+ * @throws IllegalArgumentException Return Exception if Month is less than 1 or greater than 12
+ */
+ public PersianDate setShMonth(int shMonth) throws IllegalArgumentException {
+ if (shMonth < 1 || shMonth > 12) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Month must be between 1 and 12");
+ }
+ this.shMonth = shMonth;
+ this.changeTime(true);
+ return this;
+ }
+
+ public int getShDay() {
+ return shDay;
+ }
+
+ /**
+ * Set Shamsi Day
+ *
+ * @param shDay Shamsi Day as a Integer value between 1 and 29~31
+ * @return PersianDate object
+ * @throws IllegalArgumentException Return Exception if Day is less than 1 or greater than 29~31
+ * (Depend on Month Length)
+ */
+ public PersianDate setShDay(int shDay) throws IllegalArgumentException {
+ if (shDay < 1 || shDay > 31) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Day must be between 1 and 29~31");
+ }
+ if (shDay > this.getMonthLength()) {
+ throw new IllegalArgumentException(
+ "PersianDate Error: ##=> Day in the " + this.getMonthName() + " must be between 1 and "
+ + this.getMonthLength());
+ }
+ this.shDay = shDay;
+ this.changeTime(true);
+ return this;
+ }
+
+ public int getGrgYear() {
+ return grgYear;
+ }
+
+ /**
+ * Set Gregorian Year
+ *
+ * @param grgYear Gregorian Year as a Integer value grate than 0
+ * @return PersianDate
+ * @throws IllegalArgumentException Return Exception if Year is less than 1
+ */
+ public PersianDate setGrgYear(int grgYear) throws IllegalArgumentException {
+ if (grgYear < 1) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Year must be greater than 0");
+ }
+ this.grgYear = grgYear;
+ changeTime(false);
+ return this;
+ }
+
+ public int getGrgMonth() {
+ return grgMonth;
+ }
+
+ /**
+ * set Gregorian Month
+ *
+ * @param grgMonth Gregorian Month as a Integer value between 1 and 12
+ * @return PersianDate
+ * @throws IllegalArgumentException Return Exception if Month is less than 1 or greater than 12
+ */
+ public PersianDate setGrgMonth(int grgMonth) throws IllegalArgumentException {
+ if (grgMonth < 1 || grgMonth > 12) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Month must be between 1 and 12");
+ }
+ this.grgMonth = grgMonth;
+ changeTime(false);
+ return this;
+ }
+
+ public int getGrgDay() {
+ return grgDay;
+ }
+
+ /**
+ * Set Gregorian Day
+ *
+ * @param grgDay Gregorian Day as a Integer value between 1 and 28~31
+ * @return PersianDate object
+ * @throws IllegalArgumentException Return Exception if Day is less than 1 or greater than 28~31
+ * (Depend on Month Length)
+ */
+ public PersianDate setGrgDay(int grgDay) throws IllegalArgumentException {
+ if (grgDay < 1 || grgDay > 31) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Day must be between 1 and 28~31");
+ }
+ if (grgDay > this.getGrgMonthLength()) {
+ throw new IllegalArgumentException(
+ "PersianDate Error: ##=> Day in the " + this.getGrgMonthName() + " must be between 1 and "
+ + this.getGrgMonthLength());
+ }
+ this.grgDay = grgDay;
+ changeTime(false);
+ return this;
+ }
+
+ public int getHour() {
+ return hour;
+ }
+
+ public int get12FormatHour(int hour) {
+ return hour <= 12 ? hour : (hour - 12);
+ }
+
+ public int get12FormatHour() {
+ return this.get12FormatHour(this.hour);
+ }
+
+ /**
+ * Set Minute
+ *
+ * @param hour Hour as a integer number between 0 and 23
+ * @return PersianDate object
+ * @throws IllegalArgumentException Returns an IllegalArgumentException if hour is not between 0
+ * and 23
+ */
+ public PersianDate setHour(int hour) throws IllegalArgumentException {
+ if (hour < 0 || hour > 23) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Hour must be between 0 and 23");
+ }
+ this.hour = hour;
+ changeTime(false);
+ return this;
+ }
+
+ public int getMinute() {
+ return minute;
+ }
+
+ /**
+ * Set Minute
+ *
+ * @param minute Minute as a integer number between 0 and 59
+ * @return PersianDate object
+ * @throws IllegalArgumentException Returns an IllegalArgumentException if second is not between 0
+ * and 59
+ */
+ public PersianDate setMinute(int minute) throws IllegalArgumentException {
+ if (minute < 0 || minute > 59) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Minute must be between 0 and 59");
+ }
+ this.minute = minute;
+ changeTime(false);
+ return this;
+ }
+
+ public int getSecond() {
+ return second;
+ }
+
+ /**
+ * Set Second
+ *
+ * @param second Second as a integer number between 0 and 59
+ * @return PersianDate object
+ * @throws IllegalArgumentException Returns an IllegalArgumentException if second is not between 0
+ * and 59
+ */
+ public PersianDate setSecond(int second) throws IllegalArgumentException {
+ if (second < 0 || second > 59) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Second must be between 0 and 59");
+ }
+ this.second = second;
+ changeTime(false);
+ return this;
+ }
+
+ /**
+ * init without time
+ *
+ * @param year Year in Grg
+ * @param month Month in Grg
+ * @param day Day in Grg
+ * @return persianDate
+ */
+ public PersianDate initGrgDate(int year, int month, int day) throws IllegalArgumentException {
+ return this.initGrgDate(year, month, day, 0, 0, 0);
+ }
+
+ /**
+ * init with Grg data
+ *
+ * @param year Year in Grg
+ * @param month Month in Grg
+ * @param day day in Grg
+ * @param hour hour
+ * @param minute min
+ * @param second second
+ * @return PersianDate
+ */
+ public PersianDate initGrgDate(int year, int month, int day, int hour, int minute, int second)
+ throws IllegalArgumentException {
+ //check input parameter
+ if (year < 1) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Year must be greater than 0");
+ }
+ if (month < 1 || month > 12) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Month must be between 1 and 12");
+ }
+ if (day < 1 || day > 31) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Day must be between 1 and 28~31");
+ }
+ if (hour < 0 || hour > 23) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Hour must be between 0 and 23");
+ }
+ if (minute < 0 || minute > 59) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Minute must be between 0 and 59");
+ }
+ if (second < 0 || second > 59) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Second must be between 0 and 59");
+ }
+ if (day > this.getGrgMonthLength(year, month)) {
+ throw new IllegalArgumentException(
+ "PersianDate Error: ##=> Day in the " + this.getGrgMonthName(month)
+ + " must be between 1 and "
+ + this.getGrgMonthLength(year, month));
+ }
+ this.grgYear = year;
+ this.grgMonth = month;
+ this.grgDay = day;
+ this.hour = hour;
+ this.minute = minute;
+ this.second = second;
+ changeTime(false);
+ return this;
+ }
+
+ /**
+ * initialize date from Jallali date
+ *
+ * @param year Year in Jallali date
+ * @param month Month in Jallali date
+ * @param day day in Jallali date
+ * @return PersianDate
+ */
+ public PersianDate initJalaliDate(int year, int month, int day) throws IllegalArgumentException {
+ return this.initJalaliDate(year, month, day, 0, 0, 0);
+ }
+
+ /**
+ * initialize date from Jallali date
+ *
+ * @param year Year in jallali date
+ * @param month Month in Jallali date
+ * @param day day in Jallali date
+ * @param hour Hour
+ * @param minute Minute
+ * @param second Second
+ * @return PersianDate
+ */
+ public PersianDate initJalaliDate(int year, int month, int day, int hour, int minute,
+ int second) throws IllegalArgumentException {
+ //validate input parameters
+ if (year < 1) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Year must be greater than 0");
+ }
+ if (month < 1 || month > 12) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Month must be between 1 and 12");
+ }
+ if (day < 1 || day > 31) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Day must be between 1 and 28~31");
+ }
+ if (hour < 0 || hour > 23) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Hour must be between 0 and 23");
+ }
+ if (minute < 0 || minute > 59) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Minute must be between 0 and 59");
+ }
+ if (second < 0 || second > 59) {
+ throw new IllegalArgumentException("PersianDate Error: ##=> Second must be between 0 and 59");
+ }
+ if (day > this.getMonthLength(year, month)) {
+ throw new IllegalArgumentException(
+ "PersianDate Error: ##=> Day in the " + this.monthName(month) + " must be between 1 and "
+ + this.getMonthLength(year, month));
+ }
+ this.shYear = year;
+ this.shMonth = month;
+ this.shDay = day;
+ this.hour = hour;
+ this.minute = minute;
+ this.second = second;
+ this.changeTime(true);
+ return this;
+ }
+
+
+ /**
+ * return time in long value
+ *
+ * @return Value of time in mile
+ */
+ public Long getTime() {
+ return this.timeInMilliSecond;
+ }
+
+ /**
+ * Check Grg year is leap
+ *
+ * @param Year Year
+ * @return boolean
+ */
+ public boolean grgIsLeap(int Year) {
+ if (Year % 4 == 0) {
+ if (Year % 100 == 0) {
+ return Year % 400 == 0;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ public boolean grgIsLeap() {
+ return this.grgIsLeap(this.grgYear);
+ }
+
+ /**
+ * Check year in Leap
+ *
+ * @return true or false
+ */
+ public boolean isLeap() {
+ return this.isLeap(this.shYear);
+ }
+
+ /**
+ * Check custom year is leap
+ *
+ * @param year int year
+ * @return true or false
+ */
+ public boolean isLeap(int year) {
+ double referenceYear = 1375;
+ double startYear = 1375;
+ double yearRes = year - referenceYear;
+ //first of all make sure year is not multiplier of 1375
+ if (yearRes == 0 || yearRes % 33 == 0) {
+ return true;//year is 1375 or 1375+-(i)*33
+ }
+
+ if (yearRes > 0) {
+ if (yearRes > 33) {
+ double numb = yearRes / 33;
+ startYear = referenceYear + (Math.floor(numb) * 33);
+ }
+ } else {
+ if (yearRes > -33) {
+ startYear = referenceYear - 33;
+ } else {
+ double numb = Math.abs(yearRes / 33);
+ startYear = referenceYear - (Math.ceil(numb) * 33);
+ }
+ }
+ double[] leapYears = {startYear, startYear + 4, startYear + 8, startYear + 12, startYear + 16,
+ startYear + 20,
+ startYear + 24, startYear + 28, startYear + 33};
+ return (Arrays.binarySearch(leapYears, year)) >= 0;
+ }
+
+ /**
+ * Check static is leap year for Jalali Date
+ *
+ * @param year Jalali year
+ * @return true if year is leap
+ */
+ public static boolean isJalaliLeap(int year) {
+ return (new PersianDate().isLeap(year));
+ }
+
+ /**
+ * Check static is leap year for Grg Date
+ *
+ * @param year Year
+ * @return boolean
+ */
+ public static boolean isGrgLeap(int year) {
+ return (new PersianDate().grgIsLeap(year));
+ }
+
+ /**
+ * Author: JDF.SCR.IR =>> Download Full Version : http://jdf.scr.ir/jdf License: GNU/LGPL _ Open
+ * Source & Free :: Version: 2.80 : [2020=1399]
+ */
+ public int[] gregorian_to_jalali(int gy, int gm, int gd) {
+ int[] out = {
+ (gm > 2) ? (gy + 1) : gy,
+ 0,
+ 0
+ };
+ {
+ int[] g_d_m = {0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334};
+ out[2] = 355666 + (365 * gy) + ((int) ((out[0] + 3) / 4)) - ((int) ((out[0] + 99) / 100))
+ + ((int) ((out[0] + 399) / 400)) + gd + g_d_m[gm - 1];
+ }
+ out[0] = -1595 + (33 * ((int) (out[2] / 12053)));
+ out[2] %= 12053;
+ out[0] += 4 * ((int) (out[2] / 1461));
+ out[2] %= 1461;
+ if (out[2] > 365) {
+ out[0] += (int) ((out[2] - 1) / 365);
+ out[2] = (out[2] - 1) % 365;
+ }
+ if (out[2] < 186) {
+ out[1] = 1 + (int) (out[2] / 31);
+ out[2] = 1 + (out[2] % 31);
+ } else {
+ out[1] = 7 + (int) ((out[2] - 186) / 30);
+ out[2] = 1 + ((out[2] - 186) % 30);
+ }
+ return out;
+ }
+
+ /**
+ * Author: JDF.SCR.IR =>> Download Full Version : http://jdf.scr.ir/jdf License: GNU/LGPL _ Open
+ * Source & Free :: Version: 2.80 : [2020=1399]
+ */
+ public int[] jalali_to_gregorian(int jy, int jm, int jd) {
+ jy += 1595;
+ int[] out = {
+ 0,
+ 0,
+ -355668 + (365 * jy) + (((int) (jy / 33)) * 8) + ((int) (((jy % 33) + 3) / 4)) + jd + (
+ (jm < 7) ? (jm - 1) * 31 : ((jm - 7) * 30) + 186)
+ };
+ out[0] = 400 * ((int) (out[2] / 146097));
+ out[2] %= 146097;
+ if (out[2] > 36524) {
+ out[0] += 100 * ((int) (--out[2] / 36524));
+ out[2] %= 36524;
+ if (out[2] >= 365) {
+ out[2]++;
+ }
+ }
+ out[0] += 4 * ((int) (out[2] / 1461));
+ out[2] %= 1461;
+ if (out[2] > 365) {
+ out[0] += (int) ((out[2] - 1) / 365);
+ out[2] = (out[2] - 1) % 365;
+ }
+ int[] sal_a = {0, 31, ((out[0] % 4 == 0 && out[0] % 100 != 0) || (out[0] % 400 == 0)) ? 29 : 28,
+ 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
+ for (out[2]++; out[1] < 13 && out[2] > sal_a[out[1]]; out[1]++) {
+ out[2] -= sal_a[out[1]];
+ }
+ return out;
+ }
+
+ /**
+ * calc day of week
+ *
+ * @return int
+ */
+ public int dayOfWeek() {
+ return this.dayOfWeek(this);
+ }
+
+ /**
+ * Get day of week from PersianDate object
+ *
+ * @param date persianDate
+ * @return int
+ */
+ public int dayOfWeek(PersianDate date) {
+ return this.dayOfWeek(date.toDate());
+ }
+
+ /**
+ * Get day of week from Date object
+ *
+ * @param date Date
+ * @return int
+ */
+ public int dayOfWeek(Date date) {
+ Calendar cal = Calendar.getInstance();
+ cal.setTime(date);
+ if (cal.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY) {
+ return 0;
+ }
+ return (cal.get(Calendar.DAY_OF_WEEK));
+ }
+
+ public ArrayList getWeek(PersianDate date) {
+ ArrayList currentWeek = new ArrayList<>();
+ for (int i = 0; i < date.dayOfWeek(); i++) {
+ PersianDate dateTmp = new PersianDate(date.timeInMilliSecond);
+ currentWeek.add(dateTmp.subDays((date.dayOfWeek() - i)));
+ }
+ currentWeek.add(date);
+ int threshold = (7 - currentWeek.size());
+ for (int j = 1; j <= threshold; j++) {
+ PersianDate dateTmp = new PersianDate(date.timeInMilliSecond);
+ currentWeek.add(dateTmp.addDays(j));
+ }
+ return currentWeek;
+ }
+
+ public PersianDate[] getWeek() {
+ return getWeek(this).toArray(new PersianDate[0]);
+ }
+
+ /**
+ * Return list of month
+ *
+ * @param dialect dialect
+ * @return month names
+ */
+ public String[] monthList(Dialect dialect) {
+ switch (dialect) {
+ case FINGLISH:
+ return this.FinglishMonthNames;
+ case AFGHAN:
+ return this.AfghanMonthNames;
+ case KURDISH:
+ return this.KurdishMonthNames;
+ case PASHTO:
+ return this.PashtoMonthNames;
+ default:
+ return this.monthNames;
+ }
+ }
+
+ /**
+ * Return list of month
+ *
+ * @return month names
+ */
+ public String[] monthList() {
+ return monthList(Dialect.IRANIAN);
+ }
+
+ /**
+ * Return month name
+ *
+ * @param month Month
+ */
+ public String monthName(int month, Dialect dialect) {
+ switch (dialect) {
+ case FINGLISH:
+ return this.FinglishMonthNames[month - 1];
+ case AFGHAN:
+ return this.AfghanMonthNames[month - 1];
+ case KURDISH:
+ return this.KurdishMonthNames[month - 1];
+ case PASHTO:
+ return this.PashtoMonthNames[month - 1];
+ default:
+ return this.monthNames[month - 1];
+ }
+ }
+
+ /**
+ * return month name
+ *
+ * @return string
+ */
+ public String monthName(Dialect dialect) {
+ return monthName(this.getShMonth(), dialect);
+ }
+
+ public String monthName(int shMonth) {
+ return monthName(shMonth, this.getDialect());
+ }
+
+ /**
+ * Get current month name in Persian
+ */
+ public String monthName() {
+ return monthName(Dialect.IRANIAN);
+ }
+
+ /**
+ * Get month name in Finglish
+ */
+ public String FinglishMonthName(int month) {
+ return this.FinglishMonthNames[month - 1];
+ }
+
+ /**
+ * Get current date Finglish month name
+ */
+ public String FinglishMonthName() {
+ return this.FinglishMonthName(this.getShMonth());
+ }
+
+ /**
+ * Get month name in Afghan
+ */
+ public String AfghanMonthName(int month) {
+ return this.AfghanMonthNames[month - 1];
+ }
+
+ /**
+ * Get current date Afghan month name
+ */
+ public String AfghanMonthName() {
+ return this.AfghanMonthName(this.getShMonth());
+ }
+
+ /**
+ * Get month name in Kurdish
+ */
+ public String KurdishMonthName(int month) {
+ return this.KurdishMonthNames[month - 1];
+ }
+
+ /**
+ * Get current date Kurdish month name
+ */
+ public String KurdishMonthName() {
+ return this.KurdishMonthName(this.getShMonth());
+ }
+
+ /**
+ * Get month name in Pashto
+ */
+ public String PashtoMonthName(int month) {
+ return this.PashtoMonthNames[month - 1];
+ }
+
+ /**
+ * Get current date Pashto month name
+ */
+ public String PashtoMonthName() {
+ return this.PashtoMonthName(this.getShMonth());
+ }
+
+ /**
+ * get day name
+ */
+ public String dayName() {
+ return this.dayName(this);
+ }
+
+ /**
+ * Get Day Name
+ */
+ public String dayName(PersianDate date) {
+ return this.dayNames[this.dayOfWeek(date)];
+ }
+
+ /**
+ * Get Finglish Day Name
+ */
+ public String dayFinglishName() {
+ return this.dayFinglishName(this);
+ }
+
+ /**
+ * Get Finglish Day Name
+ */
+ public String dayFinglishName(PersianDate date) {
+ return this.dayFinglishNames[this.dayOfWeek(date)];
+ }
+
+ /**
+ * Get English Day Name
+ */
+ public String dayEnglishName() {
+ return this.dayEnglishName(this);
+ }
+
+ /**
+ * Get English Day Name
+ */
+ public String dayEnglishName(PersianDate date) {
+ return this.dayEnglishNames[this.dayOfWeek(date)];
+ }
+
+ /**
+ * Number days of month
+ *
+ * @return return days
+ */
+ public int getMonthDays() {
+ return this.getMonthDays(this.getShYear(), this.getShMonth());
+ }
+
+ /**
+ * calc count of day in month
+ */
+ public int getMonthDays(int Year, int month) {
+ if (month == 12 && !this.isLeap(Year)) {
+ return 29;
+ }
+ if (month <= 6) {
+ return 31;
+ } else {
+ return 30;
+ }
+ }
+
+ /**
+ * calculate day in year
+ */
+ public int getDayInYear() {
+ return this.getDayInYear(this.getShMonth(), getShDay());
+ }
+
+ /**
+ * Calc day of the year
+ *
+ * @param month Month
+ * @param day Day
+ */
+ public int getDayInYear(int month, int day) {
+ for (int i = 1; i < month; i++) {
+ if (i <= 6) {
+ day += 31;
+ } else {
+ day += 30;
+ }
+ }
+ return day;
+ }
+
+ /**
+ * Subtract date
+ *
+ * @param SubYear Number of subtraction years
+ * @param SubMonth Number of subtraction months
+ * @param SubDay Number of subtraction year days
+ * @param SubHour Number of subtraction year Hours
+ * @param SubMinute Number of subtraction year minutes
+ * @param SubSecond Number of subtraction year seconds
+ * @return this
+ */
+ public PersianDate subDate(long SubYear, long SubMonth, long SubDay, long SubHour, long SubMinute,
+ long SubSecond) {
+ Calendar cal = Calendar.getInstance();
+ //add day to cal
+ cal.setTime(this.toDate());
+ if (SubYear >= 1) {
+ cal.add(Calendar.YEAR, (int) -SubYear);
+ }
+ if (SubMonth >= 1) {
+ cal.add(Calendar.MONTH, (int) -SubMonth);
+ }
+ if (SubDay >= 1) {
+ cal.add(Calendar.DAY_OF_MONTH, (int) -SubDay);
+ }
+ if (SubHour >= 1) {
+ cal.add(Calendar.HOUR_OF_DAY, (int) -SubHour);
+ }
+ if (SubMinute >= 1) {
+ cal.add(Calendar.MINUTE, (int) -SubMinute);
+ }
+ if (SubSecond >= 1) {
+ cal.add(Calendar.SECOND, (int) -SubSecond);
+ }
+ this.timeInMilliSecond = cal.getTimeInMillis();
+ this.init();
+ return this;
+ }
+
+ /**
+ * Subtract date
+ *
+ * @param SubYear Number of subtraction years
+ * @param SubMonth Number of subtraction months
+ * @param SubDay Number of subtraction year days
+ * @return this
+ */
+ public PersianDate subDate(long SubYear, long SubMonth, long SubDay) {
+ return this.subDate(SubYear, SubMonth, SubDay, 0, 0, 0);
+ }
+
+ /**
+ * Subtract more than one year
+ *
+ * @param years: Number of subtraction years
+ * @return this
+ */
+ public PersianDate subYears(int years) {
+ return this.subDate(years, 0, 0);
+ }
+
+ /**
+ * Subtract a year
+ *
+ * @return this
+ */
+ public PersianDate subYear() {
+ return this.subYears(1);
+ }
+
+ /**
+ * Subtract months
+ *
+ * @param months: Number of subtraction months
+ * @return this
+ */
+ public PersianDate subMonths(int months) {
+ return this.subDate(0, months, 0);
+ }
+
+ /**
+ * Subtract a month
+ *
+ * @return this
+ */
+ public PersianDate subMonth() {
+ return this.subMonths(1);
+ }
+
+ /**
+ * Subtract days
+ *
+ * @param days: Number of subtraction days
+ * @return this
+ */
+ public PersianDate subDays(int days) {
+ return this.subDate(0, 0, days);
+ }
+
+ /**
+ * Subtract a day
+ *
+ * @return this
+ */
+ public PersianDate subDay() {
+ return this.subDays(1);
+ }
+
+ /**
+ * Subtract hours
+ *
+ * @param hours: Number of subtraction hours
+ * @return this
+ */
+ public PersianDate subHours(int hours) {
+ return this.subDate(0, 0, 0, hours, 0, 0);
+ }
+
+ /**
+ * Subtract an hour
+ *
+ * @return this
+ */
+ public PersianDate subHour() {
+ return this.subHours(1);
+ }
+
+ /**
+ * Subtract minutes
+ *
+ * @param minutes: Number of subtraction minutes
+ * @return this
+ */
+ public PersianDate subMinutes(int minutes) {
+ return this.subDate(0, 0, 0, 0, minutes, 0);
+ }
+
+ /**
+ * Subtract a minute
+ *
+ * @return this
+ */
+ public PersianDate subMinute() {
+ return this.subMinutes(1);
+ }
+
+ /**
+ * Subtract Seconds
+ *
+ * @param seconds: Number of subtraction seconds
+ * @return this
+ */
+ public PersianDate subSeconds(int seconds) {
+ return this.subDate(0, 0, 0, 0, 0, seconds);
+ }
+
+ /**
+ * Subtract a Second
+ *
+ * @return this
+ */
+ public PersianDate subSecond() {
+ return this.subSeconds(1);
+ }
+
+ public PersianDate subWeeks(int weeks) {
+ return this.subDays(7*weeks);
+ }
+
+ public PersianDate subWeek() {
+ return this.subWeeks(1);
+ }
+
+ /**
+ * add date
+ *
+ * @param AddYear Number of Year you want add
+ * @param AddMonth Number of month you want add
+ * @param AddDay Number of day you want add
+ * @param AddHour Number of hour you want add
+ * @param AddMinute Number of minute you want add
+ * @param AddSecond Number of second you want add
+ * @return new date
+ */
+ public PersianDate addDate(long AddYear, long AddMonth, long AddDay, long AddHour, long AddMinute,
+ long AddSecond) throws IllegalArgumentException{
+ Calendar cal = Calendar.getInstance();
+ //add day to cal
+ cal.setTime(this.toDate());
+ if (AddYear >= 1) {
+ cal.add(Calendar.YEAR, (int) AddYear);
+ }
+ if (AddMonth >= 1) {
+ cal.add(Calendar.MONTH, (int) AddMonth);
+ }
+ if (AddDay >= 1) {
+ cal.add(Calendar.DAY_OF_MONTH, (int) AddDay);
+ }
+ if (AddHour >= 1) {
+ cal.add(Calendar.HOUR_OF_DAY, (int) AddHour);
+ }
+ if (AddMinute >= 1) {
+ cal.add(Calendar.MINUTE, (int) AddMinute);
+ }
+ if (AddSecond >= 1) {
+ cal.add(Calendar.SECOND, (int) AddSecond);
+ }
+
+ this.timeInMilliSecond = cal.getTimeInMillis();
+ this.init();
+ return this;
+ }
+
+ /**
+ * add to date
+ *
+ * @param year Number of Year you want add
+ * @param month Number of month you want add
+ * @param day Number of day you want add
+ */
+ public PersianDate addDate(long year, long month, long day) {
+ return this.addDate(year, month, day, 0, 0, 0);
+ }
+
+ public PersianDate addYear() {
+ return this.addYears(1);
+ }
+ public PersianDate addYears(int year) {
+ return this.addDate(year, 0, 0);
+ }
+
+ public PersianDate addMonth() {
+ return this.addMonths(1);
+ }
+ public PersianDate addMonths(int month) {
+ return this.addDate(0, month, 0);
+ }
+
+ public PersianDate addWeek() {
+ return this.addWeeks(1);
+ }
+ public PersianDate addWeeks(int week) {
+ return this.addDays(7*week);
+ }
+
+ public PersianDate addDay() {
+ return this.addDays(1);
+ }
+
+ public PersianDate addDays(int day) {
+ return this.addDate(0, 0, day);
+ }
+
+ public PersianDate addHour() {
+ return this.addHours(1);
+ }
+
+ public PersianDate addHours(int hour) {
+ return this.addDate(0, 0, 0, hour, 0, 0);
+ }
+
+ public PersianDate addMinute() {
+ return this.addMinutes(1);
+ }
+
+ public PersianDate addMinutes(int minute) {
+ return this.addDate(0, 0, 0, 0, minute, 0);
+ }
+
+ public PersianDate addSecond() {
+ return this.addSeconds(1);
+ }
+
+ public PersianDate addSeconds(int second) {
+ return this.addDate(0, 0, 0, 0, 0, second);
+ }
+
+ /**
+ * Compare 2 date
+ *
+ * @param dateInput PersianDate type
+ */
+ public Boolean after(PersianDate dateInput) {
+ return (this.timeInMilliSecond < dateInput.getTime());
+ }
+
+ /**
+ * compare to data
+ *
+ * @param dateInput Input
+ */
+ public Boolean before(PersianDate dateInput) {
+ return (!this.after(dateInput));
+ }
+
+ /**
+ * Check date equals
+ */
+ public Boolean equals(PersianDate dateInput) {
+ return (this.timeInMilliSecond.equals(dateInput.getTime()));
+ }
+
+ /**
+ * compare two data
+ *
+ * @return 0 = equal,1=data1 > anotherDate,-1=data1 > anotherDate
+ */
+ public int compareTo(PersianDate anotherDate) {
+ return (this.timeInMilliSecond.compareTo(anotherDate.getTime()));
+ }
+
+ /**
+ * Check if PersianDate is today or not
+ *
+ * @return boolean
+ */
+ public boolean isToday() {
+ return this.isToday(this);
+ }
+
+ /**
+ * Check if given PersianDate is today or not
+ *
+ * @param date Date to check is today or not
+ * @return boolean
+ */
+ public boolean isToday(PersianDate date) {
+ return date.getDayUntilToday() == 0;
+ }
+
+ /**
+ * Return Day in different date
+ */
+ public long getDayUntilToday() {
+ return this.getDayUntilToday(new PersianDate());
+ }
+
+ /**
+ * Return different just day in compare 2 date
+ *
+ * @param date date for compare
+ */
+ public long getDayUntilToday(PersianDate date) {
+ long[] ret = this.untilToday(date);
+ return ret[0];
+ }
+
+ /**
+ * Calc different date until now
+ */
+ public long[] untilToday() {
+ return this.untilToday(new PersianDate());
+ }
+
+ /**
+ * calculate different between 2 date
+ *
+ * @param date Date 1
+ */
+ public long[] untilToday(PersianDate date) {
+ long secondsInMilli = 1000;
+ long minutesInMilli = secondsInMilli * 60;
+ long hoursInMilli = minutesInMilli * 60;
+ long daysInMilli = hoursInMilli * 24;
+ long different = Math.abs(this.timeInMilliSecond - date.getTime());
+
+ long elapsedDays = different / daysInMilli;
+ different = different % daysInMilli;
+
+ long elapsedHours = different / hoursInMilli;
+ different = different % hoursInMilli;
+ long elapsedMinutes = different / minutesInMilli;
+ different = different % minutesInMilli;
+ long elapsedSeconds = different / secondsInMilli;
+ return new long[]{elapsedDays, elapsedHours, elapsedMinutes, elapsedSeconds};
+ }
+
+ /**
+ * convert PersianDate class to date
+ */
+ public Date toDate() {
+ return new Date(this.timeInMilliSecond);
+ }
+
+
+ /**
+ * Return today
+ */
+ public static PersianDate today() {
+ PersianDate persianDate = new PersianDate();
+ persianDate.setHour(0).setMinute(0).setSecond(0);
+ return persianDate;
+ }
+
+ /**
+ * Get tomorrow
+ */
+ public static PersianDate tomorrow() {
+ PersianDate persianDate = new PersianDate();
+ persianDate.addDay();
+ persianDate.setHour(0).setMinute(0).setSecond(0);
+ return persianDate;
+ }
+
+ /**
+ * Get start of day
+ */
+ public PersianDate startOfDay(PersianDate persianDate) {
+ persianDate.setHour(0).setMinute(0).setSecond(0);
+ return persianDate;
+ }
+
+ /**
+ * Get Start of day
+ */
+ public PersianDate startOfDay() {
+ return this.startOfDay(this);
+ }
+
+ /**
+ * Get end of day
+ */
+ public PersianDate endOfDay(PersianDate persianDate) {
+ persianDate.setHour(23).setMinute(59).setSecond(59);
+ return persianDate;
+ }
+
+ /**
+ * Get end of day
+ */
+ public PersianDate endOfDay() {
+ return this.endOfDay(this);
+ }
+
+ /**
+ * Check midnight
+ */
+ public Boolean isMidNight(PersianDate persianDate) {
+ return persianDate.isMidNight();
+ }
+
+ /**
+ * Check is midNight
+ */
+ public Boolean isMidNight() {
+ return (this.hour < 12);
+ }
+
+ /**
+ * Get short name time of the day
+ */
+ public String getShortTimeOfTheDay() {
+ return (this.isMidNight()) ? AM_SHORT_NAME : PM_SHORT_NAME;
+ }
+
+ /**
+ * Get short name time of the day
+ */
+ public String getShortTimeOfTheDay(PersianDate persianDate) {
+ return (persianDate.isMidNight()) ? AM_SHORT_NAME : PM_SHORT_NAME;
+ }
+
+ /**
+ * Get time of the day
+ */
+ public String getTimeOfTheDay() {
+ return (this.isMidNight()) ? AM_NAME : PM_NAME;
+ }
+
+ /**
+ * Get time of the day
+ */
+ public String getTimeOfTheDay(PersianDate persianDate) {
+ return (persianDate.isMidNight()) ? AM_NAME : PM_NAME;
+ }
+
+ public int getGrgMonthLength(int grgYear, int grgMonth) {
+ Calendar cal = Calendar.getInstance();
+ //set year in cal object
+ cal.set(Calendar.YEAR, grgYear);
+ //set month in cal object
+ cal.set(Calendar.MONTH, grgMonth - 1);
+ return (cal.getActualMaximum(Calendar.DATE));
+ }
+
+ public int getGrgMonthLength(Date date) {
+ Calendar cal = Calendar.getInstance();
+ cal.setTime(date);
+ return (cal.getActualMaximum(Calendar.DATE));
+ }
+
+ public int getGrgMonthLength() {
+ return this.getGrgMonthLength(this.toDate());
+ }
+
+ public String getGrgMonthName(int grgMonth) {
+ if (grgMonth < 1 || grgMonth > 12) {
+ return "";
+ }
+ return GrgMonthNames[grgMonth - 1];
+ }
+
+ public String getGrgMonthName() {
+ return this.getGrgMonthName(this.getGrgMonth());
+ }
+
+ /**
+ * Get number of days in month
+ *
+ * @param year Jalali year
+ * @param month Jalali month
+ * @return number of days in month
+ */
+ public int getMonthLength(Integer year, Integer month) {
+ if (month <= 6) {
+ return 31;
+ } else if (month <= 11) {
+ return 30;
+ } else {
+ if (this.isLeap(year)) {
+ return 30;
+ } else {
+ return 29;
+ }
+ }
+ }
+
+ /**
+ * Get number of days in month
+ *
+ * @param persianDate persianDate object
+ * @return number of days in month
+ */
+ public int getMonthLength(PersianDate persianDate) {
+ return this.getMonthLength(persianDate.getShYear(), persianDate.getShMonth());
+ }
+
+ /**
+ * Get number of days in month
+ *
+ * @return number of days in month
+ */
+ public int getMonthLength() {
+ return this.getMonthLength(this);
+ }
+
+ public String getMonthName() {
+ return this.getMonthName(this);
+ }
+
+ public String getMonthName(PersianDate pDate, Dialect dialect) {
+ if (pDate.getShMonth() > pDate.monthList(dialect).length) {
+ return "";
+ }
+ return pDate.monthList(dialect)[pDate.getShMonth() - 1];
+ }
+
+ public String getMonthName(Dialect dialect) {
+ if (this.getShMonth() > this.monthList(dialect).length) {
+ return "";
+ }
+ return this.monthList(dialect)[this.getShMonth() - 1];
+ }
+
+ public String getMonthName(PersianDate pDate) {
+ if (pDate.getShMonth() > pDate.monthList().length) {
+ return "";
+ }
+ return pDate.monthList()[pDate.getShMonth() - 1];
+ }
+ //endregion
+
+ /*----- Helper Function-----*/
+ //region Private functions
+
+ /**
+ * Helper function
+ */
+ private String textNumberFilter(String date) {
+ if (date.length() < 2) {
+ return "0" + date;
+ }
+ return date;
+ }
+
+ private void init() {
+ this.grgYear = Integer
+ .parseInt(new SimpleDateFormat("yyyy", this.locale).format(this.timeInMilliSecond));
+ this.grgMonth = Integer
+ .parseInt(new SimpleDateFormat("MM", this.locale).format(this.timeInMilliSecond));
+ this.grgDay = Integer
+ .parseInt(new SimpleDateFormat("dd", this.locale).format(this.timeInMilliSecond));
+ this.hour = Integer
+ .parseInt(new SimpleDateFormat("HH", this.locale).format(this.timeInMilliSecond));
+ this.minute = Integer
+ .parseInt(new SimpleDateFormat("mm", this.locale).format(this.timeInMilliSecond));
+ this.second = Integer
+ .parseInt(new SimpleDateFormat("ss", this.locale).format(this.timeInMilliSecond));
+
+ changeTime(false);
+ }
+
+ private void changeTime(boolean isJalaliChanged) {
+ if (isJalaliChanged) {
+ this.TimeCalcFromJalali(this.shYear, this.shMonth, this.shDay, this.hour, this.minute,
+ this.second);
+ } else {
+ this.TimeCalcFromGrg(this.grgYear, this.grgMonth, this.grgDay, this.hour, this.minute,
+ this.second);
+ }
+ }
+
+ private void TimeCalcFromJalali(int year, int month, int day, int hr, int min, int sec) {
+ int[] grgTimes = {0/*YEAR*/, 0/*MONTH*/, 0/*DAY*/, 0/*HOUR*/, 0/*MINUTE*/, 0/*SECOND*/};
+ int[] jalaliTimes = {year/*YEAR*/, month/*MONTH*/, day/*DAY*/, hr/*HOUR*/, min/*MINUTE*/, sec
+/*SECOND*/};
+ //convert timestamp to grg date
+ int[] convertedTime = this.jalali_to_gregorian(year, month, day);
+ grgTimes[0] = convertedTime[0];
+ grgTimes[1] = convertedTime[1];
+ grgTimes[2] = convertedTime[2];
+ grgTimes[3] = hr;
+ grgTimes[4] = min;
+ grgTimes[5] = sec;
+ notify(grgTimes, jalaliTimes);
+ }
+
+ private void TimeCalcFromGrg(int year, int month, int day, int hr, int min, int sec) {
+ int[] grgTimes = {year/*YEAR*/, month/*MONTH*/, day/*DAY*/, hr/*HOUR*/, min/*MINUTE*/, sec
+/*SECOND*/};
+ int[] jalaliTimes = {0/*YEAR*/, 0/*MONTH*/, 0/*DAY*/, 0/*HOUR*/, 0/*MINUTE*/, 0/*SECOND*/};
+ int[] convertedTime = this.gregorian_to_jalali(year, month, day);
+ jalaliTimes[0] = convertedTime[0];
+ jalaliTimes[1] = convertedTime[1];
+ jalaliTimes[2] = convertedTime[2];
+ jalaliTimes[3] = hr;
+ jalaliTimes[4] = min;
+ jalaliTimes[5] = sec;
+ notify(grgTimes, jalaliTimes);
+ }
+
+ private void updateTimeStamp() {
+ try {
+ this.timeInMilliSecond = Objects
+ .requireNonNull(new SimpleDateFormat("dd/MM/yyyy HH:mm:ss", this.locale)
+ .parse(
+ "" + this.grgDay + "/" + this.grgMonth + "/" + this.getGrgYear() + " " + this.hour
+ + ":" + this.minute + ":" + this.second))
+ .getTime();//reported in #https://github.com/samanzamani/PersianDate/issues/54
+ } catch (ParseException e) {
+ this.timeInMilliSecond = new Date().getTime();
+ }
+ }
+
+ private void notify(int[] grg, int[] jalali) {
+ this.grgYear = grg[0];
+ this.grgMonth = grg[1];
+ this.grgDay = grg[2];
+ this.shYear = jalali[0];
+ this.shMonth = jalali[1];
+ this.shDay = jalali[2];
+ this.hour = jalali[3];
+ this.minute = jalali[4];
+ this.second = jalali[5];
+ updateTimeStamp();
+ }
+ //endregion
+}
diff --git a/PersianDate/src/main/java/saman/zamani/persiandate/PersianDateFormat.java b/PersianDate/src/main/java/saman/zamani/persiandate/PersianDateFormat.java
new file mode 100644
index 0000000..98ae280
--- /dev/null
+++ b/PersianDate/src/main/java/saman/zamani/persiandate/PersianDateFormat.java
@@ -0,0 +1,303 @@
+package saman.zamani.persiandate;
+
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+
+/**
+ * Created by Saman Zamani(saman.zamani1@gmail.com) on 3/31/2017 AD.
+ *
+ * Last update on Friday, July 15, 2022
+ */
+
+public class PersianDateFormat {
+ //number Format
+ public enum PersianDateNumberCharacter {
+ ENGLISH,
+ FARSI
+ }
+ //variable
+ /**
+ * Key for convert Date to String
+ */
+ private final String[] key = {"a", "l", "j", "F", "Y", "H", "i", "s", "d", "g", "n", "m", "t", "w", "y",
+ "z", "A",
+ "L","X","C","E","P","Q","R"};
+ //from version 1.3.3 pattern is public and has de
+ private String pattern = "l j F Y H:i:s";
+ private PersianDateNumberCharacter numberCharacter = PersianDateNumberCharacter.ENGLISH;
+
+ /**
+ * key_parse for convert String to PersianDate
+ *
+ * yyyy = Year (1396) MM = month (02-12-...) dd = day (13-02-15-...) HH = Hour (13-02-15-...) mm =
+ * minutes (13-02-15-...) ss = second (13-02-15-...)
+ */
+ private final String[] key_parse = {"yyyy", "MM", "dd", "HH", "mm", "ss"};
+
+ /**
+ * Constructor for create formatter with just pattern
+ */
+ public PersianDateFormat(String pattern) {
+ this.pattern = pattern;
+ }
+
+ /**
+ * Constructor for create pattern with number character format
+ *
+ * @param pattern pattern use for format
+ * @param numberCharacter character type can be PersianDateNumberCharacter.FARSI | PersianDateNumberCharacter.English
+ */
+ public PersianDateFormat(String pattern,PersianDateNumberCharacter numberCharacter) {
+ this.pattern = pattern;
+ this.numberCharacter = numberCharacter;
+ }
+ /**
+ * Change pattern
+ *
+ * @param pattern change format pattern
+ */
+ public void setPattern(String pattern) {
+ this.pattern = pattern;
+ }
+
+ /**
+ * Change number character
+ *
+ * @param numberCharacter number character
+ */
+ public void setNumberCharacter(
+ PersianDateNumberCharacter numberCharacter) {
+ this.numberCharacter = numberCharacter;
+ }
+
+ /**
+ * Constructor without pattern
+ */
+ public PersianDateFormat() {}
+
+ /**
+ * Format date
+ *
+ *
+ * @param date PersianDate object of date
+ * @param pattern Pattern you want to show
+ * @return date in pattern
+ */
+ public static String format(PersianDate date, String pattern){
+ return format(date,pattern, PersianDateNumberCharacter.ENGLISH);
+ }
+
+ /**
+ * Convert with charter type
+ *
+ * @param date date in PersianDate object
+ * @param pattern pattern
+ * @param numberFormatCharacter number charter
+ * @return return date
+ */
+ public static String format(PersianDate date, String pattern, PersianDateNumberCharacter numberFormatCharacter) {
+ if(pattern == null) pattern="l j F Y H:i:s";
+ String[] key = {"a", "l", "j", "F", "Y", "H", "i", "s", "d", "g", "n", "m", "t", "w", "y", "z",
+ "A", "L","X","C","E","P","Q","R"};
+ String year2;
+ if (("" + date.getShYear()).length() == 2) {
+ year2 = "" + date.getShYear();
+ } else if (("" + date.getShYear()).length() == 3) {
+ year2 = ("" + date.getShYear()).substring(2, 3);
+ } else {
+ year2 = ("" + date.getShYear()).substring(2, 4);
+ }
+ String[] values = {
+ date.getShortTimeOfTheDay(),
+ date.dayName(),
+ "" + date.getShDay(),
+ date.monthName(),
+ "" + date.getShYear(),
+ textNumberFilterStatic("" + date.getHour()),
+ textNumberFilterStatic("" + date.getMinute()),
+ textNumberFilterStatic("" + date.getSecond()),
+ textNumberFilterStatic("" + date.getShDay()),
+ "" + date.get12FormatHour(),
+ "" + date.getShMonth(),
+ textNumberFilterStatic("" + date.getShMonth()),
+ "" + date.getMonthDays(),
+ "" + date.dayOfWeek(),
+ year2,
+ "" + date.getDayInYear(),
+ date.getTimeOfTheDay(),
+ (date.isLeap() ? "1" : "0"),
+ date.AfghanMonthName(),
+ date.KurdishMonthName(),
+ date.PashtoMonthName(),
+ date.FinglishMonthName(),
+ date.dayFinglishName(),
+ date.dayEnglishName()
+ };
+ if(numberFormatCharacter == PersianDateNumberCharacter.FARSI){
+ PersianDateFormat.farsiCharacter(values);
+ }
+ for (int i = 0; i < key.length; i++) {
+ pattern = pattern.replace(key[i], values[i]);
+ }
+ return pattern;
+ }
+
+ public String format(PersianDate date) {
+ String year2;
+ if (("" + date.getShYear()).length() == 2) {
+ year2 = "" + date.getShYear();
+ } else if (("" + date.getShYear()).length() == 3) {
+ year2 = ("" + date.getShYear()).substring(2, 3);
+ } else {
+ year2 = ("" + date.getShYear()).substring(2, 4);
+ }
+ String[] values = {
+ date.getShortTimeOfTheDay(),
+ date.dayName(),
+ "" + date.getShDay(),
+ date.monthName(),
+ "" + date.getShYear(),
+ this.textNumberFilter("" + date.getHour()),
+ this.textNumberFilter("" + date.getMinute()),
+ this.textNumberFilter("" + date.getSecond()),
+ this.textNumberFilter("" + date.getShDay()),
+ "" + date.get12FormatHour(),
+ "" + date.getShMonth(),
+ this.textNumberFilter("" + date.getShMonth()),
+ "" + date.getMonthDays(),
+ "" + date.dayOfWeek(),
+ year2, "" + date.getDayInYear(),
+ date.getTimeOfTheDay(),
+ (date.isLeap() ? "1" : "0"),
+ date.AfghanMonthName(),
+ date.KurdishMonthName(),
+ date.PashtoMonthName(),
+ date.FinglishMonthName(),
+ date.dayFinglishName(),
+ date.dayEnglishName()
+ };
+ if(this.numberCharacter == PersianDateNumberCharacter.FARSI){
+ farsiCharacter(values);
+ }
+ return this.stringUtils(this.pattern, this.key, values);
+ }
+
+ /**
+ * Parse Jallali date from String
+ *
+ * @param date date in string
+ */
+ public PersianDate parse(String date) throws ParseException {
+ return this.parse(date, this.pattern);
+ }
+
+ /**
+ * Parse Jallali date from String
+ *
+ * @param date date in string
+ * @param pattern pattern
+ */
+ public PersianDate parse(String date, String pattern) throws ParseException {
+ ArrayList JalaliDate = new ArrayList() {{
+ add(0);
+ add(0);
+ add(0);
+ add(0);
+ add(0);
+ add(0);
+ }};
+ for (int i = 0; i < key_parse.length; i++) {
+ if ((pattern.contains(key_parse[i]))) {
+ int start_temp = pattern.indexOf(key_parse[i]);
+ int end_temp = start_temp + key_parse[i].length();
+ String dateReplace = date.substring(start_temp, end_temp);
+ if (dateReplace.matches("[-+]?\\d*\\.?\\d+")) {
+ JalaliDate.set(i, Integer.parseInt(dateReplace));
+ } else {
+ throw new ParseException("Parse Exception", 10);
+ }
+ }
+ }
+ return new PersianDate()
+ .initJalaliDate(JalaliDate.get(0), JalaliDate.get(1), JalaliDate.get(2), JalaliDate.get(3),
+ JalaliDate.get(4), JalaliDate.get(5));
+ }
+
+ /**
+ * Convert String Grg date to persian date object
+ *
+ * @param date date in String
+ * @return PersianDate object
+ */
+ public PersianDate parseGrg(String date) throws ParseException {
+ return this.parseGrg(date, this.pattern);
+ }
+
+ /**
+ * Convert String Grg date to persian date object
+ *
+ * @param date date String
+ * @param pattern pattern
+ * @return PersianDate object
+ */
+ public PersianDate parseGrg(String date, String pattern) throws ParseException {
+ Date dateInGrg = new SimpleDateFormat(pattern).parse(date);
+ return new PersianDate(dateInGrg.getTime());
+ }
+
+ /**
+ * Replace String
+ *
+ * @param text String
+ * @param key Looking for
+ * @param values Replace with
+ */
+ private String stringUtils(String text, String[] key, String[] values) {
+ for (int i = 0; i < key.length; i++) {
+ text = text.replace(key[i], values[i]);
+ }
+ return text;
+ }
+
+ /**
+ * add zero to start
+ *
+ * @param date data
+ * @return return string with 0 in start
+ */
+ private String textNumberFilter(String date) {
+ if (date.length() < 2) {
+ return "0" + date;
+ }
+ return date;
+ }
+
+ public static String textNumberFilterStatic(String date) {
+ if (date.length() < 2) {
+ return "0" + date;
+ }
+ return date;
+ }
+
+ /**
+ * Convert English characters to Farsi characters
+ *
+ * @param values a string array of values
+ * @return a converted string array
+ */
+ public static String[] farsiCharacter(String[] values){
+ String[] persianChars = {"۰", "۱", "۲", "٣", "۴", "۵", "۶", "۷", "۸", "٩"};
+ String[] englishChars = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"};
+ for (int i = 0; i < values.length; i++) {
+ String tmpValue = values[i];
+ for (int j = 0; j< persianChars.length;j++){
+ tmpValue = tmpValue.replaceAll(englishChars[j],persianChars[j]);
+ }
+ values[i] = tmpValue;
+ }
+
+ return values;
+ }
+}
\ No newline at end of file
diff --git a/PersianDate/src/main/res/values/strings.xml b/PersianDate/src/main/res/values/strings.xml
new file mode 100644
index 0000000..476bc2a
--- /dev/null
+++ b/PersianDate/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ PersianDate
+
diff --git a/PersianDate/src/test/java/saman/zamani/persiandate/PersianDateTest.java b/PersianDate/src/test/java/saman/zamani/persiandate/PersianDateTest.java
new file mode 100644
index 0000000..38f58ee
--- /dev/null
+++ b/PersianDate/src/test/java/saman/zamani/persiandate/PersianDateTest.java
@@ -0,0 +1,667 @@
+package saman.zamani.persiandate;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Test;
+import saman.zamani.persiandate.PersianDate.Dialect;
+
+public class PersianDateTest {
+ public String testStringHelper(int j) {
+ if (j < 10) {
+ return "0" + j;
+ } else {
+ return "" + j;
+ }
+ }
+
+ public final String testCase = "{'1981':{'04':{'15':'13600126'},'01':{'09':'13591019'}},'1923':{'05':{'03':'13020212'}},'1993':{'03':{'30':'13720110'},'02':{'13':'13711124'}},'2041':{'09':{'14':'14200624'},'12':{'12':'14200922'}},'1920':{'01':{'06':'12981015'},'08':{'25':'12990603'}},'1977':{'10':{'16':'13560724'}},'2098':{'05':{'07':'14770218'},'12':{'15':'14770925'}},'2063':{'11':{'15':'14420824'}},'2061':{'08':{'06':'14400516'},'09':{'28':'14400707'}},'1974':{'11':{'07':'13530816'}},'2056':{'10':{'23':'14350802'}},'1924':{'10':{'27':'13030805'},'02':{'25':'13021205','23':'13021203'}},'1906':{'12':{'08':'12850916'},'01':{'01':'12841011'}},'1908':{'01':{'13':'12861022'}},'2053':{'12':{'20':'14320930'}},'2038':{'06':{'08':'14170318'},'02':{'10':'14161122'},'01':{'09':'14161020'}},'1975':{'07':{'06':'13540415'}},'1921':{'08':{'02':'13000511'}},'2040':{'09':{'14':'14190624'}},'1990':{'12':{'09':'13690918'}},'2019':{'02':{'04':'13971115'}},'2082':{'05':{'29':'14610309'},'11':{'27':'14610907'}},'2021':{'07':{'27':'14000505'}},'2018':{'08':{'21':'13970530'},'02':{'16':'13961127'}},'1996':{'10':{'26':'13750805'}},'1913':{'12':{'12':'12920921'}},'2090':{'07':{'04':'14690414'}},'2074':{'04':{'17':'14530129'}},'1942':{'10':{'08':'13210716'}},'2023':{'01':{'27':'14011107'},'08':{'21':'14020530'}},'1949':{'10':{'15':'13280723'}},'2087':{'03':{'28':'14660108'}},'2088':{'09':{'02':'14670612'}},'1904':{'05':{'11':'12830221'}},'1936':{'08':{'23':'13150601'},'02':{'13':'13141123'},'01':{'15':'13141024'}},'2043':{'05':{'28':'14220307'}},'2010':{'06':{'01':'13890311'}},'1950':{'03':{'23':'13290103'},'10':{'06':'13290714'}},'2055':{'03':{'13':'14331222'}},'2094':{'02':{'22':'14721204'}},'1952':{'07':{'03':'13310412'}},'1930':{'01':{'27':'13081107'}},'1969':{'10':{'03':'13480711'}},'1926':{'08':{'12':'13050520'}},'1962':{'11':{'14':'13410823'}},'1965':{'03':{'07':'13431216'}},'1907':{'07':{'11':'12860419'}},'1954':{'02':{'18':'13321129'}},'1955':{'08':{'10':'13340518'}},'1959':{'04':{'15':'13380125'}},'2064':{'05':{'18':'14430229'}},'2078':{'01':{'17':'14561028'}},'2022':{'07':{'28':'14010506'}},'1994':{'07':{'16':'13730425'}},'1970':{'06':{'07':'13490317'},'03':{'01':'13481210'}},'2068':{'10':{'25':'14470804'},'05':{'11':'14470222'}},'2037':{'09':{'15':'14160625'}},'2048':{'01':{'04':'14261014'}},'1992':{'05':{'24':'13710303'}},'2065':{'04':{'17':'14440129'}},'2052':{'02':{'26':'14301207'}},'2085':{'12':{'12':'14640922'}},'1999':{'08':{'26':'13780604'}},'2044':{'12':{'10':'14230920'}},'1912':{'09':{'29':'12910707'}},'2042':{'08':{'26':'14210604'}},'2032':{'05':{'09':'14110220'}},'1916':{'02':{'27':'12941207'},'09':{'09':'12950618'}},'2079':{'07':{'07':'14580416'}},'2039':{'07':{'03':'14180412'}},'1917':{'12':{'30':'12961009'}},'1987':{'07':{'27':'13660505'}},'2027':{'12':{'31':'14061010'}},'1910':{'12':{'16':'12890924'}},'2008':{'02':{'09':'13861120'}},'1958':{'05':{'29':'13370308'}},'1935':{'06':{'09':'13140318'}},'2009':{'08':{'09':'13880518'}},'1903':{'02':{'06':'12811116'}},'2083':{'02':{'11':'14611123'}}}";
+
+
+ @Test
+ public void JalaliToGrgTest() throws JSONException {
+ JSONObject obj = new JSONObject(testCase);
+
+ for (int i = 1981; i <= 2083; i++) {
+ if (!obj.isNull("" + i)) {
+ for (int j = 1; j <= 12; j++) {
+ if (!obj.getJSONObject("" + i).isNull(testStringHelper(j))) {
+ for (int k = 1; k <= 31; k++) {
+ if (!obj.getJSONObject("" + i).getJSONObject(testStringHelper(j))
+ .isNull(testStringHelper(k))) {
+ String jalali = obj.getJSONObject("" + i).getJSONObject(testStringHelper(j))
+ .getString(testStringHelper(k));
+ int jalaliYear = Integer.parseInt(jalali.substring(0, 4));
+ int jalaliMonth = Integer.parseInt(jalali.substring(4, 6));
+ int jalaliDay = Integer.parseInt(jalali.substring(6, 8));
+ PersianDate pDate = new PersianDate()
+ .initJalaliDate(jalaliYear, jalaliMonth, jalaliDay);
+ assertEquals("" + i + j + k,
+ "" + pDate.getGrgYear() + pDate.getGrgMonth() + pDate.getGrgDay());
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ public void grgToJalaliTest() throws JSONException {
+ JSONObject obj = new JSONObject(testCase);
+
+ for (int i = 1981; i <= 2083; i++) {
+ if (!obj.isNull("" + i)) {
+ for (int j = 1; j <= 12; j++) {
+ if (!obj.getJSONObject("" + i).isNull(testStringHelper(j))) {
+ for (int k = 1; k <= 31; k++) {
+ if (!obj.getJSONObject("" + i).getJSONObject(testStringHelper(j))
+ .isNull(testStringHelper(k))) {
+ assertEquals(
+ PersianDateFormat.format(new PersianDate().initGrgDate(i, j, k), "Ymd"),
+ obj.getJSONObject("" + i).getJSONObject(testStringHelper(j))
+ .getString(testStringHelper(k)));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ public void reportedBug() {
+ Map dates = new HashMap<>();
+ dates.put("19891226", new PersianDate()
+ .initJalaliDate(1368, 10, 5));//https://github.com/samanzamani/PersianDate/issues/29
+ //https://github.com/samanzamani/PersianDate/issues/16
+ //https://github.com/samanzamani/PersianDate/issues/35
+ dates.put("2030320", new PersianDate().initJalaliDate(1408, 12, 30));
+ dates.put("2030321", new PersianDate().initJalaliDate(1409, 1, 1));
+ dates.put("2025320", new PersianDate().initJalaliDate(1403, 12, 30));
+ dates.put("2025321", new PersianDate().initJalaliDate(1404, 1, 1));
+ dates.put("2026320", new PersianDate().initJalaliDate(1404, 12, 29));
+ dates.put("2026321", new PersianDate().initJalaliDate(1405, 1, 1));
+ dates.put("2029319", new PersianDate().initJalaliDate(1407, 12, 29));
+ dates.put("2029320", new PersianDate().initJalaliDate(1408, 1, 1));
+
+ for (Map.Entry entry : dates.entrySet()) {
+ String key = entry.getKey();
+ PersianDate value = entry.getValue();
+ assertEquals(key, "" + value.getGrgYear() + value.getGrgMonth() + value.getGrgDay());
+ }
+ }
+
+ //https://github.com/samanzamani/PersianDate/issues/42
+ @Test
+ public void testTimestamp() {
+ PersianDate pDate = new PersianDate().initJalaliDate(1360, 8, 2);
+ assertEquals("13600802", PersianDateFormat.format(new PersianDate(pDate.getTime()), "Ymd"));
+ }
+
+ @Test
+ public void testIsTodayMethodWithoutParameter() {
+ PersianDate pDate = new PersianDate();
+ assertTrue(pDate.isToday());
+ assertFalse(pDate.addDay().isToday());
+ }
+ @Test
+ public void testIsTodayMethodWithParameter() {
+ PersianDate pDate = new PersianDate();
+ assertTrue(pDate.isToday(new PersianDate()));
+ assertFalse(pDate.isToday(new PersianDate().subDay()));
+ }
+
+ @Test
+ public void testSetJalaliMonth() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShMonth(1);
+ assertEquals(1, pDate.getShMonth());
+ pDate.setShMonth(12);
+ assertEquals(12, pDate.getShMonth());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetJalaliMonthExceptionBiggerThan12() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShMonth(13);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetJalaliMonthExceptionSmallerThan1() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShMonth(0);
+ }
+
+ @Test
+ public void testSetJalaliYear() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShYear(1395);
+ assertEquals(1395, pDate.getShYear());
+ pDate.setShYear(1396);
+ assertEquals(1396, pDate.getShYear());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetJalaliYearExceptionSmallerThan1() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShYear(0);
+ }
+
+ @Test
+ public void testSetJalaliDay() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShDay(1);
+ assertEquals(1, pDate.getShDay());
+ pDate.setShDay(27);
+ assertEquals(27, pDate.getShDay());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetJalaliDayExceptionBiggerThan31() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShDay(32);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetJalaliDayExceptionSmallerThan1() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShDay(0);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetJalaliDayExceptionWrongDayForMonth() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShMonth(11);
+ pDate.setShDay(31);
+ }
+
+ @Test
+ public void testSetGrgYear() {
+ PersianDate pDate = new PersianDate();
+ pDate.setGrgYear(2017);
+ assertEquals(2017, pDate.getGrgYear());
+ pDate.setGrgYear(2022);
+ assertEquals(2022, pDate.getGrgYear());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetGrgYearExceptionSmallerThan1() {
+ PersianDate pDate = new PersianDate();
+ pDate.setGrgYear(0);
+ }
+
+ @Test
+ public void testSetGrgMonth() {
+ PersianDate pDate = new PersianDate();
+ pDate.setGrgMonth(1);
+ assertEquals(1, pDate.getGrgMonth());
+ pDate.setGrgMonth(12);
+ assertEquals(12, pDate.getGrgMonth());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetGrgMonthExceptionBiggerThan12() {
+ PersianDate pDate = new PersianDate();
+ pDate.setGrgMonth(13);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetGrgMonthExceptionSmallerThan1() {
+ PersianDate pDate = new PersianDate();
+ pDate.setGrgMonth(0);
+ }
+
+ @Test
+ public void testSetGrgDay() {
+ PersianDate pDate = new PersianDate();
+ pDate.setGrgDay(1);
+ assertEquals(1, pDate.getGrgDay());
+ pDate.setGrgDay(27);
+ assertEquals(27, pDate.getGrgDay());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetGrgDayExceptionBiggerThan31() {
+ PersianDate pDate = new PersianDate();
+ pDate.setGrgDay(32);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetGrgDayExceptionSmallerThan1() {
+ PersianDate pDate = new PersianDate();
+ pDate.setGrgDay(0);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetGrgDayExceptionWrongDayForMonth() {
+ PersianDate pDate = new PersianDate();
+ pDate.setGrgMonth(11);
+ pDate.setGrgDay(31);
+ }
+
+ @Test
+ public void testGetGrgMonthLengthWithParameter(){
+ PersianDate pDate = new PersianDate();
+ PersianDate pDate2 = new PersianDate();
+ assertEquals(31, pDate2.getGrgMonthLength(pDate.setGrgMonth(1).toDate()));
+ assertEquals(28, pDate2.getGrgMonthLength(pDate.setGrgMonth(2).toDate()));
+ assertEquals(31, pDate2.getGrgMonthLength(pDate.setGrgMonth(3).toDate()));
+ assertEquals(30, pDate2.getGrgMonthLength(pDate.setGrgMonth(4).toDate()));
+ assertEquals(31, pDate2.getGrgMonthLength(pDate.setGrgMonth(5).toDate()));
+ assertEquals(30, pDate2.getGrgMonthLength(pDate.setGrgMonth(6).toDate()));
+ assertEquals(31, pDate2.getGrgMonthLength(pDate.setGrgMonth(7).toDate()));
+ assertEquals(31, pDate2.getGrgMonthLength(pDate.setGrgMonth(8).toDate()));
+ assertEquals(30, pDate2.getGrgMonthLength(pDate.setGrgMonth(9).toDate()));
+ }
+
+ @Test
+ public void testGetGrgMonthLengthWithoutParameter(){
+ PersianDate pDate = new PersianDate();
+ pDate.setGrgMonth(1);
+ assertEquals(31, pDate.getGrgMonthLength(pDate.toDate()));
+ }
+
+ @Test
+ public void testSetSecond() {
+ PersianDate pDate = new PersianDate();
+ pDate.setSecond(10);
+ assertEquals(10, pDate.getSecond());
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetSecondWithInvalidValue() {
+ PersianDate pDate = new PersianDate();
+ pDate.setSecond(60);
+ }
+
+ @Test
+ public void testSetMinute() {
+ PersianDate pDate = new PersianDate();
+ pDate.setMinute(10);
+ assertEquals(10, pDate.getMinute());
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetMinuteWithInvalidValue() {
+ PersianDate pDate = new PersianDate();
+ pDate.setMinute(60);
+ }
+
+ @Test
+ public void testSetHour() {
+ PersianDate pDate = new PersianDate();
+ pDate.setHour(10);
+ assertEquals(10, pDate.getHour());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testSetHourWithInvalidValue() {
+ PersianDate pDate = new PersianDate();
+ pDate.setHour(24);
+ }
+
+ @Test
+ public void testGetGrgMonthLength() {
+ PersianDate pDate = new PersianDate();
+ pDate.setGrgMonth(1);
+ assertEquals(31, pDate.getGrgMonthLength());
+ }
+
+ @Test
+ public void testGetGrgMonthLengthWithDateParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setGrgMonth(1);
+ assertEquals(31, pDate.getGrgMonthLength(pDate.toDate()));
+ }
+
+ @Test
+ public void testGetGrgMonthLengthWithYearAndMonthParameters() {
+ PersianDate pDate = new PersianDate();
+ assertEquals(31, pDate.getGrgMonthLength(2022,1));
+ }
+
+ @Test
+ public void testInitGrgDate() {
+ PersianDate pDate = new PersianDate();
+ pDate.initGrgDate(2022,1,1,23,59,13);
+ assertEquals(2022, pDate.getGrgYear());
+ assertEquals(1, pDate.getGrgMonth());
+ assertEquals(1, pDate.getGrgDay());
+ assertEquals(23, pDate.getHour());
+ assertEquals(59, pDate.getMinute());
+ assertEquals(13, pDate.getSecond());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitGrgDateWithInvalidYear() {
+ PersianDate pDate = new PersianDate();
+ pDate.initGrgDate(0,13,1,23,59,13);
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitGrgDateWithInvalidMonth() {
+ PersianDate pDate = new PersianDate();
+ pDate.initGrgDate(2022,13,1,23,59,13);
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitGrgDateWithInvalidDay() {
+ PersianDate pDate = new PersianDate();
+ pDate.initGrgDate(2022,1,0,23,59,13);
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitGrgDateWithInvalidDayForSpecificMonth() {
+ PersianDate pDate = new PersianDate();
+ pDate.initGrgDate(2022,11,31,23,59,13);
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitGrgDateWithInvalidHour() {
+ PersianDate pDate = new PersianDate();
+ pDate.initGrgDate(2022,1,1,24,59,13);
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitGrgDateWithInvalidMinute() {
+ PersianDate pDate = new PersianDate();
+ pDate.initGrgDate(2022,1,1,23,60,13);
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitGrgDateWithInvalidSecond() {
+ PersianDate pDate = new PersianDate();
+ pDate.initGrgDate(2022,1,1,23,59,60);
+ }
+
+ @Test
+ public void testDefaultDialect() {
+ PersianDate pDate = new PersianDate();
+ assertEquals(Dialect.IRANIAN, pDate.getDialect());
+ }
+
+ @Test
+ public void testSetDialect() {
+ PersianDate pDate = new PersianDate();
+ pDate.setDialect(Dialect.IRANIAN);
+ assertEquals(Dialect.IRANIAN, pDate.getDialect());
+ }
+
+ @Test
+ public void testGetDialect() {
+ PersianDate pDate = new PersianDate();
+ pDate.setDialect(Dialect.PASHTO);
+ assertEquals(Dialect.PASHTO, pDate.getDialect());
+ pDate.setDialect(Dialect.AFGHAN);
+ assertEquals(Dialect.AFGHAN, pDate.getDialect());
+ }
+
+ @Test
+ public void testInitJalaliDate() {
+ PersianDate pDate = new PersianDate();
+ pDate.initJalaliDate(1399,1,1,23,59,13);
+ assertEquals(1399, pDate.getShYear());
+ assertEquals(1, pDate.getShMonth());
+ assertEquals(1, pDate.getShDay());
+ assertEquals(23, pDate.getHour());
+ assertEquals(59, pDate.getMinute());
+ assertEquals(13, pDate.getSecond());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitJalaliDateWithInvalidYear() {
+ PersianDate pDate = new PersianDate();
+ pDate.initJalaliDate(0,13,1,23,59,13);
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitJalaliDateWithInvalidMonth() {
+ PersianDate pDate = new PersianDate();
+ pDate.initJalaliDate(1399,13,1,23,59,13);
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitJalaliDateWithInvalidDay() {
+ PersianDate pDate = new PersianDate();
+ pDate.initJalaliDate(1399,1,0,23,59,13);
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitJalaliDateWithInvalidDayForSpecificMonth() {
+ PersianDate pDate = new PersianDate();
+ pDate.initJalaliDate(1399,11,31,23,59,13);
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitJalaliDateWithInvalidHour() {
+ PersianDate pDate = new PersianDate();
+ pDate.initJalaliDate(1399,1,1,24,59,13);
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitJalaliDateWithInvalidMinute() {
+ PersianDate pDate = new PersianDate();
+ pDate.initJalaliDate(1399,1,1,23,60,13);
+ }
+ @Test(expected = IllegalArgumentException.class)
+ public void testInitJalaliDateWithInvalidSecond() {
+ PersianDate pDate = new PersianDate();
+ pDate.initJalaliDate(1399,1,1,23,59,60);
+ }
+
+ //test add dates methods
+ @Test
+ public void testAddDay() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShDay(1);
+ pDate.addDay();
+ assertEquals(2, pDate.getShDay());
+ }
+
+ @Test
+ public void testAddDayWithParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShDay(1);
+ pDate.addDays(2);
+ assertEquals(3, pDate.getShDay());
+ }
+
+ @Test
+ public void testAddMonth() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShMonth(1);
+ pDate.addMonth();
+ assertEquals(2, pDate.getShMonth());
+ }
+
+ @Test
+ public void testAddMonthWithParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShMonth(1);
+ pDate.addMonths(2);
+ assertEquals(3, pDate.getShMonth());
+ }
+
+ @Test
+ public void testAddYear() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShYear(1399);
+ pDate.addYear();
+ assertEquals(1400, pDate.getShYear());
+ }
+
+ @Test
+ public void testAddYearWithParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShYear(1399);
+ pDate.addYears(2);
+ assertEquals(1401, pDate.getShYear());
+ }
+
+ @Test
+ public void testAddHour() {
+ PersianDate pDate = new PersianDate();
+ pDate.setHour(1);
+ pDate.addHour();
+ assertEquals(2, pDate.getHour());
+ }
+
+ @Test
+ public void testAddHourWithParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setHour(1);
+ pDate.addHours(2);
+ assertEquals(3, pDate.getHour());
+ }
+
+ @Test
+ public void testAddMinute() {
+ PersianDate pDate = new PersianDate();
+ pDate.setMinute(1);
+ pDate.addMinute();
+ assertEquals(2, pDate.getMinute());
+ }
+
+ @Test
+ public void testAddMinuteWithParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setMinute(1);
+ pDate.addMinutes(2);
+ assertEquals(3, pDate.getMinute());
+ }
+
+ @Test
+ public void testAddSecond() {
+ PersianDate pDate = new PersianDate();
+ pDate.setSecond(1);
+ pDate.addSecond();
+ assertEquals(2, pDate.getSecond());
+ }
+
+ @Test
+ public void testAddSecondWithParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setSecond(1);
+ pDate.addSeconds(2);
+ assertEquals(3, pDate.getSecond());
+ }
+
+ @Test
+ public void testAddWeek() {
+ PersianDate pDate = new PersianDate().initJalaliDate(1399,1,1);
+ pDate.addWeek();
+ assertEquals(8, pDate.getShDay());
+ }
+
+ @Test
+ public void testAddWeekWithParameter() {
+ PersianDate pDate = new PersianDate().initJalaliDate(1399,1,1);
+ pDate.addWeeks(3);
+ assertEquals(22, pDate.getShDay());
+ }
+
+ //test sub dates
+ @Test
+ public void testSubDay() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShDay(3);
+ pDate.subDay();
+ assertEquals(2, pDate.getShDay());
+ }
+
+ @Test
+ public void testSubDayWithParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShDay(3);
+ pDate.subDays(2);
+ assertEquals(1, pDate.getShDay());
+ }
+
+ @Test
+ public void testSubMonth() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShMonth(3);
+ pDate.subMonth();
+ assertEquals(2, pDate.getShMonth());
+ }
+
+ @Test
+ public void testSubMonthWithParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShMonth(3);
+ pDate.subMonths(2);
+ assertEquals(1, pDate.getShMonth());
+ }
+
+ @Test
+ public void testSubYear() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShYear(1399);
+ pDate.subYear();
+ assertEquals(1398, pDate.getShYear());
+ }
+
+ @Test
+ public void testSubYearWithParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setShYear(1399);
+ pDate.subYears(2);
+ assertEquals(1397, pDate.getShYear());
+ }
+
+ @Test
+ public void testSubHour() {
+ PersianDate pDate = new PersianDate();
+ pDate.setHour(3);
+ pDate.subHour();
+ assertEquals(2, pDate.getHour());
+ }
+
+ @Test
+ public void testSubHourWithParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setHour(3);
+ pDate.subHours(2);
+ assertEquals(1, pDate.getHour());
+ }
+
+ @Test
+ public void testSubMinute() {
+ PersianDate pDate = new PersianDate();
+ pDate.setMinute(3);
+ pDate.subMinute();
+ assertEquals(2, pDate.getMinute());
+ }
+
+ @Test
+ public void testSubMinuteWithParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setMinute(3);
+ pDate.subMinutes(2);
+ assertEquals(1, pDate.getMinute());
+ }
+
+ @Test
+ public void testSubSecond() {
+ PersianDate pDate = new PersianDate();
+ pDate.setSecond(3);
+ pDate.subSecond();
+ assertEquals(2, pDate.getSecond());
+ }
+
+ @Test
+ public void testSubSecondWithParameter() {
+ PersianDate pDate = new PersianDate();
+ pDate.setSecond(3);
+ pDate.subSeconds(2);
+ assertEquals(1, pDate.getSecond());
+ }
+
+ @Test
+ public void testSubWeek() {
+ PersianDate pDate = new PersianDate().initJalaliDate(1399,1,8);
+ pDate.subWeek();
+ assertEquals(1, pDate.getShDay());
+ }
+
+ @Test
+ public void testSubWeekWithParameter() {
+ PersianDate pDate = new PersianDate().initJalaliDate(1399,1,22);
+ pDate.subWeeks(3);
+ assertEquals(1, pDate.getShDay());
+ }
+
+}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2ced059..cdf8aee 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,17 +1,31 @@
// app/build.gradle.kts
+import java.util.Properties
+import java.io.FileInputStream
+
plugins {
alias(libs.plugins.android.application)
- alias(libs.plugins.jetbrains.kotlin.android)
- // ⭐️ پلاگین Serialization فقط یک بار با alias تعریف میشود (رفع خطای تکراری)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.google.devtools.ksp)
alias(libs.plugins.kotlin.serialization)
- alias(libs.plugins.ksp)
+ alias(libs.plugins.hilt)
}
android {
namespace = "com.oqba26.monthlypaymentapp"
compileSdk = 34
+ val keystorePropertiesFile = rootProject.file("keystore.properties")
+ val keystoreProperties = Properties()
+ if (keystorePropertiesFile.exists()) {
+ keystoreProperties.load(FileInputStream(keystorePropertiesFile))
+ }
+
+ fun getProp(name: String): String? {
+ return (keystoreProperties[name] as? String) ?: System.getenv(name) ?: project.findProperty(name) as? String
+ }
+
defaultConfig {
applicationId = "com.oqba26.monthlypaymentapp"
minSdk = 25
@@ -26,34 +40,45 @@ android {
compose = true
buildConfig = true
}
- composeOptions {
- kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
+
+ signingConfigs {
+ create("release") {
+ val storeFileProp = getProp("RELEASE_STORE_FILE")
+ if (storeFileProp != null) {
+ storeFile = file(storeFileProp)
+ storePassword = getProp("RELEASE_STORE_PASSWORD")
+ keyAlias = getProp("RELEASE_KEY_ALIAS")
+ keyPassword = getProp("RELEASE_KEY_PASSWORD")
+ }
+ }
}
buildTypes {
debug {
- // ⭐️ استفاده از آدرس نهایی سرور برای Consistency
- buildConfigField("String", "API_BASE_URL", "\"http://167.235.136.65:8080/\"")
+ buildConfigField("String", "SUPABASE_URL", "\"https://ftufsygeartwukonclkz.supabase.co\"")
+ buildConfigField("String", "SUPABASE_KEY", "\"sb_publishable_H4mAU1Ds-vZ22HwMSfCaBQ_QjBfmRd5\"")
}
release {
+ val storeFileProp = getProp("RELEASE_STORE_FILE")
+ if (storeFileProp != null) {
+ signingConfig = signingConfigs.getByName("release")
+ }
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
- // ⭐️ استفاده از آدرس نهایی سرور برای Consistency
- buildConfigField("String", "API_BASE_URL", "\"http://167.235.136.65:8080/\"")
+ buildConfigField("String", "SUPABASE_URL", "\"https://ftufsygeartwukonclkz.supabase.co\"")
+ buildConfigField("String", "SUPABASE_KEY", "\"sb_publishable_H4mAU1Ds-vZ22HwMSfCaBQ_QjBfmRd5\"")
}
}
compileOptions {
- // Align with the JDK version used by Android Studio
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions { jvmTarget = "21" }
- // Ensure JDK 21
kotlin { jvmToolchain(21) }
packaging {
@@ -72,23 +97,28 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.navigation.compose)
- implementation("androidx.compose.material:material") // برای PullRefresh
+ implementation("androidx.compose.material:material")
implementation("androidx.compose.material:material-icons-extended-android:1.6.7")
implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6")
+ // Supabase
+ implementation(libs.supabase.postgrest)
+ implementation(libs.supabase.auth)
+ implementation(libs.supabase.realtime)
+ implementation(libs.ktor.client.okhttp)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.serialization.kotlinx.json)
+
// Core & Coroutines
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
- // Networking (Retrofit, OkHttp)
+ // Networking
implementation(libs.retrofit.core)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
-
- // ⭐️ Kotlinx Serialization - حذف وابستگیهای Gson و تکراری
- implementation(libs.kotlin.serialization.json) // kotlinx.serialization.json
- // ⭐️ مبدل Retrofit برای Kotlinx Serialization (فقط این یکی لازم است)
+ implementation(libs.kotlin.serialization.json)
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
// Database (Room)
@@ -103,6 +133,11 @@ dependencies {
implementation(project(":PersianDate"))
implementation(libs.google.material)
+ // Hilt
+ implementation(libs.hilt.android)
+ ksp(libs.hilt.compiler)
+ implementation(libs.hilt.navigation.compose)
+
// Desugaring
coreLibraryDesugaring(libs.android.desugar.jdk.libs)
diff --git a/app/src/androidTest/java/com/oqba26/monthlypaymentapp/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/oqba26/monthlypaymentapp/ExampleInstrumentedTest.kt
deleted file mode 100644
index ae899d3..0000000
--- a/app/src/androidTest/java/com/oqba26/monthlypaymentapp/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.oqba26.monthlypaymentapp
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.oqba26.monthlypaymentapp", appContext.packageName)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a55be95..221b3d7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,10 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt
index 1f58764..e58ca49 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt
@@ -1,32 +1,47 @@
@file:OptIn(ExperimentalMaterial3Api::class)
-
package com.oqba26.monthlypaymentapp
-
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
+import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.compose.animation.Crossfade
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+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.Add
import androidx.compose.material.icons.filled.Archive
+import androidx.compose.material.icons.filled.ArrowDownward
+import androidx.compose.material.icons.filled.ArrowUpward
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Paid
import androidx.compose.material.icons.filled.Settings
-import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
@@ -41,17 +56,25 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+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.unit.LayoutDirection
+import dagger.hilt.android.AndroidEntryPoint
+import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.ui.unit.dp
-import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.window.Dialog
+import androidx.core.content.ContextCompat
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
@@ -61,7 +84,8 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
-import com.oqba26.monthlypaymentapp.core.PaymentApplication
+import com.oqba26.monthlypaymentapp.ui.components.PermissionsDialog
+import com.oqba26.monthlypaymentapp.ui.components.UpdateDialog
import com.oqba26.monthlypaymentapp.ui.screens.ArchiveScreen
import com.oqba26.monthlypaymentapp.ui.screens.AuthScreen
import com.oqba26.monthlypaymentapp.ui.screens.PaidListScreen
@@ -69,17 +93,25 @@ import com.oqba26.monthlypaymentapp.ui.screens.PersonDetailScreen
import com.oqba26.monthlypaymentapp.ui.screens.PersonScreen
import com.oqba26.monthlypaymentapp.ui.screens.SettingsScreen
import com.oqba26.monthlypaymentapp.ui.theme.MonthlyPaymentManagement2Theme
+import com.oqba26.monthlypaymentapp.utils.UpdateInfo
+import com.oqba26.monthlypaymentapp.utils.UpdateManager
+import com.oqba26.monthlypaymentapp.viewmodel.ContactViewModel
+import com.oqba26.monthlypaymentapp.viewmodel.AuthViewModel
import com.oqba26.monthlypaymentapp.viewmodel.AuthState
+import com.oqba26.monthlypaymentapp.viewmodel.PersonListType
+import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
+import kotlin.time.Duration.Companion.milliseconds
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
- val application = application as PaymentApplication
- val personViewModel: PersonViewModel =
- viewModel(factory = application.personViewModelFactory)
+ val personViewModel: PersonViewModel = hiltViewModel()
MonthlyPaymentManagement2Theme {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
@@ -97,27 +129,135 @@ class MainActivity : ComponentActivity() {
@Composable
fun MainAppHost(viewModel: PersonViewModel) {
- val authState by viewModel.authState.collectAsState()
+ val authViewModel: AuthViewModel = hiltViewModel()
+ val contactViewModel: ContactViewModel = hiltViewModel()
+ val authState by authViewModel.authState.collectAsState()
val context = LocalContext.current
+ val scope = rememberCoroutineScope()
- LaunchedEffect(Unit) {
- viewModel.toastMessage.collect { message ->
- Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ // Update and Permission States
+ val updateManager = remember { UpdateManager(context) }
+ var updateInfo by remember { mutableStateOf(null) }
+ var downloadProgress by remember { mutableFloatStateOf(0f) }
+ var isDownloading by remember { mutableStateOf(false) }
+ var showPermissionsDialog by remember { mutableStateOf(false) }
+
+ val requiredPermissions = mutableListOf(
+ Manifest.permission.READ_CONTACTS
+ ).apply {
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
+ add(Manifest.permission.READ_EXTERNAL_STORAGE)
+ add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ }
+ }.toTypedArray()
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions(),
+ ) { permissions ->
+ val allGranted = permissions.entries.all { it.value }
+ if (allGranted) {
+ showPermissionsDialog = false
}
}
- Crossfade(targetState = authState, label = "AuthScreenSwitch") { state ->
- when (state) {
- is AuthState.Loading -> LoadingScreen()
- is AuthState.Unauthenticated -> AuthScreen()
- is AuthState.Authenticated -> AuthenticatedContent(viewModel)
+ LaunchedEffect(Unit) {
+ delay(2000.milliseconds)
+ updateInfo = updateManager.checkForUpdate()
+
+ val allGranted = requiredPermissions.all {
+ ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
+ if (!allGranted) {
+ showPermissionsDialog = true
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ launch {
+ viewModel.toastMessage.collect { message ->
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+ launch {
+ viewModel.infoMessage.collect { message ->
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }
+ }
+ }
+
+ // UI elements for update and permissions
+ if (showPermissionsDialog) {
+ PermissionsDialog(
+ onRequestPermissions = { permissionLauncher.launch(requiredPermissions) },
+ onExitClick = { (context as? ComponentActivity)?.finish() }
+ )
+ }
+
+ updateInfo?.let { info ->
+ UpdateDialog(
+ updateInfo = info,
+ onDismiss = { updateInfo = null },
+ onConfirm = {
+ val id = updateManager.downloadAndInstall(info.url, "MonthlyPaymentApp_v${info.versionName}.apk")
+ if (id != -1L) {
+ isDownloading = true
+ scope.launch {
+ updateManager.getDownloadProgress(id).collect { progress ->
+ downloadProgress = progress
+ if (progress >= 1f) isDownloading = false
+ }
+ }
+ updateInfo = null
+ }
+ }
+ )
+ }
+
+ if (isDownloading) {
+ Dialog(onDismissRequest = { }) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ Surface(
+ shape = MaterialTheme.shapes.extraLarge,
+ tonalElevation = 6.dp,
+ modifier = Modifier.fillMaxWidth().padding(16.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "در حال دریافت بهروزرسانی",
+ style = MaterialTheme.typography.titleLarge,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+ LinearProgressIndicator(
+ progress = { downloadProgress },
+ modifier = Modifier.fillMaxWidth().height(8.dp),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "${(downloadProgress * 100).toInt()}%",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // استفاده از when ساده به جای Crossfade برای جلوگیری از پرشهای لحظهای
+ when (authState) {
+ is AuthState.Loading -> LoadingScreen()
+ is AuthState.Unauthenticated -> AuthScreen()
+ is AuthState.Authenticated -> AuthenticatedContent(viewModel, contactViewModel)
}
}
@Composable
-fun AuthenticatedContent(viewModel: PersonViewModel) {
+fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactViewModel) {
val navController = rememberNavController()
var showExitDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
@@ -141,31 +281,138 @@ fun AuthenticatedContent(viewModel: PersonViewModel) {
ExitDialog(
onConfirmExit = { (context as? ComponentActivity)?.finish() },
onDismiss = {
- @Suppress("AssignedValueIsNeverRead")
showExitDialog = false
}
)
}
+ val isSelectionMode by viewModel.isSelectionMode.collectAsState()
+ var showConfirmMoveDialog by remember { mutableStateOf(false) }
+
+ if (showConfirmMoveDialog) {
+ Dialog(
+ onDismissRequest = { showConfirmMoveDialog = false }
+ ) {
+ CompositionLocalProvider(
+ LocalLayoutDirection provides LayoutDirection.Rtl
+ ) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ 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 = "آیا از ثبت تغییرات در ترتیب افراد مطمئن هستید؟",
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = {
+ val listType = when (navController.currentBackStackEntry?.destination?.route) {
+ Screen.Paid.route -> PersonListType.PAID
+ else -> PersonListType.UNPAID
+ }
+ viewModel.onEvent(PersonScreenEvent.CommitReorder(listType))
+ viewModel.onEvent(PersonScreenEvent.ClearSelection)
+ showConfirmMoveDialog = false
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text("بله، ذخیره کن")
+ }
+
+ Button(
+ onClick = { showConfirmMoveDialog = false },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondary
+ )
+ ) {
+ Text("لغو")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
Scaffold(
topBar = {
TopAppBar(
- title = { Text("مدیریت حقوق ماهانه") },
+ title = {
+ val titleText = when {
+ isSelectionMode -> "انتخاب شدهها"
+ currentRoute == Screen.Settings.route -> "تنظیمات"
+ else -> "مدیریت حقوق ماهانه"
+ }
+ Text(titleText)
+ },
colors = TopAppBarDefaults.topAppBarColors(
- containerColor = MaterialTheme.colorScheme.primary,
- titleContentColor = Color.White,
- actionIconContentColor = Color.White
+ containerColor = if (isSelectionMode) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.primary,
+ titleContentColor = if (isSelectionMode) MaterialTheme.colorScheme.onSecondaryContainer else Color.White,
+ actionIconContentColor = if (isSelectionMode) MaterialTheme.colorScheme.onSecondaryContainer else Color.White
),
+ navigationIcon = {
+ if (isSelectionMode) {
+ IconButton(onClick = { viewModel.onEvent(PersonScreenEvent.ClearSelection) }) {
+ Icon(Icons.Default.Close, contentDescription = "Clear selection")
+ }
+ }
+ },
actions = {
- if (currentRoute == Screen.Unpaid.route) {
- Button(
+ if (isSelectionMode) {
+ val listType = when (currentRoute) {
+ Screen.Paid.route -> PersonListType.PAID
+ else -> PersonListType.UNPAID
+ }
+ IconButton(onClick = { viewModel.onEvent(PersonScreenEvent.MoveSelected(-1, listType)) }) {
+ Icon(Icons.Default.ArrowUpward, contentDescription = "Move Up")
+ }
+ IconButton(onClick = { viewModel.onEvent(PersonScreenEvent.MoveSelected(1, listType)) }) {
+ Icon(Icons.Default.ArrowDownward, contentDescription = "Move Down")
+ }
+ IconButton(onClick = { showConfirmMoveDialog = true }) {
+ Icon(Icons.Default.Check, contentDescription = "Confirm Move", tint = Color.Green)
+ }
+ } else if (currentRoute == Screen.Unpaid.route) {
+ FilledIconButton(
onClick = { viewModel.onAddPersonClicked() },
- modifier = Modifier.padding(horizontal = 8.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.secondary
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = Color.White
)
) {
- Text("افزودن شخص")
+ Icon(Icons.Default.Add, contentDescription = "افزودن شخص")
+ }
+ FilledIconButton(
+ onClick = { contactViewModel.onBulkSmsClicked() },
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = Color.White
+ ),
+ modifier = Modifier.padding(start = 4.dp)
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Message,
+ contentDescription = "Bulk SMS",
+ modifier = Modifier.graphicsLayer(scaleX = -1f)
+ )
}
}
}
@@ -175,7 +422,8 @@ fun AuthenticatedContent(viewModel: PersonViewModel) {
if (showBottomBar) {
AppBottomNavigationBar(navController = navController)
}
- }
+ },
+ /* Floating Action Button removed and moved to TopAppBar actions */
) { innerPadding ->
NavHost(
navController = navController,
@@ -185,17 +433,23 @@ fun AuthenticatedContent(viewModel: PersonViewModel) {
composable(Screen.Unpaid.route) {
PersonScreen(
viewModel = viewModel,
+ contactViewModel = contactViewModel,
navController = navController
)
}
composable(Screen.Paid.route) {
- PaidListScreen(viewModel, navController)
+ PaidListScreen(
+ viewModel = viewModel,
+ contactViewModel = contactViewModel,
+ navController = navController
+ )
}
composable(Screen.Archive.route) {
ArchiveScreen(viewModel)
}
composable(Screen.Settings.route) {
- SettingsScreen(onLogout = { viewModel.logout() })
+ val authViewModel: AuthViewModel = hiltViewModel()
+ SettingsScreen(onLogout = { authViewModel.logout() })
}
composable(
route = "person_detail/{personId}",
@@ -206,6 +460,7 @@ fun AuthenticatedContent(viewModel: PersonViewModel) {
PersonDetailScreen(
personId = personId,
viewModel = viewModel,
+ contactViewModel = contactViewModel,
navController = navController
)
}
@@ -268,32 +523,52 @@ sealed class Screen(
@Composable
fun ExitDialog(onConfirmExit: () -> Unit, onDismiss: () -> Unit) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text("خروج از برنامه") },
- text = { Text("آیا برای خروج از برنامه مطمئن هستید؟") },
- confirmButton = {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(all = 8.dp),
- horizontalArrangement = Arrangement.SpaceBetween
+ Dialog(
+ onDismissRequest = onDismiss
+ ) {
+ CompositionLocalProvider(
+ LocalLayoutDirection provides LayoutDirection.Rtl
+ ) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(24.dp)
) {
- Button(
- onClick = onConfirmExit,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error
- )
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
) {
- Text("بله")
- }
- Button(onClick = onDismiss) {
- Text("خیر")
+ Text(
+ text = stringResource(R.string.exit_app),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Text(
+ text = stringResource(R.string.exit_app_confirm),
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = onConfirmExit,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text(stringResource(R.string.yes))
+ }
+ Button(onClick = onDismiss) {
+ Text(stringResource(R.string.no))
+ }
+ }
}
}
- },
- dismissButton = {}
- )
+ }
+ }
}
@Composable
@@ -304,4 +579,4 @@ fun LoadingScreen() {
Text("در حال بررسی وضعیت...", modifier = Modifier.padding(top = 16.dp))
}
}
-}
\ No newline at end of file
+}
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 3d4f1d3..05b6eec 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/core/PaymentApplication.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/core/PaymentApplication.kt
@@ -1,59 +1,7 @@
package com.oqba26.monthlypaymentapp.core
import android.app.Application
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import com.oqba26.monthlypaymentapp.data.database.AppDatabase
-import com.oqba26.monthlypaymentapp.data.remote.ApiClient
-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.viewmodel.PersonViewModel
-import com.oqba26.monthlypaymentapp.viewmodel.SettingsViewModel
+import dagger.hilt.android.HiltAndroidApp
-class PaymentApplication : Application() {
-
- lateinit var localPersonRepository: LocalPersonRepository
- lateinit var networkRepository: NetworkRepository
- lateinit var settingsRepository: SettingsRepository
- private lateinit var apiClient: ApiClient
-
- override fun onCreate() {
- super.onCreate()
- val db = AppDatabase.getInstance(this)
-
- settingsRepository = SettingsRepository(this)
- apiClient = ApiClient(settingsRepository)
-
- localPersonRepository = LocalPersonRepository(
- personDao = db.personDao(),
- paymentDao = db.paymentDao(),
- database = db
- )
-
- networkRepository = NetworkRepository(api = apiClient.api)
- }
-
- val personViewModelFactory: ViewModelProvider.Factory
- get() = object : ViewModelProvider.Factory {
- @Suppress("UNCHECKED_CAST")
- override fun create(modelClass: Class): T {
- if (modelClass.isAssignableFrom(PersonViewModel::class.java)) {
- // ⭐️ FIX: Removed the unnecessary 'localPersonRepository' argument.
- return PersonViewModel(networkRepository, settingsRepository) as T
- }
- throw IllegalArgumentException("Unknown ViewModel class")
- }
- }
-
- val settingsViewModelFactory: ViewModelProvider.Factory
- get() = object : ViewModelProvider.Factory {
- @Suppress("UNCHECKED_CAST")
- override fun create(modelClass: Class): T {
- if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) {
- return SettingsViewModel(settingsRepository, localPersonRepository) as T
- }
- throw IllegalArgumentException("Unknown ViewModel class")
- }
- }
-}
+@HiltAndroidApp
+class PaymentApplication : Application()
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
new file mode 100644
index 0000000..98b0797
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/DatabaseModule.kt
@@ -0,0 +1,33 @@
+package com.oqba26.monthlypaymentapp.core.di
+
+import android.content.Context
+import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
+import com.oqba26.monthlypaymentapp.data.dao.PersonDao
+import com.oqba26.monthlypaymentapp.data.database.AppDatabase
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object DatabaseModule {
+
+ @Provides
+ @Singleton
+ fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
+ return AppDatabase.getInstance(context)
+ }
+
+ @Provides
+ fun providePersonDao(database: AppDatabase): PersonDao {
+ return database.personDao()
+ }
+
+ @Provides
+ fun providePaymentDao(database: AppDatabase): PaymentDao {
+ return database.paymentDao()
+ }
+}
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
new file mode 100644
index 0000000..d843c5f
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/RepositoryModule.kt
@@ -0,0 +1,42 @@
+package com.oqba26.monthlypaymentapp.core.di
+
+import android.content.Context
+import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
+import com.oqba26.monthlypaymentapp.data.dao.PersonDao
+import com.oqba26.monthlypaymentapp.data.database.AppDatabase
+import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
+import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
+import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object RepositoryModule {
+
+ @Provides
+ @Singleton
+ fun provideNetworkRepository(): NetworkRepository {
+ return NetworkRepository()
+ }
+
+ @Provides
+ @Singleton
+ fun provideLocalPersonRepository(
+ personDao: PersonDao,
+ paymentDao: PaymentDao,
+ database: AppDatabase
+ ): LocalPersonRepository {
+ return LocalPersonRepository(personDao, paymentDao, database)
+ }
+
+ @Provides
+ @Singleton
+ fun provideSettingsRepository(@ApplicationContext context: Context): SettingsRepository {
+ return SettingsRepository(context)
+ }
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/core/manager/ExportManager.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/core/manager/ExportManager.kt
new file mode 100644
index 0000000..0fcca6c
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/core/manager/ExportManager.kt
@@ -0,0 +1,87 @@
+package com.oqba26.monthlypaymentapp.core.manager
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.content.FileProvider
+import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
+import com.oqba26.monthlypaymentapp.data.model.Person
+import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
+import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
+import com.oqba26.monthlypaymentapp.utils.toPersianDigits
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.File
+import java.io.FileOutputStream
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ExportManager @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ fun exportYearlyReport(year: Int, persons: List, payments: List): Boolean {
+ val csvContent = StringBuilder()
+ csvContent.append("نام,")
+ (1..12).forEach { csvContent.append("${getPersianMonthName(it)},") }
+ csvContent.append("مجموع\n")
+
+ persons.filter { !it.isArchived }.forEach { person ->
+ csvContent.append("${person.name},")
+ var total = 0.0
+ (1..12).forEach { month ->
+ val payment = payments.find { it.personId == person.id && it.shamsiMonth == month }
+ if (payment != null) {
+ csvContent.append("${payment.amount},")
+ total += payment.amount
+ } else {
+ csvContent.append("-,")
+ }
+ }
+ csvContent.append("$total\n")
+ }
+
+ return try {
+ val fileName = "Report_$year.csv"
+ val file = File(context.cacheDir, fileName)
+ FileOutputStream(file).use { it.write(csvContent.toString().toByteArray()) }
+
+ val uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ file
+ )
+
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/csv"
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val chooser = Intent.createChooser(intent, "اشتراکگذاری گزارش سال $year")
+ chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(chooser)
+ true
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ fun shareReceipt(payment: PaymentRecord, personName: String) {
+ val message = """
+ 📜 رسید پرداخت ماهانه
+ 👤 نام: $personName
+ 📅 بابت: ${getPersianMonthName(payment.shamsiMonth)} ${payment.shamsiYear.toString().toPersianDigits()}
+ 💰 مبلغ: ${formatNumberAsPersian(payment.amount)} تومان
+ 📝 توضیحات: ${payment.description ?: "---"}
+ ✅ با موفقیت ثبت شد.
+ """.trimIndent()
+
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, message)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ val chooser = Intent.createChooser(intent, "اشتراکگذاری رسید")
+ chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(chooser)
+ }
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/core/manager/SmsManager.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/core/manager/SmsManager.kt
new file mode 100644
index 0000000..c9d982c
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/core/manager/SmsManager.kt
@@ -0,0 +1,47 @@
+package com.oqba26.monthlypaymentapp.core.manager
+
+import android.content.Context
+import android.content.Intent
+import androidx.core.net.toUri
+import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
+import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SmsManager @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ fun sendSmsReminder(person: PersonUiModel, selectedCard: String? = null) {
+ val intent = getSmsIntent(person, selectedCard) ?: return
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ }
+
+ fun getSmsIntent(person: PersonUiModel, selectedCard: String? = null): Intent? {
+ if (person.phoneNumber.isNullOrBlank()) return null
+
+ val monthsText = if (person.unpaidMonthsNames.size == 1) {
+ "بابت ماه ${person.unpaidMonthsNames.first()}"
+ } else {
+ "بابت ماههای ${person.unpaidMonthsNames.joinToString("، ")}"
+ }
+
+ var message = """
+ سلام ${person.name} عزیز،
+ وقت بخیر. پرداخت حقوق شما $monthsText هنوز ثبت نشده است.
+ مبلغ بدهی: ${formatNumberAsPersian(person.totalDebtAmount)} تومان
+ لطفاً در اسرع وقت بررسی نمایید. باتشکر.
+ """.trimIndent()
+
+ if (!selectedCard.isNullOrBlank()) {
+ message += "\nشماره کارت جهت واریز:\n$selectedCard"
+ }
+
+ return Intent(Intent.ACTION_VIEW).apply {
+ data = "sms:${person.phoneNumber}".toUri()
+ putExtra("sms_body", message)
+ }
+ }
+}
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 ed6c334..f505f0b 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
@@ -13,6 +13,9 @@ interface PaymentDao {
@Query("SELECT * FROM payments ORDER BY timestamp DESC")
fun getAllPaymentsFlow(): Flow>
+ @Query("SELECT * FROM payments ORDER BY timestamp DESC")
+ suspend fun getAllPayments(): List
+
@Query("SELECT * FROM payments WHERE personId = :personId ORDER BY timestamp DESC")
fun getPaymentsForPersonFlow(personId: String): Flow>
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 4f89f75..fb7652b 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
@@ -13,6 +13,9 @@ interface PersonDao {
@Query("SELECT * FROM persons ORDER BY displayOrder ASC")
fun getAllPersonsFlow(): Flow>
+ @Query("SELECT * FROM persons ORDER BY displayOrder ASC")
+ suspend fun getAllPersons(): List
+
@Query("SELECT * FROM persons WHERE id = :id LIMIT 1")
fun getPersonByIdFlow(id: String): Flow
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthModels.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthModels.kt
index ab2c3d8..2810f84 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthModels.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthModels.kt
@@ -1,8 +1,12 @@
+@file:Suppress("unused")
+
package com.oqba26.monthlypaymentapp.data.model
+import android.annotation.SuppressLint
import kotlinx.serialization.Serializable
// مدل برای ارسال به سرور هنگام ورود/ثبت نام
+@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class LoginRequest(
val username: String, // یا email
@@ -10,6 +14,7 @@ data class LoginRequest(
)
// مدلی که سرور پس از ورود موفق برمیگرداند
+@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class LoginResponse(
val token: String, // توکن دسترسی (JWT)
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthRequest.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthRequest.kt
index fc451fe..2a86bf4 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthRequest.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthRequest.kt
@@ -1,11 +1,11 @@
+@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
+
package com.oqba26.monthlypaymentapp.data.model
-import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.Serializable
-@OptIn(InternalSerializationApi::class) // <<< CRITICAL FIX: Added the required Opt-In annotation
@Serializable
data class AuthRequest(
- val username: String,
+ val email: String,
val password: String
)
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthResponse.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthResponse.kt
index 234bdc6..169fe9f 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthResponse.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthResponse.kt
@@ -1,3 +1,4 @@
+@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
package com.oqba26.monthlypaymentapp.data.model
import kotlinx.serialization.Serializable
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/BackupData.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/BackupData.kt
index 9b363f8..168cad1 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/BackupData.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/BackupData.kt
@@ -1,5 +1,8 @@
package com.oqba26.monthlypaymentapp.data.model
+import android.annotation.SuppressLint
+
+@SuppressLint("UnsafeOptInUsageError")
@kotlinx.serialization.Serializable
data class BackupData(
val persons: List,
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 71c6955..9c347b2 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
@@ -4,16 +4,27 @@ package com.oqba26.monthlypaymentapp.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
import java.util.UUID
-@kotlinx.serialization.Serializable
+@Serializable
@Entity(tableName = "payments")
data class PaymentRecord(
- @PrimaryKey val id: String = UUID.randomUUID().toString(),
- val personId: String,
- val amount: Double,
- val shamsiYear: Int,
- val shamsiMonth: Int,
- val timestamp: Long = System.currentTimeMillis(),
- val description: String = ""
-)
\ No newline at end of file
+ @PrimaryKey
+ @SerialName("id") val id: String = UUID.randomUUID().toString(),
+
+ @SerialName("personId") val personId: String,
+
+ @SerialName("amount") val amount: Double,
+
+ @SerialName("shamsiYear") val shamsiYear: Int,
+
+ @SerialName("shamsiMonth") val shamsiMonth: Int,
+
+ @SerialName("timestamp") val timestamp: Long = System.currentTimeMillis(),
+
+ @SerialName("description") val description: String? = "", // ⭐️ تغییر به String? برای پذیرش مقادیر null
+
+ @SerialName("createdAt") val createdAt: Long? = null
+)
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 838bdb5..26b0685 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
@@ -1,18 +1,25 @@
+@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
+
package com.oqba26.monthlypaymentapp.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
-import kotlinx.serialization.InternalSerializationApi
+import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-import kotlin.OptIn // CORRECTED IMPORT
-@OptIn(InternalSerializationApi::class)
@Serializable
@Entity(tableName = "persons")
data class Person(
- @PrimaryKey val id: String = "",
- val name: String,
- val isArchived: Boolean = false,
- val displayOrder: Long = 0L,
- val createdAt: Long = 0L
+ @PrimaryKey
+ @SerialName("id") val id: String = "",
+
+ @SerialName("name") val name: String,
+
+ @SerialName("isArchived") val isArchived: Boolean = false,
+
+ @SerialName("displayOrder") val displayOrder: Long? = 0L,
+
+ @SerialName("createdAt") val createdAt: Long? = 0L,
+
+ @SerialName("phoneNumber") val phoneNumber: String? = ""
)
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 e0d17b6..210e84f 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
@@ -1,44 +1,38 @@
+@file:OptIn(io.github.jan.supabase.annotations.SupabaseInternal::class)
+
package com.oqba26.monthlypaymentapp.data.remote
-import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.oqba26.monthlypaymentapp.BuildConfig
-import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
+import io.github.jan.supabase.createSupabaseClient
+import io.github.jan.supabase.gotrue.Auth
+import io.github.jan.supabase.postgrest.Postgrest
+import io.github.jan.supabase.realtime.Realtime
+import io.github.jan.supabase.serializer.KotlinXSerializer
+import io.ktor.client.plugins.HttpTimeout
import kotlinx.serialization.json.Json
-import okhttp3.MediaType.Companion.toMediaType
-import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
-import retrofit2.Retrofit
-// ⭐️ تغییر به کلاس برای تزریق SettingsRepository
-class ApiClient(settingsRepository: SettingsRepository) {
+object ApiClient {
- // تنظیمات Kotlinx.Serialization
- private val json = Json {
- ignoreUnknownKeys = true
- isLenient = true
+ val client = createSupabaseClient(
+ supabaseUrl = BuildConfig.SUPABASE_URL,
+ supabaseKey = BuildConfig.SUPABASE_KEY
+ ) {
+ defaultSerializer = KotlinXSerializer(Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ encodeDefaults = true
+ })
+
+ httpConfig {
+ install(HttpTimeout) {
+ requestTimeoutMillis = 30000
+ connectTimeoutMillis = 30000
+ socketTimeoutMillis = 30000
+ }
+ }
+
+ install(Postgrest)
+ install(Auth)
+ install(Realtime)
}
-
- // ⭐️ ایجاد اینترسپتور احراز هویت
- private val authInterceptor = AuthInterceptor(settingsRepository)
-
- private val logging = HttpLoggingInterceptor().apply {
- level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
- }
-
- // ⭐️ افزودن AuthInterceptor به OkHttpClient
- private val client: OkHttpClient = OkHttpClient.Builder()
- .addInterceptor(authInterceptor)
- .addInterceptor(logging)
- .build()
-
- val api: ApiService by lazy {
- val contentType = "application/json".toMediaType()
-
- Retrofit.Builder()
- .baseUrl("http://167.235.136.65:8080/") // آدرس سرور
- .client(client)
- .addConverterFactory(json.asConverterFactory(contentType))
- .build()
- .create(ApiService::class.java)
- }
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt
index 514441d..b407ed6 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt
@@ -1,3 +1,5 @@
+@file:Suppress("unused")
+
package com.oqba26.monthlypaymentapp.data.remote
import com.oqba26.monthlypaymentapp.data.model.AuthRequest
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt
index 75e8644..42323a0 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt
@@ -6,6 +6,7 @@ import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
+@Suppress("unused")
class AuthInterceptor(
private val settingsRepository: SettingsRepository
) : Interceptor {
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 e8b107e..281f931 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
@@ -1,3 +1,5 @@
+@file:Suppress("unused")
+
package com.oqba26.monthlypaymentapp.data.repository
import androidx.room.withTransaction
@@ -35,7 +37,7 @@ class LocalPersonRepository(
currentByName.containsKey(norm) -> currentByName[norm]!!.id
else -> UUID.randomUUID().toString()
}
- Person(id = resolvedId, name = name)
+ p.copy(id = resolvedId, name = name)
}
personDao.insertAll(safePersons)
@@ -60,7 +62,7 @@ class LocalPersonRepository(
currentByName.containsKey(norm) -> currentByName[norm]!!.id
else -> UUID.randomUUID().toString()
}
- Person(id = resolvedId, name = name)
+ p.copy(id = resolvedId, name = name)
}
// ۲. حذف کامل تمام دادههای محلی
@@ -75,8 +77,8 @@ class LocalPersonRepository(
// Backup/Restore
suspend fun getDataForBackup(): BackupData {
- val persons = getAllPersonsFlow().first()
- val payments = getAllPaymentsFlow().first()
+ val persons = personDao.getAllPersons()
+ val payments = paymentDao.getAllPayments()
return BackupData(persons = persons, payments = payments)
}
@@ -88,7 +90,7 @@ class LocalPersonRepository(
val safePersons = backupData.persons.map { p ->
val name = p.name.trim()
val id = p.id.ifBlank { UUID.randomUUID().toString() }
- Person(id = id, name = name)
+ p.copy(id = id, name = name)
}
personDao.insertAll(safePersons)
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 0ddc4c6..4671669 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
@@ -5,38 +5,87 @@ import com.oqba26.monthlypaymentapp.data.model.AuthRequest
import com.oqba26.monthlypaymentapp.data.model.AuthResponse
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
-import com.oqba26.monthlypaymentapp.data.remote.ApiService
+import com.oqba26.monthlypaymentapp.data.remote.ApiClient
+import io.github.jan.supabase.gotrue.auth
+import io.github.jan.supabase.gotrue.providers.builtin.Email
+import io.github.jan.supabase.postgrest.from
+import io.github.jan.supabase.postgrest.query.Order
+import io.github.jan.supabase.realtime.PostgresAction
+import io.github.jan.supabase.realtime.channel
+import io.github.jan.supabase.realtime.postgresChangeFlow
+import io.github.jan.supabase.realtime.realtime
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
-import retrofit2.HttpException
+import kotlinx.coroutines.launch
+import java.util.UUID
-class NetworkRepository(private val api: ApiService) {
+class NetworkRepository {
+ private val supabase = ApiClient.client
private val _personsFlow = MutableStateFlow>(emptyList())
private val _paymentsFlow = MutableStateFlow>(emptyList())
+ private val repositoryScope = CoroutineScope(Dispatchers.IO)
+
+ // --- 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()
+ }
+ }
+
+ // گوش دادن به تغییرات جدول پرداختها
+ launch {
+ myChannel.postgresChangeFlow(schema = "public") {
+ table = "payments"
+ }.collect {
+ refresh()
+ }
+ }
+
+ myChannel.subscribe()
+ } catch (e: Exception) {
+ Log.e("Supabase", "Realtime connection error", e)
+ }
+ }
+ }
// --- Auth Operations ---
suspend fun register(request: AuthRequest): AuthResponse? {
return try {
- api.register(request)
- } catch (e: HttpException) {
- Log.e("API", "register HttpException: ${e.code()}", e)
- null
+ supabase.auth.signUpWith(Email) {
+ email = request.email
+ password = request.password
+ }
+ val session = supabase.auth.currentSessionOrNull()
+ AuthResponse(token = session?.accessToken ?: "success", userId = session?.user?.id)
} catch (e: Exception) {
- Log.e("API", "register error", e)
+ Log.e("Supabase", "register error", e)
null
}
}
suspend fun login(request: AuthRequest): AuthResponse? {
return try {
- api.login(request)
- } catch (e: HttpException) {
- Log.e("API", "login HttpException: ${e.code()}", e)
- null
+ supabase.auth.signInWith(Email) {
+ email = request.email
+ password = request.password
+ }
+ val session = supabase.auth.currentSessionOrNull()
+ AuthResponse(token = session?.accessToken ?: "", userId = session?.user?.id)
} catch (e: Exception) {
- Log.e("API", "login error", e)
+ Log.e("Supabase", "login error", e)
null
}
}
@@ -44,61 +93,87 @@ class NetworkRepository(private val api: ApiService) {
// --- Person Operations ---
suspend fun addPerson(person: Person): Int? {
return try {
- val trimmed = person.name.trim()
- if (trimmed.isEmpty()) return 400
- val resp = api.addPerson(person.copy(name = trimmed))
- resp.code()
- } catch (e: HttpException) {
- e.code()
+ val trimmedName = person.name.trim()
+ if (trimmedName.isEmpty()) return 400
+
+ // بررسی تکراری بودن نام در لیست موجود
+ val isDuplicate = _personsFlow.value.any { it.name.trim().equals(trimmedName, ignoreCase = true) }
+ if (isDuplicate) return 409
+
+ val personToInsert = if (person.id.isEmpty()) {
+ person.copy(id = UUID.randomUUID().toString(), name = trimmedName, createdAt = System.currentTimeMillis())
+ } else {
+ person.copy(name = trimmedName)
+ }
+
+ supabase.from("persons").insert(personToInsert)
+ 201
} catch (e: Exception) {
- Log.e("API", "addPerson error", e)
+ Log.e("Supabase", "addPerson error", e)
null
}
}
suspend fun deletePersonAndPayments(personId: String): Boolean {
return try {
- api.deletePersonAndPayments(personId).isSuccessful
+ supabase.from("persons").delete {
+ filter { eq("id", personId) }
+ }
+ true
} catch (e: Exception) {
- Log.e("API", "deletePersonAndPayments error", e); false
+ Log.e("Supabase", "deletePerson error", e); false
}
}
- suspend fun updatePerson(personId: String, name: String): Boolean {
+ suspend fun updatePerson(personId: String, name: String, phoneNumber: String?): Int {
return try {
- val trimmed = name.trim()
- if (trimmed.isEmpty()) return false
- // Note: displayOrder is not updated here, only the name.
- val personUpdate = Person(id = personId, name = trimmed, displayOrder = 0)
- api.updatePerson(personId, personUpdate).isSuccessful
+ val trimmedName = name.trim()
+ if (trimmedName.isEmpty()) return 400
+
+ // بررسی تکراری بودن نام (به جز خودِ این شخص)
+ val isDuplicate = _personsFlow.value.any {
+ it.id != personId && it.name.trim().equals(trimmedName, ignoreCase = true)
+ }
+ if (isDuplicate) return 409
+
+ supabase.from("persons").update(
+ mapOf(
+ "name" to trimmedName,
+ "phoneNumber" to phoneNumber?.trim()
+ )
+ ) {
+ filter { eq("id", personId) }
+ }
+ 200
} catch (e: Exception) {
- Log.e("API", "updatePerson error", e); false
+ Log.e("Supabase", "updatePerson error", e)
+ 500
}
}
suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean {
return try {
- api.updatePersonArchivedStatus(personId, mapOf("isArchived" to isArchived)).isSuccessful
+ supabase.from("persons").update(
+ mapOf("isArchived" to isArchived)
+ ) {
+ filter { eq("id", personId) }
+ }
+ true
} catch (e: Exception) {
- Log.e("API", "updatePersonArchivedStatus error", e); false
+ Log.e("Supabase", "updateArchived error", e); false
}
}
suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean {
return try {
- api.updatePersonDisplayOrder(personId, mapOf("displayOrder" to displayOrder)).isSuccessful
+ supabase.from("persons").update(
+ mapOf("displayOrder" to displayOrder)
+ ) {
+ filter { eq("id", personId) }
+ }
+ true
} catch (e: Exception) {
- Log.e("API", "updatePersonDisplayOrder error", e); false
- }
- }
-
- @Suppress("unused")
- suspend fun rebalanceDisplayOrders(): Boolean {
- return try {
- api.rebalanceDisplayOrders().isSuccessful
- } catch (e: Exception) {
- Log.e("API", "rebalanceDisplayOrders error", e)
- false
+ Log.e("Supabase", "updateOrder error", e); false
}
}
@@ -107,17 +182,21 @@ class NetworkRepository(private val api: ApiService) {
// --- Payment Operations ---
suspend fun addPayment(paymentRecord: PaymentRecord): Boolean {
return try {
- api.addPayment(paymentRecord).isSuccessful
+ supabase.from("payments").insert(paymentRecord)
+ true
} catch (e: Exception) {
- Log.e("API", "addPayment error", e); false
+ Log.e("Supabase", "addPayment error", e); false
}
}
suspend fun deletePayment(paymentId: String): Boolean {
return try {
- api.deletePayment(paymentId).isSuccessful
+ supabase.from("payments").delete {
+ filter { eq("id", paymentId) }
+ }
+ true
} catch (e: Exception) {
- Log.e("API", "deletePayment error", e); false
+ Log.e("Supabase", "deletePayment error", e); false
}
}
@@ -125,16 +204,19 @@ class NetworkRepository(private val api: ApiService) {
suspend fun refresh() {
try {
- val persons = api.getPersons()
+ // دریافت و تبدیل خودکار لیست اشخاص
+ val persons = supabase.from("persons").select().decodeList()
_personsFlow.value = persons
+
+ // دریافت و تبدیل خودکار لیست پرداختها
+ val payments = supabase.from("payments").select {
+ order("timestamp", order = Order.DESCENDING)
+ }.decodeList()
+ _paymentsFlow.value = payments
+
+ Log.d("Supabase", "Refresh Success: ${persons.size} persons, ${payments.size} payments")
} catch (e: Exception) {
- Log.e("API", "refresh persons error", e)
- }
- try {
- val payments = api.getPayments(null)
- _paymentsFlow.value = payments.sortedByDescending { it.timestamp }
- } catch (e: Exception) {
- Log.e("API", "refresh payments error", e)
+ Log.e("Supabase", "refresh error", e)
}
}
}
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 c71b034..722688b 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
@@ -21,11 +21,37 @@ class SettingsRepository(context: Context) {
val SELECTED_FONT_NAME = stringPreferencesKey("selected_font_name")
val AUTH_TOKEN_KEY = stringPreferencesKey("auth_token")
val USER_ID_KEY = stringPreferencesKey("user_id")
+ val REMINDER_DAY = stringPreferencesKey("reminder_day")
+ val CARD_NUMBERS = stringPreferencesKey("card_numbers")
+ val IGNORED_CONTACT_SUGGESTIONS = stringPreferencesKey("ignored_contact_suggestions")
const val FALLBACK_AMOUNT = 200000.0
const val DEFAULT_FONT = "Estedad"
}
+ val reminderDayFlow: Flow = dataStore.data.map {
+ it[REMINDER_DAY]?.toIntOrNull()
+ }
+
+ val cardNumbersFlow: Flow> = dataStore.data.map {
+ val raw = it[CARD_NUMBERS] ?: ""
+ if (raw.isBlank()) emptyList() else raw.split("|")
+ }
+
+ suspend fun saveCardNumbers(cards: List) {
+ dataStore.edit { it[CARD_NUMBERS] = cards.joinToString("|") }
+ }
+
+ suspend fun saveReminderDay(day: Int?) {
+ dataStore.edit { preferences ->
+ if (day != null) {
+ preferences[REMINDER_DAY] = day.toString()
+ } else {
+ preferences.remove(REMINDER_DAY)
+ }
+ }
+ }
+
val defaultPaymentAmountFlow: Flow = dataStore.data.map {
it[DEFAULT_PAYMENT_AMOUNT] ?: FALLBACK_AMOUNT
}
@@ -65,4 +91,18 @@ class SettingsRepository(context: Context) {
}
}
}
+
+ val ignoredContactSuggestionsFlow: Flow> = dataStore.data.map {
+ val raw = it[IGNORED_CONTACT_SUGGESTIONS] ?: ""
+ if (raw.isBlank()) emptySet() else raw.split(",").toSet()
+ }
+
+ suspend fun ignoreContactSuggestion(personId: String) {
+ dataStore.edit { preferences ->
+ val current = preferences[IGNORED_CONTACT_SUGGESTIONS] ?: ""
+ val set = if (current.isBlank()) mutableSetOf() else current.split(",").toMutableSet()
+ set.add(personId)
+ preferences[IGNORED_CONTACT_SUGGESTIONS] = set.joinToString(",")
+ }
+ }
}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/PermissionDialogs.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/PermissionDialogs.kt
new file mode 100644
index 0000000..ece2e43
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/PermissionDialogs.kt
@@ -0,0 +1,59 @@
+package com.oqba26.monthlypaymentapp.ui.components
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+
+@Composable
+fun PermissionsDialog(
+ onRequestPermissions: () -> Unit,
+ onExitClick: () -> Unit
+) {
+ Dialog(onDismissRequest = { }) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ Surface(
+ shape = MaterialTheme.shapes.extraLarge,
+ tonalElevation = 6.dp,
+ modifier = Modifier.fillMaxWidth().padding(16.dp)
+ ) {
+ Column(modifier = Modifier.padding(24.dp)) {
+ Text(
+ text = "دسترسیهای الزامی",
+ style = MaterialTheme.typography.headlineSmall,
+ modifier = Modifier.padding(bottom = 16.dp),
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = "کاربر گرامی، برای استفاده از برنامه باید دسترسیهای زیر را تایید کنید:\n\n" +
+ "۱. مخاطبین: برای مدیریت پرداختهای افراد\n" +
+ "۲. حافظه: برای ذخیره فایلهای پشتیبان\n\n" +
+ "توجه: در صورت عدم تایید، برنامه بسته خواهد شد.",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ onClick = onRequestPermissions,
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.small,
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
+ ) {
+ Text("متوجه شدم؛ اعطای دسترسی", color = MaterialTheme.colorScheme.onPrimary)
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ TextButton(
+ onClick = onExitClick,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("خروج", color = MaterialTheme.colorScheme.error)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/UpdateDialog.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/UpdateDialog.kt
new file mode 100644
index 0000000..70048b3
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/UpdateDialog.kt
@@ -0,0 +1,112 @@
+package com.oqba26.monthlypaymentapp.ui.components
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.oqba26.monthlypaymentapp.utils.UpdateInfo
+
+@Composable
+fun UpdateDialog(
+ updateInfo: UpdateInfo,
+ onDismiss: () -> Unit,
+ onConfirm: () -> Unit,
+) {
+ Dialog(
+ onDismissRequest = { if (!updateInfo.isForceUpdate) onDismiss() },
+ properties = DialogProperties(
+ dismissOnBackPress = !updateInfo.isForceUpdate,
+ dismissOnClickOutside = !updateInfo.isForceUpdate
+ )
+ ) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ Surface(
+ shape = MaterialTheme.shapes.extraLarge,
+ tonalElevation = 6.dp,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(24.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Text(
+ text = "بهروزرسانی جدید موجود است",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "نسخه ${updateInfo.versionName}",
+ style = MaterialTheme.typography.labelLarge,
+ color = Color.Gray
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "تغییرات این نسخه:",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Text(
+ text = updateInfo.releaseNotes,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(vertical = 8.dp)
+ )
+
+ if (updateInfo.isForceUpdate) {
+ Text(
+ text = "نصب این بهروزرسانی برای ادامه استفاده از برنامه الزامی است.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Button(
+ onClick = onConfirm,
+ modifier = Modifier.weight(1f),
+ shape = MaterialTheme.shapes.small
+ ) {
+ Text("تایید و بروزرسانی")
+ }
+
+ if (!updateInfo.isForceUpdate) {
+ Button(
+ onClick = onDismiss,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
+ shape = MaterialTheme.shapes.small
+ ) {
+ Text("بعداً")
+ }
+ }
+ }
+ }
+ }
+ }
+}
+}
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 dafdecf..1ae9a14 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
@@ -2,6 +2,7 @@ package com.oqba26.monthlypaymentapp.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -11,21 +12,32 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Restore
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
@@ -33,6 +45,8 @@ import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
@Composable
fun ArchiveScreen(viewModel: PersonViewModel) {
val uiState by viewModel.uiState.collectAsState()
+ var personToDelete by remember { mutableStateOf(null) }
+ var personToRestore by remember { mutableStateOf(null) }
if (uiState.archivedPersons.isEmpty()) {
Box(
@@ -50,19 +64,135 @@ fun ArchiveScreen(viewModel: PersonViewModel) {
ArchivedPersonListItem(
person = person,
onRestore = {
- viewModel.onEvent(PersonScreenEvent.RestorePerson(person.id))
+ personToRestore = person
+ },
+ onDelete = {
+ personToDelete = person
}
)
HorizontalDivider()
}
}
}
+
+ // دیالوگ تایید حذف قطعی
+ 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("حذف قطعی")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // دیالوگ تایید بازیابی
+ personToRestore?.let { person ->
+ Dialog(
+ onDismissRequest = { personToRestore = 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 = { personToRestore = null }
+ ) {
+ Text("لغو")
+ }
+
+ Button(
+ onClick = {
+ viewModel.onEvent(PersonScreenEvent.RestorePerson(person.id))
+ personToRestore = null
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text("بازیابی")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
@Composable
fun ArchivedPersonListItem(
person: PersonUiModel,
- onRestore: () -> Unit
+ onRestore: () -> Unit,
+ onDelete: () -> Unit
) {
Row(
modifier = Modifier
@@ -74,18 +204,31 @@ fun ArchivedPersonListItem(
Text(
text = person.name,
style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Bold
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.weight(1f)
)
- Button(
- onClick = onRestore,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primary
- )
- ) {
- Icon(Icons.Default.Restore, contentDescription = "بازیابی")
- Spacer(Modifier.width(4.dp))
- Text("بازیابی")
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = onDelete) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "حذف قطعی",
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+
+ Spacer(Modifier.width(8.dp))
+
+ Button(
+ onClick = onRestore,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Icon(Icons.Default.Restore, contentDescription = "بازیابی")
+ Spacer(Modifier.width(4.dp))
+ Text("بازیابی")
+ }
}
}
}
\ No newline at end of file
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 8605c09..2d44c00 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
@@ -10,8 +10,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Lock
-import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
@@ -27,28 +27,20 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.oqba26.monthlypaymentapp.core.PaymentApplication
+import androidx.hilt.navigation.compose.hiltViewModel
import com.oqba26.monthlypaymentapp.data.model.AuthRequest
import com.oqba26.monthlypaymentapp.viewmodel.AuthState
-import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
+import com.oqba26.monthlypaymentapp.viewmodel.AuthViewModel
@Composable
fun AuthScreen(
- viewModel: PersonViewModel = viewModel(
- // استفاده از Factory که در PaymentApplication تعریف شده است
- factory = (LocalContext.current.applicationContext as PaymentApplication).personViewModelFactory
- )
+ viewModel: AuthViewModel = hiltViewModel()
) {
- // مشاهده وضعیت لودینگ از ViewModel
val authState by viewModel.authState.collectAsState()
val isLoading = authState is AuthState.Loading
-
- // مدیریت وضعیت نمایش فرم (ورود یا ثبت نام)
var isLoginMode by remember { mutableStateOf(true) }
Column(
@@ -67,8 +59,8 @@ fun AuthScreen(
AuthForm(
isLoginMode = isLoginMode,
isLoading = isLoading,
- onAuthenticate = { username, password ->
- val request = AuthRequest(username, password)
+ onAuthenticate = { email, password ->
+ val request = AuthRequest(email, password)
if (isLoginMode) {
viewModel.login(request)
} else {
@@ -79,7 +71,6 @@ fun AuthScreen(
Spacer(modifier = Modifier.height(16.dp))
- // دکمه تغییر وضعیت: رفتن به ثبت نام یا ورود
TextButton(onClick = { isLoginMode = !isLoginMode }, enabled = !isLoading) {
Text(
text = if (isLoginMode) "حساب کاربری ندارید؟ ثبت نام کنید." else "قبلاً ثبت نام کردهاید؟ وارد شوید."
@@ -94,17 +85,17 @@ private fun AuthForm(
isLoading: Boolean,
onAuthenticate: (String, String) -> Unit
) {
- var username by remember { mutableStateOf("") }
+ var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
val buttonText = if (isLoginMode) "ورود" else "ثبت نام"
Column(horizontalAlignment = Alignment.CenterHorizontally) {
- // فیلد نام کاربری
OutlinedTextField(
- value = username,
- onValueChange = { username = it },
- label = { Text("نام کاربری") },
- leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
+ value = email,
+ onValueChange = { email = it },
+ label = { Text("ایمیل") },
+ leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth(0.8f),
enabled = !isLoading
@@ -112,7 +103,6 @@ private fun AuthForm(
Spacer(modifier = Modifier.height(12.dp))
- // فیلد رمز عبور
OutlinedTextField(
value = password,
onValueChange = { password = it },
@@ -127,11 +117,10 @@ private fun AuthForm(
Spacer(modifier = Modifier.height(24.dp))
- // دکمه ورود/ثبت نام
Button(
- onClick = { onAuthenticate(username, password) },
+ onClick = { onAuthenticate(email, password) },
modifier = Modifier.fillMaxWidth(0.8f).height(50.dp),
- enabled = username.isNotBlank() && password.isNotBlank() && !isLoading
+ enabled = email.isNotBlank() && password.isNotBlank() && !isLoading
) {
if (isLoading) {
CircularProgressIndicator(
@@ -144,4 +133,4 @@ private fun AuthForm(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PaidListScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PaidListScreen.kt
index 2aeb0fe..26d2840 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PaidListScreen.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PaidListScreen.kt
@@ -8,11 +8,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
-import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -20,14 +21,21 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
+import com.oqba26.monthlypaymentapp.viewmodel.ContactViewModel
import com.oqba26.monthlypaymentapp.viewmodel.PersonListType
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
@@ -38,47 +46,37 @@ import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable
@Composable
-fun PaidListScreen(viewModel: PersonViewModel, navController: NavController) {
+fun PaidListScreen(
+ viewModel: PersonViewModel,
+ contactViewModel: ContactViewModel = hiltViewModel(),
+ navController: NavController
+) {
val uiState by viewModel.uiState.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState()
- // شخصی که قرار است در این صفحه با تأیید کاربر به آرشیو منتقل شود
val personToArchive = remember { mutableStateOf(null) }
val state = rememberReorderableLazyListState(
onMove = { from, to ->
if (searchQuery.isBlank()) {
- viewModel.onEvent(
- PersonScreenEvent.MovePersonNew(
- fromIndex = from.index,
- toIndex = to.index,
- listType = PersonListType.PAID
- )
- )
+ viewModel.onEvent(PersonScreenEvent.MovePersonNew(from.index, to.index, PersonListType.PAID))
}
},
onDragEnd = { _, _ ->
if (searchQuery.isBlank()) {
- viewModel.onEvent(
- PersonScreenEvent.CommitReorder(
- listType = PersonListType.PAID
- )
- )
+ viewModel.onEvent(PersonScreenEvent.CommitReorder(PersonListType.PAID))
}
}
)
Scaffold { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) {
-
OutlinedTextField(
value = searchQuery,
onValueChange = { viewModel.onSearchQueryChange(it) },
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp),
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
label = { Text("جستجوی نام...") },
- leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
singleLine = true
)
@@ -86,84 +84,47 @@ fun PaidListScreen(viewModel: PersonViewModel, navController: NavController) {
state = state.listState,
modifier = Modifier.reorderable(state)
) {
- itemsIndexed(
- uiState.paidPersons,
- key = { _, person -> person.id }
- ) { index, person ->
+ itemsIndexed(uiState.paidPersons, key = { _, person -> person.id }) { index, person ->
ReorderableItem(state, key = person.id) { _ ->
-
- val rowDragModifier =
- if (searchQuery.isBlank()) {
- Modifier.detectReorderAfterLongPress(state)
- } else {
- Modifier
- }
-
- Row(
- modifier = rowDragModifier
- .fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically
- ) {
+ val rowDragModifier = if (searchQuery.isBlank()) Modifier.detectReorderAfterLongPress(state) else Modifier
+ Row(modifier = rowDragModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.weight(1f)) {
PersonListItem(
person = person,
index = index + 1,
- onPersonClick = {
- navController.navigate("person_detail/${person.id}")
- },
- // در لیست پرداخت شدهها، دکمه پرداخت سریع کاری نمیکند
+ onPersonClick = { navController.navigate("person_detail/${person.id}") },
onQuickPayClick = { },
- onArchiveClick = { selectedPerson ->
- personToArchive.value = selectedPerson
- }
+ onArchiveClick = { personToArchive.value = it },
+ onSmsClick = { contactViewModel.sendSmsReminder(it) }
)
}
}
-
- HorizontalDivider(
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
- )
+ HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
}
}
}
}
- // دیالوگ تأیید انتقال به آرشیو برای لیست پرداختشدهها
personToArchive.value?.let { person ->
- AlertDialog(
- onDismissRequest = { personToArchive.value = null },
- title = { Text("انتقال به آرشیو") },
- text = { Text("آیا از انتقال ${person.name} به آرشیو مطمئن هستید؟") },
- confirmButton = {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- // در RTL: این دکمه کاملاً به سمت راست دیالوگ میچسبد
- Button(onClick = { personToArchive.value = null }) {
- Text("لغو")
- }
-
- // در RTL: این دکمه کاملاً به سمت چپ دیالوگ میچسبد
- Button(
- onClick = {
- viewModel.onEvent(
- PersonScreenEvent.ArchivePerson(person.id)
- )
- personToArchive.value = null
- },
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.secondary
- )
- ) {
- Text("آرشیو")
+ Dialog(onDismissRequest = { personToArchive.value = 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 = { personToArchive.value = null }) { Text("لغو") }
+ Button(onClick = {
+ viewModel.onEvent(PersonScreenEvent.ArchivePerson(person.id))
+ personToArchive.value = null
+ }, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) {
+ Text("آرشیو")
+ }
+ }
}
}
- },
- dismissButton = {}
- )
+ }
+ }
}
}
-}
\ No newline at end of file
+}
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 8698166..79c87fb 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
@@ -1,7 +1,10 @@
-@file:Suppress("AssignedValueIsNeverRead")
-
package com.oqba26.monthlypaymentapp.ui.screens
+import android.Manifest
+import android.content.pm.PackageManager
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -11,45 +14,66 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
-import androidx.compose.material3.AlertDialog
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Payments
+import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+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 androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.core.content.ContextCompat
import androidx.navigation.NavController
+import com.oqba26.monthlypaymentapp.viewmodel.ContactViewModel
+import androidx.hilt.navigation.compose.hiltViewModel
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
+import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
-import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDate
-import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianTime
+import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
+import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
+import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
import com.oqba26.monthlypaymentapp.utils.toPersianDigits
+import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch
import com.oqba26.monthlypaymentapp.viewmodel.MonthStatus
import com.oqba26.monthlypaymentapp.viewmodel.MonthUiModel
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
@@ -59,7 +83,8 @@ import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
fun PersonDetailScreen(
personId: String,
viewModel: PersonViewModel,
- navController: NavController
+ contactViewModel: ContactViewModel = hiltViewModel(),
+ navController: NavController,
) {
LaunchedEffect(personId) {
viewModel.onEvent(PersonScreenEvent.SelectPerson(personId))
@@ -67,31 +92,22 @@ 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 paymentDialogState by remember { mutableStateOf(null) }
- var infoDialogMessage by remember { mutableStateOf(null) }
- var paymentToDelete 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) }
- LaunchedEffect(Unit) {
- viewModel.infoMessage.collect { message ->
- infoDialogMessage = message
- }
- }
-
- Column(
- modifier = Modifier.fillMaxSize()
- ) {
+ Column(modifier = Modifier.fillMaxSize()) {
PersonDetailHeader(
name = detailState.person?.name ?: "...",
onBack = { navController.popBackStack() }
- )
+ ) { editingPerson = true }
if (detailState.person == null) {
- Box(
- modifier = Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center
- ) {
+ Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
@@ -102,34 +118,36 @@ fun PersonDetailScreen(
}
)
+ val availableMonths = detailState.monthStates
+ .filter { it.status == MonthStatus.AVAILABLE }
+ .map { it.month }
+
+ if (availableMonths.isNotEmpty()) {
+ Button(
+ onClick = { showBulkPaymentDialog = true },
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary)
+ ) {
+ Icon(Icons.Default.Payments, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text("پرداخت دسته جمعی (${availableMonths.size.toString().toPersianDigits()} ماه)")
+ }
+ }
+
Spacer(modifier = Modifier.height(4.dp))
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(bottom = 8.dp)
- ) {
+ LazyColumn(modifier = Modifier.fillMaxSize().padding(bottom = 8.dp)) {
items(detailState.monthStates) { monthModel ->
MonthListItem(
month = monthModel,
- isExpanded = expandedMonth == monthModel.month,
onHeaderClick = {
- expandedMonth =
- if (expandedMonth == monthModel.month) null else monthModel.month
+ expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month
},
onEditClick = {
- when (monthModel.status) {
- MonthStatus.PAID -> {
- paymentDialogState =
- PaymentDialogState.Edit(monthModel.payment!!)
- }
-
- MonthStatus.AVAILABLE -> {
- paymentDialogState =
- PaymentDialogState.Add(monthModel.month)
- }
-
- else -> { /* Do nothing for other states */ }
+ if (monthModel.status == MonthStatus.PAID) {
+ editingPayment = monthModel.payment
+ } else if (monthModel.status == MonthStatus.AVAILABLE) {
+ addingPaymentMonth = monthModel.month
}
}
)
@@ -138,95 +156,388 @@ fun PersonDetailScreen(
}
}
- infoDialogMessage?.let {
- InfoDialog(message = it, onDismiss = { infoDialogMessage = null })
- }
-
- paymentToDelete?.let { payment ->
- DeleteConfirmationDialog(
- payment = payment,
- onConfirm = {
- viewModel.onEvent(PersonScreenEvent.DeletePayment(payment))
- paymentToDelete = null
+ editingPayment?.let { payment ->
+ EditPaymentDialog(
+ paymentRecord = payment,
+ onConfirm = { amount, desc ->
+ viewModel.onEvent(PersonScreenEvent.UpdatePayment(payment, amount, desc))
+ editingPayment = null
},
- onDismiss = { paymentToDelete = null }
+ onDelete = {
+ viewModel.onEvent(PersonScreenEvent.DeletePayment(payment))
+ editingPayment = null
+ },
+ onShare = { p ->
+ viewModel.shareReceipt(p, detailState.person?.name ?: "")
+ },
+ onDismiss = {
+ editingPayment = null
+ }
)
}
- paymentDialogState?.let { state ->
- when (state) {
- is PaymentDialogState.Add -> {
- AddPaymentDialog(
- personName = detailState.person!!.name,
- defaultAmount = defaultPaymentAmount,
- onConfirm = { amount, description ->
- viewModel.onEvent(
- PersonScreenEvent.AddPaymentForMonth(
- personId = personId,
- month = state.month,
- year = detailState.selectedYear,
- amount = amount,
- description = description
- )
- )
- paymentDialogState = null
- },
- onDismiss = { paymentDialogState = null }
- )
- }
+ // دیالوگ افزودن (اصلاح شده)
+ addingPaymentMonth?.let { month ->
+ val monthName = getPersianMonthName(month)
+ val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
+
+ val isCurrentMonth = (month == getCurrentShamsiMonth()) && (detailState.selectedYear == getCurrentShamsiYear())
+ val initialDesc = if (isCurrentMonth) {
+ "پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
+ } else {
+ "پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
+ }
- is PaymentDialogState.Edit -> {
- val payment = state.payment
- AddPaymentDialog(
- personName = detailState.person!!.name,
- defaultAmount = payment.amount,
- initialDescription = payment.description,
- onConfirm = { amount, description ->
- viewModel.onEvent(
- PersonScreenEvent.UpdatePayment(
- payment = payment,
- newAmount = amount,
- newDescription = description
- )
- )
- paymentDialogState = null
- },
- onDismiss = { paymentDialogState = null },
- onDelete = {
- paymentToDelete = payment
- paymentDialogState = null
- }
+ AddPaymentDialog(
+ personName = detailState.person?.name ?: "",
+ defaultAmount = defaultPaymentAmount,
+ initialDescription = initialDesc,
+ onConfirm = { amount, description ->
+ viewModel.onEvent(
+ PersonScreenEvent.AddPaymentForMonth(
+ personId = personId,
+ month = month,
+ year = detailState.selectedYear,
+ amount = amount,
+ description = description
+ )
)
+ addingPaymentMonth = null
+ },
+ onDismiss = {
+ addingPaymentMonth = null
+ }
+ )
+ }
+
+ if (editingPerson) {
+ UpdatePersonDialog(
+ initialName = detailState.person?.name ?: "",
+ initialPhone = detailState.person?.phoneNumber,
+ onConfirm = { name, phone ->
+ viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone))
+ editingPerson = false
+ },
+ onDismiss = { editingPerson = false },
+ onSearchContact = { name ->
+ contactViewModel.findSimilarContacts(name)
+ }
+ )
+ }
+
+ if (showBulkPaymentDialog) {
+ val availableMonths = detailState.monthStates
+ .filter { it.status == MonthStatus.AVAILABLE }
+ .map { it.month }
+
+ BulkPaymentDialog(
+ availableMonths = availableMonths,
+ defaultAmount = defaultPaymentAmount,
+ onConfirm = { selected, amount ->
+ viewModel.onEvent(
+ PersonScreenEvent.AddBulkPayments(
+ personId = personId,
+ months = selected,
+ year = detailState.selectedYear,
+ amount = amount
+ )
+ )
+ showBulkPaymentDialog = false
+ },
+ onDismiss = { showBulkPaymentDialog = false }
+ )
+ }
+}
+
+@Composable
+fun BulkPaymentDialog(
+ availableMonths: List,
+ defaultAmount: Double,
+ onConfirm: (List, Double) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val selectedMonths = remember { mutableStateListOf().apply { addAll(availableMonths) } }
+ var amountText by remember { mutableStateOf(defaultAmount.toInt().toString()) }
+
+ Dialog(
+ onDismissRequest = onDismiss
+ ) {
+ CompositionLocalProvider(
+ LocalLayoutDirection provides LayoutDirection.Rtl
+ ) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ 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("ماههای مورد نظر را انتخاب کنید:")
+
+ LazyColumn(modifier = Modifier.heightIn(max = 250.dp)) {
+ items(availableMonths) { month ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ if (selectedMonths.contains(month)) selectedMonths.remove(month)
+ else selectedMonths.add(month)
+ }
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = selectedMonths.contains(month),
+ onCheckedChange = { checked ->
+ if (checked) selectedMonths.add(month)
+ else selectedMonths.remove(month)
+ }
+ )
+ Text(getPersianMonthName(month))
+ }
+ }
+ }
+
+ OutlinedTextField(
+ value = amountText,
+ onValueChange = { amountText = it },
+ label = { Text("مبلغ برای هر ماه (تومان)") },
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ visualTransformation = PersianDigitsTransformation()
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = {
+ val finalAmount = amountText.toDoubleOrNull() ?: defaultAmount
+ onConfirm(selectedMonths.toList(), finalAmount)
+ },
+ enabled = selectedMonths.isNotEmpty()
+ ) {
+ Text("ثبت پرداختها")
+ }
+
+ TextButton(onClick = onDismiss) {
+ Text("لغو")
+ }
+ }
+ }
}
}
}
}
@Composable
-private fun PersonDetailHeader(name: String, onBack: () -> Unit) {
- Surface(
- color = MaterialTheme.colorScheme.primary,
- contentColor = Color.White
- ) {
+private fun PersonDetailHeader(
+ name: String,
+ onBack: () -> Unit,
+ onEdit: () -> Unit
+) {
+ Surface(color = MaterialTheme.colorScheme.primary, contentColor = Color.White) {
Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(44.dp)
- .padding(horizontal = 8.dp),
+ modifier = Modifier.fillMaxWidth().height(44.dp).padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = "بازگشت"
- )
+ Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "بازگشت")
}
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
Text(
text = name,
+ modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
+ IconButton(onClick = onEdit) {
+ Icon(imageVector = Icons.Default.Edit, contentDescription = "ویرایش شخص")
+ }
+ }
+ }
+}
+
+@Composable
+fun UpdatePersonDialog(
+ initialName: String,
+ initialPhone: String?,
+ onConfirm: (String, String) -> Unit,
+ onDismiss: () -> Unit,
+ onSearchContact: (String) -> List
+) {
+ var name by remember { mutableStateOf(initialName) }
+ var phoneNumber by remember { mutableStateOf(initialPhone ?: "") }
+ var similarContacts by remember { mutableStateOf>(emptyList()) }
+ val context = LocalContext.current
+
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ val matches = onSearchContact(name)
+ if (matches.isNotEmpty()) {
+ similarContacts = matches
+ } else {
+ Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(context, "دسترسی به مخاطبین داده نشد", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ Dialog(
+ onDismissRequest = onDismiss
+ ) {
+ CompositionLocalProvider(
+ LocalLayoutDirection provides LayoutDirection.Rtl
+ ) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(24.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "ویرایش اطلاعات شخص",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ 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)
+ }
+ }
+ }
+ ) {
+ 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
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = {
+ if (name.isNotBlank()) {
+ onConfirm(name, phoneNumber)
+ }
+ },
+ enabled = name.isNotBlank()
+ ) {
+ Text("ذخیره تغییرات")
+ }
+
+ Button(
+ onClick = onDismiss,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text("لغو")
+ }
+ }
+ }
+ }
}
}
}
@@ -239,208 +550,74 @@ fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
modifier = Modifier.fillMaxWidth()
) {
Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 6.dp),
+ modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
IconButton(onClick = { onYearChange(-1) }) {
- Icon(
- imageVector = Icons.Default.ChevronRight,
- contentDescription = "سال قبل"
- )
+ Icon(imageVector = Icons.Default.ChevronRight, contentDescription = "سال قبل")
}
-
Text(
text = "سال ${year.toString().toPersianDigits()}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 12.dp)
)
-
IconButton(onClick = { onYearChange(1) }) {
- Icon(
- imageVector = Icons.Default.ChevronLeft,
- contentDescription = "سال بعد"
- )
+ Icon(imageVector = Icons.Default.ChevronLeft, contentDescription = "سال بعد")
}
}
}
}
-@Suppress("UNUSED_PARAMETER")
@Composable
fun MonthListItem(
month: MonthUiModel,
- isExpanded: Boolean, // فعلاً استفاده نمیشود
onHeaderClick: () -> Unit,
onEditClick: () -> Unit
) {
val containerColor = when (month.status) {
MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer
MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.surface
- MonthStatus.PAST_YEAR, MonthStatus.FUTURE_YEAR, MonthStatus.FUTURE_MONTH ->
- MaterialTheme.colorScheme.surfaceVariant
+ 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(onClick = onHeaderClick),
colors = CardDefaults.cardColors(containerColor = containerColor),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 10.dp)
- ) {
- // ردیف بالا: نام ماه + مبلغ + دکمه
+ Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
- // نام ماه + مبلغ در صورت پرداخت
- Row(
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = getPersianMonthName(month.month),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold
- )
-
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(text = getPersianMonthName(month.month), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (month.status == MonthStatus.PAID && month.payment != null) {
Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = "${formatNumberAsPersian(month.payment.amount)} تومان",
- style = MaterialTheme.typography.bodyMedium
- )
+ Text(text = "${formatNumberAsPersian(month.payment.amount)} ت", style = MaterialTheme.typography.bodyMedium)
}
}
-
- // دکمه سمت چپ
- when (month.status) {
- MonthStatus.PAID -> {
- Button(
- onClick = onEditClick,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primary
- )
- ) {
- Text("ویرایش")
- }
- }
-
- MonthStatus.AVAILABLE -> {
- Button(
- onClick = onEditClick,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.secondary
- )
- ) {
- Text("پرداخت")
- }
- }
-
- else -> {
- Text(
- text = "غیرفعال",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant
+ if (month.status == MonthStatus.PAID || month.status == MonthStatus.AVAILABLE) {
+ Button(
+ onClick = onEditClick,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = if (month.status == MonthStatus.PAID) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary
)
+ ) {
+ Text(if (month.status == MonthStatus.PAID) "ویرایش" else "پرداخت")
}
}
}
-
- // جزئیات پرداخت: تاریخ + (در صورت وجود) توضیحات
- month.payment?.let { payment ->
- Spacer(modifier = Modifier.height(10.dp))
-
- Text(
- text = "ثبت شده در: " +
- "${formatTimestampToPersianDate(payment.timestamp)} " +
- "ساعت ${formatTimestampToPersianTime(payment.timestamp)}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
- )
-
- if (payment.description.isNotBlank()) {
+ if (month.status == MonthStatus.PAID && month.payment != null) {
+ val desc = month.payment.description ?: ""
+ if (desc.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = "توضیحات: ${payment.description}",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onPrimaryContainer
- )
+ Text(text = desc, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
-
-@Composable
-fun DeleteConfirmationDialog(
- payment: PaymentRecord,
- onConfirm: () -> Unit,
- onDismiss: () -> Unit
-) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text("تایید حذف") },
- text = {
- Text(
- "آیا برای حذف پرداخت ماه ${
- getPersianMonthName(payment.shamsiMonth)
- } به مبلغ ${
- formatNumberAsPersian(payment.amount)
- } تومان مطمئن هستید؟"
- )
- },
- confirmButton = {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- // در RTL: این دکمه میچسبد به سمت راست دیالوگ
- Button(onClick = onDismiss) {
- Text("لغو")
- }
-
- // در RTL: این دکمه میچسبد به سمت چپ دیالوگ
- Button(
- onClick = onConfirm,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error
- )
- ) {
- Text("حذف")
- }
- }
- },
- dismissButton = {}
- )
-}
-
-@Composable
-fun InfoDialog(message: String, onDismiss: () -> Unit) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text("توجه") },
- text = { Text(message) },
- confirmButton = {
- TextButton(onClick = onDismiss) {
- Text("باشه")
- }
- }
- )
-}
-
-sealed class PaymentDialogState {
- data class Add(val month: Int) : PaymentDialogState()
- data class Edit(val payment: PaymentRecord) : PaymentDialogState()
-}
\ No newline at end of file
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 f19ef82..6d4eb0b 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
@@ -1,10 +1,14 @@
-@file:Suppress("AssignedValueIsNeverRead")
-
package com.oqba26.monthlypaymentapp.ui.screens
+import android.Manifest
+import android.content.pm.PackageManager
+import android.widget.Toast
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,32 +17,46 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.ExperimentalMaterialApi
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.Payments
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
-import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
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
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -46,17 +64,29 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+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.input.KeyboardType
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.core.content.ContextCompat
+import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
+import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
import com.oqba26.monthlypaymentapp.utils.toPersianDigits
+import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch
+import com.oqba26.monthlypaymentapp.viewmodel.ContactSuggestion
import com.oqba26.monthlypaymentapp.viewmodel.DashboardUiModel
import com.oqba26.monthlypaymentapp.viewmodel.PersonListType
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
+import com.oqba26.monthlypaymentapp.viewmodel.ContactViewModel
import org.burnoutcrew.reorderable.ReorderableItem
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
@@ -66,18 +96,35 @@ import org.burnoutcrew.reorderable.reorderable
@Composable
fun PersonScreen(
viewModel: PersonViewModel,
+ contactViewModel: ContactViewModel = hiltViewModel(),
navController: NavController
) {
val searchQuery by viewModel.searchQuery.collectAsState()
val uiState by viewModel.uiState.collectAsState()
val dashboardData by viewModel.dashboardData.collectAsState()
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
+ val cardNumbers by viewModel.cardNumbersFlow.collectAsState(initial = emptyList())
val personForPaymentDialog by viewModel.personForPaymentDialog.collectAsState()
+ val contactState by contactViewModel.uiState.collectAsState()
+
+ val personForSmsDialog = contactState.personForSmsDialog
+ val bulkSmsQueueForDialog = contactState.bulkSmsQueueForDialog
+
val isAddPersonDialogShown by viewModel.showAddPersonDialog.collectAsState()
val isRefreshing by viewModel.isRefreshing.collectAsState()
+ val isSelectionMode by viewModel.isSelectionMode.collectAsState()
+ val selectedIds by viewModel.selectedIds.collectAsState()
+ val context = LocalContext.current
+
+ LaunchedEffect(uiState.unpaidPersons, uiState.paidPersons) {
+ val allPersons = uiState.unpaidPersons + uiState.paidPersons
+ if (allPersons.isNotEmpty()) {
+ contactViewModel.checkContactsForMissingNumbers(allPersons)
+ }
+ }
- // شخصی که قرار است با تأیید کاربر به آرشیو منتقل شود
var personToArchive by remember { mutableStateOf(null) }
+ val showBulkSelection = contactState.showBulkSmsDialog
val reorderableState = rememberReorderableLazyListState(
onMove = { from, to ->
@@ -98,7 +145,6 @@ fun PersonScreen(
}
)
- // Pull-to-refresh
val pullRefreshState = rememberPullRefreshState(
refreshing = isRefreshing,
onRefresh = { viewModel.onEvent(PersonScreenEvent.RefreshData) }
@@ -110,13 +156,11 @@ fun PersonScreen(
.pullRefresh(pullRefreshState)
) {
Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(top = 8.dp)
+ modifier = Modifier.fillMaxSize()
) {
Column(
- modifier = Modifier.padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 2.dp, bottom = 0.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
) {
DashboardCard(data = dashboardData)
@@ -125,12 +169,12 @@ fun PersonScreen(
onValueChange = { viewModel.onSearchQueryChange(it) },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("جستجوی نام...") },
- leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") }
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
+ singleLine = true,
+ shape = MaterialTheme.shapes.medium
)
}
- Spacer(modifier = Modifier.height(8.dp))
-
LazyColumn(
state = reorderableState.listState,
modifier = Modifier
@@ -138,271 +182,623 @@ fun PersonScreen(
.reorderable(reorderableState)
) {
itemsIndexed(uiState.unpaidPersons, key = { _, person -> person.id }) { index, person ->
- ReorderableItem(reorderableState, key = person.id) { _ ->
- val dragModifier =
- if (searchQuery.isBlank()) Modifier.detectReorderAfterLongPress(
- reorderableState
- ) else Modifier
-
- Box(modifier = dragModifier) {
- PersonListItem(
- person = person,
- index = index + 1,
- onPersonClick = {
+ ReorderableItem(reorderableState, key = person.id) { isDragging ->
+ val elevation = animateFloatAsState(if (isDragging) 8f else 0f, label = "").value
+
+ PersonListItem(
+ person = person,
+ index = index + 1,
+ isSelectionMode = isSelectionMode,
+ isSelected = selectedIds.contains(person.id),
+ reorderableState = reorderableState,
+ modifier = Modifier.graphicsLayer(translationY = elevation),
+ onPersonClick = {
+ if (isSelectionMode) {
+ viewModel.onEvent(PersonScreenEvent.ToggleSelection(person.id))
+ } else {
navController.navigate("person_detail/${person.id}")
- },
- onQuickPayClick = { viewModel.onQuickPayClicked(it) },
- onArchiveClick = { selectedPerson ->
- personToArchive = selectedPerson
}
- )
- }
- HorizontalDivider(
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
+ },
+ onLongClick = {
+ if (!isSelectionMode) {
+ viewModel.onEvent(PersonScreenEvent.ToggleSelection(person.id))
+ }
+ },
+ onQuickPayClick = { viewModel.onQuickPayClicked(it) },
+ onArchiveClick = { selectedPerson ->
+ personToArchive = selectedPerson
+ },
+ onSmsClick = { selectedPerson ->
+ contactViewModel.onSmsClicked(selectedPerson)
+ }
)
}
}
+ item { Spacer(modifier = Modifier.height(60.dp)) }
}
}
- // اندیکاتور رفرش
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
+ }
- // دیالوگ پرداخت سریع
- personForPaymentDialog?.let { person ->
- AddPaymentDialog(
- personName = person.name,
- defaultAmount = defaultPaymentAmount,
- onConfirm = { amount, description ->
- viewModel.onEvent(
- PersonScreenEvent.AddQuickPayment(
- person.id,
- amount,
- description
- )
- )
- viewModel.onDismissPaymentDialog()
- },
- onDismiss = { viewModel.onDismissPaymentDialog() }
+ // Dialogs
+ personForPaymentDialog?.let { person ->
+ AddPaymentDialog(
+ personName = person.name,
+ defaultAmount = defaultPaymentAmount,
+ onConfirm = { amount, description ->
+ viewModel.onEvent(PersonScreenEvent.AddQuickPayment(person.id, amount, description))
+ viewModel.onDismissPaymentDialog()
+ },
+ onDismiss = { viewModel.onDismissPaymentDialog() }
+ )
+ }
+
+ personForSmsDialog?.let { person ->
+ SelectCardDialog(
+ cardNumbers = cardNumbers,
+ onCardSelected = { card ->
+ contactViewModel.sendSmsReminder(person, card)
+ contactViewModel.onDismissSmsDialog()
+ },
+ onDismiss = { contactViewModel.onDismissSmsDialog() }
+ )
+ }
+
+ if (bulkSmsQueueForDialog.isNotEmpty()) {
+ SelectCardDialog(
+ cardNumbers = cardNumbers,
+ onCardSelected = { card ->
+ contactViewModel.startBulkSms(bulkSmsQueueForDialog, card)
+ contactViewModel.onDismissSmsDialog()
+ },
+ onDismiss = { contactViewModel.onDismissSmsDialog() }
+ )
+ }
+
+ if (isAddPersonDialogShown) {
+ AddNewPersonDialog(
+ onConfirm = { name, phone ->
+ viewModel.onEvent(PersonScreenEvent.AddPerson(name, phone, context))
+ viewModel.onDismissAddPersonDialog()
+ },
+ onDismiss = { viewModel.onDismissAddPersonDialog() },
+ onSearchContact = { name ->
+ contactViewModel.findSimilarContacts(name)
+ }
+ )
+ }
+
+ personToArchive?.let { person ->
+ ArchiveConfirmationDialog(
+ personName = person.name,
+ onConfirm = {
+ viewModel.onEvent(PersonScreenEvent.ArchivePerson(person.id))
+ personToArchive = null
+ },
+ onDismiss = { personToArchive = null }
+ )
+ }
+
+ if (showBulkSelection) {
+ val debtors = (uiState.unpaidPersons + uiState.paidPersons).filter { it.debtCount > 0 }
+ BulkSmsSelectionDialog(
+ debtors = debtors,
+ onStart = { selected ->
+ contactViewModel.onBulkSelectionConfirmed(selected)
+ },
+ onDismiss = { contactViewModel.onDismissBulkSmsDialog() }
+ )
+ }
+
+ if (contactState.currentBulkIndex != -1) {
+ val person = contactState.bulkSmsQueue.getOrNull(contactState.currentBulkIndex)
+ if (person != null) {
+ BulkSmsProgressDialog(
+ currentName = person.name,
+ currentIndex = contactState.currentBulkIndex,
+ total = contactState.bulkSmsQueue.size,
+ onSend = { contactViewModel.processNextBulkSms() },
+ onSkip = { contactViewModel.skipBulkSms() },
+ onCancel = { contactViewModel.cancelBulkSms() }
)
}
+ }
- // دیالوگ افزودن شخص جدید
- if (isAddPersonDialogShown) {
- AddNewPersonDialog(
- onConfirm = { name ->
- viewModel.onEvent(PersonScreenEvent.AddPerson(name))
- viewModel.onDismissAddPersonDialog()
- },
- onDismiss = { viewModel.onDismissAddPersonDialog() }
- )
- }
+ contactState.contactSuggestions.firstOrNull()?.let { suggestion ->
+ ContactSuggestionDialog(
+ suggestion = suggestion,
+ onConfirm = { phone ->
+ contactViewModel.confirmContactSuggestion(suggestion.personId, suggestion.personName, phone)
+ },
+ onDismiss = { contactViewModel.dismissContactSuggestion(suggestion) }
+ )
+ }
+}
- // دیالوگ تایید انتقال به آرشیو
- personToArchive?.let { person ->
- AlertDialog(
- onDismissRequest = { personToArchive = null },
- title = { Text("انتقال به آرشیو") },
- text = { Text("آیا از انتقال ${person.name} به آرشیو مطمئن هستید؟") },
- confirmButton = {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- // در RTL: این دکمه کاملاً به سمت راست دیالوگ میچسبد
- Button(onClick = { personToArchive = null }) {
- Text("لغو")
- }
-
- // در RTL: این دکمه کاملاً به سمت چپ دیالوگ میچسبد
- Button(
- onClick = {
- viewModel.onEvent(
- PersonScreenEvent.ArchivePerson(person.id)
- )
- personToArchive = null
- },
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.secondary
- )
- ) {
+@Composable
+fun ArchiveConfirmationDialog(personName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
+ Dialog(onDismissRequest = onDismiss) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ 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 = "آیا از انتقال $personName به آرشیو مطمئن هستید؟", style = MaterialTheme.typography.bodyMedium)
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Button(onClick = onDismiss) { Text("لغو") }
+ Button(onClick = onConfirm, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) {
Text("آرشیو")
}
}
- },
- dismissButton = {}
- )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ContactSuggestionDialog(
+ suggestion: ContactSuggestion,
+ onConfirm: (String) -> Unit,
+ onDismiss: () -> Unit
+) {
+ var selectedNumber by remember { mutableStateOf(suggestion.matches.firstOrNull()?.phoneNumber ?: "") }
+
+ 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 = "شماره تماس پیدا شد",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Column {
+ Text("برای «${suggestion.personName}» موارد زیر در مخاطبین پیدا شد:")
+ Spacer(Modifier.height(12.dp))
+
+ LazyColumn(
+ modifier = Modifier.heightIn(max = 300.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(suggestion.matches) { match ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .clickable { selectedNumber = match.phoneNumber }
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = selectedNumber == match.phoneNumber,
+ onClick = { selectedNumber = match.phoneNumber }
+ )
+ Spacer(Modifier.width(8.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = match.nameInContacts,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium
+ )
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
+ Text(
+ text = match.phoneNumber.toPersianDigits(),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.secondary,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = androidx.compose.ui.text.style.TextAlign.Right
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(Modifier.height(8.dp))
+ Text("آیا میخواهید شماره انتخاب شده به اطلاعات شخص اضافه شود؟")
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = { onConfirm(selectedNumber) },
+ enabled = selectedNumber.isNotEmpty()
+ ) {
+ Text("بله، اضافه کن")
+ }
+ TextButton(onClick = onDismiss) {
+ Text("خیر")
+ }
+ }
+ }
+ }
}
}
}
@Composable
fun AddNewPersonDialog(
- onConfirm: (String) -> Unit,
- onDismiss: () -> Unit
+ onConfirm: (String, String) -> Unit,
+ onDismiss: () -> Unit,
+ onSearchContact: (String) -> List
) {
var name by remember { mutableStateOf("") }
+ var phoneNumber by remember { mutableStateOf("") }
+ var similarContacts by remember { mutableStateOf>(emptyList()) }
+ val context = LocalContext.current
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text("افزودن شخص جدید") },
- text = {
- OutlinedTextField(
- value = name,
- onValueChange = { name = it },
- label = { Text("نام شخص") },
- singleLine = true,
- modifier = Modifier.fillMaxWidth()
- )
- },
- confirmButton = {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(all = 8.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Button(
- onClick = {
- if (name.isNotBlank()) {
- onConfirm(name)
- }
- },
- enabled = name.isNotBlank()
- ) {
- Text("افزودن")
- }
- Button(
- onClick = onDismiss,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error
- )
- ) {
- Text("لغو")
- }
- }
- },
- dismissButton = {}
- )
-}
-
-@Composable
-fun AddPaymentDialog(
- personName: String,
- defaultAmount: Double,
- initialDescription: String = "",
- onConfirm: (amount: Double, description: String) -> Unit,
- onDismiss: () -> Unit,
- onDelete: (() -> Unit)? = null
-) {
- var rawAmount by remember { mutableStateOf(defaultAmount.toLong().toString()) }
- var description by remember { mutableStateOf(initialDescription) }
-
- val title =
- if (onDelete != null) "ویرایش پرداخت برای $personName" else "ثبت پرداخت برای $personName"
-
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text(title) },
- text = {
- Column {
- OutlinedTextField(
- value = formatNumberAsPersian(rawAmount.toDoubleOrNull() ?: 0.0)
- .removeSuffix(".00"),
- onValueChange = { value ->
- rawAmount = value.filter { it.isDigit() }
- },
- label = { Text("مبلغ (به تومان)") },
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
- singleLine = true,
- modifier = Modifier.fillMaxWidth(),
- )
- Spacer(modifier = Modifier.height(16.dp))
- OutlinedTextField(
- value = description,
- onValueChange = { description = it },
- label = { Text("توضیحات (اختیاری)") },
- modifier = Modifier.fillMaxWidth()
- )
- }
- },
- confirmButton = {
- if (onDelete != null) {
- // حالت ویرایش: ویرایش راست، لغو وسط، حذف چپ (در RTL)
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Button(
- onClick = {
- val amountAsDouble = rawAmount.toDoubleOrNull() ?: defaultAmount
- onConfirm(amountAsDouble, description)
- },
- enabled = rawAmount.isNotBlank()
- ) {
- Text("ویرایش")
- }
- Button(
- onClick = onDismiss,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error
- )
- ) {
- Text("لغو")
- }
- Button(
- onClick = onDelete,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error
- )
- ) {
- Text("حذف")
- }
- }
+ val permissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ if (isGranted) {
+ val matches = onSearchContact(name)
+ if (matches.isNotEmpty()) {
+ similarContacts = matches
} else {
- // حالت افزودن: ثبت و لغو
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
+ Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ Toast.makeText(context, "دسترسی به مخاطبین داده نشد", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ 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)
) {
- Button(
- onClick = {
- val amountAsDouble = rawAmount.toDoubleOrNull() ?: defaultAmount
- onConfirm(amountAsDouble, description)
- },
- enabled = rawAmount.isNotBlank()
- ) {
- Text("ثبت")
- }
- Button(
- onClick = onDismiss,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error
+ Text(
+ text = "افزودن شخص جدید",
+ 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()
)
+ 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)
+ }
+ }
+ }
+ ) {
+ 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
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
) {
- Text("لغو")
+ Button(
+ onClick = {
+ if (name.isNotBlank()) {
+ onConfirm(name, phoneNumber)
+ }
+ },
+ enabled = name.isNotBlank()
+ ) {
+ Text("افزودن")
+ }
+ Button(
+ onClick = onDismiss,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text("لغو")
+ }
}
}
}
- },
- dismissButton = {}
- )
+ }
+ }
}
@Composable
-fun DashboardCard(data: DashboardUiModel) {
+fun BulkSmsSelectionDialog(
+ debtors: List,
+ onStart: (List) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val selectedIds = remember { mutableStateListOf().apply { addAll(debtors.map { it.id }) } }
+
+ 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 = "انتخاب افراد برای یادآور گروهی",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ LazyColumn(modifier = Modifier.height(300.dp)) {
+ itemsIndexed(debtors) { _, person ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ if (selectedIds.contains(person.id)) selectedIds.remove(person.id)
+ else selectedIds.add(person.id)
+ }
+ .padding(vertical = 4.dp)
+ ) {
+ Checkbox(
+ checked = selectedIds.contains(person.id),
+ onCheckedChange = null
+ )
+ Text(person.name, modifier = Modifier.padding(start = 8.dp))
+ Spacer(Modifier.weight(1f))
+ Text(
+ "${person.debtCount.toString().toPersianDigits()} بدهی",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(onClick = {
+ val selectedList = debtors.filter { selectedIds.contains(it.id) }
+ onStart(selectedList)
+ }) {
+ Text("شروع ارسال (${selectedIds.size.toString().toPersianDigits()})")
+ }
+ TextButton(onClick = onDismiss) { Text("لغو") }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun BulkSmsProgressDialog(
+ currentName: String,
+ currentIndex: Int,
+ total: Int,
+ onSend: () -> Unit,
+ onSkip: () -> Unit,
+ onCancel: () -> Unit
+) {
+ Dialog(onDismissRequest = {}) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ shape = RoundedCornerShape(24.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "ارسال گروهی پیامک",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text("در حال آمادهسازی پیام برای:")
+ Text(
+ currentName,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ "نفر ${(currentIndex + 1).toString().toPersianDigits()} از ${total.toString().toPersianDigits()}",
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Spacer(Modifier.height(16.dp))
+ LinearProgressIndicator(
+ progress = { (currentIndex.toFloat() / total.toFloat()) },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(onClick = onSend) { Text("ارسال پیام") }
+ Row {
+ TextButton(onClick = onSkip) { Text("رد کردن") }
+ TextButton(onClick = onCancel, colors = ButtonDefaults.textButtonColors(contentColor = Color.Red)) {
+ Text("توقف")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun SelectCardDialog(
+ cardNumbers: List,
+ onCardSelected: (String?) -> 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 = "انتخاب شماره کارت",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ Column {
+ Text("کدام شماره کارت در متن پیامک درج شود؟")
+ Spacer(Modifier.height(16.dp))
+
+ if (cardNumbers.isEmpty()) {
+ Text("هیچ شماره کارتی در تنظیمات ثبت نشده است.", color = MaterialTheme.colorScheme.error)
+ }
+
+ cardNumbers.forEach { card ->
+ OutlinedButton(
+ onClick = { onCardSelected(card) },
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
+ ) {
+ Text(card.toPersianDigits())
+ }
+ }
+
+ TextButton(
+ onClick = { onCardSelected(null) },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("ارسال بدون شماره کارت")
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ TextButton(onClick = onDismiss) {
+ Text("لغو")
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun DashboardCard(
+ data: DashboardUiModel
+) {
val animatedProgress by animateFloatAsState(
targetValue = data.progress,
animationSpec = tween(durationMillis = 1000),
@@ -410,12 +806,9 @@ fun DashboardCard(data: DashboardUiModel) {
)
Card(
- modifier = Modifier
- .fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(2.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant
- )
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier.padding(12.dp),
@@ -426,116 +819,164 @@ fun DashboardCard(data: DashboardUiModel) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
- Text(
- "پرداختی های این ماه",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold
- )
- Text(
- "افراد باقیمانده: ${(data.totalCount - data.paidCount).toString().toPersianDigits()} نفر",
- style = MaterialTheme.typography.bodyMedium
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("وضعیت پرداختها", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ }
+ Text("باقیمانده: ${(data.totalCount - data.paidCount).toString().toPersianDigits()}", style = MaterialTheme.typography.bodySmall)
}
-
LinearProgressIndicator(
progress = { animatedProgress },
- modifier = Modifier
- .fillMaxWidth()
- .height(8.dp)
- .clip(MaterialTheme.shapes.medium)
+ modifier = Modifier.fillMaxWidth().height(8.dp).clip(MaterialTheme.shapes.medium)
)
-
- Spacer(modifier = Modifier.height(4.dp))
-
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
- Text(
- "مجموع درآمد ماه",
- style = MaterialTheme.typography.titleSmall,
- fontWeight = FontWeight.Bold
- )
- Text(
- "${formatNumberAsPersian(data.totalIncome)} تومان",
- style = MaterialTheme.typography.bodyMedium
+ Text("مجموع درآمد ماه", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
+ Text("${formatNumberAsPersian(data.totalIncome)} تومان", style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ }
+}
+
+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
+@Composable
+fun PersonListItem(
+ person: PersonUiModel,
+ index: Int,
+ modifier: Modifier = Modifier,
+ isSelectionMode: Boolean = false,
+ isSelected: Boolean = false,
+ reorderableState: org.burnoutcrew.reorderable.ReorderableLazyListState? = null,
+ onPersonClick: () -> Unit,
+ onLongClick: () -> Unit = {},
+ onQuickPayClick: (PersonUiModel) -> Unit,
+ onArchiveClick: (PersonUiModel) -> Unit,
+ onSmsClick: (PersonUiModel) -> Unit
+) {
+ Card(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 6.dp)
+ .combinedClickable(
+ onClick = onPersonClick,
+ onLongClick = onLongClick
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer
+ else MaterialTheme.colorScheme.surface
+ )
+ ) {
+ Row(
+ modifier = Modifier.padding(start = 12.dp, end = 6.dp, top = 8.dp, bottom = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ if (isSelectionMode) {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = { onPersonClick() },
+ modifier = Modifier.padding(end = 8.dp)
)
}
+
+ val dragModifier = if (reorderableState != null && !isSelectionMode) {
+ Modifier.detectReorderAfterLongPress(reorderableState)
+ } else Modifier
+
+ Text(
+ text = "${index.toString().toPersianDigits()} - ",
+ style = MaterialTheme.typography.bodyLarge,
+ modifier = dragModifier.padding(end = 4.dp)
+ )
+ Text(
+ person.name,
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Bold,
+ maxLines = 2,
+ overflow = TextOverflow.Visible
+ )
+
+ if (person.debtCount > 0) {
+ Surface(
+ color = MaterialTheme.colorScheme.errorContainer,
+ shape = MaterialTheme.shapes.small
+ ) {
+ Text(
+ text = "${person.debtCount.toString().toPersianDigits()} بدهی",
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ style = MaterialTheme.typography.labelSmall,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
+ )
+ }
+ }
+
+ Spacer(Modifier.width(4.dp))
+
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ if (person.hasPaidThisMonth) {
+ Icon(Icons.Filled.Check, contentDescription = "Paid", tint = MaterialTheme.colorScheme.primary)
+ } else {
+ IconButton(onClick = { onQuickPayClick(person) }, modifier = Modifier.size(32.dp)) {
+ Icon(Icons.Filled.Payments, contentDescription = "Pay", tint = MaterialTheme.colorScheme.primary)
+ }
+ }
+ IconButton(onClick = { onArchiveClick(person) }, modifier = Modifier.size(32.dp)) {
+ Icon(Icons.Filled.Archive, contentDescription = "Archive", tint = MaterialTheme.colorScheme.secondary)
+ }
+ if (person.debtCount > 0) {
+ IconButton(onClick = { onSmsClick(person) }, modifier = Modifier.size(32.dp)) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Message,
+ contentDescription = "SMS",
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier
+ .size(20.dp)
+ .graphicsLayer(scaleX = -1f)
+ )
+ }
+ }
+ }
}
}
}
@Composable
-fun PersonListItem(
- person: PersonUiModel,
- index: Int,
- onPersonClick: () -> Unit,
- onQuickPayClick: (PersonUiModel) -> Unit,
- onArchiveClick: (PersonUiModel) -> Unit
+fun AddPaymentDialog(
+ personName: String,
+ defaultAmount: Double,
+ onConfirm: (Double, String) -> Unit,
+ onDismiss: () -> Unit
) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .clickable { onPersonClick() }
- .padding(horizontal = 16.dp, vertical = 8.dp)
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = "${index.toString().toPersianDigits()} - ",
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Normal
- )
- Text(
- person.name,
- modifier = Modifier.weight(1f),
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Bold
- )
+ var amount by remember { mutableStateOf(defaultAmount.toString()) }
+ var description by remember { mutableStateOf("") }
- Spacer(Modifier.width(8.dp))
-
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp)
- ) {
- if (person.hasPaidThisMonth) {
- Icon(
- Icons.Filled.Check,
- contentDescription = "پرداخت شده",
- tint = MaterialTheme.colorScheme.primary
+ 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)
)
-
- Button(
- onClick = { onArchiveClick(person) },
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.secondary
- )
- ) {
- Text("آرشیو", color = Color.White)
- }
- } else {
- Button(
- onClick = { onQuickPayClick(person) },
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primary
- )
- ) {
- Text("پرداخت", color = Color.White)
- }
-
- Button(
- onClick = { onArchiveClick(person) },
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.secondary
- )
- ) {
- Text("آرشیو", color = Color.White)
+ 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("ثبت") }
}
}
}
}
}
-}
\ No newline at end of file
+}
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 96599ac..f181ec3 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
@@ -1,5 +1,3 @@
-@file:Suppress("AssignedValueIsNeverRead")
-
package com.oqba26.monthlypaymentapp.ui.screens
import android.widget.Toast
@@ -11,16 +9,18 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
-import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -33,10 +33,10 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -47,15 +47,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
-import androidx.lifecycle.viewmodel.compose.viewModel
-import com.oqba26.monthlypaymentapp.core.PaymentApplication
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
+import com.oqba26.monthlypaymentapp.utils.toPersianDigits
import com.oqba26.monthlypaymentapp.viewmodel.SettingsViewModel
import kotlinx.coroutines.launch
+import androidx.compose.ui.res.stringResource
+import com.oqba26.monthlypaymentapp.R
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
@@ -66,8 +73,7 @@ import java.util.Locale
@Composable
fun SettingsScreen(onLogout: () -> Unit) {
val context = LocalContext.current
- val app = context.applicationContext as PaymentApplication
- val viewModel: SettingsViewModel = viewModel(factory = app.settingsViewModelFactory)
+ val viewModel: SettingsViewModel = hiltViewModel()
val coroutineScope = rememberCoroutineScope()
val defaultAmount by viewModel.defaultPaymentAmount.collectAsState()
@@ -93,17 +99,9 @@ fun SettingsScreen(onLogout: () -> Unit) {
writer.write(jsonString)
}
}
- Toast.makeText(
- context,
- "پشتیبانگیری با موفقیت انجام شد",
- Toast.LENGTH_LONG
- ).show()
+ Toast.makeText(context, "پشتیبانگیری با موفقیت انجام شد", Toast.LENGTH_LONG).show()
} catch (_: Exception) {
- Toast.makeText(
- context,
- "خطا در ایجاد فایل پشتیبان",
- Toast.LENGTH_LONG
- ).show()
+ Toast.makeText(context, "خطا در ایجاد فایل پشتیبان", Toast.LENGTH_LONG).show()
}
}
}
@@ -127,186 +125,169 @@ fun SettingsScreen(onLogout: () -> Unit) {
}
)
- Scaffold { innerPadding ->
- Column(
- modifier = Modifier
- .padding(innerPadding)
- .padding(horizontal = 16.dp, vertical = 12.dp)
- ) {
- Text(
- "تنظیمات عمومی",
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(bottom = 6.dp)
- )
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .fillMaxWidth()
+ .fillMaxHeight()
+ ) {
+ Text(
+ stringResource(R.string.settings),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
- DefaultAmountSetting(
- defaultAmount = defaultAmount,
- onSave = { viewModel.saveDefaultPaymentAmount(it) }
- )
+ DefaultAmountSetting(
+ defaultAmount = defaultAmount,
+ onSave = { viewModel.saveDefaultPaymentAmount(it) }
+ )
- Spacer(modifier = Modifier.height(18.dp))
+ Spacer(Modifier.weight(1f))
- FontSelectionDropdown(
- selectedFont = selectedFont,
- onFontSelected = { viewModel.onFontSelected(it) }
- )
+ val reminderDay by viewModel.reminderDay.collectAsState()
+ ReminderDaySetting(
+ currentDay = reminderDay,
+ onSave = { viewModel.saveReminderDay(it) }
+ )
- HorizontalDivider(modifier = Modifier.padding(vertical = 20.dp))
+ Spacer(Modifier.weight(1f))
- Text(
- "پشتیبانگیری و بازیابی",
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(bottom = 6.dp)
- )
+ val cardNumbers by viewModel.cardNumbers.collectAsState()
+ CardNumbersSetting(
+ cardNumbers = cardNumbers,
+ onAdd = { viewModel.addCardNumber(it) },
+ onRemove = { viewModel.removeCardNumber(it) }
+ )
+ Spacer(Modifier.weight(1f))
+
+ FontSelectionDropdown(
+ selectedFont = selectedFont,
+ onFontSelected = { viewModel.onFontSelected(it) }
+ )
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
+
+ Text(
+ "پشتیبانگیری و بازیابی",
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
- val simpleDateFormat =
- SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault())
+ val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault())
val fileName = "payment_backup_${simpleDateFormat.format(Date())}.json"
backupLauncher.launch(fileName)
},
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.weight(1f)
) {
- Text("تهیه نسخه پشتیبان")
+ Text("تهیه پشتیبان", fontSize = 13.sp)
}
- Spacer(modifier = Modifier.height(8.dp))
-
OutlinedButton(
onClick = { restoreLauncher.launch(arrayOf("application/json")) },
- modifier = Modifier.fillMaxWidth(),
- colors = ButtonDefaults.outlinedButtonColors(
- contentColor = MaterialTheme.colorScheme.error
- )
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
- Text("بازیابی اطلاعات از فایل")
+ Text("بازیابی فایل", fontSize = 13.sp)
}
+ }
- HorizontalDivider(modifier = Modifier.padding(vertical = 20.dp))
+ Spacer(Modifier.weight(1f))
- Button(
- onClick = onLogout,
- modifier = Modifier.fillMaxWidth(),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error
- )
- ) {
- Icon(
- Icons.AutoMirrored.Filled.Logout,
- contentDescription = "خروج"
- )
- Spacer(Modifier.width(8.dp))
- Text("خروج از حساب کاربری")
- }
+ Button(
+ onClick = onLogout,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp)
+ ) {
+ Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(R.string.logout))
}
}
backupJsonToRestore?.let { json ->
- AlertDialog(
- onDismissRequest = { backupJsonToRestore = null },
- title = { Text("هشدار جدی!") },
- text = {
- Text(
- "آیا مطمئن هستید؟ با بازیابی اطلاعات، تمام دادههای فعلی برنامه (اشخاص و پرداختها) برای همیشه حذف شده و اطلاعات فایل پشتیبان جایگزین آن خواهد شد. این عمل غیرقابل بازگشت است."
- )
- },
- confirmButton = {
- Button(
- onClick = {
- viewModel.restoreFromBackupJson(json)
- backupJsonToRestore = null
- },
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error
- )
+ Dialog(onDismissRequest = { backupJsonToRestore = null }) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ shape = RoundedCornerShape(24.dp)
) {
- Text("بله، بازیابی کن")
- }
- },
- dismissButton = {
- TextButton(onClick = { backupJsonToRestore = null }) {
- Text("لغو")
+ 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 = stringResource(R.string.restore_warning_text),
+ style = MaterialTheme.typography.bodyMedium
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = {
+ viewModel.restoreFromBackupJson(json)
+ backupJsonToRestore = null
+ },
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
+ ) { Text(stringResource(R.string.yes)) }
+
+ Button(onClick = { backupJsonToRestore = null }) { Text(stringResource(R.string.cancel)) }
+ }
+ }
}
}
- )
+ }
}
}
@Composable
-fun DefaultAmountSetting(
- defaultAmount: Double,
- onSave: (String) -> Unit
-) {
+fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
var isEditing by remember { mutableStateOf(false) }
- var amountText by remember(defaultAmount, isEditing) {
- mutableStateOf(defaultAmount.toLong().toString())
- }
+ var amountText by remember(defaultAmount, isEditing) { mutableStateOf(defaultAmount.toLong().toString()) }
Card(
modifier = Modifier.fillMaxWidth(),
- elevation = CardDefaults.cardElevation(0.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
- )
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
) {
- AnimatedContent(
- targetState = isEditing,
- label = "EditAmountAnimation",
- modifier = Modifier.padding(12.dp)
- ) { editing ->
+ AnimatedContent(targetState = isEditing, label = "EditAmount") { editing ->
if (editing) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Column(modifier = Modifier.padding(12.dp)) {
OutlinedTextField(
value = amountText,
- onValueChange = {
- amountText = it.filter { c -> c.isDigit() }
- },
+ onValueChange = { amountText = it.filter { c -> c.isDigit() } },
modifier = Modifier.fillMaxWidth(),
- label = { Text("مبلغ پیش فرض پرداخت (تومان)") },
+ label = { Text("مبلغ پیشفرض (تومان)") },
visualTransformation = PersianNumberVisualTransformation(),
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Number
- )
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
- Spacer(Modifier.height(12.dp))
- Row(
- horizontalArrangement = Arrangement.End,
- modifier = Modifier.fillMaxWidth()
- ) {
- TextButton(onClick = { isEditing = false }) {
- Text("لغو")
- }
- Spacer(modifier = Modifier.width(8.dp))
- Button(onClick = {
- onSave(amountText)
- isEditing = false
- }) {
- Text("ذخیره")
- }
+ Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ TextButton(onClick = { isEditing = false }) { Text("لغو") }
+ Button(onClick = { onSave(amountText); isEditing = false }) { Text("ذخیره") }
}
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween,
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
- Column {
- Text(
- "مبلغ پیش فرض پرداخت",
- style = MaterialTheme.typography.bodyLarge
- )
- Text(
- "${formatNumberAsPersian(defaultAmount)} تومان",
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.primary
- )
- }
- IconButton(onClick = { isEditing = true }) {
- Icon(Icons.Filled.Edit, contentDescription = "Edit Amount")
+ Column(modifier = Modifier.weight(1f)) {
+ Text("مبلغ پیش فرض پرداخت", style = MaterialTheme.typography.bodyLarge)
+ Text("${formatNumberAsPersian(defaultAmount)} تومان", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleMedium)
}
+ IconButton(onClick = { isEditing = true }) { Icon(Icons.Filled.Edit, null) }
}
}
}
@@ -314,35 +295,98 @@ fun DefaultAmountSetting(
}
@Composable
-fun FontSelectionDropdown(
- selectedFont: String,
- onFontSelected: (String) -> Unit
-) {
+fun ReminderDaySetting(currentDay: Int?, onSave: (String) -> Unit) {
+ var isEditing by remember { mutableStateOf(false) }
+ var dayText by remember(currentDay, isEditing) { mutableStateOf(currentDay?.toString() ?: "") }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
+ ) {
+ AnimatedContent(targetState = isEditing, label = "EditReminder") { editing ->
+ if (editing) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ OutlinedTextField(
+ value = dayText,
+ onValueChange = { dayText = it.filter { c -> c.isDigit() }.take(2) },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("روز یادآور (۱ تا ۳۱)") },
+ visualTransformation = PersianDigitsTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
+ )
+ Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ TextButton(onClick = { isEditing = false }) { Text("لغو") }
+ Button(onClick = { onSave(dayText); isEditing = false }) { Text("ذخیره") }
+ }
+ }
+ } else {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("روز یادآور ماهانه", style = MaterialTheme.typography.bodyLarge)
+ Text(if (currentDay != null) "روز ${currentDay.toString().toPersianDigits()} هر ماه" else "غیرفعال", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleMedium)
+ }
+ IconButton(onClick = { isEditing = true }) { Icon(Icons.Filled.Edit, null) }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun CardNumbersSetting(cardNumbers: List, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
+ var isAdding by remember { mutableStateOf(false) }
+ var newCardText by remember { mutableStateOf("") }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
+ ) {
+ Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("شماره کارتهای بانکی", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
+ IconButton(onClick = { isAdding = true }) { Icon(Icons.Default.Add, null) }
+ }
+ if (isAdding) {
+ OutlinedTextField(
+ value = newCardText,
+ onValueChange = { newCardText = it.filter { c -> c.isDigit() } },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("شماره ۱۶ رقمی") },
+ visualTransformation = PersianDigitsTransformation(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
+ )
+ Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
+ TextButton(onClick = { isAdding = false; newCardText = "" }) { Text("لغو") }
+ Button(onClick = { if (newCardText.length >= 16) { onAdd(newCardText); isAdding = false; newCardText = "" } }) { Text("افزودن") }
+ }
+ }
+ 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) }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun FontSelectionDropdown(selectedFont: String, onFontSelected: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
val fonts = listOf("Estedad", "Vazirmatn", "BYekan", "Sahel", "IranianSans")
Box(modifier = Modifier.fillMaxWidth()) {
- OutlinedButton(
- onClick = { expanded = true },
- modifier = Modifier.fillMaxWidth()
- ) {
- Text("فونت برنامه: $selectedFont")
- Icon(Icons.Filled.ArrowDropDown, contentDescription = "Select Font")
+ OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
+ Text("فونت برنامه: $selectedFont", fontSize = 14.sp)
+ Icon(Icons.Filled.ArrowDropDown, null)
}
- DropdownMenu(
- expanded = expanded,
- onDismissRequest = { expanded = false },
- modifier = Modifier.fillMaxWidth()
- ) {
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
fonts.forEach { fontName ->
- DropdownMenuItem(
- text = { Text(fontName) },
- onClick = {
- onFontSelected(fontName)
- expanded = false
- }
- )
+ DropdownMenuItem(text = { Text(fontName) }, onClick = { onFontSelected(fontName); expanded = false })
}
}
}
-}
\ No newline at end of file
+}
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 310a80c..2ad275c 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
@@ -2,27 +2,38 @@
package com.oqba26.monthlypaymentapp.ui.screens
-import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material3.AlertDialog
+import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
+import com.oqba26.monthlypaymentapp.R
+import androidx.compose.ui.window.Dialog
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
@@ -31,78 +42,204 @@ fun EditPaymentDialog(
paymentRecord: PaymentRecord,
onConfirm: (amount: Double, description: String) -> Unit,
onDelete: () -> Unit,
+ onShare: (PaymentRecord) -> Unit,
onDismiss: () -> Unit
) {
var amountText by remember { mutableStateOf(paymentRecord.amount.toLong().toString()) }
- var description by remember { mutableStateOf(paymentRecord.description) }
+ var description by remember { mutableStateOf(paymentRecord.description ?: "") }
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text("ویرایش پرداخت") },
- text = {
- Column {
- OutlinedTextField(
- value = amountText,
- onValueChange = { value ->
- amountText = value.filter { c -> c.isDigit() }
- },
- label = { Text("مبلغ") },
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
- visualTransformation = PersianNumberVisualTransformation(),
- modifier = Modifier.fillMaxWidth()
- )
- Spacer(modifier = Modifier.height(8.dp))
- OutlinedTextField(
- value = description,
- onValueChange = { description = it },
- label = { Text("توضیحات (اختیاری)") },
- modifier = Modifier.fillMaxWidth()
- )
- }
- },
- // تمام دکمهها را خودمان داخل confirmButton میچینیم
- confirmButton = {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 8.dp)
+ Dialog(onDismissRequest = onDismiss) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ shape = RoundedCornerShape(24.dp)
) {
- // در RTL: Start = راست، End = چپ
-
- // راست: ذخیره
- Button(
- onClick = {
- val amount = amountText.toDoubleOrNull() ?: paymentRecord.amount
- onConfirm(amount, description)
- },
- modifier = Modifier.align(androidx.compose.ui.Alignment.CenterStart)
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
) {
- Text("ذخیره")
- }
-
- // وسط: لغو
- Button(
- onClick = onDismiss,
- modifier = Modifier.align(androidx.compose.ui.Alignment.Center),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.secondary
+ Text(
+ text = "ویرایش پرداخت",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
)
- ) {
- Text("لغو")
- }
- // چپ: حذف
- Button(
- onClick = onDelete,
- modifier = Modifier.align(androidx.compose.ui.Alignment.CenterEnd),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error
+ OutlinedTextField(
+ value = amountText,
+ 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("مبلغ") },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ visualTransformation = PersianNumberVisualTransformation(),
+ modifier = Modifier.fillMaxWidth()
)
- ) {
- Text("حذف")
+
+ OutlinedTextField(
+ value = description,
+ onValueChange = { description = it },
+ label = { Text("توضیحات (اختیاری)") },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Button(
+ onClick = { onShare(paymentRecord) },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.tertiary
+ ),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("اشتراکگذاری رسید")
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = {
+ val amount = amountText.toDoubleOrNull() ?: paymentRecord.amount
+ onConfirm(amount, description)
+ },
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("ذخیره")
+ }
+
+ Button(
+ onClick = onDismiss,
+ modifier = Modifier.weight(1f),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondary
+ )
+ ) {
+ Text("لغو")
+ }
+
+ IconButton(
+ onClick = onDelete,
+ ) {
+ Icon(
+ imageVector = androidx.compose.material.icons.Icons.Default.Delete,
+ contentDescription = "حذف",
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
+ }
}
}
- },
- dismissButton = {}
- )
-}
\ No newline at end of file
+ }
+ }
+}
+
+@Composable
+fun AddPaymentDialog(
+ personName: String,
+ defaultAmount: Double,
+ initialDescription: String = "",
+ onConfirm: (amount: Double, description: String) -> Unit,
+ onDismiss: () -> Unit,
+ onDelete: (() -> Unit)? = null
+) {
+ var rawAmount by remember { mutableStateOf(defaultAmount.toLong().toString()) }
+ var description by remember { mutableStateOf(initialDescription) }
+
+ val title =
+ if (onDelete != null) "ویرایش پرداخت برای $personName" else "ثبت پرداخت برای $personName"
+
+ 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 = title,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+
+ OutlinedTextField(
+ value = rawAmount,
+ onValueChange = { value ->
+ // اجازه ورود فقط به اعداد (چه انگلیسی چه فارسی)
+ val digitsOnly = value.filter { it.isDigit() || it in '\u0660'..'\u0669' || it in '\u06f0'..'\u06f9' }
+ // تبدیل اعداد فارسی به انگلیسی برای ذخیره در استیت
+ rawAmount = 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("مبلغ (به تومان)") },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ visualTransformation = PersianNumberVisualTransformation(),
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ OutlinedTextField(
+ value = description,
+ onValueChange = { description = it },
+ label = { Text("توضیحات (اختیاری)") },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = {
+ val amountAsDouble = rawAmount.toDoubleOrNull() ?: defaultAmount
+ onConfirm(amountAsDouble, description)
+ },
+ enabled = rawAmount.isNotBlank()
+ ) {
+ val label = if (onDelete != null) "ویرایش" else "ثبت"
+ Text(label)
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (onDelete != null) {
+ Button(
+ onClick = onDelete,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error
+ )
+ ) {
+ Text("حذف")
+ }
+ }
+ Button(
+ onClick = onDismiss,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondary
+ )
+ ) {
+ Text("لغو")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Color.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Color.kt
index 7d75135..1c45809 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Color.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Color.kt
@@ -1,3 +1,5 @@
+@file:Suppress("unused")
+
package com.oqba26.monthlypaymentapp.ui.theme
import androidx.compose.ui.graphics.Color
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/PersianNumberVisualTransformation.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/PersianNumberVisualTransformation.kt
index b823d96..45811a0 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/PersianNumberVisualTransformation.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/PersianNumberVisualTransformation.kt
@@ -14,7 +14,7 @@ class PersianNumberVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val originalText = text.text
if (originalText.isBlank()) {
- return TransformedText(text, OffsetMapping.Identity)
+ return TransformedText(AnnotatedString(""), OffsetMapping.Identity)
}
val number = originalText.toLongOrNull() ?: 0L
@@ -22,21 +22,28 @@ class PersianNumberVisualTransformation : VisualTransformation {
val offsetMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
- try {
- val thousandsSeparators = formattedText.count { it == '٬' }
- return (offset + thousandsSeparators).coerceIn(0, formattedText.length)
- } catch (e: Exception) {
- return formattedText.length
+ if (offset == 0) return 0
+ var transformedOffset = 0
+ var originalOffset = 0
+ for (char in formattedText) {
+ if (char != '٬') {
+ originalOffset++
+ }
+ transformedOffset++
+ if (originalOffset == offset) break
}
+ return transformedOffset
}
override fun transformedToOriginal(offset: Int): Int {
- try {
- val separatorsBeforeCursor = formattedText.substring(0, offset).count { it == '٬' }
- return (offset - separatorsBeforeCursor).coerceIn(0, originalText.length)
- } catch (e: Exception) {
- return originalText.length
+ if (offset == 0) return 0
+ var originalOffset = 0
+ for (i in 0 until offset.coerceAtMost(formattedText.length)) {
+ if (formattedText[i] != '٬') {
+ originalOffset++
+ }
}
+ return originalOffset
}
}
@@ -46,3 +53,13 @@ class PersianNumberVisualTransformation : VisualTransformation {
)
}
}
+
+class PersianDigitsTransformation : VisualTransformation {
+ override fun filter(text: AnnotatedString): TransformedText {
+ val transformed = text.text.toPersianDigits()
+ return TransformedText(
+ text = AnnotatedString(transformed),
+ offsetMapping = OffsetMapping.Identity
+ )
+ }
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/ShamsiUtils.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/ShamsiUtils.kt
index f88716e..676d946 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/ShamsiUtils.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/ShamsiUtils.kt
@@ -9,6 +9,7 @@ import java.util.Locale
fun getCurrentShamsiYear(): Int = PersianDate().shYear
fun getCurrentShamsiMonth(): Int = PersianDate().shMonth
+fun getCurrentShamsiDay(): Int = PersianDate().shDay
fun formatTimestampToPersianDate(timestamp: Long): String {
val persianDate = PersianDate(Date(timestamp))
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/StringUtils.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/StringUtils.kt
index 48e798c..01a25a9 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/StringUtils.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/StringUtils.kt
@@ -1,6 +1,7 @@
package com.oqba26.monthlypaymentapp.utils
// An extension function to convert any Int to a Persian-digit String.
+@Suppress("unused")
fun Int.toPersianDigits(): String {
return this.toString().toPersianDigits()
}
@@ -23,3 +24,56 @@ fun String.toPersianDigits(): String {
}
}.joinToString("")
}
+
+fun String.normalizePhoneNumber(): String {
+ // فقط اعداد را نگه دار
+ val digitsOnly = this.filter { it.isDigit() }
+
+ // اگر با 98 شروع میشد، آن را به 0 تبدیل کن (فرمت رایج موبایل در ایران)
+ return when {
+ digitsOnly.startsWith("98") -> "0${digitsOnly.substring(2)}"
+ !digitsOnly.startsWith("0") && digitsOnly.length == 10 -> "0$digitsOnly"
+ else -> digitsOnly
+ }
+}
+
+/**
+ * الگوریتم لوناشتاین برای محاسبه شباهت دو رشته
+ * مقدار برگشتی 0 یعنی کاملاً مشابه و هرچه بیشتر باشد شباهت کمتر است
+ */
+fun calculateLevenshteinDistance(s1: String, s2: String): Int {
+ val dp = Array(s1.length + 1) { IntArray(s2.length + 1) }
+
+ for (i in 0..s1.length) dp[i][0] = i
+ for (j in 0..s2.length) dp[0][j] = j
+
+ for (i in 1..s1.length) {
+ for (j in 1..s2.length) {
+ val cost = if (s1[i - 1] == s2[j - 1]) 0 else 1
+ dp[i][j] = minOf(
+ dp[i - 1][j] + 1,
+ dp[i][j - 1] + 1,
+ dp[i - 1][j - 1] + cost
+ )
+ }
+ }
+ return dp[s1.length][s2.length]
+}
+
+/**
+ * بررسی شباهت دو نام به صورت هوشمند
+ */
+fun areNamesSimilar(name1: String, name2: String, threshold: Double = 0.4): Boolean {
+ val n1 = name1.lowercase().trim()
+ val n2 = name2.lowercase().trim()
+
+ // اگر یکی شامل دیگری باشد
+ if (n1.contains(n2) || n2.contains(n1)) return true
+
+ val distance = calculateLevenshteinDistance(n1, n2)
+ val maxLength = maxOf(n1.length, n2.length)
+ if (maxLength == 0) return true
+
+ val normalizedDistance = distance.toDouble() / maxLength
+ return normalizedDistance <= threshold
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/UpdateManager.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/UpdateManager.kt
new file mode 100644
index 0000000..066d2e6
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/UpdateManager.kt
@@ -0,0 +1,197 @@
+@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
+package com.oqba26.monthlypaymentapp.utils
+
+import android.app.DownloadManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.os.Build
+import android.os.Environment
+import android.widget.Toast
+import androidx.core.content.ContextCompat
+import androidx.core.content.FileProvider
+import androidx.core.net.toUri
+import io.ktor.client.*
+import io.ktor.client.statement.bodyAsText
+import io.ktor.client.plugins.contentnegotiation.*
+import io.ktor.client.request.*
+import io.ktor.serialization.kotlinx.json.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import java.io.File
+import kotlin.time.Duration.Companion.milliseconds
+
+@Serializable
+data class UpdateInfo(
+ val versionCode: Int,
+ val versionName: String,
+ val url: String,
+ val releaseNotes: String,
+ val isForceUpdate: Boolean = false,
+)
+
+class UpdateManager(private val context: Context) {
+
+ private val updateUrl = "https://raw.githubusercontent.com/oqba26/MonthlyPaymentApp/main/update.json"
+
+ private val json = Json {
+ ignoreUnknownKeys = true
+ coerceInputValues = true
+ }
+
+ private val client = HttpClient {
+ install(ContentNegotiation) {
+ json(json)
+ }
+ }
+
+ suspend fun checkForUpdate(): UpdateInfo? = withContext(Dispatchers.IO) {
+ if (!isNetworkAvailable()) return@withContext null
+
+ try {
+ val timestamp = System.currentTimeMillis()
+ val urlWithParams = if (updateUrl.contains("?")) "$updateUrl&t=$timestamp" else "$updateUrl?t=$timestamp"
+
+ val responseText: String = client.get(urlWithParams).bodyAsText()
+ val updateInfo: UpdateInfo = json.decodeFromString(responseText)
+
+ val pInfo = context.packageManager.getPackageInfo(context.packageName, 0)
+ val currentVersionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ pInfo.longVersionCode.toInt()
+ } else {
+ @Suppress("DEPRECATION")
+ pInfo.versionCode
+ }
+
+ if (updateInfo.versionCode > currentVersionCode) {
+ return@withContext updateInfo
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ null
+ }
+
+ fun downloadAndInstall(url: String, fileName: String): Long {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ if (!context.packageManager.canRequestPackageInstalls()) {
+ Toast.makeText(context, "لطفاً اجازه نصب برنامههای ناشناخته را بدهید", Toast.LENGTH_LONG).show()
+ val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
+ data = "package:${context.packageName}".toUri()
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ }
+ context.startActivity(intent)
+ return -1L
+ }
+ }
+
+ val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+ val uri = url.toUri()
+
+ val oldFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
+ if (oldFile.exists()) oldFile.delete()
+
+ val request = DownloadManager.Request(uri)
+ .setTitle("دریافت بهروزرسانی")
+ .setDescription("نسخه جدید در حال دانلود است...")
+ .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, fileName)
+ .setAllowedOverMetered(true)
+ .setAllowedOverRoaming(true)
+
+ val downloadId = downloadManager.enqueue(request)
+
+ val onComplete = object : BroadcastReceiver() {
+ override fun onReceive(receiverContext: Context, intent: Intent) {
+ val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
+ if (id == downloadId) {
+ installApk(fileName)
+ try {
+ receiverContext.unregisterReceiver(this)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ }
+ }
+
+ val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
+ ContextCompat.registerReceiver(context, onComplete, filter, ContextCompat.RECEIVER_EXPORTED)
+
+ return downloadId
+ }
+
+ fun getDownloadProgress(downloadId: Long): Flow = flow {
+ val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
+ var isDownloading = true
+ while (isDownloading) {
+ val query = DownloadManager.Query().setFilterById(downloadId)
+ val cursor = downloadManager.query(query)
+ if (cursor != null && cursor.moveToFirst()) {
+ val bytesDownloadedIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
+ val bytesTotalIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
+ val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
+
+ if (bytesDownloadedIndex != -1 && bytesTotalIndex != -1 && statusIndex != -1) {
+ val bytesDownloaded = cursor.getInt(bytesDownloadedIndex)
+ val bytesTotal = cursor.getInt(bytesTotalIndex)
+ val status = cursor.getInt(statusIndex)
+
+ if (status == DownloadManager.STATUS_SUCCESSFUL || status == DownloadManager.STATUS_FAILED) {
+ isDownloading = false
+ }
+
+ if (bytesTotal > 0) {
+ emit(bytesDownloaded.toFloat() / bytesTotal.toFloat())
+ }
+ }
+ cursor.close()
+ } else {
+ isDownloading = false
+ cursor?.close()
+ }
+ if (isDownloading) delay(500.milliseconds)
+ }
+ }.flowOn(Dispatchers.IO)
+
+ private fun installApk(fileName: String) {
+ val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
+ if (!apkFile.exists()) return
+
+ try {
+ val contentUri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ apkFile
+ )
+
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(contentUri, "application/vnd.android.package-archive")
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
+ }
+ context.startActivity(intent)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ private fun isNetworkAvailable(): Boolean {
+ val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = connectivityManager.activeNetwork ?: return false
+ val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
+ return when {
+ activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
+ activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
+ else -> false
+ }
+ }
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/AuthViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/AuthViewModel.kt
new file mode 100644
index 0000000..714037b
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/AuthViewModel.kt
@@ -0,0 +1,65 @@
+package com.oqba26.monthlypaymentapp.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.oqba26.monthlypaymentapp.data.model.AuthRequest
+import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
+import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+sealed interface AuthState {
+ data object Loading : AuthState
+ data object Authenticated : AuthState
+ data object Unauthenticated : AuthState
+}
+
+@HiltViewModel
+class AuthViewModel @Inject constructor(
+ private val networkRepository: NetworkRepository,
+ private val settingsRepository: SettingsRepository
+) : ViewModel() {
+
+ private val _toastMessage = MutableSharedFlow()
+ val toastMessage = _toastMessage.asSharedFlow()
+
+ val authState: StateFlow = settingsRepository.authTokenFlow
+ .map { token ->
+ if (token != null) AuthState.Authenticated else AuthState.Unauthenticated
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AuthState.Loading)
+
+ fun login(request: AuthRequest) = viewModelScope.launch {
+ val response = networkRepository.login(request)
+ if (response != null && response.token.isNotBlank()) {
+ settingsRepository.saveAuthData(response.token, response.userId)
+ _toastMessage.emit("ورود موفقیتآمیز بود")
+ networkRepository.refresh()
+ } else {
+ _toastMessage.emit("نام کاربری یا رمز عبور اشتباه است.")
+ }
+ }
+
+ fun register(request: AuthRequest) = viewModelScope.launch {
+ val response = networkRepository.register(request)
+ if (response != null && response.token.isNotBlank()) {
+ settingsRepository.saveAuthData(response.token, response.userId)
+ _toastMessage.emit("ثبت نام و ورود موفقیتآمیز بود")
+ networkRepository.refresh()
+ } else {
+ _toastMessage.emit("خطا در ثبت نام.")
+ }
+ }
+
+ fun logout() = viewModelScope.launch {
+ settingsRepository.saveAuthData(null, null)
+ _toastMessage.emit("از حساب کاربری خارج شدید.")
+ }
+}
diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/ContactViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/ContactViewModel.kt
new file mode 100644
index 0000000..f65ecd9
--- /dev/null
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/ContactViewModel.kt
@@ -0,0 +1,186 @@
+package com.oqba26.monthlypaymentapp.viewmodel
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.provider.ContactsContract
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.oqba26.monthlypaymentapp.core.manager.SmsManager
+import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
+import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
+import com.oqba26.monthlypaymentapp.utils.normalizePhoneNumber
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+data class ContactUiState(
+ val contactSuggestions: List = emptyList(),
+ val bulkSmsQueue: List = emptyList(),
+ val currentBulkIndex: Int = -1,
+ val selectedCardForBulk: String? = null,
+ val showBulkSmsDialog: Boolean = false,
+ val personForSmsDialog: PersonUiModel? = null,
+ val bulkSmsQueueForDialog: List = emptyList()
+)
+
+@HiltViewModel
+class ContactViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val networkRepository: NetworkRepository,
+ private val settingsRepository: SettingsRepository,
+ private val smsManager: SmsManager
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(ContactUiState())
+ val uiState = _uiState.asStateFlow()
+
+ private val _toastMessage = MutableSharedFlow()
+ val toastMessage = _toastMessage.asSharedFlow()
+
+ fun onBulkSmsClicked() {
+ _uiState.update { it.copy(showBulkSmsDialog = true) }
+ }
+
+ fun onBulkSelectionConfirmed(selected: List) {
+ _uiState.update { it.copy(showBulkSmsDialog = false, bulkSmsQueueForDialog = selected) }
+ }
+
+ fun onDismissBulkSmsDialog() {
+ _uiState.update { it.copy(showBulkSmsDialog = false) }
+ }
+
+ fun onDismissSmsDialog() {
+ _uiState.update { it.copy(personForSmsDialog = null, bulkSmsQueueForDialog = emptyList()) }
+ }
+
+ fun onSmsClicked(person: PersonUiModel) {
+ _uiState.update { it.copy(personForSmsDialog = person) }
+ }
+
+ fun sendSmsReminder(person: PersonUiModel, selectedCard: String? = null) {
+ smsManager.sendSmsReminder(person, selectedCard)
+ }
+
+ fun startBulkSms(selected: List, selectedCard: String? = null) {
+ if (selected.isEmpty()) return
+ _uiState.update { it.copy(bulkSmsQueue = selected, currentBulkIndex = 0, selectedCardForBulk = selectedCard) }
+ }
+
+ fun processNextBulkSms() {
+ val state = _uiState.value
+ if (state.currentBulkIndex in state.bulkSmsQueue.indices) {
+ val person = state.bulkSmsQueue[state.currentBulkIndex]
+ val intent = smsManager.getSmsIntent(person, state.selectedCardForBulk)
+ if (intent != null) {
+ // We still need to start the activity. Since we have application context in manager, it uses FLAG_ACTIVITY_NEW_TASK.
+ smsManager.sendSmsReminder(person, state.selectedCardForBulk)
+ _uiState.update { it.copy(currentBulkIndex = it.currentBulkIndex + 1) }
+ } else {
+ skipBulkSms()
+ processNextBulkSms()
+ }
+ } else {
+ cancelBulkSms()
+ viewModelScope.launch { _toastMessage.emit("ارسال گروهی به پایان رسید.") }
+ }
+ }
+
+ fun skipBulkSms() {
+ _uiState.update {
+ val nextIndex = it.currentBulkIndex + 1
+ if (nextIndex >= it.bulkSmsQueue.size) {
+ it.copy(bulkSmsQueue = emptyList(), currentBulkIndex = -1)
+ } else {
+ it.copy(currentBulkIndex = nextIndex)
+ }
+ }
+ }
+
+ fun cancelBulkSms() {
+ _uiState.update { it.copy(bulkSmsQueue = emptyList(), currentBulkIndex = -1) }
+ }
+
+ fun checkContactsForMissingNumbers(persons: List) {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return
+
+ viewModelScope.launch {
+ val ignoredIds = settingsRepository.ignoredContactSuggestionsFlow.first()
+ val personsWithoutPhone = persons.filter {
+ it.phoneNumber.isNullOrBlank() && !ignoredIds.contains(it.id)
+ }
+
+ if (personsWithoutPhone.isEmpty()) {
+ _uiState.update { it.copy(contactSuggestions = emptyList()) }
+ return@launch
+ }
+
+ val suggestions = mutableListOf()
+ personsWithoutPhone.forEach { person ->
+ val matches = findSimilarContacts(person.name)
+ if (matches.isNotEmpty()) {
+ suggestions.add(ContactSuggestion(person.id, person.name, matches))
+ }
+ }
+ _uiState.update { it.copy(contactSuggestions = suggestions) }
+ }
+ }
+
+ fun findSimilarContacts(name: String): List {
+ val matches = mutableListOf()
+ val contentResolver = context.contentResolver
+ val uri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI
+ val projection = arrayOf(
+ ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
+ ContactsContract.CommonDataKinds.Phone.NUMBER
+ )
+
+ contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
+ val nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
+ val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
+
+ while (cursor.moveToNext()) {
+ if (nameIndex != -1 && numberIndex != -1) {
+ val contactName = cursor.getString(nameIndex) ?: ""
+ val rawNumber = cursor.getString(numberIndex) ?: ""
+
+ if (com.oqba26.monthlypaymentapp.utils.areNamesSimilar(name, contactName)) {
+ matches.add(ContactMatch(contactName, rawNumber.normalizePhoneNumber()))
+ }
+ }
+ }
+ }
+ return matches.distinctBy { it.phoneNumber }
+ }
+
+ fun confirmContactSuggestion(personId: String, personName: String, phoneNumber: String) {
+ viewModelScope.launch {
+ val statusCode = networkRepository.updatePerson(personId, personName, phoneNumber)
+ if (statusCode == 200) {
+ networkRepository.refresh()
+ _uiState.update { state ->
+ state.copy(contactSuggestions = state.contactSuggestions.filter { it.personId != personId })
+ }
+ } else if (statusCode == 409) {
+ _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
+ }
+ }
+ }
+
+ fun dismissContactSuggestion(suggestion: ContactSuggestion) {
+ viewModelScope.launch {
+ settingsRepository.ignoreContactSuggestion(suggestion.personId)
+ _uiState.update { state ->
+ state.copy(contactSuggestions = state.contactSuggestions.filter { it.personId != suggestion.personId })
+ }
+ }
+ }
+}
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 b21f84b..cb9fdf9 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt
@@ -1,14 +1,20 @@
package com.oqba26.monthlypaymentapp.viewmodel
+import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.oqba26.monthlypaymentapp.data.model.AuthRequest
+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
+import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
+import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,35 +24,27 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-
-sealed interface AuthState {
- data object Loading : AuthState
- data object Authenticated : AuthState
- data object Unauthenticated : AuthState
-}
-
-enum class PersonListType {
- UNPAID,
- PAID
-}
+import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
-class PersonViewModel(
+@HiltViewModel
+class PersonViewModel @Inject constructor(
private val networkRepository: NetworkRepository,
- private val settingsRepository: SettingsRepository
+ private val localPersonRepository: LocalPersonRepository,
+ private val settingsRepository: SettingsRepository,
+ private val exportManager: ExportManager
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow = _searchQuery.asStateFlow()
private val _isRefreshing = MutableStateFlow(false)
- @Suppress("unused")
val isRefreshing: StateFlow = _isRefreshing.asStateFlow()
private val _selectedPersonId = MutableStateFlow(null)
@@ -56,6 +54,7 @@ class PersonViewModel(
val dashboardData: StateFlow = _dashboardData.asStateFlow()
val defaultPaymentAmountFlow = settingsRepository.defaultPaymentAmountFlow
+ val cardNumbersFlow = settingsRepository.cardNumbersFlow
private val _toastMessage = MutableSharedFlow()
val toastMessage = _toastMessage.asSharedFlow()
@@ -72,12 +71,11 @@ class PersonViewModel(
private val _personForPaymentDialog = MutableStateFlow(null)
val personForPaymentDialog: StateFlow = _personForPaymentDialog.asStateFlow()
+ private val _selectedIds = MutableStateFlow>(emptySet())
+ val selectedIds: StateFlow> = _selectedIds.asStateFlow()
- val authState: StateFlow = settingsRepository.authTokenFlow
- .map { token ->
- if (token != null) AuthState.Authenticated else AuthState.Unauthenticated
- }
- .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AuthState.Loading)
+ private val _isSelectionMode = MutableStateFlow(false)
+ val isSelectionMode: StateFlow = _isSelectionMode.asStateFlow()
val personDetailState: StateFlow = _selectedPersonId
.flatMapLatest { personId ->
@@ -85,8 +83,8 @@ class PersonViewModel(
flowOf(PersonDetailUiState())
} else {
combine(
- networkRepository.getPersonsFlow(),
- networkRepository.getPaymentsFlow(),
+ localPersonRepository.getAllPersonsFlow(),
+ localPersonRepository.getAllPaymentsFlow(),
_selectedYear
) { persons, allPayments, year ->
val person = persons.find { it.id == personId }
@@ -95,12 +93,13 @@ class PersonViewModel(
}
val currentYear = getCurrentShamsiYear()
val currentMonth = getCurrentShamsiMonth()
+ val currentDay = getCurrentShamsiDay()
val monthStates = (1..12).map { month ->
val payment = paymentsForPersonInYear.find { it.shamsiMonth == month }
val status = when {
year < currentYear -> MonthStatus.PAST_YEAR
year > currentYear -> MonthStatus.FUTURE_YEAR
- month > currentMonth -> MonthStatus.FUTURE_MONTH
+ month > currentMonth || (month == currentMonth && currentDay < 20) -> MonthStatus.FUTURE_MONTH
payment != null -> MonthStatus.PAID
else -> MonthStatus.AVAILABLE
}
@@ -113,189 +112,152 @@ class PersonViewModel(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PersonDetailUiState())
init {
+ // ۱. منطق همگامسازی شبکه به دیتابیس محلی
viewModelScope.launch {
- authState.collectLatest { authState ->
- if (authState is AuthState.Authenticated) {
- // اولین بار دیتای سرور
+ settingsRepository.authTokenFlow.collectLatest { token ->
+ if (token != null) {
+ networkRepository.observeRealtimeChanges()
networkRepository.refresh()
- // گوش دادن به تغییرات اشخاص + پرداختها + سرچ
combine(
networkRepository.getPersonsFlow(),
- networkRepository.getPaymentsFlow(),
- _searchQuery
- ) { persons, allPayments, query ->
- val currentMonth = getCurrentShamsiMonth()
- val currentYear = getCurrentShamsiYear()
-
- val paymentsThisMonth = allPayments.filter {
- it.shamsiMonth == currentMonth && it.shamsiYear == currentYear
+ networkRepository.getPaymentsFlow()
+ ) { persons, payments ->
+ if (persons.isNotEmpty()) {
+ localPersonRepository.syncAll(persons, payments)
+ true
+ } else false
+ }.collect { isUpdated ->
+ if (isUpdated) {
+ _toastMessage.emit("اطلاعات با سرور همگامسازی شد")
}
-
- val activePersons = persons
- .filter { !it.isArchived }
- .sortedWith(compareBy({ it.displayOrder }, { it.createdAt }))
-
- val uiModels = activePersons.map { person ->
- val hasPaid = paymentsThisMonth.any { it.personId == person.id }
- PersonUiModel(
- id = person.id,
- name = person.name,
- hasPaidThisMonth = hasPaid,
- displayOrder = person.displayOrder,
- createdAt = person.createdAt
- )
- }
-
- updateDashboard(uiModels.size, paymentsThisMonth)
-
- val (paidModels, unpaidModels) =
- uiModels.partition { it.hasPaidThisMonth }
-
- 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 }
- .sortedBy { it.displayOrder }
- .map {
- PersonUiModel(
- id = it.id,
- name = it.name,
- hasPaidThisMonth = false,
- displayOrder = it.displayOrder,
- createdAt = it.createdAt
- )
- }
-
- PersonListUiState(
- unpaidPersons = filteredUnpaid,
- paidPersons = filteredPaid,
- archivedPersons = archivedPersons,
- payments = allPayments
- )
- }.collect { newUiState ->
- _uiState.value = newUiState
}
- } else {
- _uiState.value = PersonListUiState()
}
}
}
+
+ // ۲. منطق نمایش UI
+ viewModelScope.launch {
+ combine(
+ localPersonRepository.getAllPersonsFlow(),
+ localPersonRepository.getAllPaymentsFlow(),
+ _searchQuery
+ ) { persons, allPayments, query ->
+ val currentMonth = getCurrentShamsiMonth()
+ val currentYear = getCurrentShamsiYear()
+ val currentDay = getCurrentShamsiDay()
+
+ val targetMonth = if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1
+ val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear
+
+ val relevantPayments = allPayments.filter {
+ it.shamsiMonth == targetMonth && it.shamsiYear == targetYear
+ }
+
+ val activePersons = persons
+ .filter { !it.isArchived }
+ .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 }
+ }
+
+ PersonUiModel(
+ id = person.id,
+ name = person.name,
+ hasPaidThisMonth = hasPaid,
+ displayOrder = person.displayOrder,
+ createdAt = person.createdAt,
+ debtCount = unpaidMonthsInCurrentYear.size,
+ totalDebtAmount = unpaidMonthsInCurrentYear.size * (settingsRepository.defaultPaymentAmountFlow.first()),
+ phoneNumber = person.phoneNumber,
+ unpaidMonthsNames = unpaidMonthsInCurrentYear.map { getPersianMonthName(it) }
+ )
+ }
+
+ updateDashboard(uiModels.size, relevantPayments)
+
+ val (paidModels, unpaidModels) = uiModels.partition { it.hasPaidThisMonth }
+
+ 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)
+ }
+
+ PersonListUiState(
+ unpaidPersons = filteredUnpaid,
+ paidPersons = filteredPaid,
+ archivedPersons = archivedPersons,
+ payments = allPayments
+ )
+ }.collect { newUiState ->
+ _uiState.value = newUiState
+ }
+ }
}
- fun onSearchQueryChange(query: String) {
- _searchQuery.value = query
- }
-
- fun onAddPersonClicked() {
- _showAddPersonDialog.value = true
- }
-
- fun onDismissAddPersonDialog() {
- _showAddPersonDialog.value = false
- }
-
- fun onQuickPayClicked(person: PersonUiModel) {
- _personForPaymentDialog.value = person
- }
-
- fun onDismissPaymentDialog() {
- _personForPaymentDialog.value = null
- }
-
+ fun onSearchQueryChange(query: String) { _searchQuery.value = query }
+ fun onAddPersonClicked() { _showAddPersonDialog.value = true }
+ fun onDismissAddPersonDialog() { _showAddPersonDialog.value = false }
+ fun onQuickPayClicked(person: PersonUiModel) { _personForPaymentDialog.value = person }
+ fun onDismissPaymentDialog() { _personForPaymentDialog.value = null }
fun onEvent(event: PersonScreenEvent) {
viewModelScope.launch {
when (event) {
is PersonScreenEvent.MovePersonNew -> {
- // فقط جابهجا کردن لیست لوکال (بدون شبکه)
val fromIndex = event.fromIndex
val toIndex = event.toIndex
- val currentListState = _uiState.value
-
- val list = when (event.listType) {
- PersonListType.UNPAID -> currentListState.unpaidPersons
- PersonListType.PAID -> currentListState.paidPersons
- }
-
+ val list = if (event.listType == PersonListType.UNPAID) _uiState.value.unpaidPersons else _uiState.value.paidPersons
if (fromIndex !in list.indices || toIndex !in list.indices) return@launch
- val reorderedList = list.toMutableList().apply {
- add(toIndex, removeAt(fromIndex))
- }
-
+ val reorderedList = list.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
_uiState.update {
- when (event.listType) {
- PersonListType.UNPAID -> it.copy(unpaidPersons = reorderedList)
- PersonListType.PAID -> it.copy(paidPersons = reorderedList)
- }
+ if (event.listType == PersonListType.UNPAID) it.copy(unpaidPersons = reorderedList)
+ else it.copy(paidPersons = reorderedList)
}
}
is PersonScreenEvent.AddPerson -> {
if (event.name.isNotBlank()) {
- val person = Person(name = event.name, displayOrder = 0)
+ val person = Person(name = event.name, phoneNumber = event.phoneNumber, displayOrder = 0)
val statusCode = networkRepository.addPerson(person)
- when (statusCode) {
- 409 -> {
- _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
- }
- null, !in 200..299 -> {
- _toastMessage.emit("خطا در افزودن شخص.")
- }
- else -> {
- networkRepository.refresh()
- }
- }
+ if (statusCode == 409) _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
+ else if (statusCode != 200 && statusCode != 201) _toastMessage.emit("خطا در افزودن شخص.")
+ else networkRepository.refresh()
}
}
+
is PersonScreenEvent.AddQuickPayment -> {
- val record = createPaymentRecord(
- event.personId,
- getCurrentShamsiMonth(),
- getCurrentShamsiYear(),
- event.amount,
- event.description
- )
+ val currentDay = getCurrentShamsiDay()
+ val currentMonth = getCurrentShamsiMonth()
+ val currentYear = getCurrentShamsiYear()
+ val targetMonth = if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1
+ val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear
- if (networkRepository.addPayment(record)) {
- networkRepository.refresh()
- } else {
- _toastMessage.emit("خطا در ثبت پرداخت. لطفا دوباره تلاش کنید.")
- }
+ val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description)
+ if (networkRepository.addPayment(record)) networkRepository.refresh()
+ else _toastMessage.emit("خطا در ثبت پرداخت.")
}
- else -> handleOtherEvents(event)
- }
- }
- }
-
- private fun handleOtherEvents(event: PersonScreenEvent) {
- viewModelScope.launch {
- when (event) {
is PersonScreenEvent.SelectPerson -> {
_selectedPersonId.value = event.personId
_selectedYear.value = getCurrentShamsiYear()
}
is PersonScreenEvent.DeletePerson -> {
- if (networkRepository.deletePersonAndPayments(event.personId)) {
- networkRepository.refresh()
- }
+ if (networkRepository.deletePersonAndPayments(event.personId)) networkRepository.refresh()
}
is PersonScreenEvent.ArchivePerson -> {
if (networkRepository.updatePersonArchivedStatus(event.personId, true)) {
_toastMessage.emit("شخص به آرشیو انتقال یافت.")
networkRepository.refresh()
- } else {
- _toastMessage.emit("خطا در آرشیو کردن.")
}
}
@@ -303,105 +265,73 @@ class PersonViewModel(
if (networkRepository.updatePersonArchivedStatus(event.personId, false)) {
_toastMessage.emit("شخص از آرشیو بازیابی شد.")
networkRepository.refresh()
- } else {
- _toastMessage.emit("خطا در بازیابی.")
}
}
is PersonScreenEvent.UpdatePerson -> {
- if (networkRepository.updatePerson(event.personId, event.name)) {
- networkRepository.refresh()
- }
+ val statusCode = networkRepository.updatePerson(event.personId, event.name, event.phoneNumber)
+ if (statusCode == 409) _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
+ else if (statusCode == 200) networkRepository.refresh()
+ else _toastMessage.emit("خطا در ویرایش اطلاعات.")
}
- is PersonScreenEvent.Login -> login(AuthRequest(event.username, event.password))
-
- is PersonScreenEvent.Register -> register(AuthRequest(event.username, event.password))
-
is PersonScreenEvent.ChangeYear -> {
- when (event.offset) {
- 1 -> {
- if (_selectedYear.value < getCurrentShamsiYear()) {
- _selectedYear.update { it + 1 }
- } else {
- _infoMessage.emit("امکان مشاهده سال آینده وجود ندارد.")
- }
- }
-
- -1 -> {
- val personId = _selectedPersonId.value ?: return@launch
- val hasPaymentsInPast = uiState.value.payments.any {
- it.personId == personId && it.shamsiYear < _selectedYear.value
- }
- if (hasPaymentsInPast) {
- _selectedYear.update { it - 1 }
- } else {
- _infoMessage.emit("هیچ پرداختی برای سالهای گذشته ثبت نشده است.")
- }
- }
+ val currentYear = getCurrentShamsiYear()
+ if (event.offset == 1) {
+ if (_selectedYear.value < currentYear) _selectedYear.update { it + 1 }
+ else _infoMessage.emit("امکان مشاهده سال آینده وجود ندارد.")
+ } else {
+ _selectedYear.update { it - 1 }
}
}
is PersonScreenEvent.AddPaymentForMonth -> {
- val record = createPaymentRecord(
- event.personId,
- event.month,
- event.year,
- event.amount,
- event.description
- )
- if (networkRepository.addPayment(record)) {
- networkRepository.refresh()
- } else {
- _toastMessage.emit("خطا در ثبت پرداخت")
- }
+ val record = createPaymentRecord(event.personId, event.month, event.year, event.amount, event.description)
+ if (networkRepository.addPayment(record)) networkRepository.refresh()
+ else _toastMessage.emit("خطا در ثبت پرداخت")
}
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("خطا در ویرایش پرداخت")
- }
+ val updatedRecord = event.payment.copy(amount = event.newAmount, description = event.newDescription, timestamp = System.currentTimeMillis())
+ if (networkRepository.addPayment(updatedRecord)) networkRepository.refresh()
+ else _toastMessage.emit("خطا در ویرایش پرداخت")
}
is PersonScreenEvent.DeletePayment -> {
- if (networkRepository.deletePayment(event.payment.id)) {
- networkRepository.refresh()
- } else {
- _toastMessage.emit("خطا در حذف پرداخت")
+ if (networkRepository.deletePayment(event.payment.id)) networkRepository.refresh()
+ else _toastMessage.emit("خطا در حذف پرداخت")
+ }
+
+ is PersonScreenEvent.ToggleSelection -> {
+ _selectedIds.update { current ->
+ if (current.contains(event.personId)) {
+ val newSet = current - event.personId
+ if (newSet.isEmpty()) _isSelectionMode.value = false
+ newSet
+ } else {
+ _isSelectionMode.value = true
+ current + event.personId
+ }
}
}
+ is PersonScreenEvent.ClearSelection -> {
+ _selectedIds.value = emptySet()
+ _isSelectionMode.value = false
+ }
+
+ is PersonScreenEvent.ExportYearlyReport -> exportYearlyReport(event.year, event.context)
+
is PersonScreenEvent.CommitReorder -> {
- // بعد از اتمام درگ: ترتیب فعلی لیست را به عنوان displayOrder جدید ذخیره کن
- val currentListState = _uiState.value
- val list = when (event.listType) {
- PersonListType.UNPAID -> currentListState.unpaidPersons
- PersonListType.PAID -> currentListState.paidPersons
- }
-
+ 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 ->
val newOrder = (index + 1) * 10_000L
if (person.displayOrder != newOrder) {
- val success = networkRepository.updatePersonDisplayOrder(
- person.id,
- newOrder
- )
- if (!success) {
- throw Exception("update failed")
- }
+ networkRepository.updatePersonDisplayOrder(person.id, newOrder)
}
}
- // بعد از ذخیره، از سرور رفرش کن تا همهچیز باهم سینک بشه
networkRepository.refresh()
} catch (_: Exception) {
_toastMessage.emit("خطا در ذخیره ترتیب")
@@ -414,109 +344,33 @@ class PersonViewModel(
networkRepository.refresh()
_isRefreshing.value = false
}
-
else -> {}
}
}
}
- private fun createPaymentRecord(
- personId: String,
- month: Int,
- year: Int,
- amount: Double,
- description: String
- ): PaymentRecord {
- return PaymentRecord(
- personId = personId,
- amount = amount,
- shamsiYear = year,
- shamsiMonth = month,
- description = description
- )
+ private fun exportYearlyReport(year: Int, context: Context) {
+ viewModelScope.launch {
+ val persons = localPersonRepository.getAllPersonsFlow().first()
+ val payments = localPersonRepository.getAllPaymentsFlow().first().filter { it.shamsiYear == year }
+ if (!exportManager.exportYearlyReport(year, persons, payments)) {
+ _toastMessage.emit("خطا در ایجاد گزارش")
+ }
+ }
+ }
+
+ fun shareReceipt(payment: PaymentRecord, personName: String) {
+ exportManager.shareReceipt(payment, personName)
}
private fun updateDashboard(totalPersonCount: Int, paymentsThisMonth: List) {
val paidCount = paymentsThisMonth.map { it.personId }.distinct().size
val totalIncome = paymentsThisMonth.sumOf { it.amount }
- val progress =
- if (totalPersonCount > 0) paidCount.toFloat() / totalPersonCount.toFloat() else 0f
- _dashboardData.value =
- DashboardUiModel(paidCount, totalPersonCount, totalIncome, progress)
+ val progress = if (totalPersonCount > 0) paidCount.toFloat() / totalPersonCount.toFloat() else 0f
+ _dashboardData.value = DashboardUiModel(paidCount, totalPersonCount, totalIncome, progress)
}
- fun logout() = viewModelScope.launch {
- settingsRepository.saveAuthData(null, null)
- _toastMessage.emit("از حساب کاربری خارج شدید.")
- }
-
- fun login(request: AuthRequest) = viewModelScope.launch {
- val response = networkRepository.login(request)
- if (response != null && response.token.isNotBlank()) {
- settingsRepository.saveAuthData(response.token, response.userId)
- _toastMessage.emit("ورود موفقیتآمیز بود")
- networkRepository.refresh()
- } else {
- _toastMessage.emit("نام کاربری یا رمز عبور اشتباه است.")
- }
- }
-
- fun register(request: AuthRequest) = viewModelScope.launch {
- val response = networkRepository.register(request)
- if (response != null && response.token.isNotBlank()) {
- settingsRepository.saveAuthData(response.token, response.userId)
- _toastMessage.emit("ثبت نام و ورود موفقیتآمیز بود")
- networkRepository.refresh()
- } else {
- _toastMessage.emit("خطا در ثبت نام.")
- }
+ private fun createPaymentRecord(personId: String, month: Int, year: Int, amount: Double, description: String): PaymentRecord {
+ return PaymentRecord(personId = personId, amount = amount, shamsiYear = year, shamsiMonth = month, description = description)
}
}
-
-sealed class PersonScreenEvent {
- data object RefreshData : PersonScreenEvent()
- data class AddPerson(val name: String) : PersonScreenEvent()
- data class UpdatePerson(val personId: String, val name: String) : PersonScreenEvent()
- data class DeletePerson(val personId: String) : PersonScreenEvent()
- data class ArchivePerson(val personId: String) : PersonScreenEvent()
- data class RestorePerson(val personId: String) : PersonScreenEvent()
-
- // درگ اند دراپ
- data class MovePersonNew(
- val fromIndex: Int,
- val toIndex: Int,
- val listType: PersonListType
- ) : PersonScreenEvent()
-
- // پایان درگ: ذخیره ترتیب
- data class CommitReorder(
- val listType: PersonListType
- ) : PersonScreenEvent()
-
- data class AddQuickPayment(
- val personId: String,
- val amount: Double,
- val description: String
- ) : PersonScreenEvent()
-
- data class Login(val username: String, val password: String) : PersonScreenEvent()
- data class Register(val username: String, val password: String) : PersonScreenEvent()
-
- data class SelectPerson(val personId: String) : PersonScreenEvent()
- data class ChangeYear(val offset: Int) : PersonScreenEvent()
- data class AddPaymentForMonth(
- val personId: String,
- val month: Int,
- val year: Int,
- val amount: Double,
- val description: String
- ) : PersonScreenEvent()
-
- data class UpdatePayment(
- val payment: PaymentRecord,
- val newAmount: Double,
- val newDescription: String
- ) : PersonScreenEvent()
-
- data class DeletePayment(val payment: PaymentRecord) : PersonScreenEvent()
-}
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 6fc7b65..51f9c36 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/SettingsViewModel.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/SettingsViewModel.kt
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
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.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -14,7 +16,8 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
-class SettingsViewModel(
+@HiltViewModel
+class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val localPersonRepository: LocalPersonRepository
) : ViewModel() {
@@ -32,9 +35,49 @@ class SettingsViewModel(
val selectedFont: StateFlow = settingsRepository.selectedFontFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), SettingsRepository.DEFAULT_FONT)
+ val reminderDay: StateFlow = settingsRepository.reminderDayFlow
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null)
+
+ val cardNumbers: StateFlow> = settingsRepository.cardNumbersFlow
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), emptyList())
+
private val _toastMessage = MutableSharedFlow()
val toastMessage = _toastMessage.asSharedFlow()
+ fun saveReminderDay(day: String) {
+ viewModelScope.launch {
+ val dayInt = day.toIntOrNull()
+ if (dayInt == null || dayInt in 1..31) {
+ settingsRepository.saveReminderDay(dayInt)
+ _toastMessage.emit(if (dayInt == null) "یادآور غیرفعال شد" else "یادآور برای روز $day ماه تنظیم شد")
+ } else {
+ _toastMessage.emit("روز وارد شده نامعتبر است (۱ تا ۳۱)")
+ }
+ }
+ }
+
+ fun addCardNumber(cardNumber: String) {
+ viewModelScope.launch {
+ if (cardNumber.isBlank()) return@launch
+ val current = cardNumbers.value.toMutableList()
+ if (!current.contains(cardNumber)) {
+ current.add(cardNumber)
+ settingsRepository.saveCardNumbers(current)
+ _toastMessage.emit("شماره کارت اضافه شد")
+ }
+ }
+ }
+
+ fun removeCardNumber(cardNumber: String) {
+ viewModelScope.launch {
+ val current = cardNumbers.value.toMutableList()
+ if (current.remove(cardNumber)) {
+ settingsRepository.saveCardNumbers(current)
+ _toastMessage.emit("شماره کارت حذف شد")
+ }
+ }
+ }
+
fun saveDefaultPaymentAmount(amount: String) {
viewModelScope.launch {
val amountAsDouble = amount.toDoubleOrNull() ?: return@launch
@@ -61,7 +104,7 @@ class SettingsViewModel(
val backupData = json.decodeFromString(jsonString)
localPersonRepository.restoreBackup(backupData)
_toastMessage.emit("اطلاعات با موفقیت بازیابی شد! لطفاً برنامه را مجدد اجرا کنید.")
- } catch (e: Exception) {
+ } catch (_: Exception) {
_toastMessage.emit("خطا در بازیابی اطلاعات: فایل نامعتبر است.")
}
}
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 b665610..6f22665 100644
--- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt
+++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt
@@ -1,5 +1,6 @@
package com.oqba26.monthlypaymentapp.viewmodel
+import android.content.Context
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
@@ -7,8 +8,12 @@ data class PersonUiModel(
val id: String,
val name: String,
val hasPaidThisMonth: Boolean,
- val displayOrder: Long, // FIX: Changed from Int to Long
- val createdAt: Long
+ val displayOrder: Long?,
+ val createdAt: Long?,
+ val debtCount: Int = 0,
+ val totalDebtAmount: Double = 0.0,
+ val phoneNumber: String? = null,
+ val unpaidMonthsNames: List = emptyList()
)
data class PersonListUiState(
@@ -16,6 +21,21 @@ data class PersonListUiState(
val paidPersons: List = emptyList(),
val archivedPersons: List = emptyList(),
val payments: List = emptyList(),
+ val bulkSmsQueue: List = emptyList(),
+ val currentBulkIndex: Int = -1,
+ val selectedCardForBulk: String? = null,
+ val contactSuggestions: List = emptyList()
+)
+
+data class ContactSuggestion(
+ val personId: String,
+ val personName: String,
+ val matches: List
+)
+
+data class ContactMatch(
+ val nameInContacts: String,
+ val phoneNumber: String
)
data class PersonDetailUiState(
@@ -38,6 +58,68 @@ enum class MonthStatus {
FUTURE_YEAR
}
+enum class PersonListType {
+ UNPAID,
+ PAID
+}
+
+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 DeletePerson(val personId: String) : PersonScreenEvent()
+ data class ArchivePerson(val personId: String) : PersonScreenEvent()
+ data class RestorePerson(val personId: String) : PersonScreenEvent()
+
+ data class MovePersonNew(
+ val fromIndex: Int,
+ val toIndex: Int,
+ val listType: PersonListType
+ ) : PersonScreenEvent()
+
+ data class CommitReorder(
+ val listType: PersonListType
+ ) : PersonScreenEvent()
+
+ data class AddQuickPayment(
+ val personId: String,
+ val amount: Double,
+ val description: String
+ ) : PersonScreenEvent()
+
+ data class SelectPerson(val personId: String) : PersonScreenEvent()
+ data class ChangeYear(val offset: Int) : PersonScreenEvent()
+ data class AddPaymentForMonth(
+ val personId: String,
+ val month: Int,
+ val year: Int,
+ val amount: Double,
+ val description: String
+ ) : PersonScreenEvent()
+
+ data class UpdatePayment(
+ val payment: PaymentRecord,
+ val newAmount: Double,
+ val newDescription: String
+ ) : PersonScreenEvent()
+
+ data class DeletePayment(val payment: PaymentRecord) : PersonScreenEvent()
+ data class AddBulkPayments(
+ val personId: String,
+ val months: List,
+ val year: Int,
+ val amount: Double
+ ) : PersonScreenEvent()
+ data class ExportYearlyReport(val year: Int, val context: Context) : 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 DashboardUiModel(
val paidCount: Int = 0,
val totalCount: Int = 0,
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9ec299c..e7dd9e6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,33 @@
مدیریت حقوق ماهانه
+ آرشیو شخص
+ آیا از انتقال %1$s به آرشیو مطمئن هستید؟
+ حذف قطعی شخص
+ آیا از حذف قطعی %1$s و تمامی سوابق پرداخت او مطمئن هستید؟ این عمل غیرقابل بازگشت است.
+ بازیابی شخص
+ آیا از بازیابی %1$s به لیست اصلی مطمئن هستید؟
+ خروج از برنامه
+ آیا برای خروج از برنامه مطمئن هستید؟
+ بله
+ خیر
+ لغو
+ ذخیره
+ افزودن
+ ویرایش
+ تنظیمات
+ پشتیبانگیری
+ بازیابی
+ پشتیبانگیری با موفقیت انجام شد
+ خطا در ایجاد فایل پشتیبان
+ هشدار بازیابی
+ با بازیابی، تمام دادههای فعلی حذف و اطلاعات فایل جایگزین میشود. مطمئن هستید؟
+ خروج از حساب کاربری
+ مبلغ پیشفرض پرداخت
+ روز یادآور ماهانه
+ شماره کارتهای بانکی
+ افزودن شخص جدید
+ ارسال گروهی پیامک
+ پرداخت نشده
+ پرداخت شده
+ آرشیو شده
\ No newline at end of file
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..0ccae3f
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/test/java/com/oqba26/monthlypaymentapp/ExampleUnitTest.kt b/app/src/test/java/com/oqba26/monthlypaymentapp/ExampleUnitTest.kt
deleted file mode 100644
index f52b299..0000000
--- a/app/src/test/java/com/oqba26/monthlypaymentapp/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.oqba26.monthlypaymentapp
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 2977b40..f23d3b7 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,7 +1,9 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
- alias(libs.plugins.jetbrains.kotlin.android) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.google.devtools.ksp) apply false
alias(libs.plugins.kotlin.serialization) apply false
- alias(libs.plugins.ksp) apply false
+ alias(libs.plugins.hilt) apply false
}
diff --git a/gradle.properties b/gradle.properties
index b39947a..3c1cc5a 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,17 +1,24 @@
# gradle.properties
-org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
-
-# ????: ???? ? backoff ????? ???? ?????
-systemProp.org.gradle.internal.http.connectionTimeout=60000
-systemProp.org.gradle.internal.http.socketTimeout=60000
-systemProp.org.gradle.internal.repository.max.retries=4
-systemProp.org.gradle.internal.repository.initialBackoff=2000
+# org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx2560m -XX:+UseParallelGC -Dfile.encoding=UTF-8
android.useAndroidX=true
+android.enableJetifier=true
kotlin.code.style=official
android.nonTransitiveRClass=true
android.suppressUnsupportedCompileSdk=34
-org.gradle.parallel=true
+org.gradle.parallel=false
+org.gradle.configureondemand=false
org.gradle.caching=true
-org.gradle.offline=false
\ No newline at end of file
+org.gradle.configuration-cache=false
+org.gradle.daemon=true
+
+# KSP optimizations
+#org.gradle.java.home=C\:/Program Files/Java/jdk-21
+ksp.incremental=true
+
+# Fix for AGP 8.7+ "Several environment variables" error
+systemProp.ANDROID_PREFS_ROOT=
+systemProp.ANDROID_USER_HOME=C:\\Users\\msi\\.android
+org.gradle.project.ANDROID_PREFS_ROOT=
diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties
new file mode 100644
index 0000000..b0a8e7b
--- /dev/null
+++ b/gradle/gradle-daemon-jvm.properties
@@ -0,0 +1,3 @@
+#This file is generated by updateDaemonJvm
+toolchainVendor=oracle
+toolchainVersion=21
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 783705e..a0573ea 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,8 +1,10 @@
# gradle/libs.versions.toml
[versions]
-agp = "8.4.2"
-kotlin = "1.9.24"
-ksp = "1.9.24-1.0.20"
+agp = "8.7.3"
+kotlin = "2.0.21"
+ksp = "2.0.21-1.0.26"
+hilt = "2.51.1"
+hiltNavigationCompose = "1.2.0"
composeCompiler = "1.5.14"
composeBom = "2024.06.00"
@@ -29,6 +31,10 @@ junit = "4.13.2"
junitExt = "1.1.5"
espressoCore = "3.5.1"
+# Supabase & Ktor
+supabase = "2.5.0"
+ktor = "2.3.12"
+
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
@@ -67,6 +73,19 @@ google-material = { group = "com.google.android.material", name = "material", ve
kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
+# Supabase Libraries
+supabase-postgrest = { group = "io.github.jan-tennert.supabase", name = "postgrest-kt", version.ref = "supabase" }
+supabase-auth = { group = "io.github.jan-tennert.supabase", name = "gotrue-kt", version.ref = "supabase" }
+supabase-realtime = { group = "io.github.jan-tennert.supabase", name = "realtime-kt", version.ref = "supabase" }
+ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
+ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
+
+# Hilt
+hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
+hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
+hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
+
test-junit = { group = "junit", name = "junit", version.ref = "junit" }
androidTest-junit-ext = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }
androidTest-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -75,6 +94,8 @@ androidTest-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
\ No newline at end of file
+google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index e846b64..ecd1dc1 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,4 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
-zipStoreBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
zipStorePath=wrapper/dists
\ No newline at end of file
diff --git a/keystore.properties.example b/keystore.properties.example
new file mode 100644
index 0000000..6283ad0
--- /dev/null
+++ b/keystore.properties.example
@@ -0,0 +1,4 @@
+RELEASE_STORE_FILE=app/release-key.jks
+RELEASE_STORE_PASSWORD=your_keystore_password
+RELEASE_KEY_ALIAS=your_key_alias
+RELEASE_KEY_PASSWORD=your_key_password
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 70a3684..a529124 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,36 +1,32 @@
+// Forcefully clear conflicting Android environment variables
+System.clearProperty("ANDROID_PREFS_ROOT")
+System.clearProperty("ANDROID_USER_HOME")
+
pluginManagement {
repositories {
- // رسمیها (بهتره اول باشند برای اطمینان و امنیت)
- gradlePluginPortal()
google()
mavenCentral()
- // آینهٔ مِیونسنترال (گوگل)
- maven("https://maven-central.storage-download.googleapis.com/maven2/")
-
- // آینههای Aliyun (برای مواقع تحریم/اختلال)
- maven("https://maven.aliyun.com/repository/gradle-plugin")
- maven("https://maven.aliyun.com/repository/google")
- maven("https://maven.aliyun.com/repository/public")
+ gradlePluginPortal()
+ maven { url = uri("https://maven.aliyun.com/repository/google") }
+ maven { url = uri("https://maven.aliyun.com/repository/public") }
+ maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
+ maven { url = uri("https://maven.myket.ir/") }
}
}
-
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0"
+}
dependencyResolutionManagement {
- repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
- // رسمیها
google()
mavenCentral()
- // آینهٔ مِیونسنترال (گوگل)
- maven("https://maven-central.storage-download.googleapis.com/maven2/")
-
- // JitPack برای PersianDate
- maven("https://jitpack.io")
-
- // آینههای Aliyun
- maven("https://maven.aliyun.com/repository/google")
- maven("https://maven.aliyun.com/repository/public")
+ maven { url = uri("https://maven.aliyun.com/repository/google") }
+ maven { url = uri("https://maven.aliyun.com/repository/public") }
+ maven { url = uri("https://jitpack.io") }
+ maven { url = uri("https://maven.myket.ir/") }
}
}
rootProject.name = "MonthlyPaymentApp"
-include(":app", ":PersianDate")
\ No newline at end of file
+include(":app", ":PersianDate")
diff --git a/update.json b/update.json
index 9cb5d0d..49e7283 100644
--- a/update.json
+++ b/update.json
@@ -1,7 +1,7 @@
{
"versionCode": 1,
"versionName": "1.0.0",
- "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.0/MonthlyPaymentApp-v1.0.0.apk",
+ "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.0/MonthlyPaymentApp.apk",
"releaseNotes": "نسخه اولیه برنامه مدیریت حقوق ماهانه:\n- مدیریت لیست افراد\n- تفکیک وضعیت پرداخت\n- سیستم آپدیت خودکار\n- مدیریت دسترسیهای اجباری",
"isForceUpdate": false
}