Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aadcdca90 | ||
|
|
5ef94eaa41 | ||
|
|
c135a98fd8 | ||
|
|
929182d234 | ||
|
|
a9a9281f79 | ||
|
|
c7065295b8 | ||
|
|
9ed2b59d81 | ||
|
|
f1c19cba52 | ||
|
|
1931e20deb | ||
|
|
ee0bd0ba2b | ||
|
|
7d865a3d6d | ||
|
|
fcf4d02caf | ||
|
|
6166a2f249 | ||
|
|
6213bbf30f | ||
|
|
2c52c2c538 | ||
|
|
a7620302e9 | ||
|
|
af0e7a76de |
No files matched your search
@@ -11,14 +11,31 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
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
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
cache: gradle
|
|
||||||
|
- name: Setup Android SDK (Self-Hosted / Gitea)
|
||||||
|
if: "!contains(github.server_url, 'github.com')"
|
||||||
|
run: |
|
||||||
|
which unzip || (apt-get update && apt-get install -y unzip curl)
|
||||||
|
mkdir -p /opt/android-sdk/cmdline-tools
|
||||||
|
curl -s -L -o /tmp/cmdline-tools.zip https://mirrors.cloud.tencent.com/AndroidSDK/commandlinetools-linux-11076708_latest.zip
|
||||||
|
unzip -q /tmp/cmdline-tools.zip -d /opt/android-sdk/cmdline-tools
|
||||||
|
mv /opt/android-sdk/cmdline-tools/cmdline-tools /opt/android-sdk/cmdline-tools/latest
|
||||||
|
rm /tmp/cmdline-tools.zip
|
||||||
|
echo "ANDROID_HOME=/opt/android-sdk" >> $GITHUB_ENV
|
||||||
|
echo "/opt/android-sdk/cmdline-tools/latest/bin" >> $GITHUB_PATH
|
||||||
|
echo "/opt/android-sdk/platform-tools" >> $GITHUB_PATH
|
||||||
|
export ANDROID_HOME=/opt/android-sdk
|
||||||
|
export PATH="/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:$PATH"
|
||||||
|
yes | sdkmanager --licenses || true
|
||||||
|
sdkmanager "platforms;android-34" "build-tools;34.0.0" "platform-tools"
|
||||||
|
|
||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x gradlew
|
run: chmod +x gradlew
|
||||||
@@ -34,15 +51,62 @@ jobs:
|
|||||||
RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
|
RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
|
||||||
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
||||||
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease --no-daemon
|
||||||
|
|
||||||
- name: Rename APK
|
- name: Rename APK
|
||||||
run: |
|
run: |
|
||||||
mv app/build/outputs/apk/release/app-release.apk app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk
|
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
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk
|
files: app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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 }}
|
||||||
|
run: |
|
||||||
|
TAG_NAME="${{ github.ref_name }}"
|
||||||
|
VERSION_NAME="${TAG_NAME#v}"
|
||||||
|
VERSION_CODE=$(grep 'versionCode' app/build.gradle.kts | head -n 1 | awk '{print $3}')
|
||||||
|
|
||||||
|
git config user.name "actions-bot"
|
||||||
|
git config user.email "bot@mizbanplatform.ir"
|
||||||
|
git fetch origin main
|
||||||
|
git checkout main
|
||||||
|
|
||||||
|
cat <<EOF > update.json
|
||||||
|
{
|
||||||
|
"versionCode": ${VERSION_CODE},
|
||||||
|
"versionName": "${VERSION_NAME}",
|
||||||
|
"url": "${{ github.server_url }}/${{ github.repository }}/releases/download/${TAG_NAME}/MonthlyPaymentApp-${TAG_NAME}.apk",
|
||||||
|
"releaseNotes": "بهبودهای پایداری و عملکرد همگامسازی",
|
||||||
|
"isForceUpdate": false
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
git add update.json
|
||||||
|
git commit -m "auto-update update.json for release ${TAG_NAME}" || true
|
||||||
|
git push origin main
|
||||||
@@ -30,8 +30,8 @@ android {
|
|||||||
applicationId = "com.oqba26.monthlypaymentapp"
|
applicationId = "com.oqba26.monthlypaymentapp"
|
||||||
minSdk = 25
|
minSdk = 25
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 9
|
versionCode = 17
|
||||||
versionName = "1.0.8"
|
versionName = "1.1.6"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables { useSupportLibrary = true }
|
vectorDrawables { useSupportLibrary = true }
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,13 @@ android {
|
|||||||
targetCompatibility = JavaVersion.VERSION_21
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
kotlinOptions { jvmTarget = "21" }
|
kotlinOptions {
|
||||||
|
jvmTarget = "21"
|
||||||
|
freeCompilerArgs += listOf(
|
||||||
|
"-opt-in=kotlinx.serialization.InternalSerializationApi",
|
||||||
|
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
|
||||||
|
)
|
||||||
|
}
|
||||||
kotlin { jvmToolchain(21) }
|
kotlin { jvmToolchain(21) }
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
@@ -120,11 +126,9 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
// Networking
|
// Networking
|
||||||
implementation(libs.retrofit.core)
|
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.okhttp.logging)
|
implementation(libs.okhttp.logging)
|
||||||
implementation(libs.kotlin.serialization.json)
|
implementation(libs.kotlin.serialization.json)
|
||||||
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
|
|
||||||
|
|
||||||
// Database (Room)
|
// Database (Room)
|
||||||
implementation(libs.androidx.room.runtime)
|
implementation(libs.androidx.room.runtime)
|
||||||
|
|||||||
@@ -333,10 +333,34 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
|
|||||||
Screen.Settings.route
|
Screen.Settings.route
|
||||||
)
|
)
|
||||||
|
|
||||||
// هندل کردن دکمه Back
|
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
||||||
|
|
||||||
|
// هندل کردن دکمه Back:
|
||||||
|
// ۱. اگر در حالت انتخاب آیتمها باشد، لغو انتخاب
|
||||||
|
// ۲. اگر در صفحهای غیر از تب اصلی «پرداخت نشده» باشد، بازگشت به صفحه قبل / تب پرداخت نشده
|
||||||
|
// ۳. فقط در صفحه اصلی «پرداخت نشده»، دیالوگ خروج نمایش داده میشود
|
||||||
BackHandler {
|
BackHandler {
|
||||||
|
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
|
showExitDialog = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showExitDialog) {
|
if (showExitDialog) {
|
||||||
ExitDialog(
|
ExitDialog(
|
||||||
@@ -347,7 +371,6 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
|
||||||
var showConfirmMoveDialog by remember { mutableStateOf(false) }
|
var showConfirmMoveDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
if (showConfirmMoveDialog) {
|
if (showConfirmMoveDialog) {
|
||||||
|
|||||||
@@ -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<Person>
|
|
||||||
|
|
||||||
@POST("persons")
|
|
||||||
suspend fun addPerson(@Body person: Person): Response<Unit>
|
|
||||||
|
|
||||||
// ⭐️ NEW: مسیر PUT برای ویرایش نام
|
|
||||||
@PUT("persons/{id}")
|
|
||||||
suspend fun updatePerson(@Path("id") personId: String, @Body person: Person): Response<Unit>
|
|
||||||
|
|
||||||
@PATCH("persons/{id}/archived")
|
|
||||||
suspend fun updatePersonArchivedStatus(@Path("id") personId: String, @Body isArchived: Map<String, Boolean>): Response<Unit>
|
|
||||||
|
|
||||||
@PATCH("persons/{id}/display-order")
|
|
||||||
suspend fun updatePersonDisplayOrder(@Path("id") personId: String, @Body displayOrder: Map<String, Long>): Response<Unit>
|
|
||||||
|
|
||||||
@POST("persons/rebalance")
|
|
||||||
suspend fun rebalanceDisplayOrders(): Response<Unit>
|
|
||||||
|
|
||||||
@DELETE("persons/{id}")
|
|
||||||
suspend fun deletePersonAndPayments(@Path("id") personId: String): Response<Unit>
|
|
||||||
|
|
||||||
// Payments
|
|
||||||
@GET("payments")
|
|
||||||
suspend fun getPayments(
|
|
||||||
@Query("personId") personId: String? = null
|
|
||||||
): List<PaymentRecord>
|
|
||||||
|
|
||||||
@POST("payments")
|
|
||||||
suspend fun addPayment(@Body paymentRecord: PaymentRecord): Response<Unit>
|
|
||||||
|
|
||||||
@DELETE("payments/{id}")
|
|
||||||
suspend fun deletePayment(@Path("id") paymentId: String): Response<Unit>
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:OptIn(InternalSerializationApi::class)
|
||||||
|
|
||||||
package com.oqba26.monthlypaymentapp.data.remote
|
package com.oqba26.monthlypaymentapp.data.remote
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -5,6 +7,7 @@ import io.ktor.client.HttpClient
|
|||||||
import io.ktor.client.engine.okhttp.OkHttp
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
import io.ktor.client.plugins.HttpTimeout
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.plugins.timeout
|
||||||
import io.ktor.client.request.delete
|
import io.ktor.client.request.delete
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.request.header
|
import io.ktor.client.request.header
|
||||||
@@ -25,6 +28,7 @@ import io.ktor.utils.io.readUTF8Line
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.serialization.InternalSerializationApi
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
@@ -347,6 +351,10 @@ class PocketBaseClient(
|
|||||||
authToken?.let { token ->
|
authToken?.let { token ->
|
||||||
header(HttpHeaders.Authorization, "Bearer $token")
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
}
|
}
|
||||||
|
timeout {
|
||||||
|
requestTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
|
||||||
|
socketTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
|
||||||
|
}
|
||||||
}.execute { response ->
|
}.execute { response ->
|
||||||
val channel: ByteReadChannel = response.bodyAsChannel()
|
val channel: ByteReadChannel = response.bodyAsChannel()
|
||||||
var currentEvent = ""
|
var currentEvent = ""
|
||||||
@@ -362,6 +370,7 @@ class PocketBaseClient(
|
|||||||
val connectObj = json.decodeFromString<PocketBaseConnectEvent>(dataJson)
|
val connectObj = json.decodeFromString<PocketBaseConnectEvent>(dataJson)
|
||||||
subscribeRealtime(cleanBaseUrl, connectObj.clientId, subscriptions)
|
subscribeRealtime(cleanBaseUrl, connectObj.clientId, subscriptions)
|
||||||
Log.d(TAG, "اتصال ریلتایم برقرار شد. ClientId: ${connectObj.clientId}")
|
Log.d(TAG, "اتصال ریلتایم برقرار شد. ClientId: ${connectObj.clientId}")
|
||||||
|
emit("PB_CONNECT")
|
||||||
} else if (currentEvent.isNotEmpty()) {
|
} else if (currentEvent.isNotEmpty()) {
|
||||||
emit(currentEvent)
|
emit(currentEvent)
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-8
@@ -56,21 +56,46 @@ class LocalPersonRepository(
|
|||||||
if (personPlan.upserts.isNotEmpty()) personDao.insertAll(personPlan.upserts)
|
if (personPlan.upserts.isNotEmpty()) personDao.insertAll(personPlan.upserts)
|
||||||
if (paymentPlan.upserts.isNotEmpty()) paymentDao.insertAllPaymentRecords(paymentPlan.upserts)
|
if (paymentPlan.upserts.isNotEmpty()) paymentDao.insertAllPaymentRecords(paymentPlan.upserts)
|
||||||
|
|
||||||
// خودترمیمی: اگر رکوردهایی در دیتابیس محلی هستند که در سرور وجود ندارند و در صف هم نیستند،
|
// خودترمیمی: فقط رکوردهای محلی که نیاز به سینک دارند (آفلاین ایجاد شدهاند) و در سرور نیستند ارسال شوند
|
||||||
// آنها را دوباره برای ارسال به سرور ثبت میکنیم تا سرور و کلاینت همگام شوند.
|
|
||||||
val serverPersonIds = persons.map { it.id }.toSet()
|
val serverPersonIds = persons.map { it.id }.toSet()
|
||||||
val missingPersons = localPersons.filter { it.id !in serverPersonIds && it.id !in pendingIds }
|
val offlinePersons = localPersons.filter { it.needsSync && it.id !in serverPersonIds && it.id !in pendingIds }
|
||||||
for (p in missingPersons) {
|
for (p in offlinePersons) {
|
||||||
personDao.insertAll(listOf(p.copy(needsSync = true)))
|
personDao.insertAll(listOf(p))
|
||||||
syncQueueDao.insert(SyncQueue(entityId = p.id, type = SyncType.PERSON, operation = SyncOperation.INSERT))
|
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 serverPaymentIds = payments.map { it.id }.toSet()
|
||||||
val missingPayments = localPayments.filter { it.id !in serverPaymentIds && it.id !in pendingIds }
|
val offlinePayments = localPayments.filter { it.needsSync && it.id !in serverPaymentIds && it.id !in pendingIds }
|
||||||
for (p in missingPayments) {
|
for (p in offlinePayments) {
|
||||||
paymentDao.insertAllPaymentRecords(listOf(p.copy(needsSync = true)))
|
paymentDao.insertAllPaymentRecords(listOf(p))
|
||||||
syncQueueDao.insert(SyncQueue(entityId = p.id, type = SyncType.PAYMENT, operation = SyncOperation.INSERT))
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-31
@@ -14,9 +14,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class NetworkRepository(
|
class NetworkRepository(
|
||||||
private val settingsRepository: SettingsRepository? = null
|
settingsRepository: SettingsRepository? = null
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val pocketBase = ApiClient.pocketBase
|
private val pocketBase = ApiClient.pocketBase
|
||||||
@@ -43,6 +44,7 @@ class NetworkRepository(
|
|||||||
// --- Realtime Operations ---
|
// --- Realtime Operations ---
|
||||||
fun observeRealtimeChanges() {
|
fun observeRealtimeChanges() {
|
||||||
repositoryScope.launch {
|
repositoryScope.launch {
|
||||||
|
while (true) {
|
||||||
try {
|
try {
|
||||||
pocketBase.listenRealtime(listOf("persons", "payments")).collect { event ->
|
pocketBase.listenRealtime(listOf("persons", "payments")).collect { event ->
|
||||||
Log.d("PocketBase-Realtime", "Change detected in PocketBase: $event")
|
Log.d("PocketBase-Realtime", "Change detected in PocketBase: $event")
|
||||||
@@ -51,6 +53,8 @@ class NetworkRepository(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("PocketBase-Realtime", "Realtime connection error, retrying...", e)
|
Log.e("PocketBase-Realtime", "Realtime connection error, retrying...", e)
|
||||||
}
|
}
|
||||||
|
delay(3000.milliseconds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,31 +156,7 @@ class NetworkRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean {
|
|
||||||
return try {
|
|
||||||
val currentPerson = _personsFlow.value.find { it.id == personId }
|
|
||||||
if (currentPerson != null) {
|
|
||||||
pocketBase.upsertRecord("persons", "id='$personId'", currentPerson.copy(isArchived = isArchived))
|
|
||||||
}
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PocketBase", "updateArchived error", e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean {
|
|
||||||
return try {
|
|
||||||
val currentPerson = _personsFlow.value.find { it.id == personId }
|
|
||||||
if (currentPerson != null) {
|
|
||||||
pocketBase.upsertRecord("persons", "id='$personId'", currentPerson.copy(displayOrder = displayOrder))
|
|
||||||
}
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("PocketBase", "updateOrder error", e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow()
|
fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow()
|
||||||
|
|
||||||
@@ -192,19 +172,17 @@ class NetworkRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deletePayment(paymentId: String): Boolean {
|
suspend fun deletePayment(paymentId: String): Boolean {
|
||||||
var retries = 0
|
|
||||||
val maxRetries = 2
|
val maxRetries = 2
|
||||||
|
for (retries in 1..maxRetries) {
|
||||||
while (retries <= maxRetries) {
|
|
||||||
try {
|
try {
|
||||||
pocketBase.deleteRecordByFilter("payments", "id='$paymentId'")
|
pocketBase.deleteRecordByFilter("payments", "id='$paymentId'")
|
||||||
Log.d("PocketBase", "deletePayment Success: $paymentId")
|
Log.d("PocketBase", "deletePayment Success: $paymentId")
|
||||||
return true
|
return true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
retries++
|
|
||||||
Log.e("PocketBase", "deletePayment error (Attempt $retries): ${e.message}", e)
|
Log.e("PocketBase", "deletePayment error (Attempt $retries): ${e.message}", e)
|
||||||
if (retries > maxRetries) return false
|
if (retries < maxRetries) {
|
||||||
delay(1000L * retries)
|
delay((1000L * retries).milliseconds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -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<ContactMatch>
|
||||||
|
) {
|
||||||
|
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<List<ContactMatch>>(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<PersonUiModel>,
|
||||||
|
onStart: (List<PersonUiModel>) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val selectedIds = remember { mutableStateListOf<String>().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<String>,
|
||||||
|
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("لغو")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,6 +113,7 @@ fun PaidListScreen(
|
|||||||
PersonListItem(
|
PersonListItem(
|
||||||
person = person,
|
person = person,
|
||||||
index = index + 1,
|
index = index + 1,
|
||||||
|
listType = PersonListType.PAID,
|
||||||
onPersonClick = { navController.navigate("person_detail/${person.id}") },
|
onPersonClick = { navController.navigate("person_detail/${person.id}") },
|
||||||
onQuickPayClick = { },
|
onQuickPayClick = { },
|
||||||
onArchiveClick = { personToArchive.value = it },
|
onArchiveClick = { personToArchive.value = it },
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
package com.oqba26.monthlypaymentapp.ui.screens
|
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.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -25,12 +20,10 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Message
|
import androidx.compose.material.icons.automirrored.filled.Message
|
||||||
import androidx.compose.material.icons.filled.Archive
|
import androidx.compose.material.icons.filled.Archive
|
||||||
import androidx.compose.material.icons.filled.Assessment
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Sync
|
import androidx.compose.material.icons.filled.Sync
|
||||||
@@ -39,51 +32,38 @@ import androidx.compose.material.icons.filled.Search
|
|||||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||||
import androidx.compose.material.pullrefresh.pullRefresh
|
import androidx.compose.material.pullrefresh.pullRefresh
|
||||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
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.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
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.formatNumberAsPersian
|
||||||
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
|
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
|
||||||
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay
|
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay
|
||||||
@@ -91,8 +71,6 @@ import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
|
|||||||
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
|
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
|
||||||
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
|
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
|
||||||
import com.oqba26.monthlypaymentapp.utils.toPersianDigits
|
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.DashboardUiModel
|
||||||
import com.oqba26.monthlypaymentapp.viewmodel.FinancialReportData
|
import com.oqba26.monthlypaymentapp.viewmodel.FinancialReportData
|
||||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonListType
|
import com.oqba26.monthlypaymentapp.viewmodel.PersonListType
|
||||||
@@ -117,7 +95,6 @@ fun PersonScreen(
|
|||||||
val dashboardData by viewModel.dashboardData.collectAsState()
|
val dashboardData by viewModel.dashboardData.collectAsState()
|
||||||
val financialReportData by viewModel.financialReportData.collectAsState()
|
val financialReportData by viewModel.financialReportData.collectAsState()
|
||||||
val showFinancialReportDialog by viewModel.showFinancialReportDialog.collectAsState()
|
val showFinancialReportDialog by viewModel.showFinancialReportDialog.collectAsState()
|
||||||
val isOperatorMode by viewModel.isOperatorMode.collectAsState()
|
|
||||||
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
|
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
|
||||||
val cardNumbers by viewModel.cardNumbersFlow.collectAsState(initial = emptyList())
|
val cardNumbers by viewModel.cardNumbersFlow.collectAsState(initial = emptyList())
|
||||||
val personForPaymentDialog by viewModel.personForPaymentDialog.collectAsState()
|
val personForPaymentDialog by viewModel.personForPaymentDialog.collectAsState()
|
||||||
@@ -188,8 +165,7 @@ fun PersonScreen(
|
|||||||
DashboardCard(
|
DashboardCard(
|
||||||
data = dashboardData,
|
data = dashboardData,
|
||||||
financialReportData = financialReportData,
|
financialReportData = financialReportData,
|
||||||
isOperatorMode = isOperatorMode,
|
isMosqueCategory = (currentCategory == "mosque")
|
||||||
onOpenReportClick = { viewModel.onShowFinancialReport() }
|
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -452,628 +428,13 @@ fun PersonScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
// دیالوگهای این صفحه به کامپوننت اختصاصی ui.components.PersonDialogs منتقل شدند.
|
||||||
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<ContactMatch>
|
|
||||||
) {
|
|
||||||
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(currentShamsiMonth) }
|
|
||||||
var startYear by remember { mutableIntStateOf(currentShamsiYear) }
|
|
||||||
|
|
||||||
var similarContacts by remember { mutableStateOf<List<ContactMatch>>(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<PersonUiModel>,
|
|
||||||
onStart: (List<PersonUiModel>) -> Unit,
|
|
||||||
onDismiss: () -> Unit
|
|
||||||
) {
|
|
||||||
val selectedIds = remember { mutableStateListOf<String>().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<String>,
|
|
||||||
onCardSelected: (String?) -> Unit,
|
|
||||||
onDismiss: () -> Unit
|
|
||||||
) {
|
|
||||||
Dialog(onDismissRequest = onDismiss) {
|
|
||||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
|
||||||
shape = RoundedCornerShape(24.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(20.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "انتخاب شماره کارت",
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
Column {
|
|
||||||
Text("کدام شماره کارت در متن پیامک درج شود؟")
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
if (cardNumbers.isEmpty()) {
|
|
||||||
Text("هیچ شماره کارتی در تنظیمات ثبت نشده است.", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
cardNumbers.forEach { card ->
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { onCardSelected(card) },
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(card.toPersianDigits())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TextButton(
|
|
||||||
onClick = { onCardSelected(null) },
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text("ارسال بدون شماره کارت")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End
|
|
||||||
) {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text("لغو")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DashboardCard(
|
fun DashboardCard(
|
||||||
data: DashboardUiModel,
|
data: DashboardUiModel,
|
||||||
financialReportData: FinancialReportData,
|
financialReportData: FinancialReportData,
|
||||||
isOperatorMode: Boolean = false,
|
isMosqueCategory: Boolean = false
|
||||||
onOpenReportClick: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
val animatedProgress by animateFloatAsState(
|
val animatedProgress by animateFloatAsState(
|
||||||
targetValue = data.progress,
|
targetValue = data.progress,
|
||||||
@@ -1110,17 +471,30 @@ fun DashboardCard(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text("مجموع درآمد ماه جاری", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
Text(
|
||||||
|
text = if (isMosqueCategory) "کمکهای جمعآوری شده ماه جاری" else "حقوق جمعآوری شده ماه جاری",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
Text("${formatNumberAsPersian(data.totalIncome)} تومان", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
Text("${formatNumberAsPersian(data.totalIncome)} تومان", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isMosqueCategory) "مجموع کمکهای جمعآوری شده دوره جاری" else "مجموع حقوق جمعآوری شده دوره جاری",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text("${formatNumberAsPersian(data.totalCycleIncome)} تومان", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
|
||||||
if (financialReportData.totalOverdueDebt > 0 || financialReportData.debtors.isNotEmpty()) {
|
if (financialReportData.totalOverdueDebt > 0 || financialReportData.debtors.isNotEmpty()) {
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 2.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 2.dp))
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -1139,37 +513,6 @@ fun DashboardCard(
|
|||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val debtorNamesText = financialReportData.debtors.joinToString("، ") {
|
|
||||||
"${it.personName} (${formatNumberAsPersian(it.totalDebtAmount)})"
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "بدهکاران (${financialReportData.totalDebtorsCount.toString().toPersianDigits()} نفر): $debtorNamesText",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOperatorMode) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onOpenReportClick,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 2.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Assessment,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text("مشاهده گزارش کامل مالی و درآمدهای گذشته", style = MaterialTheme.typography.labelLarge)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1185,7 +528,7 @@ fun FinancialReportDialog(
|
|||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(max = 600.dp),
|
.heightIn(max = 650.dp),
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(24.dp),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||||
) {
|
) {
|
||||||
@@ -1212,6 +555,11 @@ fun FinancialReportDialog(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f))
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f))
|
||||||
@@ -1236,19 +584,51 @@ fun FinancialReportDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
if (data.debtors.isNotEmpty()) {
|
||||||
HorizontalDivider()
|
item {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f))
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "لیست بدهکاران (${data.totalDebtorsCount.toString().toPersianDigits()} نفر):",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
val debtorNamesText = data.debtors.joinToString("، ") {
|
||||||
|
"${it.personName} (${formatNumberAsPersian(it.totalDebtAmount)})"
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = debtorNamesText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Column {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "تفکیک درآمد ماهانه ماههای گذشته",
|
text = "تفکیک درآمد ماهانه ماههای گذشته",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (data.monthlyReports.isEmpty()) {
|
if (data.monthlyReports.isEmpty()) {
|
||||||
|
item {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1257,11 +637,8 @@ fun FinancialReportDialog(
|
|||||||
) {
|
) {
|
||||||
Text("هیچ دریافتی تا کنون ثبت نشده است.", style = MaterialTheme.typography.bodyMedium)
|
Text("هیچ دریافتی تا کنون ثبت نشده است.", style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
items(data.monthlyReports) { report ->
|
items(data.monthlyReports) { report ->
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -1308,15 +685,6 @@ fun FinancialReportDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
TextButton(
|
|
||||||
onClick = onDismiss,
|
|
||||||
modifier = Modifier.align(Alignment.End)
|
|
||||||
) {
|
|
||||||
Text("بستن")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1329,6 +697,7 @@ fun PersonListItem(
|
|||||||
person: PersonUiModel,
|
person: PersonUiModel,
|
||||||
index: Int,
|
index: Int,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
listType: PersonListType = PersonListType.UNPAID,
|
||||||
isSelectionMode: Boolean = false,
|
isSelectionMode: Boolean = false,
|
||||||
isSelected: Boolean = false,
|
isSelected: Boolean = false,
|
||||||
reorderableState: org.burnoutcrew.reorderable.ReorderableLazyListState? = null,
|
reorderableState: org.burnoutcrew.reorderable.ReorderableLazyListState? = null,
|
||||||
@@ -1365,6 +734,15 @@ fun PersonListItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (listType == PersonListType.PAID && person.hasPaidThisMonth) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = "Paid",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(20.dp).padding(end = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val dragModifier = if (reorderableState != null && !isSelectionMode) {
|
val dragModifier = if (reorderableState != null && !isSelectionMode) {
|
||||||
Modifier.detectReorderAfterLongPress(reorderableState)
|
Modifier.detectReorderAfterLongPress(reorderableState)
|
||||||
} else Modifier
|
} else Modifier
|
||||||
@@ -1374,14 +752,33 @@ fun PersonListItem(
|
|||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
modifier = dragModifier.padding(end = 4.dp)
|
modifier = dragModifier.padding(end = 4.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
person.name,
|
person.name,
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Visible
|
overflow = TextOverflow.Visible
|
||||||
)
|
)
|
||||||
|
if (listType == PersonListType.PAID) {
|
||||||
|
person.lastPaymentTime?.let { time ->
|
||||||
|
Text(
|
||||||
|
text = formatTimestampToPersianDateTime(time),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
person.lastPaymentAmount?.let { amount ->
|
||||||
|
Text(
|
||||||
|
text = "مبلغ: ${formatNumberAsPersian(amount)} تومان",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (person.debtCount > 0) {
|
if (person.debtCount > 0) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -1417,6 +814,7 @@ fun PersonListItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (listType != PersonListType.PAID) {
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
@@ -1445,5 +843,6 @@ fun PersonListItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,24 +5,32 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.AdminPanelSettings
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.ContentCopy
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.LockOpen
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
@@ -38,6 +46,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -136,16 +145,18 @@ fun SettingsScreen(onLogout: () -> Unit) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
.fillMaxSize()
|
||||||
.fillMaxWidth()
|
.verticalScroll(scrollState)
|
||||||
.fillMaxHeight()
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.settings),
|
stringResource(R.string.settings),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
DefaultAmountSetting(
|
DefaultAmountSetting(
|
||||||
@@ -163,16 +174,12 @@ fun SettingsScreen(onLogout: () -> Unit) {
|
|||||||
onChangePin = { viewModel.setAdminPin(it) }
|
onChangePin = { viewModel.setAdminPin(it) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
|
|
||||||
val reminderDay by viewModel.reminderDay.collectAsState()
|
val reminderDay by viewModel.reminderDay.collectAsState()
|
||||||
ReminderDaySetting(
|
ReminderDaySetting(
|
||||||
currentDay = reminderDay,
|
currentDay = reminderDay,
|
||||||
onSave = { viewModel.saveReminderDay(it) }
|
onSave = { viewModel.saveReminderDay(it) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
|
|
||||||
val cardNumbers by viewModel.cardNumbers.collectAsState()
|
val cardNumbers by viewModel.cardNumbers.collectAsState()
|
||||||
CardNumbersSetting(
|
CardNumbersSetting(
|
||||||
cardNumbers = cardNumbers,
|
cardNumbers = cardNumbers,
|
||||||
@@ -181,20 +188,17 @@ fun SettingsScreen(onLogout: () -> Unit) {
|
|||||||
onRemove = { viewModel.removeCardNumber(it) }
|
onRemove = { viewModel.removeCardNumber(it) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
|
|
||||||
FontSelectionDropdown(
|
FontSelectionDropdown(
|
||||||
selectedFont = selectedFont,
|
selectedFont = selectedFont,
|
||||||
onFontSelected = { viewModel.onFontSelected(it) }
|
onFontSelected = { viewModel.onFontSelected(it) }
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!isOperatorMode) {
|
if (!isOperatorMode) {
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
"پشتیبانگیری و بازیابی",
|
"پشتیبانگیری و بازیابی",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -224,24 +228,26 @@ fun SettingsScreen(onLogout: () -> Unit) {
|
|||||||
viewModel.loadSnapshots()
|
viewModel.loadSnapshots()
|
||||||
showSnapshotDialog = true
|
showSnapshotDialog = true
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
|
Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onLogout,
|
onClick = onLogout,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp)
|
contentPadding = PaddingValues(vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
|
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(stringResource(R.string.logout))
|
Text(stringResource(R.string.logout))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showSnapshotDialog) {
|
if (showSnapshotDialog) {
|
||||||
@@ -621,23 +627,51 @@ fun OperatorModeSetting(
|
|||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = if (isOperatorMode) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
containerColor = if (isOperatorMode) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Icon(
|
||||||
Text("حالت دسترسی برنامه", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
imageVector = if (isOperatorMode) Icons.Default.Lock else Icons.Default.AdminPanelSettings,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (isOperatorMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
if (isOperatorMode) "وضعیت فعلی: حالت اپراتور (محدود)" else "وضعیت فعلی: حالت مدیریت (کامل)",
|
text = "حالت دسترسی برنامه",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = if (isOperatorMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = if (isOperatorMode) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (isOperatorMode) "اپراتور (محدود)" else "مدیریت (کامل)",
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (isOperatorMode) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
HorizontalDivider(thickness = 0.5.dp)
|
||||||
|
|
||||||
if (isOperatorMode) {
|
if (isOperatorMode) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -646,32 +680,52 @@ fun OperatorModeSetting(
|
|||||||
errorMessage = ""
|
errorMessage = ""
|
||||||
showPinDialog = true
|
showPinDialog = true
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||||
) {
|
) {
|
||||||
Text("خروج از حالت اپراتور")
|
Icon(Icons.Default.LockOpen, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("خروج از حالت اپراتور (ورود رمز مدیر)", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = { onToggleMode(true) },
|
||||||
onToggleMode(true)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
},
|
shape = RoundedCornerShape(10.dp)
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
) {
|
||||||
Text("فعالسازی حالت اپراتور")
|
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("فعالسازی حالت اپراتور (محدودسازی)", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedButton(
|
Row(
|
||||||
onClick = {
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
pinAction = PinAction.CHANGE_PIN
|
pinAction = PinAction.CHANGE_PIN
|
||||||
enteredPin = ""
|
enteredPin = ""
|
||||||
newPinInput = ""
|
newPinInput = ""
|
||||||
errorMessage = ""
|
errorMessage = ""
|
||||||
showPinDialog = true
|
showPinDialog = true
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Text("تغییر رمز مدیر")
|
|
||||||
}
|
}
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Edit,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "تغییر رمز عبور مدیریت",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,3 +30,36 @@ fun formatTimestampToPersianDateTime(timestamp: Long): String {
|
|||||||
val time = formatTimestampToPersianTime(timestamp)
|
val time = formatTimestampToPersianTime(timestamp)
|
||||||
return "$date، ساعت $time"
|
return "$date، ساعت $time"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isTimestampInCurrentCycle(rawTimestamp: Long): Boolean {
|
||||||
|
if (rawTimestamp <= 0L) return false
|
||||||
|
|
||||||
|
val timestamp = if (rawTimestamp < 100000000000L) rawTimestamp * 1000L else rawTimestamp
|
||||||
|
|
||||||
|
val now = PersianDate()
|
||||||
|
val curYear = now.shYear
|
||||||
|
val curMonth = now.shMonth
|
||||||
|
val curDay = now.shDay
|
||||||
|
|
||||||
|
val (startYear, startMonth) = if (curDay >= 20) {
|
||||||
|
curYear to curMonth
|
||||||
|
} else {
|
||||||
|
if (curMonth == 1) (curYear - 1) to 12 else curYear to (curMonth - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val (endYear, endMonth) = if (startMonth == 12) {
|
||||||
|
(startYear + 1) to 1
|
||||||
|
} else {
|
||||||
|
startYear to (startMonth + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val recordDate = PersianDate(Date(timestamp))
|
||||||
|
val rYear = recordDate.shYear
|
||||||
|
val rMonth = recordDate.shMonth
|
||||||
|
val rDay = recordDate.shDay
|
||||||
|
|
||||||
|
val isFirstHalf = (rYear == startYear && rMonth == startMonth && rDay >= 20)
|
||||||
|
val isSecondHalf = (rYear == endYear && rMonth == endMonth && rDay < 20)
|
||||||
|
|
||||||
|
return isFirstHalf || isSecondHalf
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ sealed class DownloadState {
|
|||||||
|
|
||||||
class UpdateManager(private val context: Context) {
|
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 {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay
|
|||||||
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
|
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
|
||||||
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
|
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
|
||||||
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
|
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
|
||||||
|
import com.oqba26.monthlypaymentapp.utils.isTimestampInCurrentCycle
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
@@ -69,9 +70,6 @@ class PersonViewModel @Inject constructor(
|
|||||||
private val _infoMessage = MutableSharedFlow<String>()
|
private val _infoMessage = MutableSharedFlow<String>()
|
||||||
val infoMessage = _infoMessage.asSharedFlow()
|
val infoMessage = _infoMessage.asSharedFlow()
|
||||||
|
|
||||||
val isOperatorMode: StateFlow<Boolean> = settingsRepository.isOperatorModeFlow
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false)
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(PersonListUiState())
|
private val _uiState = MutableStateFlow(PersonListUiState())
|
||||||
val uiState: StateFlow<PersonListUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<PersonListUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
@@ -187,8 +185,9 @@ class PersonViewModel @Inject constructor(
|
|||||||
localPersonRepository.getAllPersonsFlow(),
|
localPersonRepository.getAllPersonsFlow(),
|
||||||
localPersonRepository.getAllPaymentsFlow(),
|
localPersonRepository.getAllPaymentsFlow(),
|
||||||
_searchQuery,
|
_searchQuery,
|
||||||
_currentCategory
|
_currentCategory,
|
||||||
) { persons, allPayments, query, category ->
|
settingsRepository.defaultPaymentAmountFlow
|
||||||
|
) { persons, allPayments, query, category, defaultPaymentAmount ->
|
||||||
val currentMonth = getCurrentShamsiMonth()
|
val currentMonth = getCurrentShamsiMonth()
|
||||||
val currentYear = getCurrentShamsiYear()
|
val currentYear = getCurrentShamsiYear()
|
||||||
val currentDay = getCurrentShamsiDay()
|
val currentDay = getCurrentShamsiDay()
|
||||||
@@ -200,6 +199,8 @@ class PersonViewModel @Inject constructor(
|
|||||||
it.shamsiMonth == targetMonth && it.shamsiYear == targetYear && it.category == category
|
it.shamsiMonth == targetMonth && it.shamsiYear == targetYear && it.category == category
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val categoryPayments = allPayments.filter { it.category == category }
|
||||||
|
|
||||||
val activePersons = persons
|
val activePersons = persons
|
||||||
.filter { !it.isArchived && it.category == category }
|
.filter { !it.isArchived && it.category == category }
|
||||||
.sortedWith(compareBy({ it.displayOrder }, { it.createdAt }))
|
.sortedWith(compareBy({ it.displayOrder }, { it.createdAt }))
|
||||||
@@ -207,6 +208,13 @@ class PersonViewModel @Inject constructor(
|
|||||||
val uiModels = activePersons.map { person ->
|
val uiModels = activePersons.map { person ->
|
||||||
val hasPaid = relevantPayments.any { it.personId == person.id }
|
val hasPaid = relevantPayments.any { it.personId == person.id }
|
||||||
|
|
||||||
|
val personCyclePayments = categoryPayments.filter {
|
||||||
|
it.personId == person.id && isTimestampInCurrentCycle(if ((it.createdAt ?: 0L) > 0L) it.createdAt!! else it.timestamp)
|
||||||
|
}
|
||||||
|
val latestPayment = personCyclePayments.maxByOrNull { if ((it.createdAt ?: 0L) > 0L) it.createdAt!! else it.timestamp }
|
||||||
|
val lastPaymentTime = latestPayment?.let { if ((it.createdAt ?: 0L) > 0L) it.createdAt!! else it.timestamp }
|
||||||
|
val lastPaymentAmount = if (personCyclePayments.isNotEmpty()) personCyclePayments.sumOf { it.amount } else null
|
||||||
|
|
||||||
val unpaidResult = if (category == "mosque" || person.isAnonymous) {
|
val unpaidResult = if (category == "mosque" || person.isAnonymous) {
|
||||||
Triple(0, 0.0, emptyList<String>() to emptyList())
|
Triple(0, 0.0, emptyList<String>() to emptyList())
|
||||||
} else {
|
} else {
|
||||||
@@ -217,8 +225,7 @@ class PersonViewModel @Inject constructor(
|
|||||||
val unpaid = (debtStartMonth..debtEndMonth).filter { m ->
|
val unpaid = (debtStartMonth..debtEndMonth).filter { m ->
|
||||||
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category }
|
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 defaultPaymentAmount
|
||||||
val amountPerMonth = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultAmount
|
|
||||||
Triple(unpaid.size, unpaid.size * amountPerMonth, unpaid.map { getPersianMonthName(it) } to unpaid)
|
Triple(unpaid.size, unpaid.size * amountPerMonth, unpaid.map { getPersianMonthName(it) } to unpaid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,17 +251,19 @@ class PersonViewModel @Inject constructor(
|
|||||||
monthlyCommitment = person.monthlyCommitment,
|
monthlyCommitment = person.monthlyCommitment,
|
||||||
unpaidMonthsNames = unpaidMonthsNames,
|
unpaidMonthsNames = unpaidMonthsNames,
|
||||||
unpaidMonths = unpaidMonths,
|
unpaidMonths = unpaidMonths,
|
||||||
needsSync = person.needsSync
|
needsSync = person.needsSync,
|
||||||
|
lastPaymentTime = lastPaymentTime,
|
||||||
|
lastPaymentAmount = lastPaymentAmount
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDashboard(uiModels.size, relevantPayments)
|
updateDashboard(uiModels.size, relevantPayments, categoryPayments)
|
||||||
|
|
||||||
val (paidModels, unpaidModels) = uiModels.partition { it.hasPaidThisMonth }
|
val (rawPaidModels, unpaidModels) = uiModels.partition { it.hasPaidThisMonth }
|
||||||
|
val paidModels = rawPaidModels.sortedByDescending { it.lastPaymentTime ?: 0L }
|
||||||
|
|
||||||
// محاسبه گزارشات مالی جامع
|
// محاسبه گزارشات مالی جامع
|
||||||
val personsMap = persons.associateBy { it.id }
|
val personsMap = persons.associateBy { it.id }
|
||||||
val categoryPayments = allPayments.filter { it.category == category }
|
|
||||||
val totalAllTimeIncome = categoryPayments.sumOf { it.amount }
|
val totalAllTimeIncome = categoryPayments.sumOf { it.amount }
|
||||||
|
|
||||||
val debtorsList = unpaidModels.filter { it.debtCount > 0 }.map { personModel ->
|
val debtorsList = unpaidModels.filter { it.debtCount > 0 }.map { personModel ->
|
||||||
@@ -406,11 +415,23 @@ class PersonViewModel @Inject constructor(
|
|||||||
val isMosque = _currentCategory.value == "mosque"
|
val isMosque = _currentCategory.value == "mosque"
|
||||||
val targetMonth = if (isMosque) currentMonth else (if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1)
|
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 targetYear = if (isMosque) currentYear else (if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear)
|
||||||
|
val category = _currentCategory.value
|
||||||
|
|
||||||
|
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)
|
val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description)
|
||||||
.copy(category = _currentCategory.value)
|
.copy(category = category)
|
||||||
localPersonRepository.insertPaymentLocally(record)
|
localPersonRepository.insertPaymentLocally(record)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is PersonScreenEvent.SelectPerson -> {
|
is PersonScreenEvent.SelectPerson -> {
|
||||||
_selectedPersonId.value = event.personId
|
_selectedPersonId.value = event.personId
|
||||||
@@ -468,10 +489,21 @@ class PersonViewModel @Inject constructor(
|
|||||||
is PersonScreenEvent.AddPaymentForMonth -> {
|
is PersonScreenEvent.AddPaymentForMonth -> {
|
||||||
val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == event.personId }
|
val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == event.personId }
|
||||||
val category = person?.category ?: _currentCategory.value
|
val category = person?.category ?: _currentCategory.value
|
||||||
|
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)
|
val record = createPaymentRecord(event.personId, event.month, event.year, event.amount, event.description)
|
||||||
.copy(category = category)
|
.copy(category = category)
|
||||||
localPersonRepository.insertPaymentLocally(record)
|
localPersonRepository.insertPaymentLocally(record)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is PersonScreenEvent.UpdatePayment -> {
|
is PersonScreenEvent.UpdatePayment -> {
|
||||||
val updatedRecord = event.payment.copy(amount = event.newAmount, description = event.newDescription, timestamp = System.currentTimeMillis())
|
val updatedRecord = event.payment.copy(amount = event.newAmount, description = event.newDescription, timestamp = System.currentTimeMillis())
|
||||||
@@ -479,7 +511,20 @@ class PersonViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is PersonScreenEvent.DeletePayment -> {
|
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("پرداخت با موفقیت حذف شد")
|
_toastMessage.emit("پرداخت با موفقیت حذف شد")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +555,7 @@ class PersonViewModel @Inject constructor(
|
|||||||
val currentMonth = getCurrentShamsiMonth()
|
val currentMonth = getCurrentShamsiMonth()
|
||||||
val currentYear = getCurrentShamsiYear()
|
val currentYear = getCurrentShamsiYear()
|
||||||
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
|
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
|
||||||
|
val allPayments = localPersonRepository.getAllPayments()
|
||||||
|
|
||||||
event.months.forEach { month ->
|
event.months.forEach { month ->
|
||||||
val monthName = getPersianMonthName(month)
|
val monthName = getPersianMonthName(month)
|
||||||
@@ -520,6 +566,20 @@ class PersonViewModel @Inject constructor(
|
|||||||
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
|
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
val record = createPaymentRecord(
|
||||||
personId = event.personId,
|
personId = event.personId,
|
||||||
month = month,
|
month = month,
|
||||||
@@ -529,6 +589,7 @@ class PersonViewModel @Inject constructor(
|
|||||||
).copy(category = category)
|
).copy(category = category)
|
||||||
localPersonRepository.insertPaymentLocally(record)
|
localPersonRepository.insertPaymentLocally(record)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
_toastMessage.emit("خطا در ثبت پرداختهای دسته جمعی")
|
_toastMessage.emit("خطا در ثبت پرداختهای دسته جمعی")
|
||||||
}
|
}
|
||||||
@@ -590,11 +651,20 @@ class PersonViewModel @Inject constructor(
|
|||||||
exportManager.shareReceipt(payment, personName)
|
exportManager.shareReceipt(payment, personName)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateDashboard(totalPersonCount: Int, paymentsThisMonth: List<PaymentRecord>) {
|
private fun updateDashboard(
|
||||||
|
totalPersonCount: Int,
|
||||||
|
paymentsThisMonth: List<PaymentRecord>,
|
||||||
|
categoryPayments: List<PaymentRecord>
|
||||||
|
) {
|
||||||
val paidCount = paymentsThisMonth.map { it.personId }.distinct().size
|
val paidCount = paymentsThisMonth.map { it.personId }.distinct().size
|
||||||
val totalIncome = paymentsThisMonth.sumOf { it.amount }
|
val totalIncome = paymentsThisMonth.sumOf { it.amount }
|
||||||
|
val cyclePayments = categoryPayments.filter { payment ->
|
||||||
|
val time = if ((payment.createdAt ?: 0L) > 0L) payment.createdAt!! else payment.timestamp
|
||||||
|
isTimestampInCurrentCycle(time)
|
||||||
|
}
|
||||||
|
val totalCycleIncome = cyclePayments.sumOf { it.amount }
|
||||||
val progress = if (totalPersonCount > 0) paidCount.toFloat() / totalPersonCount.toFloat() else 0f
|
val progress = if (totalPersonCount > 0) paidCount.toFloat() / totalPersonCount.toFloat() else 0f
|
||||||
_dashboardData.value = DashboardUiModel(paidCount, totalPersonCount, totalIncome, progress)
|
_dashboardData.value = DashboardUiModel(paidCount, totalPersonCount, totalIncome, totalCycleIncome, progress)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createPaymentRecord(personId: String, month: Int, year: Int, amount: Double, description: String): PaymentRecord {
|
private fun createPaymentRecord(personId: String, month: Int, year: Int, amount: Double, description: String): PaymentRecord {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ data class PersonUiModel(
|
|||||||
val unpaidMonthsNames: List<String> = emptyList(),
|
val unpaidMonthsNames: List<String> = emptyList(),
|
||||||
val unpaidMonths: List<Int> = emptyList(),
|
val unpaidMonths: List<Int> = emptyList(),
|
||||||
val needsSync: Boolean = false,
|
val needsSync: Boolean = false,
|
||||||
|
val lastPaymentTime: Long? = null,
|
||||||
|
val lastPaymentAmount: Double? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PersonListUiState(
|
data class PersonListUiState(
|
||||||
@@ -143,6 +145,7 @@ data class DashboardUiModel(
|
|||||||
val paidCount: Int = 0,
|
val paidCount: Int = 0,
|
||||||
val totalCount: Int = 0,
|
val totalCount: Int = 0,
|
||||||
val totalIncome: Double = 0.0,
|
val totalIncome: Double = 0.0,
|
||||||
|
val totalCycleIncome: Double = 0.0,
|
||||||
val progress: Float = 0f
|
val progress: Float = 0f
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
# gradle.properties
|
# gradle.properties
|
||||||
# org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
|
# org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
|
||||||
org.gradle.jvmargs=-Xmx2560m -XX:+UseParallelGC -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2048m -XX:+UseG1GC -Dfile.encoding=UTF-8
|
||||||
|
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=false
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
android.suppressUnsupportedCompileSdk=34
|
android.suppressUnsupportedCompileSdk=34
|
||||||
@@ -20,5 +20,5 @@ ksp.incremental=true
|
|||||||
|
|
||||||
# Fix for AGP 8.7+ "Several environment variables" error
|
# Fix for AGP 8.7+ "Several environment variables" error
|
||||||
systemProp.ANDROID_PREFS_ROOT=
|
systemProp.ANDROID_PREFS_ROOT=
|
||||||
systemProp.ANDROID_USER_HOME=C:\\Users\\msi\\.android
|
# systemProp.ANDROID_USER_HOME=C:\\Users\\msi\\.android
|
||||||
org.gradle.project.ANDROID_PREFS_ROOT=
|
org.gradle.project.ANDROID_PREFS_ROOT=
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.10.2-bin.zip
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"versionCode": 9,
|
"versionCode": 17,
|
||||||
"versionName": "1.0.8",
|
"versionName": "1.1.6",
|
||||||
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.8/MonthlyPaymentApp-v1.0.8.apk",
|
"url": "https://git.mizbanplatform.ir/oqba26/MonthlyPaymentApp/releases/download/v1.1.6/MonthlyPaymentApp-v1.1.6.apk",
|
||||||
"releaseNotes": "بهبودهای تب مسجد و افزودن حالت اپراتور (محدودسازی دسترسی)",
|
"releaseNotes": "رفع باگ حذف پرداختها و پایداری همگامسازی",
|
||||||
"isForceUpdate": false
|
"isForceUpdate": false
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user