Add auto-release and update system

This commit is contained in:
oqba26 committed 2026-07-31 21:42:29 +03:30
1 parent 502cdacb44
commit 0557679e9c
125 files changed
+6768 -1649

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. تست فرآیند دانلود و باز شدن صفحه نصب اندروید.
+45
View File
@@ -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 }}
+2
View File
@@ -13,3 +13,5 @@
.externalNativeBuild
.cxx
local.properties
keystore.properties
*.jks
-27
View File
@@ -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>
-7
View File
@@ -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>
-1
View File
@@ -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
View File
@@ -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>
-6
View File
@@ -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>
+8
View File
@@ -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>
+2 -3
View File
@@ -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>
+11
View File
@@ -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
View File
@@ -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>
+4 -4
View File
@@ -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
@@ -0,0 +1 @@
o/classes
@@ -0,0 +1 @@
o/classes
@@ -0,0 +1 @@
o/bundleLibRuntimeToDirDebug
@@ -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>
@@ -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"
}
@@ -0,0 +1,6 @@
aarFormatVersion=1.0
aarMetadataVersion=1.0
minCompileSdk=1
minCompileSdkExtension=0
minAndroidGradlePluginVersion=1.0.0
coreLibraryDesugaringEnabled=false
@@ -0,0 +1 @@
int string app_name 0x0
@@ -0,0 +1 @@
#Fri Jul 31 20:09:38 GMT+03:30 2026
@@ -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:.*:&lt;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:.*:&lt;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:.*:&lt;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:.*:&lt;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:.*:&lt;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:.*:&lt;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:.*:&lt;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:.*:&lt;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:.*:&lt;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:.*:&lt;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:.*:&lt;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:.*:&lt;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:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="E:\AndroidStudioProjects\MonthlyPaymentApp\PersianDate\build\intermediates\shader_assets\debug\compileDebugShaders\out"/></dataSet></merger>
@@ -0,0 +1,3 @@
R_DEF: Internal format may change without notice
local
string app_name
@@ -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>
@@ -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>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">PersianDate</string>
</resources>
@@ -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
+9
View File
@@ -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
View File
@@ -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)
}
}
+14
View File
@@ -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()
LaunchedEffect(Unit) {
viewModel.toastMessage.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
// Update and Permission States
val updateManager = remember { UpdateManager(context) }
var updateInfo by remember { mutableStateOf<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
}
}
Crossfade(targetState = authState, label = "AuthScreenSwitch") { state ->
when (state) {
is AuthState.Loading -> LoadingScreen()
is AuthState.Unauthenticated -> AuthScreen()
is AuthState.Authenticated -> AuthenticatedContent(viewModel)
LaunchedEffect(Unit) {
delay(2000.milliseconds)
updateInfo = updateManager.checkForUpdate()
val allGranted = requiredPermissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
if (!allGranted) {
showPermissionsDialog = true
}
}
LaunchedEffect(Unit) {
launch {
viewModel.toastMessage.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
launch {
viewModel.infoMessage.collect { message ->
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
}
// UI elements for update and permissions
if (showPermissionsDialog) {
PermissionsDialog(
onRequestPermissions = { permissionLauncher.launch(requiredPermissions) },
onExitClick = { (context as? ComponentActivity)?.finish() }
)
}
updateInfo?.let { info ->
UpdateDialog(
updateInfo = info,
onDismiss = { updateInfo = null },
onConfirm = {
val id = updateManager.downloadAndInstall(info.url, "MonthlyPaymentApp_v${info.versionName}.apk")
if (id != -1L) {
isDownloading = true
scope.launch {
updateManager.getDownloadProgress(id).collect { progress ->
downloadProgress = progress
if (progress >= 1f) isDownloading = false
}
}
updateInfo = null
}
}
)
}
if (isDownloading) {
Dialog(onDismissRequest = { }) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
tonalElevation = 6.dp,
modifier = Modifier.fillMaxWidth().padding(16.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "در حال دریافت به‌روزرسانی",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp)
)
LinearProgressIndicator(
progress = { downloadProgress },
modifier = Modifier.fillMaxWidth().height(8.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${(downloadProgress * 100).toInt()}%",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
// استفاده از when ساده به جای Crossfade برای جلوگیری از پرش‌های لحظه‌ای
when (authState) {
is AuthState.Loading -> LoadingScreen()
is AuthState.Unauthenticated -> AuthScreen()
is AuthState.Authenticated -> AuthenticatedContent(viewModel, contactViewModel)
}
}
@Composable
fun AuthenticatedContent(viewModel: PersonViewModel) {
fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactViewModel) {
val navController = rememberNavController()
var showExitDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
@@ -141,31 +281,138 @@ fun AuthenticatedContent(viewModel: PersonViewModel) {
ExitDialog(
onConfirmExit = { (context as? ComponentActivity)?.finish() },
onDismiss = {
@Suppress("AssignedValueIsNeverRead")
showExitDialog = false
}
)
}
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
var showConfirmMoveDialog by remember { mutableStateOf(false) }
if (showConfirmMoveDialog) {
Dialog(
onDismissRequest = { showConfirmMoveDialog = false }
) {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "تایید نهایی جابجایی",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "آیا از ثبت تغییرات در ترتیب افراد مطمئن هستید؟",
style = MaterialTheme.typography.bodyMedium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
val listType = when (navController.currentBackStackEntry?.destination?.route) {
Screen.Paid.route -> PersonListType.PAID
else -> PersonListType.UNPAID
}
viewModel.onEvent(PersonScreenEvent.CommitReorder(listType))
viewModel.onEvent(PersonScreenEvent.ClearSelection)
showConfirmMoveDialog = false
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("بله، ذخیره کن")
}
Button(
onClick = { showConfirmMoveDialog = false },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Text("لغو")
}
}
}
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("مدیریت حقوق ماهانه") },
title = {
val titleText = when {
isSelectionMode -> "انتخاب شده‌ها"
currentRoute == Screen.Settings.route -> "تنظیمات"
else -> "مدیریت حقوق ماهانه"
}
Text(titleText)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = Color.White,
actionIconContentColor = Color.White
containerColor = if (isSelectionMode) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.primary,
titleContentColor = if (isSelectionMode) MaterialTheme.colorScheme.onSecondaryContainer else Color.White,
actionIconContentColor = if (isSelectionMode) MaterialTheme.colorScheme.onSecondaryContainer else Color.White
),
navigationIcon = {
if (isSelectionMode) {
IconButton(onClick = { viewModel.onEvent(PersonScreenEvent.ClearSelection) }) {
Icon(Icons.Default.Close, contentDescription = "Clear selection")
}
}
},
actions = {
if (currentRoute == Screen.Unpaid.route) {
Button(
if (isSelectionMode) {
val listType = when (currentRoute) {
Screen.Paid.route -> PersonListType.PAID
else -> PersonListType.UNPAID
}
IconButton(onClick = { viewModel.onEvent(PersonScreenEvent.MoveSelected(-1, listType)) }) {
Icon(Icons.Default.ArrowUpward, contentDescription = "Move Up")
}
IconButton(onClick = { viewModel.onEvent(PersonScreenEvent.MoveSelected(1, listType)) }) {
Icon(Icons.Default.ArrowDownward, contentDescription = "Move Down")
}
IconButton(onClick = { showConfirmMoveDialog = true }) {
Icon(Icons.Default.Check, contentDescription = "Confirm Move", tint = Color.Green)
}
} else if (currentRoute == Screen.Unpaid.route) {
FilledIconButton(
onClick = { viewModel.onAddPersonClicked() },
modifier = Modifier.padding(horizontal = 8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White
)
) {
Text("افزودن شخص")
Icon(Icons.Default.Add, contentDescription = "افزودن شخص")
}
FilledIconButton(
onClick = { contactViewModel.onBulkSmsClicked() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White
),
modifier = Modifier.padding(start = 4.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Message,
contentDescription = "Bulk SMS",
modifier = Modifier.graphicsLayer(scaleX = -1f)
)
}
}
}
@@ -175,7 +422,8 @@ fun AuthenticatedContent(viewModel: PersonViewModel) {
if (showBottomBar) {
AppBottomNavigationBar(navController = navController)
}
}
},
/* Floating Action Button removed and moved to TopAppBar actions */
) { innerPadding ->
NavHost(
navController = navController,
@@ -185,17 +433,23 @@ fun AuthenticatedContent(viewModel: PersonViewModel) {
composable(Screen.Unpaid.route) {
PersonScreen(
viewModel = viewModel,
contactViewModel = contactViewModel,
navController = navController
)
}
composable(Screen.Paid.route) {
PaidListScreen(viewModel, navController)
PaidListScreen(
viewModel = viewModel,
contactViewModel = contactViewModel,
navController = navController
)
}
composable(Screen.Archive.route) {
ArchiveScreen(viewModel)
}
composable(Screen.Settings.route) {
SettingsScreen(onLogout = { viewModel.logout() })
val authViewModel: AuthViewModel = hiltViewModel()
SettingsScreen(onLogout = { authViewModel.logout() })
}
composable(
route = "person_detail/{personId}",
@@ -206,6 +460,7 @@ fun AuthenticatedContent(viewModel: PersonViewModel) {
PersonDetailScreen(
personId = personId,
viewModel = viewModel,
contactViewModel = contactViewModel,
navController = navController
)
}
@@ -268,32 +523,52 @@ sealed class Screen(
@Composable
fun ExitDialog(onConfirmExit: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("خروج از برنامه") },
text = { Text("آیا برای خروج از برنامه مطمئن هستید؟") },
confirmButton = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(all = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
Dialog(
onDismissRequest = onDismiss
) {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp)
) {
Button(
onClick = onConfirmExit,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("بله")
}
Button(onClick = onDismiss) {
Text("خیر")
Text(
text = stringResource(R.string.exit_app),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = stringResource(R.string.exit_app_confirm),
style = MaterialTheme.typography.bodyMedium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onConfirmExit,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text(stringResource(R.string.yes))
}
Button(onClick = onDismiss) {
Text(stringResource(R.string.no))
}
}
}
}
},
dismissButton = {}
)
}
}
}
@Composable
@@ -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 {
ignoreUnknownKeys = true
isLenient = true
}
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)
httpConfig {
install(HttpTimeout) {
requestTimeoutMillis = 30000
connectTimeoutMillis = 30000
socketTimeoutMillis = 30000
}
}
private val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
}
// ⭐️ افزودن AuthInterceptor به OkHttpClient
private val client: OkHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(logging)
.build()
val api: ApiService by lazy {
val contentType = "application/json".toMediaType()
Retrofit.Builder()
.baseUrl("http://167.235.136.65:8080/") // آدرس سرور
.client(client)
.addConverterFactory(json.asConverterFactory(contentType))
.build()
.create(ApiService::class.java)
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 {
@@ -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)
@@ -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
supabase.from("persons").update(
mapOf("displayOrder" to displayOrder)
) {
filter { eq("id", personId) }
}
true
} catch (e: Exception) {
Log.e("API", "updatePersonDisplayOrder error", e); false
}
}
@Suppress("unused")
suspend fun rebalanceDisplayOrders(): Boolean {
return try {
api.rebalanceDisplayOrders().isSuccessful
} catch (e: Exception) {
Log.e("API", "rebalanceDisplayOrders error", e)
false
Log.e("Supabase", "updateOrder error", e); false
}
}
@@ -107,17 +182,21 @@ class NetworkRepository(private val api: ApiService) {
// --- Payment Operations ---
suspend fun addPayment(paymentRecord: PaymentRecord): Boolean {
return try {
api.addPayment(paymentRecord).isSuccessful
supabase.from("payments").insert(paymentRecord)
true
} catch (e: Exception) {
Log.e("API", "addPayment error", e); false
Log.e("Supabase", "addPayment error", e); false
}
}
suspend fun deletePayment(paymentId: String): Boolean {
return try {
api.deletePayment(paymentId).isSuccessful
supabase.from("payments").delete {
filter { eq("id", paymentId) }
}
true
} catch (e: Exception) {
Log.e("API", "deletePayment error", e); false
Log.e("Supabase", "deletePayment error", e); false
}
}
@@ -125,16 +204,19 @@ class NetworkRepository(private val api: ApiService) {
suspend fun refresh() {
try {
val persons = api.getPersons()
// دریافت و تبدیل خودکار لیست اشخاص
val persons = supabase.from("persons").select().decodeList<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,18 +204,31 @@ fun ArchivedPersonListItem(
Text(
text = person.name,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
Button(
onClick = onRestore,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(Icons.Default.Restore, contentDescription = "بازیابی")
Spacer(Modifier.width(4.dp))
Text("بازیابی")
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "حذف قطعی",
tint = MaterialTheme.colorScheme.error
)
}
Spacer(Modifier.width(8.dp))
Button(
onClick = onRestore,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(Icons.Default.Restore, contentDescription = "بازیابی")
Spacer(Modifier.width(4.dp))
Text("بازیابی")
}
}
}
}
@@ -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)
)
personToArchive.value = null
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Text("آرشیو")
Dialog(onDismissRequest = { personToArchive.value = null }) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Card(modifier = Modifier.fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(24.dp)) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(text = "انتقال به آرشیو", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(text = "آیا از انتقال ${person.name} به آرشیو مطمئن هستید؟", style = MaterialTheme.typography.bodyMedium)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Button(onClick = { personToArchive.value = null }) { Text("لغو") }
Button(onClick = {
viewModel.onEvent(PersonScreenEvent.ArchivePerson(person.id))
personToArchive.value = null
}, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) {
Text("آرشیو")
}
}
}
}
},
dismissButton = {}
)
}
}
}
}
}
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more