Add auto-release and update system
This commit is contained in:
1 parent
502cdacb44
commit
0557679e9c
125 files changed
+6569
-1450
No files matched your search
@@ -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** شروع کنیم که تغییراتی کمریسک اما بسیار موثر در تمیزی پروژه هستند.
|
||||
@@ -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).
|
||||
- بررسی نمایش صحیح متون در صفحات مختلف.
|
||||
@@ -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]
|
||||
> با این ساختار جدید، افزودن ویژگیهای جدید (مثل ارسال یادآور از طریق پیامرسانهای دیگر یا افزودن فیلترهای جدید به لیست) بسیار سریعتر و با ریسک کمتر انجام خواهد شد.
|
||||
@@ -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. تست فرآیند دانلود و باز شدن صفحه نصب اندروید.
|
||||
@@ -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 }}
|
||||
@@ -13,3 +13,5 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
keystore.properties
|
||||
*.jks
|
||||
Generated
-27
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AppInsightsSettings">
|
||||
<option name="selectedTabId" value="Firebase Crashlytics" />
|
||||
<option name="tabSettings">
|
||||
<map>
|
||||
<entry key="Firebase Crashlytics">
|
||||
<value>
|
||||
<InsightsFilterSettings>
|
||||
<option name="connection">
|
||||
<ConnectionSetting>
|
||||
<option name="appId" value="com.oqba26.monthlypaymentapp" />
|
||||
<option name="mobileSdkAppId" value="1:302483616421:android:c1ba5fe18311c90a0ad34c" />
|
||||
<option name="projectId" value="monthlypaymentapp-c52fc" />
|
||||
<option name="projectNumber" value="302483616421" />
|
||||
</ConnectionSetting>
|
||||
</option>
|
||||
<option name="signal" value="SIGNAL_UNSPECIFIED" />
|
||||
<option name="timeIntervalDays" value="THIRTY_DAYS" />
|
||||
<option name="visibilityType" value="ALL" />
|
||||
</InsightsFilterSettings>
|
||||
</value>
|
||||
</entry>
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-7
@@ -4,13 +4,6 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2025-11-20T10:05:59.804854700Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="Default" identifier="serial=192.168.1.101:40247;connection=cd7687f9" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
|
||||
Generated
-1
@@ -6,7 +6,6 @@
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
|
||||
+31
-8
@@ -1,13 +1,37 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
@@ -16,17 +40,16 @@
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
||||
<option name="processCode" value="true" />
|
||||
<option name="processLiterals" value="true" />
|
||||
<option name="processComments" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="KotlinJpsPluginSettings">
|
||||
<option name="version" value="1.9.24" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MarkdownSettings">
|
||||
<option name="previewPanelProviderInfo">
|
||||
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+2
-3
@@ -1,8 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK" />
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
|
||||
Generated
+11
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PlanningModeManager">
|
||||
<option name="approvalStates">
|
||||
<map>
|
||||
<entry key="15926d7b-894e-47f6-892f-6114218cde2d" value="false" />
|
||||
<entry key="b6b8bd31-26e8-4cfd-8648-5c332ef07ef3" value="false" />
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
o/bundleLibRuntimeToDirDebug
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
o/classes
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
o/classes
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
o/bundleLibRuntimeToDirDebug
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.github.samanzamani.persiandate" >
|
||||
|
||||
<uses-sdk android:minSdkVersion="21" />
|
||||
|
||||
<application android:label="@string/app_name" >
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
+18
@@ -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"
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
aarFormatVersion=1.0
|
||||
aarMetadataVersion=1.0
|
||||
minCompileSdk=1
|
||||
minCompileSdkExtension=0
|
||||
minAndroidGradlePluginVersion=1.0.0
|
||||
coreLibraryDesugaringEnabled=false
|
||||
+1
@@ -0,0 +1 @@
|
||||
{}
|
||||
BIN
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
int string app_name 0x0
|
||||
+1
@@ -0,0 +1 @@
|
||||
#Fri Jul 31 20:09:38 GMT+03:30 2026
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">PersianDate</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merger version="3"><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main" generated-set="main$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\res"><file path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\res\values\strings.xml" qualifiers=""><string name="app_name">PersianDate</string></file></source></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\debug\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug" generated-set="debug$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\debug\res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\build\generated\res\resValues\debug"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated" generated-set="generated$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\build\generated\res\resValues\debug"/></dataSet><mergedItems/></merger>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\jniLibs"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\debug\jniLibs"/></dataSet></merger>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\shaders"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\debug\shaders"/></dataSet></merger>
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\assets"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\debug\assets"/></dataSet><dataSet config="generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:<dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\build\intermediates\shader_assets\debug\compileDebugShaders\out"/></dataSet></merger>
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+3
@@ -0,0 +1,3 @@
|
||||
R_DEF: Internal format may change without notice
|
||||
local
|
||||
string app_name
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
1<?xml version="1.0" encoding="utf-8"?>
|
||||
2<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
3 package="com.github.samanzamani.persiandate" >
|
||||
4
|
||||
5 <uses-sdk android:minSdkVersion="21" />
|
||||
6
|
||||
7 <application android:label="@string/app_name" >
|
||||
7-->E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml:5:5-7:19
|
||||
7-->E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\src\main\AndroidManifest.xml:5:18-50
|
||||
8 </application>
|
||||
9
|
||||
10</manifest>
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.github.samanzamani.persiandate" >
|
||||
|
||||
<uses-sdk android:minSdkVersion="21" />
|
||||
|
||||
<application android:label="@string/app_name" >
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
+1
@@ -0,0 +1 @@
|
||||
[]
|
||||
+1
@@ -0,0 +1 @@
|
||||
0 Warning/Error
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">PersianDate</string>
|
||||
</resources>
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
@@ -0,0 +1,2 @@
|
||||
com.github.samanzamani.persiandate
|
||||
string app_name
|
||||
@@ -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
|
||||
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
|
||||
>
|
||||
|
||||
<application android:label="@string/app_name">
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<Integer> JalaliDate = new ArrayList<Integer>() {{
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">PersianDate</string>
|
||||
</resources>
|
||||
@@ -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<String, PersianDate> 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<String, PersianDate> 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());
|
||||
}
|
||||
|
||||
}
|
||||
+52
-17
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
|
||||
<application
|
||||
android:name=".core.PaymentApplication"
|
||||
@@ -25,6 +29,16 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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()
|
||||
|
||||
// Update and Permission States
|
||||
val updateManager = remember { UpdateManager(context) }
|
||||
var updateInfo by remember { mutableStateOf<UpdateInfo?>(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
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = authState, label = "AuthScreenSwitch") { state ->
|
||||
when (state) {
|
||||
// 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("مدیریت حقوق ماهانه") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
titleContentColor = Color.White,
|
||||
actionIconContentColor = Color.White
|
||||
),
|
||||
actions = {
|
||||
if (currentRoute == Screen.Unpaid.route) {
|
||||
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 = { viewModel.onAddPersonClicked() },
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
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("افزودن شخص")
|
||||
Text("لغو")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
val titleText = when {
|
||||
isSelectionMode -> "انتخاب شدهها"
|
||||
currentRoute == Screen.Settings.route -> "تنظیمات"
|
||||
else -> "مدیریت حقوق ماهانه"
|
||||
}
|
||||
Text(titleText)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
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 (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() },
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
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,16 +523,35 @@ sealed class Screen(
|
||||
|
||||
@Composable
|
||||
fun ExitDialog(onConfirmExit: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("خروج از برنامه") },
|
||||
text = { Text("آیا برای خروج از برنامه مطمئن هستید؟") },
|
||||
confirmButton = {
|
||||
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 = 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()
|
||||
.padding(all = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onConfirmExit,
|
||||
@@ -285,15 +559,16 @@ fun ExitDialog(onConfirmExit: () -> Unit, onDismiss: () -> Unit) {
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("بله")
|
||||
Text(stringResource(R.string.yes))
|
||||
}
|
||||
Button(onClick = onDismiss) {
|
||||
Text("خیر")
|
||||
Text(stringResource(R.string.no))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -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 <T : ViewModel> create(modelClass: Class<T>): 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 <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) {
|
||||
return SettingsViewModel(settingsRepository, localPersonRepository) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
}
|
||||
@HiltAndroidApp
|
||||
class PaymentApplication : Application()
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Person>, payments: List<PaymentRecord>): 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ interface PaymentDao {
|
||||
@Query("SELECT * FROM payments ORDER BY timestamp DESC")
|
||||
fun getAllPaymentsFlow(): Flow<List<PaymentRecord>>
|
||||
|
||||
@Query("SELECT * FROM payments ORDER BY timestamp DESC")
|
||||
suspend fun getAllPayments(): List<PaymentRecord>
|
||||
|
||||
@Query("SELECT * FROM payments WHERE personId = :personId ORDER BY timestamp DESC")
|
||||
fun getPaymentsForPersonFlow(personId: String): Flow<List<PaymentRecord>>
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ interface PersonDao {
|
||||
@Query("SELECT * FROM persons ORDER BY displayOrder ASC")
|
||||
fun getAllPersonsFlow(): Flow<List<Person>>
|
||||
|
||||
@Query("SELECT * FROM persons ORDER BY displayOrder ASC")
|
||||
suspend fun getAllPersons(): List<Person>
|
||||
|
||||
@Query("SELECT * FROM persons WHERE id = :id LIMIT 1")
|
||||
fun getPersonByIdFlow(id: String): Flow<Person?>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
|
||||
package com.oqba26.monthlypaymentapp.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -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<Person>,
|
||||
|
||||
@@ -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 = ""
|
||||
@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
|
||||
)
|
||||
@@ -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? = ""
|
||||
)
|
||||
@@ -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 {
|
||||
val client = createSupabaseClient(
|
||||
supabaseUrl = BuildConfig.SUPABASE_URL,
|
||||
supabaseKey = BuildConfig.SUPABASE_KEY
|
||||
) {
|
||||
defaultSerializer = KotlinXSerializer(Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
encodeDefaults = true
|
||||
})
|
||||
|
||||
// ⭐️ ایجاد اینترسپتور احراز هویت
|
||||
private val authInterceptor = AuthInterceptor(settingsRepository)
|
||||
|
||||
private val logging = HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
|
||||
httpConfig {
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 30000
|
||||
connectTimeoutMillis = 30000
|
||||
socketTimeoutMillis = 30000
|
||||
}
|
||||
}
|
||||
|
||||
// ⭐️ افزودن 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)
|
||||
install(Postgrest)
|
||||
install(Auth)
|
||||
install(Realtime)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.oqba26.monthlypaymentapp.data.remote
|
||||
|
||||
import com.oqba26.monthlypaymentapp.data.model.AuthRequest
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
@Suppress("unused")
|
||||
class AuthInterceptor(
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : Interceptor {
|
||||
|
||||
+7
-5
@@ -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)
|
||||
|
||||
+136
-54
@@ -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<List<Person>>(emptyList())
|
||||
private val _paymentsFlow = MutableStateFlow<List<PaymentRecord>>(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<PostgresAction>(schema = "public") {
|
||||
table = "persons"
|
||||
}.collect {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// گوش دادن به تغییرات جدول پرداختها
|
||||
launch {
|
||||
myChannel.postgresChangeFlow<PostgresAction>(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
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "updatePersonDisplayOrder error", e); false
|
||||
supabase.from("persons").update(
|
||||
mapOf("displayOrder" to displayOrder)
|
||||
) {
|
||||
filter { eq("id", personId) }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
suspend fun rebalanceDisplayOrders(): Boolean {
|
||||
return try {
|
||||
api.rebalanceDisplayOrders().isSuccessful
|
||||
true
|
||||
} 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<Person>()
|
||||
_personsFlow.value = persons
|
||||
|
||||
// دریافت و تبدیل خودکار لیست پرداختها
|
||||
val payments = supabase.from("payments").select {
|
||||
order("timestamp", order = Order.DESCENDING)
|
||||
}.decodeList<PaymentRecord>()
|
||||
_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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Int?> = dataStore.data.map {
|
||||
it[REMINDER_DAY]?.toIntOrNull()
|
||||
}
|
||||
|
||||
val cardNumbersFlow: Flow<List<String>> = dataStore.data.map {
|
||||
val raw = it[CARD_NUMBERS] ?: ""
|
||||
if (raw.isBlank()) emptyList() else raw.split("|")
|
||||
}
|
||||
|
||||
suspend fun saveCardNumbers(cards: List<String>) {
|
||||
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<Double> = dataStore.data.map {
|
||||
it[DEFAULT_PAYMENT_AMOUNT] ?: FALLBACK_AMOUNT
|
||||
}
|
||||
@@ -65,4 +91,18 @@ class SettingsRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ignoredContactSuggestionsFlow: Flow<Set<String>> = 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(",")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("بعداً")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PersonUiModel?>(null) }
|
||||
var personToRestore by remember { mutableStateOf<PersonUiModel?>(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,9 +204,21 @@ fun ArchivedPersonListItem(
|
||||
Text(
|
||||
text = person.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -89,3 +231,4 @@ fun ArchivedPersonListItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<PersonUiModel?>(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)
|
||||
)
|
||||
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
|
||||
)
|
||||
) {
|
||||
}, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) {
|
||||
Text("آرشیو")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user