From c7065295b824a68338227382012dfc8512fe5138 Mon Sep 17 00:00:00 2001 From: Oqba Date: Mon, 2 Nov 2026 22:05:53 +0330 Subject: [PATCH] Fix payment duplicate and sync resurrection bug, update Gitea CI/CD and bump to v1.1.6 --- .github/workflows/release.yml | 33 +- app/build.gradle.kts | 6 +- .../oqba26/monthlypaymentapp/MainActivity.kt | 29 +- .../data/remote/ApiService.kt | 62 -- .../data/remote/AuthInterceptor.kt | 31 - .../data/repository/LocalPersonRepository.kt | 41 +- .../ui/components/PersonDialogs.kt | 685 ++++++++++++++++++ .../ui/screens/PersonScreen.kt | 639 +--------------- .../monthlypaymentapp/utils/UpdateManager.kt | 2 +- .../viewmodel/PersonViewModel.kt | 90 ++- update.json | 8 +- 11 files changed, 851 insertions(+), 775 deletions(-) delete mode 100644 app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt delete mode 100644 app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt create mode 100644 app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/PersonDialogs.kt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 042f260..3ea82d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,10 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 - - name: set up JDK 21 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: java-version: '21' @@ -40,13 +41,33 @@ jobs: run: | mv app/build/outputs/apk/release/app-release.apk app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk - - name: Create Release + - name: Upload APK Artifact + uses: actions/upload-artifact@v4 + with: + name: MonthlyPaymentApp-${{ github.ref_name }} + path: app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk + + - name: Create Release (GitHub) + if: contains(github.server_url, 'github.com') uses: softprops/action-gh-release@v2 with: files: app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Create Release (Gitea) + if: "!contains(github.server_url, 'github.com')" + uses: akkuman/gitea-release-action@v1 + env: + NODE_OPTIONS: '--experimental-fetch' + with: + files: app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk + name: "Release ${{ github.ref_name }}" + tag_name: ${{ github.ref_name }} + token: ${{ secrets.GITHUB_TOKEN }} + draft: false + prerelease: false + - name: Update update.json after release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -55,8 +76,8 @@ jobs: VERSION_NAME="${TAG_NAME#v}" VERSION_CODE=$(grep 'versionCode' app/build.gradle.kts | head -n 1 | awk '{print $3}') - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "actions-bot" + git config user.email "bot@mizbanplatform.ir" git fetch origin main git checkout main @@ -64,7 +85,7 @@ jobs: { "versionCode": ${VERSION_CODE}, "versionName": "${VERSION_NAME}", - "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/${TAG_NAME}/MonthlyPaymentApp-${TAG_NAME}.apk", + "url": "${{ github.server_url }}/${{ github.repository }}/releases/download/${TAG_NAME}/MonthlyPaymentApp-${TAG_NAME}.apk", "releaseNotes": "بهبودهای پایداری و عملکرد همگام‌سازی", "isForceUpdate": false } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9171ea5..c1094ee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { applicationId = "com.oqba26.monthlypaymentapp" minSdk = 25 targetSdk = 34 - versionCode = 16 - versionName = "1.1.5" + versionCode = 17 + versionName = "1.1.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } } @@ -126,11 +126,9 @@ dependencies { implementation(libs.kotlinx.coroutines.android) // Networking - implementation(libs.retrofit.core) implementation(libs.okhttp) implementation(libs.okhttp.logging) implementation(libs.kotlin.serialization.json) - implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") // Database (Room) implementation(libs.androidx.room.runtime) diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt index 7bc60b4..8300814 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt @@ -333,9 +333,33 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi Screen.Settings.route ) - // هندل کردن دکمه Back + val isSelectionMode by viewModel.isSelectionMode.collectAsState() + + // هندل کردن دکمه Back: + // ۱. اگر در حالت انتخاب آیتم‌ها باشد، لغو انتخاب + // ۲. اگر در صفحه‌ای غیر از تب اصلی «پرداخت نشده» باشد، بازگشت به صفحه قبل / تب پرداخت نشده + // ۳. فقط در صفحه اصلی «پرداخت نشده»، دیالوگ خروج نمایش داده می‌شود BackHandler { - showExitDialog = true + when { + isSelectionMode -> { + viewModel.onEvent(PersonScreenEvent.ClearSelection) + } + currentRoute != Screen.Unpaid.route -> { + val popped = navController.popBackStack() + if (!popped) { + navController.navigate(Screen.Unpaid.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + } + else -> { + showExitDialog = true + } + } } if (showExitDialog) { @@ -347,7 +371,6 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi ) } - val isSelectionMode by viewModel.isSelectionMode.collectAsState() var showConfirmMoveDialog by remember { mutableStateOf(false) } if (showConfirmMoveDialog) { diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt deleted file mode 100644 index b407ed6..0000000 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt +++ /dev/null @@ -1,62 +0,0 @@ -@file:Suppress("unused") - -package com.oqba26.monthlypaymentapp.data.remote - -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 retrofit2.Response -import retrofit2.http.Body -import retrofit2.http.DELETE -import retrofit2.http.GET -import retrofit2.http.PATCH -import retrofit2.http.POST -import retrofit2.http.PUT -import retrofit2.http.Path -import retrofit2.http.Query - -interface ApiService { - - // ⭐️ NEW: Auth Routes - @POST("auth/register") - suspend fun register(@Body request: AuthRequest): AuthResponse - - @POST("auth/login") - suspend fun login(@Body request: AuthRequest): AuthResponse - - // Persons (AuthInterceptor توکن را به هدر اضافه می‌کند) - @GET("persons") - suspend fun getPersons(): List - - @POST("persons") - suspend fun addPerson(@Body person: Person): Response - - // ⭐️ NEW: مسیر PUT برای ویرایش نام - @PUT("persons/{id}") - suspend fun updatePerson(@Path("id") personId: String, @Body person: Person): Response - - @PATCH("persons/{id}/archived") - suspend fun updatePersonArchivedStatus(@Path("id") personId: String, @Body isArchived: Map): Response - - @PATCH("persons/{id}/display-order") - suspend fun updatePersonDisplayOrder(@Path("id") personId: String, @Body displayOrder: Map): Response - - @POST("persons/rebalance") - suspend fun rebalanceDisplayOrders(): Response - - @DELETE("persons/{id}") - suspend fun deletePersonAndPayments(@Path("id") personId: String): Response - - // Payments - @GET("payments") - suspend fun getPayments( - @Query("personId") personId: String? = null - ): List - - @POST("payments") - suspend fun addPayment(@Body paymentRecord: PaymentRecord): Response - - @DELETE("payments/{id}") - suspend fun deletePayment(@Path("id") paymentId: String): Response -} \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt deleted file mode 100644 index 42323a0..0000000 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.oqba26.monthlypaymentapp.data.remote - -import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import okhttp3.Interceptor -import okhttp3.Response - -@Suppress("unused") -class AuthInterceptor( - private val settingsRepository: SettingsRepository -) : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - // Get the token from DataStore, blocking the current thread. - // This is acceptable for an I/O-bound operation in an interceptor. - val authToken = runBlocking { - settingsRepository.authTokenFlow.first() // Use first() to wait for a value - } - - val originalRequest = chain.request() - val requestBuilder = originalRequest.newBuilder() - - // Add the Authorization header only if the token is not null or blank - if (!authToken.isNullOrBlank()) { - requestBuilder.header("Authorization", "Bearer $authToken") - } - - return chain.proceed(requestBuilder.build()) - } -} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt index 73f2bbc..78fb2ad 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt @@ -56,21 +56,46 @@ class LocalPersonRepository( if (personPlan.upserts.isNotEmpty()) personDao.insertAll(personPlan.upserts) if (paymentPlan.upserts.isNotEmpty()) paymentDao.insertAllPaymentRecords(paymentPlan.upserts) - // خودترمیمی: اگر رکوردهایی در دیتابیس محلی هستند که در سرور وجود ندارند و در صف هم نیستند، - // آن‌ها را دوباره برای ارسال به سرور ثبت می‌کنیم تا سرور و کلاینت همگام شوند. + // خودترمیمی: فقط رکوردهای محلی که نیاز به سینک دارند (آفلاین ایجاد شده‌اند) و در سرور نیستند ارسال شوند val serverPersonIds = persons.map { it.id }.toSet() - val missingPersons = localPersons.filter { it.id !in serverPersonIds && it.id !in pendingIds } - for (p in missingPersons) { - personDao.insertAll(listOf(p.copy(needsSync = true))) + val offlinePersons = localPersons.filter { it.needsSync && it.id !in serverPersonIds && it.id !in pendingIds } + for (p in offlinePersons) { + personDao.insertAll(listOf(p)) syncQueueDao.insert(SyncQueue(entityId = p.id, type = SyncType.PERSON, operation = SyncOperation.INSERT)) } + // رکوردهایی که قبلاً سینک شده بودند ولی روی سرور حذف شده‌اند، از دیتابیس محلی هم حذف می‌شوند + val deletedPersonIds = localPersons.filter { !it.needsSync && it.id !in serverPersonIds && it.id !in pendingIds }.map { it.id } + if (deletedPersonIds.isNotEmpty()) { + personDao.deleteByIds(deletedPersonIds) + } val serverPaymentIds = payments.map { it.id }.toSet() - val missingPayments = localPayments.filter { it.id !in serverPaymentIds && it.id !in pendingIds } - for (p in missingPayments) { - paymentDao.insertAllPaymentRecords(listOf(p.copy(needsSync = true))) + val offlinePayments = localPayments.filter { it.needsSync && it.id !in serverPaymentIds && it.id !in pendingIds } + for (p in offlinePayments) { + paymentDao.insertAllPaymentRecords(listOf(p)) syncQueueDao.insert(SyncQueue(entityId = p.id, type = SyncType.PAYMENT, operation = SyncOperation.INSERT)) } + val deletedPaymentIds = localPayments.filter { !it.needsSync && it.id !in serverPaymentIds && it.id !in pendingIds }.map { it.id } + if (deletedPaymentIds.isNotEmpty()) { + paymentDao.deleteByIds(deletedPaymentIds) + } + + // پاک‌سازی پرداخت‌های تکراری: برای هر شخص، سال و ماه (غیر از مسجد)، حداکثر ۱ پرداخت نگه‌داری شود + val duplicatePayments = paymentDao.getAllPayments() + .filter { it.category != "mosque" } + .groupBy { Triple(it.personId, it.shamsiYear, it.shamsiMonth) } + .values + .filter { it.size > 1 } + .flatMap { group -> + val sorted = group.sortedByDescending { it.timestamp } + sorted.drop(1).map { it.id } + } + if (duplicatePayments.isNotEmpty()) { + paymentDao.deleteByIds(duplicatePayments) + for (dupId in duplicatePayments) { + syncQueueDao.insert(SyncQueue(entityId = dupId, type = SyncType.PAYMENT, operation = SyncOperation.DELETE)) + } + } } } diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/PersonDialogs.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/PersonDialogs.kt new file mode 100644 index 0000000..ae7ec02 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/PersonDialogs.kt @@ -0,0 +1,685 @@ +package com.oqba26.monthlypaymentapp.ui.components + +import android.Manifest +import android.content.pm.PackageManager +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.core.content.ContextCompat +import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation +import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation +import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth +import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear +import com.oqba26.monthlypaymentapp.utils.getPersianMonthName +import com.oqba26.monthlypaymentapp.utils.toPersianDigits +import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch +import com.oqba26.monthlypaymentapp.viewmodel.ContactSuggestion +import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel + +@Composable +fun ArchiveConfirmationDialog(personName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { + Dialog(onDismissRequest = onDismiss) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp) + ) { + Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(text = "انتقال به آرشیو", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text(text = "آیا از انتقال $personName به آرشیو مطمئن هستید؟", style = MaterialTheme.typography.bodyMedium) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Button(onClick = onDismiss) { Text("لغو") } + Button(onClick = onConfirm, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) { + Text("آرشیو") + } + } + } + } + } + } +} + +@Composable +fun ContactSuggestionDialog( + suggestion: ContactSuggestion, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit +) { + var selectedNumber by remember { mutableStateOf(suggestion.matches.firstOrNull()?.phoneNumber ?: "") } + + Dialog(onDismissRequest = onDismiss) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Card( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "شماره تماس پیدا شد", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Column { + Text("برای «${suggestion.personName}» موارد زیر در مخاطبین پیدا شد:") + Spacer(Modifier.height(12.dp)) + + LazyColumn( + modifier = Modifier.heightIn(max = 300.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(suggestion.matches) { match -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { selectedNumber = match.phoneNumber } + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selectedNumber == match.phoneNumber, + onClick = { selectedNumber = match.phoneNumber } + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = match.nameInContacts, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium + ) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Text( + text = match.phoneNumber.toPersianDigits(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.fillMaxWidth(), + textAlign = androidx.compose.ui.text.style.TextAlign.Right + ) + } + } + } + } + } + + Spacer(Modifier.height(8.dp)) + Text("آیا می‌خواهید شماره انتخاب شده به اطلاعات شخص اضافه شود؟") + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { onConfirm(selectedNumber) }, + enabled = selectedNumber.isNotEmpty() + ) { + Text("بله، اضافه کن") + } + TextButton(onClick = onDismiss) { + Text("خیر") + } + } + } + } + } + } +} + +@Composable +fun AddNewPersonDialog( + isMosqueCategory: Boolean = false, + onConfirm: (String, String, Boolean, Double, Int, Int, Double) -> Unit, + onDismiss: () -> Unit, + onSearchContact: (String) -> List +) { + var name by remember { mutableStateOf("") } + var phoneNumber by remember { mutableStateOf("") } + var isAnonymous by remember { mutableStateOf(value = false) } + var monthlyCommitment by remember { mutableStateOf("") } + var initialPaymentAmount by remember { mutableStateOf("") } + + val currentShamsiYear = getCurrentShamsiYear() + val currentShamsiMonth = getCurrentShamsiMonth() + + var startMonth by remember { mutableIntStateOf(if (isMosqueCategory) currentShamsiMonth else 1) } + var startYear by remember { mutableIntStateOf(currentShamsiYear) } + + var similarContacts by remember { mutableStateOf>(emptyList()) } + val context = LocalContext.current + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + val matches = onSearchContact(name) + if (matches.isNotEmpty()) { + similarContacts = matches + } else { + Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(context, "دسترسی به مخاطبین داده نشد", Toast.LENGTH_SHORT).show() + } + } + + Dialog(onDismissRequest = onDismiss) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Card( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = if (isAnonymous) "ثبت کمک آنی" else "افزودن شخص جدید", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + if (isMosqueCategory) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { isAnonymous = !isAnonymous } + .padding(vertical = 4.dp) + ) { + Checkbox( + checked = isAnonymous, + onCheckedChange = { isAnonymous = it } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("ثبت به صورت خیر ناشناس") + } + } + + if (!isAnonymous) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("نام شخص") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + OutlinedTextField( + value = phoneNumber, + onValueChange = { phoneNumber = it }, + label = { Text("شماره موبایل (اختیاری)") }, + singleLine = true, + modifier = Modifier.weight(1f), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + visualTransformation = PersianDigitsTransformation(), + placeholder = { + Text( + "۰۹---------", + textAlign = androidx.compose.ui.text.style.TextAlign.Right + ) + } + ) + } + Spacer(modifier = Modifier.width(8.dp)) + IconButton( + onClick = { + if (name.isBlank()) { + Toast.makeText(context, "ابتدا نام را وارد کنید", Toast.LENGTH_SHORT) + .show() + return@IconButton + } + when (PackageManager.PERMISSION_GRANTED) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CONTACTS + ) -> { + val matches = onSearchContact(name) + if (matches.isNotEmpty()) { + similarContacts = matches + } else { + Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT) + .show() + } + } + + else -> { + permissionLauncher.launch(Manifest.permission.READ_CONTACTS) + } + } + } + ) { + Icon(Icons.Default.Search, contentDescription = "جستجو در مخاطبین") + } + } + + if (similarContacts.isNotEmpty()) { + Text( + "موارد مشابه پیدا شده:", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + LazyColumn( + modifier = Modifier.heightIn(max = 200.dp) + ) { + items(similarContacts) { match -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + phoneNumber = match.phoneNumber + similarContacts = emptyList() + } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = phoneNumber == match.phoneNumber, + onClick = { + phoneNumber = match.phoneNumber + similarContacts = emptyList() + } + ) + Column { + Text(match.nameInContacts, style = MaterialTheme.typography.bodySmall) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Text( + match.phoneNumber.toPersianDigits(), + style = MaterialTheme.typography.labelSmall, + color = Color.Gray + ) + } + } + } + } + } + } + } + } + + if (isMosqueCategory) { + if (isAnonymous) { + OutlinedTextField( + value = initialPaymentAmount, + onValueChange = { value -> + if (value.length <= 12) { + val digitsOnly = value.filter { it.isDigit() || (it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9') } + initialPaymentAmount = digitsOnly.map { + when (it) { + in '\u0660'..'\u0669' -> (it.code - '\u0660'.code + '0'.code).toChar() + in '\u06f0'..'\u06f9' -> (it.code - '\u06f0'.code + '0'.code).toChar() + else -> it + } + }.joinToString("") + } + }, + label = { Text("مبلغ پرداختی (تومان)") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + visualTransformation = PersianNumberVisualTransformation() + ) + } else { + OutlinedTextField( + value = monthlyCommitment, + onValueChange = { value -> + if (value.length <= 12) { + val digitsOnly = value.filter { it.isDigit() || (it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9') } + monthlyCommitment = digitsOnly.map { + when (it) { + in '\u0660'..'\u0669' -> (it.code - '\u0660'.code + '0'.code).toChar() + in '\u06f0'..'\u06f9' -> (it.code - '\u06f0'.code + '0'.code).toChar() + else -> it + } + }.joinToString("") + } + }, + label = { Text("مبلغ تعهد ماهانه (تومان) - اختیاری") }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + visualTransformation = PersianNumberVisualTransformation(), + placeholder = { Text("اگر مبلغ ثابتی پرداخت می‌کند وارد کنید") } + ) + } + } + + if (!isAnonymous) { + // انتخاب ماه و سال شروع + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("تاریخ شروع تعهد:", style = MaterialTheme.typography.labelLarge) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // سال + Box(modifier = Modifier.weight(1f)) { + var expandedYear by remember { mutableStateOf(false) } + OutlinedButton( + onClick = { expandedYear = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text("سال ${startYear.toString().toPersianDigits()}") + } + DropdownMenu( + expanded = expandedYear, + onDismissRequest = { expandedYear = false }) { + (currentShamsiYear - 1..currentShamsiYear + 1).forEach { y -> + DropdownMenuItem( + text = { Text(y.toString().toPersianDigits()) }, + onClick = { startYear = y; expandedYear = false } + ) + } + } + } + + // ماه + Box(modifier = Modifier.weight(1f)) { + var expandedMonth by remember { mutableStateOf(false) } + OutlinedButton( + onClick = { expandedMonth = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text(getPersianMonthName(startMonth)) + } + DropdownMenu( + expanded = expandedMonth, + onDismissRequest = { expandedMonth = false }) { + (1..12).forEach { m -> + DropdownMenuItem( + text = { Text(getPersianMonthName(m)) }, + onClick = { startMonth = m; expandedMonth = false } + ) + } + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { + if (isAnonymous || name.isNotBlank()) { + val commitment = monthlyCommitment.toDoubleOrNull() ?: 0.0 + val initial = initialPaymentAmount.toDoubleOrNull() ?: 0.0 + onConfirm(name, phoneNumber, isAnonymous, commitment, startMonth, startYear, initial) + } + }, + enabled = isAnonymous || name.isNotBlank() + ) { + Text(if (isAnonymous) "ثبت کمک" else "افزودن") + } + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("لغو") + } + } + } + } + } + } +} + +@Composable +fun BulkSmsSelectionDialog( + debtors: List, + onStart: (List) -> Unit, + onDismiss: () -> Unit +) { + val selectedIds = remember { mutableStateListOf().apply { addAll(debtors.map { it.id }) } } + + Dialog(onDismissRequest = onDismiss) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Card( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "انتخاب افراد برای یادآور گروهی", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + LazyColumn(modifier = Modifier.height(300.dp)) { + itemsIndexed(debtors) { _, person -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + if (selectedIds.contains(person.id)) selectedIds.remove(person.id) + else selectedIds.add(person.id) + } + .padding(vertical = 4.dp) + ) { + Checkbox( + checked = selectedIds.contains(person.id), + onCheckedChange = null + ) + Text(person.name, modifier = Modifier.padding(start = 8.dp)) + Spacer(Modifier.weight(1f)) + Text( + "${person.debtCount.toString().toPersianDigits()} بدهی", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error + ) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = { + val selectedList = debtors.filter { selectedIds.contains(it.id) } + onStart(selectedList) + }) { + Text("شروع ارسال (${selectedIds.size.toString().toPersianDigits()})") + } + TextButton(onClick = onDismiss) { Text("لغو") } + } + } + } + } + } +} + +@Composable +fun BulkSmsProgressDialog( + currentName: String, + currentIndex: Int, + total: Int, + onSend: () -> Unit, + onSkip: () -> Unit, + onCancel: () -> Unit +) { + Dialog(onDismissRequest = {}) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Card( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "ارسال گروهی پیامک", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("در حال آماده‌سازی پیام برای:") + Text( + currentName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.height(8.dp)) + Text( + "نفر ${(currentIndex + 1).toString().toPersianDigits()} از ${total.toString().toPersianDigits()}", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { (currentIndex.toFloat() / total.toFloat()) }, + modifier = Modifier.fillMaxWidth() + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Button(onClick = onSend) { Text("ارسال پیام") } + Row { + TextButton(onClick = onSkip) { Text("رد کردن") } + TextButton(onClick = onCancel, colors = ButtonDefaults.textButtonColors(contentColor = Color.Red)) { + Text("توقف") + } + } + } + } + } + } + } +} + +@Composable +fun SelectCardDialog( + cardNumbers: List, + onCardSelected: (String?) -> Unit, + onDismiss: () -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Card( + modifier = Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(24.dp) + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "انتخاب شماره کارت", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + + Column { + Text("کدام شماره کارت در متن پیامک درج شود؟") + Spacer(Modifier.height(16.dp)) + + if (cardNumbers.isEmpty()) { + Text("هیچ شماره کارتی در تنظیمات ثبت نشده است.", color = MaterialTheme.colorScheme.error) + } + + cardNumbers.forEach { card -> + OutlinedButton( + onClick = { onCardSelected(card) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) + ) { + Text(card.toPersianDigits()) + } + } + + TextButton( + onClick = { onCardSelected(null) }, + modifier = Modifier.fillMaxWidth() + ) { + Text("ارسال بدون شماره کارت") + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text("لغو") + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt index c8d8486..e047754 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt @@ -1,10 +1,5 @@ package com.oqba26.monthlypaymentapp.ui.screens -import android.Manifest -import android.content.pm.PackageManager -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable @@ -25,7 +20,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Message @@ -38,51 +32,38 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController -import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation +import com.oqba26.monthlypaymentapp.ui.components.* import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay @@ -90,8 +71,6 @@ import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear import com.oqba26.monthlypaymentapp.utils.getPersianMonthName import com.oqba26.monthlypaymentapp.utils.toPersianDigits -import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch -import com.oqba26.monthlypaymentapp.viewmodel.ContactSuggestion import com.oqba26.monthlypaymentapp.viewmodel.DashboardUiModel import com.oqba26.monthlypaymentapp.viewmodel.FinancialReportData import com.oqba26.monthlypaymentapp.viewmodel.PersonListType @@ -449,621 +428,7 @@ fun PersonScreen( } } -@Composable -fun ArchiveConfirmationDialog(personName: String, onConfirm: () -> Unit, onDismiss: () -> Unit) { - Dialog(onDismissRequest = onDismiss) { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(24.dp) - ) { - Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text(text = "انتقال به آرشیو", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) - Text(text = "آیا از انتقال $personName به آرشیو مطمئن هستید؟", style = MaterialTheme.typography.bodyMedium) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Button(onClick = onDismiss) { Text("لغو") } - Button(onClick = onConfirm, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) { - Text("آرشیو") - } - } - } - } - } - } -} - -@Composable -fun ContactSuggestionDialog( - suggestion: ContactSuggestion, - onConfirm: (String) -> Unit, - onDismiss: () -> Unit -) { - var selectedNumber by remember { mutableStateOf(suggestion.matches.firstOrNull()?.phoneNumber ?: "") } - - Dialog(onDismissRequest = onDismiss) { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { - Card( - modifier = Modifier.fillMaxWidth().padding(16.dp), - shape = RoundedCornerShape(24.dp) - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "شماره تماس پیدا شد", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Column { - Text("برای «${suggestion.personName}» موارد زیر در مخاطبین پیدا شد:") - Spacer(Modifier.height(12.dp)) - - LazyColumn( - modifier = Modifier.heightIn(max = 300.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(suggestion.matches) { match -> - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable { selectedNumber = match.phoneNumber } - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = selectedNumber == match.phoneNumber, - onClick = { selectedNumber = match.phoneNumber } - ) - Spacer(Modifier.width(8.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = match.nameInContacts, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium - ) - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { - Text( - text = match.phoneNumber.toPersianDigits(), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.fillMaxWidth(), - textAlign = androidx.compose.ui.text.style.TextAlign.Right - ) - } - } - } - } - } - - Spacer(Modifier.height(8.dp)) - Text("آیا می‌خواهید شماره انتخاب شده به اطلاعات شخص اضافه شود؟") - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Button( - onClick = { onConfirm(selectedNumber) }, - enabled = selectedNumber.isNotEmpty() - ) { - Text("بله، اضافه کن") - } - TextButton(onClick = onDismiss) { - Text("خیر") - } - } - } - } - } - } -} - -@Composable -fun AddNewPersonDialog( - isMosqueCategory: Boolean = false, - onConfirm: (String, String, Boolean, Double, Int, Int, Double) -> Unit, - onDismiss: () -> Unit, - onSearchContact: (String) -> List -) { - var name by remember { mutableStateOf("") } - var phoneNumber by remember { mutableStateOf("") } - var isAnonymous by remember { mutableStateOf(value = false) } - var monthlyCommitment by remember { mutableStateOf("") } - var initialPaymentAmount by remember { mutableStateOf("") } - - val currentShamsiYear = getCurrentShamsiYear() - val currentShamsiMonth = getCurrentShamsiMonth() - - var startMonth by remember { mutableIntStateOf(if (isMosqueCategory) currentShamsiMonth else 1) } - var startYear by remember { mutableIntStateOf(currentShamsiYear) } - - var similarContacts by remember { mutableStateOf>(emptyList()) } - val context = LocalContext.current - - val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted -> - if (isGranted) { - val matches = onSearchContact(name) - if (matches.isNotEmpty()) { - similarContacts = matches - } else { - Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT).show() - } - } else { - Toast.makeText(context, "دسترسی به مخاطبین داده نشد", Toast.LENGTH_SHORT).show() - } - } - - Dialog(onDismissRequest = onDismiss) { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { - Card( - modifier = Modifier.fillMaxWidth().padding(16.dp), - shape = RoundedCornerShape(24.dp) - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = if (isAnonymous) "ثبت کمک آنی" else "افزودن شخص جدید", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - if (isMosqueCategory) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { isAnonymous = !isAnonymous } - .padding(vertical = 4.dp) - ) { - Checkbox( - checked = isAnonymous, - onCheckedChange = { isAnonymous = it } - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("ثبت به صورت خیر ناشناس") - } - } - - if (!isAnonymous) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("نام شخص") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { - OutlinedTextField( - value = phoneNumber, - onValueChange = { phoneNumber = it }, - label = { Text("شماره موبایل (اختیاری)") }, - singleLine = true, - modifier = Modifier.weight(1f), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), - visualTransformation = PersianDigitsTransformation(), - placeholder = { - Text( - "۰۹---------", - textAlign = androidx.compose.ui.text.style.TextAlign.Right - ) - } - ) - } - Spacer(modifier = Modifier.width(8.dp)) - IconButton( - onClick = { - if (name.isBlank()) { - Toast.makeText(context, "ابتدا نام را وارد کنید", Toast.LENGTH_SHORT) - .show() - return@IconButton - } - when (PackageManager.PERMISSION_GRANTED) { - ContextCompat.checkSelfPermission( - context, - Manifest.permission.READ_CONTACTS - ) -> { - val matches = onSearchContact(name) - if (matches.isNotEmpty()) { - similarContacts = matches - } else { - Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT) - .show() - } - } - - else -> { - permissionLauncher.launch(Manifest.permission.READ_CONTACTS) - } - } - } - ) { - Icon(Icons.Default.Search, contentDescription = "جستجو در مخاطبین") - } - } - - if (similarContacts.isNotEmpty()) { - Text( - "موارد مشابه پیدا شده:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary - ) - LazyColumn( - modifier = Modifier.heightIn(max = 200.dp) - ) { - items(similarContacts) { match -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - phoneNumber = match.phoneNumber - similarContacts = emptyList() - } - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = phoneNumber == match.phoneNumber, - onClick = { - phoneNumber = match.phoneNumber - similarContacts = emptyList() - } - ) - Column { - Text(match.nameInContacts, style = MaterialTheme.typography.bodySmall) - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { - Text( - match.phoneNumber.toPersianDigits(), - style = MaterialTheme.typography.labelSmall, - color = Color.Gray - ) - } - } - } - } - } - } - } - } - - if (isMosqueCategory) { - if (isAnonymous) { - OutlinedTextField( - value = initialPaymentAmount, - onValueChange = { value -> - if (value.length <= 12) { - val digitsOnly = value.filter { it.isDigit() || (it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9') } - initialPaymentAmount = digitsOnly.map { - when (it) { - in '\u0660'..'\u0669' -> (it.code - '\u0660'.code + '0'.code).toChar() - in '\u06f0'..'\u06f9' -> (it.code - '\u06f0'.code + '0'.code).toChar() - else -> it - } - }.joinToString("") - } - }, - label = { Text("مبلغ پرداختی (تومان)") }, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - visualTransformation = com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation() - ) - } else { - OutlinedTextField( - value = monthlyCommitment, - onValueChange = { value -> - if (value.length <= 12) { - val digitsOnly = value.filter { it.isDigit() || (it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9') } - monthlyCommitment = digitsOnly.map { - when (it) { - in '\u0660'..'\u0669' -> (it.code - '\u0660'.code + '0'.code).toChar() - in '\u06f0'..'\u06f9' -> (it.code - '\u06f0'.code + '0'.code).toChar() - else -> it - } - }.joinToString("") - } - }, - label = { Text("مبلغ تعهد ماهانه (تومان) - اختیاری") }, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - visualTransformation = com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation(), - placeholder = { Text("اگر مبلغ ثابتی پرداخت می‌کند وارد کنید") } - ) - } - } - - if (!isAnonymous) { - // انتخاب ماه و سال شروع - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("تاریخ شروع تعهد:", style = MaterialTheme.typography.labelLarge) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // سال - Box(modifier = Modifier.weight(1f)) { - var expandedYear by remember { mutableStateOf(false) } - OutlinedButton( - onClick = { expandedYear = true }, - modifier = Modifier.fillMaxWidth() - ) { - Text("سال ${startYear.toString().toPersianDigits()}") - } - DropdownMenu( - expanded = expandedYear, - onDismissRequest = { expandedYear = false }) { - (currentShamsiYear - 1..currentShamsiYear + 1).forEach { y -> - DropdownMenuItem( - text = { Text(y.toString().toPersianDigits()) }, - onClick = { startYear = y; expandedYear = false } - ) - } - } - } - - // ماه - Box(modifier = Modifier.weight(1f)) { - var expandedMonth by remember { mutableStateOf(false) } - OutlinedButton( - onClick = { expandedMonth = true }, - modifier = Modifier.fillMaxWidth() - ) { - Text(getPersianMonthName(startMonth)) - } - DropdownMenu( - expanded = expandedMonth, - onDismissRequest = { expandedMonth = false }) { - (1..12).forEach { m -> - DropdownMenuItem( - text = { Text(getPersianMonthName(m)) }, - onClick = { startMonth = m; expandedMonth = false } - ) - } - } - } - } - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Button( - onClick = { - if (isAnonymous || name.isNotBlank()) { - val commitment = monthlyCommitment.toDoubleOrNull() ?: 0.0 - val initial = initialPaymentAmount.toDoubleOrNull() ?: 0.0 - onConfirm(name, phoneNumber, isAnonymous, commitment, startMonth, startYear, initial) - } - }, - enabled = isAnonymous || name.isNotBlank() - ) { - Text(if (isAnonymous) "ثبت کمک" else "افزودن") - } - Button( - onClick = onDismiss, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) - ) { - Text("لغو") - } - } - } - } - } - } -} - -@Composable -fun BulkSmsSelectionDialog( - debtors: List, - onStart: (List) -> Unit, - onDismiss: () -> Unit -) { - val selectedIds = remember { mutableStateListOf().apply { addAll(debtors.map { it.id }) } } - - Dialog(onDismissRequest = onDismiss) { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { - Card( - modifier = Modifier.fillMaxWidth().padding(16.dp), - shape = RoundedCornerShape(24.dp) - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "انتخاب افراد برای یادآور گروهی", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - LazyColumn(modifier = Modifier.height(300.dp)) { - itemsIndexed(debtors) { _, person -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { - if (selectedIds.contains(person.id)) selectedIds.remove(person.id) - else selectedIds.add(person.id) - } - .padding(vertical = 4.dp) - ) { - Checkbox( - checked = selectedIds.contains(person.id), - onCheckedChange = null - ) - Text(person.name, modifier = Modifier.padding(start = 8.dp)) - Spacer(Modifier.weight(1f)) - Text( - "${person.debtCount.toString().toPersianDigits()} بدهی", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.error - ) - } - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Button(onClick = { - val selectedList = debtors.filter { selectedIds.contains(it.id) } - onStart(selectedList) - }) { - Text("شروع ارسال (${selectedIds.size.toString().toPersianDigits()})") - } - TextButton(onClick = onDismiss) { Text("لغو") } - } - } - } - } - } -} - -@Composable -fun BulkSmsProgressDialog( - currentName: String, - currentIndex: Int, - total: Int, - onSend: () -> Unit, - onSkip: () -> Unit, - onCancel: () -> Unit -) { - Dialog(onDismissRequest = {}) { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { - Card( - modifier = Modifier.fillMaxWidth().padding(16.dp), - shape = RoundedCornerShape(24.dp) - ) { - Column( - modifier = Modifier.padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "ارسال گروهی پیامک", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("در حال آماده‌سازی پیام برای:") - Text( - currentName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - Spacer(Modifier.height(8.dp)) - Text( - "نفر ${(currentIndex + 1).toString().toPersianDigits()} از ${total.toString().toPersianDigits()}", - style = MaterialTheme.typography.bodyMedium - ) - Spacer(Modifier.height(16.dp)) - LinearProgressIndicator( - progress = { (currentIndex.toFloat() / total.toFloat()) }, - modifier = Modifier.fillMaxWidth() - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Button(onClick = onSend) { Text("ارسال پیام") } - Row { - TextButton(onClick = onSkip) { Text("رد کردن") } - TextButton(onClick = onCancel, colors = ButtonDefaults.textButtonColors(contentColor = Color.Red)) { - Text("توقف") - } - } - } - } - } - } - } -} - -@Composable -fun SelectCardDialog( - cardNumbers: List, - onCardSelected: (String?) -> Unit, - onDismiss: () -> Unit -) { - Dialog(onDismissRequest = onDismiss) { - CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { - Card( - modifier = Modifier.fillMaxWidth().padding(16.dp), - shape = RoundedCornerShape(24.dp) - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = "انتخاب شماره کارت", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Column { - Text("کدام شماره کارت در متن پیامک درج شود؟") - Spacer(Modifier.height(16.dp)) - - if (cardNumbers.isEmpty()) { - Text("هیچ شماره کارتی در تنظیمات ثبت نشده است.", color = MaterialTheme.colorScheme.error) - } - - cardNumbers.forEach { card -> - OutlinedButton( - onClick = { onCardSelected(card) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) - ) { - Text(card.toPersianDigits()) - } - } - - TextButton( - onClick = { onCardSelected(null) }, - modifier = Modifier.fillMaxWidth() - ) { - Text("ارسال بدون شماره کارت") - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismiss) { - Text("لغو") - } - } - } - } - } - } -} +// دیالوگ‌های این صفحه به کامپوننت اختصاصی ui.components.PersonDialogs منتقل شدند. @Composable fun DashboardCard( diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/UpdateManager.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/UpdateManager.kt index 847cbf0..ef5dcad 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/UpdateManager.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/UpdateManager.kt @@ -45,7 +45,7 @@ sealed class DownloadState { class UpdateManager(private val context: Context) { - private val updateUrl = "https://raw.githubusercontent.com/oqba26/MonthlyPaymentApp/main/update.json" + private val updateUrl = "https://git.mizbanplatform.ir/oqba26/MonthlyPaymentApp/raw/branch/main/update.json" private val json = Json { ignoreUnknownKeys = true diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt index 52d3192..803d770 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt @@ -185,8 +185,9 @@ class PersonViewModel @Inject constructor( localPersonRepository.getAllPersonsFlow(), localPersonRepository.getAllPaymentsFlow(), _searchQuery, - _currentCategory - ) { persons, allPayments, query, category -> + _currentCategory, + settingsRepository.defaultPaymentAmountFlow + ) { persons, allPayments, query, category, defaultPaymentAmount -> val currentMonth = getCurrentShamsiMonth() val currentYear = getCurrentShamsiYear() val currentDay = getCurrentShamsiDay() @@ -224,8 +225,7 @@ class PersonViewModel @Inject constructor( val unpaid = (debtStartMonth..debtEndMonth).filter { m -> allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category } } - val defaultAmount = settingsRepository.defaultPaymentAmountFlow.first() - val amountPerMonth = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultAmount + val amountPerMonth = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount Triple(unpaid.size, unpaid.size * amountPerMonth, unpaid.map { getPersianMonthName(it) } to unpaid) } @@ -415,10 +415,22 @@ class PersonViewModel @Inject constructor( val isMosque = _currentCategory.value == "mosque" val targetMonth = if (isMosque) currentMonth else (if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1) val targetYear = if (isMosque) currentYear else (if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear) + val category = _currentCategory.value - val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description) - .copy(category = _currentCategory.value) - localPersonRepository.insertPaymentLocally(record) + val existing = if (!isMosque) { + localPersonRepository.getAllPayments().find { + it.personId == event.personId && it.shamsiYear == targetYear && it.shamsiMonth == targetMonth && it.category == category + } + } else null + + if (existing != null) { + val updated = existing.copy(amount = event.amount, description = event.description, timestamp = System.currentTimeMillis()) + localPersonRepository.insertPaymentLocally(updated) + } else { + val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description) + .copy(category = category) + localPersonRepository.insertPaymentLocally(record) + } } is PersonScreenEvent.SelectPerson -> { @@ -477,9 +489,20 @@ class PersonViewModel @Inject constructor( is PersonScreenEvent.AddPaymentForMonth -> { val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == event.personId } val category = person?.category ?: _currentCategory.value - val record = createPaymentRecord(event.personId, event.month, event.year, event.amount, event.description) - .copy(category = category) - localPersonRepository.insertPaymentLocally(record) + val existing = if (category != "mosque") { + localPersonRepository.getAllPayments().find { + it.personId == event.personId && it.shamsiYear == event.year && it.shamsiMonth == event.month && it.category == category + } + } else null + + if (existing != null) { + val updated = existing.copy(amount = event.amount, description = event.description, timestamp = System.currentTimeMillis()) + localPersonRepository.insertPaymentLocally(updated) + } else { + val record = createPaymentRecord(event.personId, event.month, event.year, event.amount, event.description) + .copy(category = category) + localPersonRepository.insertPaymentLocally(record) + } } is PersonScreenEvent.UpdatePayment -> { @@ -488,7 +511,20 @@ class PersonViewModel @Inject constructor( } is PersonScreenEvent.DeletePayment -> { - localPersonRepository.deletePaymentLocally(event.payment.id) + val paymentsToDelete = if (event.payment.category != "mosque") { + localPersonRepository.getAllPayments().filter { + it.personId == event.payment.personId && + it.shamsiYear == event.payment.shamsiYear && + it.shamsiMonth == event.payment.shamsiMonth && + it.category == event.payment.category + }.ifEmpty { listOf(event.payment) } + } else { + listOf(event.payment) + } + + paymentsToDelete.forEach { payment -> + localPersonRepository.deletePaymentLocally(payment.id) + } _toastMessage.emit("پرداخت با موفقیت حذف شد") } @@ -519,6 +555,7 @@ class PersonViewModel @Inject constructor( val currentMonth = getCurrentShamsiMonth() val currentYear = getCurrentShamsiYear() val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis()) + val allPayments = localPersonRepository.getAllPayments() event.months.forEach { month -> val monthName = getPersianMonthName(month) @@ -529,14 +566,29 @@ class PersonViewModel @Inject constructor( "پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد." } - val record = createPaymentRecord( - personId = event.personId, - month = month, - year = event.year, - amount = event.amount, - description = description - ).copy(category = category) - localPersonRepository.insertPaymentLocally(record) + val existing = if (category != "mosque") { + allPayments.find { + it.personId == event.personId && it.shamsiYear == event.year && it.shamsiMonth == month && it.category == category + } + } else null + + if (existing != null) { + val updated = existing.copy( + amount = event.amount, + description = description, + timestamp = System.currentTimeMillis() + ) + localPersonRepository.insertPaymentLocally(updated) + } else { + val record = createPaymentRecord( + personId = event.personId, + month = month, + year = event.year, + amount = event.amount, + description = description + ).copy(category = category) + localPersonRepository.insertPaymentLocally(record) + } } } catch (_: Exception) { _toastMessage.emit("خطا در ثبت پرداخت‌های دسته جمعی") diff --git a/update.json b/update.json index 284f78c..384e2c1 100644 --- a/update.json +++ b/update.json @@ -1,7 +1,7 @@ { - "versionCode": 15, - "versionName": "1.1.4", - "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.1.4/MonthlyPaymentApp-v1.1.4.apk", - "releaseNotes": "بهبودهای پایداری و عملکرد همگام‌سازی", + "versionCode": 17, + "versionName": "1.1.6", + "url": "https://git.mizbanplatform.ir/oqba26/MonthlyPaymentApp/releases/download/v1.1.6/MonthlyPaymentApp-v1.1.6.apk", + "releaseNotes": "رفع باگ حذف پرداخت‌ها و پایداری همگام‌سازی", "isForceUpdate": false }