11 Commits
Author SHA1 Message Date
Oqba 44fc513589 بهبودهای تب مسجد و افزودن حالت اپراتور (نسخه 1.0.8)
Android Release / build (push) Canceled after 0s
2026-10-16 06:33:28 +03:30
Oqba fe891b2765 پاکسازی درخواست‌های مانده در سیستم DownloadManager هنگام اجرای برنامه
Android Release / build (push) Canceled after 0s
2026-09-21 18:57:32 +03:30
Oqba 07c670c1b1 مجوز ارتباط Cleartext HTTP به آی‌پی جدید PocketBase در network_security_config 2026-09-21 18:31:32 +03:30
Oqba ebf9798c16 اصلاح سیستم دانلود به‌روزرسانی با Ktor و حل مشکل گیر کردن در ۰٪ 2026-09-21 18:08:35 +03:30
Oqba ed1d3cecbb مهاجرت به PocketBase، رفع خطای بیلد و ارتقای نسخه به 1.0.7 (نسخه 8) 2026-09-21 17:50:52 +03:30
Oqba a3a8448ee5 Fix Supabase column serialization mapping (snake_case) for Person and PaymentRecord models to prevent resetting archived status during sync
Android Release / build (push) Canceled after 14s
2026-09-11 16:45:51 +03:30
Oqba b05c7c7521 Update version to 1.0.6, fix compiler warnings in PersonScreen and PersonDetailScreen, and update release notes 2026-09-11 15:24:21 +03:30
oqba26 9db6cf4fa1 بازگرداندن نسخه به 1.0.5 پس از تایید تست ظاهری
Android Release / build (push) Canceled after 24s
2026-08-05 10:29:15 +03:30
oqba26 11030e9109 تغییر موقت نسخه به 1.0.6 برای تست ظاهری دیالوگ 2026-08-05 10:02:29 +03:30
oqba26 309d0d0b19 تغییر متن دکمه آپدیت و جابجایی دکمه‌ها در دیالوگ به نسخه 1.0.5 2026-08-05 09:42:51 +03:30
oqba26 8b8696a75f اصلاح لینک دانلود در فایل آپدیت به نسخه 1.0.4 2026-08-05 09:30:38 +03:30
27 changed files with 1485 additions and 466 deletions

No files matched your search

+12
View File
@@ -6,6 +6,18 @@
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="androidTest">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="main">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
<SelectionState runConfigName="unitTest">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+5 -10
View File
@@ -30,8 +30,8 @@ android {
applicationId = "com.oqba26.monthlypaymentapp"
minSdk = 25
targetSdk = 34
versionCode = 5
versionName = "1.0.4"
versionCode = 9
versionName = "1.0.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}
@@ -55,8 +55,7 @@ android {
buildTypes {
debug {
buildConfigField("String", "SUPABASE_URL", "\"https://ftufsygeartwukonclkz.supabase.co\"")
buildConfigField("String", "SUPABASE_KEY", "\"sb_publishable_H4mAU1Ds-vZ22HwMSfCaBQ_QjBfmRd5\"")
buildConfigField("String", "POCKETBASE_URL", "\"http://194.146.68.50/\"")
}
release {
val storeFileProp = getProp("RELEASE_STORE_FILE")
@@ -68,8 +67,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("String", "SUPABASE_URL", "\"https://ftufsygeartwukonclkz.supabase.co\"")
buildConfigField("String", "SUPABASE_KEY", "\"sb_publishable_H4mAU1Ds-vZ22HwMSfCaBQ_QjBfmRd5\"")
buildConfigField("String", "POCKETBASE_URL", "\"http://194.146.68.50/\"")
}
}
@@ -111,10 +109,7 @@ dependencies {
implementation("androidx.compose.material:material-icons-extended-android:1.6.7")
implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6")
// Supabase
implementation(libs.supabase.postgrest)
implementation(libs.supabase.auth)
implementation(libs.supabase.realtime)
// PocketBase / Ktor Network
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
+1 -1
View File
@@ -16,12 +16,12 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MonthlyPaymentApp"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.MonthlyPaymentApp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -1,10 +1,13 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package com.oqba26.monthlypaymentapp
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.widget.Toast
import androidx.core.net.toUri
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -29,6 +32,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Assessment
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Paid
@@ -96,6 +100,7 @@ import com.oqba26.monthlypaymentapp.ui.screens.PersonDetailScreen
import com.oqba26.monthlypaymentapp.ui.screens.PersonScreen
import com.oqba26.monthlypaymentapp.ui.screens.SettingsScreen
import com.oqba26.monthlypaymentapp.ui.theme.MonthlyPaymentManagement2Theme
import com.oqba26.monthlypaymentapp.utils.DownloadState
import com.oqba26.monthlypaymentapp.utils.UpdateInfo
import com.oqba26.monthlypaymentapp.utils.UpdateManager
import com.oqba26.monthlypaymentapp.viewmodel.ContactViewModel
@@ -213,23 +218,40 @@ fun MainAppHost(viewModel: PersonViewModel) {
updateInfo = info,
onDismiss = { updateInfo = null },
onConfirm = {
val id = updateManager.downloadAndInstall(info.url, "MonthlyPaymentApp_v${info.versionName}.apk")
if (id != -1L) {
isDownloading = true
scope.launch {
updateManager.getDownloadProgress(id).collect { progress ->
downloadProgress = progress
if (progress >= 1f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) {
Toast.makeText(context, "لطفاً اجازه نصب برنامه‌های ناشناخته را بدهید", Toast.LENGTH_LONG).show()
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
return@UpdateDialog
}
isDownloading = true
downloadProgress = 0f
if (!info.isForceUpdate) {
updateInfo = null
}
scope.launch {
val fileName = "MonthlyPaymentApp_v${info.versionName}.apk"
updateManager.downloadApk(info.url, fileName).collect { state ->
when (state) {
is DownloadState.Progress -> {
downloadProgress = state.progress
}
is DownloadState.Success -> {
isDownloading = false
if (!info.isForceUpdate) {
updateInfo = null
}
updateManager.installApk(state.file)
}
is DownloadState.Error -> {
isDownloading = false
Toast.makeText(context, "خطا در دانلود: ${state.message}", Toast.LENGTH_LONG).show()
updateManager.openInBrowser(info.url)
}
}
}
if (!info.isForceUpdate) {
updateInfo = null
}
}
}
)
@@ -252,15 +274,23 @@ fun MainAppHost(viewModel: PersonViewModel) {
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp)
)
LinearProgressIndicator(
progress = { downloadProgress },
modifier = Modifier.fillMaxWidth().height(8.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
if (downloadProgress > 0f) {
LinearProgressIndicator(
progress = { downloadProgress },
modifier = Modifier.fillMaxWidth().height(8.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
} else {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().height(8.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${(downloadProgress * 100).toInt()}%",
text = if (downloadProgress > 0f) "${(downloadProgress * 100).toInt()}%" else "در حال اتصال...",
style = MaterialTheme.typography.bodyMedium
)
}
@@ -428,18 +458,34 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
IconButton(onClick = { showConfirmMoveDialog = true }) {
Icon(Icons.Default.Check, contentDescription = "Confirm Move", tint = Color.Green)
}
} else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) {
FilledIconButton(
onClick = { viewModel.onAddPersonClicked() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White
)
) {
Icon(Icons.Default.Add, contentDescription = "افزودن شخص")
} else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Paid.route || currentRoute == Screen.Mosque.route) {
if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) {
FilledIconButton(
onClick = { viewModel.onAddPersonClicked() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White
)
) {
Icon(Icons.Default.Add, contentDescription = "افزودن شخص")
}
FilledIconButton(
onClick = { contactViewModel.onBulkSmsClicked() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White
),
modifier = Modifier.padding(start = 4.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Message,
contentDescription = "Bulk SMS",
modifier = Modifier.graphicsLayer(scaleX = -1f)
)
}
}
FilledIconButton(
onClick = { contactViewModel.onBulkSmsClicked() },
onClick = { viewModel.onShowFinancialReport() },
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = Color.White
@@ -447,9 +493,8 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
modifier = Modifier.padding(start = 4.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Message,
contentDescription = "Bulk SMS",
modifier = Modifier.graphicsLayer(scaleX = -1f)
imageVector = Icons.Default.Assessment,
contentDescription = "گزارشات مالی"
)
}
}
@@ -490,7 +535,7 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
)
}
composable(Screen.Archive.route) {
ArchiveScreen(viewModel)
ArchiveScreen(viewModel = viewModel, navController = navController)
}
composable(Screen.Settings.route) {
val authViewModel: AuthViewModel = hiltViewModel()
@@ -20,8 +20,8 @@ object RepositoryModule {
@Provides
@Singleton
fun provideNetworkRepository(): NetworkRepository {
return NetworkRepository()
fun provideNetworkRepository(settingsRepository: SettingsRepository): NetworkRepository {
return NetworkRepository(settingsRepository)
}
@Provides
@@ -25,6 +25,9 @@ interface PaymentDao {
@Query("DELETE FROM payments WHERE id = :paymentId")
suspend fun deleteById(paymentId: String)
@Query("DELETE FROM payments WHERE personId = :personId")
suspend fun deletePaymentsForPerson(personId: String)
/** حذف گروهی — برای اعمال [com.oqba26.monthlypaymentapp.data.sync.MergePlan] در یک کوئری. */
@Query("DELETE FROM payments WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
@@ -1,11 +1,13 @@
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
@file:OptIn(InternalSerializationApi::class)
package com.oqba26.monthlypaymentapp.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.UUID
@Serializable
@@ -24,12 +26,12 @@ data class PaymentRecord(
@SerialName("timestamp") val timestamp: Long = System.currentTimeMillis(),
@SerialName("description") val description: String? = "", // ⭐️ تغییر به String? برای پذیرش مقادیر null
@SerialName("description") val description: String? = "",
@SerialName("createdAt") val createdAt: Long? = null,
@SerialName("category") val category: String = "salary",
@kotlinx.serialization.Transient
@Transient
val needsSync: Boolean = false
)
@@ -1,11 +1,13 @@
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
@file:OptIn(InternalSerializationApi::class)
package com.oqba26.monthlypaymentapp.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
@Entity(tableName = "persons")
@@ -33,6 +35,6 @@ data class Person(
@SerialName("startYear") val startYear: Int = 1403,
@kotlinx.serialization.Transient
@Transient
val needsSync: Boolean = false
)
@@ -1,38 +1,10 @@
@file:OptIn(io.github.jan.supabase.annotations.SupabaseInternal::class)
package com.oqba26.monthlypaymentapp.data.remote
import com.oqba26.monthlypaymentapp.BuildConfig
import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.gotrue.Auth
import io.github.jan.supabase.postgrest.Postgrest
import io.github.jan.supabase.realtime.Realtime
import io.github.jan.supabase.serializer.KotlinXSerializer
import io.ktor.client.plugins.HttpTimeout
import kotlinx.serialization.json.Json
object ApiClient {
val client = createSupabaseClient(
supabaseUrl = BuildConfig.SUPABASE_URL,
supabaseKey = BuildConfig.SUPABASE_KEY
) {
defaultSerializer = KotlinXSerializer(Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
})
httpConfig {
install(HttpTimeout) {
requestTimeoutMillis = 60000
connectTimeoutMillis = 60000
socketTimeoutMillis = 60000
}
}
install(Postgrest)
install(Auth)
install(Realtime)
val pocketBase: PocketBaseClient by lazy {
PocketBaseClient(baseUrl = BuildConfig.POCKETBASE_URL)
}
}
@@ -0,0 +1,388 @@
package com.oqba26.monthlypaymentapp.data.remote
import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.patch
import io.ktor.client.request.post
import io.ktor.client.request.prepareGet
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsChannel
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.time.Duration.Companion.seconds
@Serializable
data class PocketBaseListResponse<T>(
val page: Int = 1,
val perPage: Int = 500,
val totalPages: Int = 1,
val totalItems: Int = 0,
val items: List<T> = emptyList(),
)
@Serializable
data class PocketBaseAuthResponse(
val token: String,
val record: JsonObject? = null,
)
@Serializable
data class PocketBaseConnectEvent(
val clientId: String,
)
@Serializable
data class PocketBaseSubscribeRequest(
val clientId: String,
val subscriptions: List<String>,
)
/**
* کلاینت اختصاصی جهت ارتباط با APIهای REST و سیستم ریل‌تایم (SSE) بک‌اند PocketBase
*/
class PocketBaseClient(
val baseUrl: String = POCKETBASE_DEFAULT_URL
) {
companion object {
const val POCKETBASE_DEFAULT_URL = "http://194.146.68.50/"
@PublishedApi
internal const val TAG = "PocketBaseClient"
}
val json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
coerceInputValues = true
}
val httpClient = HttpClient(OkHttp) {
install(ContentNegotiation) {
json(json)
}
install(HttpTimeout) {
requestTimeoutMillis = 60000
connectTimeoutMillis = 60000
socketTimeoutMillis = 60000
}
}
@Volatile
var authToken: String? = null
/**
* ایجاد کاربر جدید در PocketBase
*/
suspend fun createUser(
collection: String = "users",
email: String,
password: String
): String? {
return try {
val cleanBaseUrl = baseUrl.trimEnd('/')
val url = "$cleanBaseUrl/api/collections/$collection/records"
val response = httpClient.post(url) {
contentType(ContentType.Application.Json)
setBody(
mapOf(
"email" to email,
"password" to password,
"passwordConfirm" to password
)
)
}
if (!response.status.isSuccess()) {
val errorText = response.bodyAsText()
Log.e(TAG, "خطا در ساخت کاربر: $errorText")
return null
}
val jsonText = response.bodyAsText()
val parsed = json.parseToJsonElement(jsonText).jsonObject
parsed["id"]?.jsonPrimitive?.content
} catch (e: Exception) {
Log.e(TAG, "خطا در ایجاد کاربر", e)
null
}
}
/**
* احراز هویت با نام کاربری/ایمیل و رمز عبور
*/
suspend fun authWithPassword(
collection: String = "users",
identity: String,
password: String
): PocketBaseAuthResponse {
val cleanBaseUrl = baseUrl.trimEnd('/')
val url = "$cleanBaseUrl/api/collections/$collection/auth-with-password"
val response = httpClient.post(url) {
contentType(ContentType.Application.Json)
setBody(mapOf("identity" to identity, "password" to password))
}
if (!response.status.isSuccess()) {
val errorText = response.bodyAsText()
throw RuntimeException("خطا در احراز هویت PocketBase: $errorText")
}
val authResp = response.bodyAsText()
val parsed = json.decodeFromString<PocketBaseAuthResponse>(authResp)
authToken = parsed.token
return parsed
}
/**
* دریافت لیست رکوردها از یک کالکشن
*/
suspend inline fun <reified T> getRecords(
collectionName: String,
page: Int = 1,
perPage: Int = 500,
filter: String? = null,
sort: String? = null
): List<T> {
val cleanBaseUrl = baseUrl.trimEnd('/')
val response = httpClient.get("$cleanBaseUrl/api/collections/$collectionName/records") {
parameter("page", page)
parameter("perPage", perPage)
if (!filter.isNullOrEmpty()) {
parameter("filter", filter)
}
if (!sort.isNullOrEmpty()) {
parameter("sort", sort)
}
authToken?.let { token ->
header(HttpHeaders.Authorization, "Bearer $token")
}
}
if (!response.status.isSuccess()) {
val errorText = response.bodyAsText()
Log.e(TAG, "خطا در دریافت $collectionName: $errorText")
return emptyList()
}
val bodyText = response.bodyAsText()
val listResp = json.decodeFromString<PocketBaseListResponse<T>>(bodyText)
return listResp.items
}
/**
* ایجاد رکورد جدید در PocketBase
*/
suspend inline fun <reified T> createRecord(
collectionName: String,
body: T
): T {
val cleanBaseUrl = baseUrl.trimEnd('/')
val url = "$cleanBaseUrl/api/collections/$collectionName/records"
val response = httpClient.post(url) {
contentType(ContentType.Application.Json)
authToken?.let { token ->
header(HttpHeaders.Authorization, "Bearer $token")
}
setBody(body)
}
if (!response.status.isSuccess()) {
val errorText = response.bodyAsText()
throw RuntimeException("خطا در ثبت رکورد $collectionName: $errorText")
}
val respText = response.bodyAsText()
return json.decodeFromString<T>(respText)
}
/**
* بروزرسانی رکورد بر اساس آی‌دی PocketBase
*/
suspend inline fun <reified T> updateRecord(
collectionName: String,
pbRecordId: String,
body: T
): T {
val cleanBaseUrl = baseUrl.trimEnd('/')
val url = "$cleanBaseUrl/api/collections/$collectionName/records/$pbRecordId"
val response = httpClient.patch(url) {
contentType(ContentType.Application.Json)
authToken?.let { token ->
header(HttpHeaders.Authorization, "Bearer $token")
}
setBody(body)
}
if (!response.status.isSuccess()) {
val errorText = response.bodyAsText()
throw RuntimeException("خطا در ویرایش رکورد $collectionName ($pbRecordId): $errorText")
}
val respText = response.bodyAsText()
return json.decodeFromString<T>(respText)
}
/**
* حذف رکورد
*/
suspend fun deleteRecord(
collectionName: String,
pbRecordId: String
): Boolean {
val cleanBaseUrl = baseUrl.trimEnd('/')
val url = "$cleanBaseUrl/api/collections/$collectionName/records/$pbRecordId"
val response = httpClient.delete(url) {
authToken?.let { token ->
header(HttpHeaders.Authorization, "Bearer $token")
}
}
return response.status.isSuccess()
}
/**
* حذف رکورد بر اساس فیلتر
*/
suspend fun deleteRecordByFilter(
collectionName: String,
filterQuery: String
): Boolean {
return try {
val cleanBaseUrl = baseUrl.trimEnd('/')
val searchResp = httpClient.get("$cleanBaseUrl/api/collections/$collectionName/records") {
parameter("filter", filterQuery)
authToken?.let { token ->
header(HttpHeaders.Authorization, "Bearer $token")
}
}
if (searchResp.status.isSuccess()) {
val jsonText = searchResp.bodyAsText()
val parsed = json.parseToJsonElement(jsonText).jsonObject
val items = parsed["items"]?.jsonArray
if (!items.isNullOrEmpty()) {
items.forEach { item ->
val pbId = item.jsonObject["id"]?.jsonPrimitive?.content
if (pbId != null) {
deleteRecord(collectionName, pbId)
}
}
return true
}
}
false
} catch (e: Exception) {
Log.e(TAG, "خطا در حذف رکورد برای $collectionName ($filterQuery)", e)
false
}
}
/**
* آپسرت رکورد (اگر بر اساس فیلتر وجود داشت ویرایش می‌شود وگرنه ایجاد می‌شود)
*/
suspend inline fun <reified T> upsertRecord(
collectionName: String,
filterQuery: String,
body: T
) {
try {
val cleanBaseUrl = baseUrl.trimEnd('/')
val searchResp = httpClient.get("$cleanBaseUrl/api/collections/$collectionName/records") {
parameter("filter", filterQuery)
authToken?.let { token ->
header(HttpHeaders.Authorization, "Bearer $token")
}
}
var existingPbId: String? = null
if (searchResp.status.isSuccess()) {
val jsonText = searchResp.bodyAsText()
val parsed = json.parseToJsonElement(jsonText).jsonObject
val items = parsed["items"]?.jsonArray
if (!items.isNullOrEmpty()) {
val firstItem = items[0].jsonObject
existingPbId = firstItem["id"]?.jsonPrimitive?.content
}
}
if (existingPbId != null) {
updateRecord(collectionName, existingPbId, body)
} else {
createRecord(collectionName, body)
}
} catch (e: Exception) {
Log.e(TAG, "خطا در upsert برای $collectionName ($filterQuery)", e)
throw e
}
}
/**
* اتصال ریل‌تایم SSE
*/
fun listenRealtime(subscriptions: List<String>): Flow<String> = flow {
val cleanBaseUrl = baseUrl.trimEnd('/')
while (true) {
try {
httpClient.prepareGet("$cleanBaseUrl/api/realtime") {
header("Accept", "text/event-stream")
authToken?.let { token ->
header(HttpHeaders.Authorization, "Bearer $token")
}
}.execute { response ->
val channel: ByteReadChannel = response.bodyAsChannel()
var currentEvent = ""
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line() ?: break
val trimmed = line.trim()
if (trimmed.startsWith("event:")) {
currentEvent = trimmed.substringAfter("event:").trim()
} else if (trimmed.startsWith("data:")) {
val dataJson = trimmed.substringAfter("data:").trim()
if (currentEvent == "PB_CONNECT") {
val connectObj = json.decodeFromString<PocketBaseConnectEvent>(dataJson)
subscribeRealtime(cleanBaseUrl, connectObj.clientId, subscriptions)
Log.d(TAG, "اتصال ریل‌تایم برقرار شد. ClientId: ${connectObj.clientId}")
} else if (currentEvent.isNotEmpty()) {
emit(currentEvent)
}
}
}
}
} catch (e: Exception) {
Log.e(TAG, "خطا در ریل‌تایم PocketBase، تلاش مجدد...", e)
}
delay(3.seconds)
}
}
private suspend fun subscribeRealtime(cleanBaseUrl: String, clientId: String, subscriptions: List<String>) {
val url = "$cleanBaseUrl/api/realtime"
httpClient.post(url) {
contentType(ContentType.Application.Json)
authToken?.let { token ->
header(HttpHeaders.Authorization, "Bearer $token")
}
setBody(PocketBaseSubscribeRequest(clientId = clientId, subscriptions = subscriptions))
}
}
}
@@ -53,12 +53,24 @@ class LocalPersonRepository(
val personPlan = SyncMerger.planPersons(localPersons, persons, pendingIds)
val paymentPlan = SyncMerger.planPayments(localPayments, payments, pendingIds)
// پرداخت‌ها اول حذف می‌شوند تا اگر کلید خارجی اضافه شد، ترتیب درست باشد.
if (paymentPlan.deleteIds.isNotEmpty()) paymentDao.deleteByIds(paymentPlan.deleteIds)
if (personPlan.deleteIds.isNotEmpty()) personDao.deleteByIds(personPlan.deleteIds)
if (personPlan.upserts.isNotEmpty()) personDao.insertAll(personPlan.upserts)
if (paymentPlan.upserts.isNotEmpty()) paymentDao.insertAllPaymentRecords(paymentPlan.upserts)
// خودترمیمی: اگر رکوردهایی در دیتابیس محلی هستند که در سرور وجود ندارند و در صف هم نیستند،
// آن‌ها را دوباره برای ارسال به سرور ثبت می‌کنیم تا سرور و کلاینت همگام شوند.
val serverPersonIds = persons.map { it.id }.toSet()
val missingPersons = localPersons.filter { it.id !in serverPersonIds && it.id !in pendingIds }
for (p in missingPersons) {
personDao.insertAll(listOf(p.copy(needsSync = true)))
syncQueueDao.insert(SyncQueue(entityId = p.id, type = SyncType.PERSON, operation = SyncOperation.INSERT))
}
val serverPaymentIds = payments.map { it.id }.toSet()
val missingPayments = localPayments.filter { it.id !in serverPaymentIds && it.id !in pendingIds }
for (p in missingPayments) {
paymentDao.insertAllPaymentRecords(listOf(p.copy(needsSync = true)))
syncQueueDao.insert(SyncQueue(entityId = p.id, type = SyncType.PAYMENT, operation = SyncOperation.INSERT))
}
}
}
@@ -135,6 +147,7 @@ class LocalPersonRepository(
suspend fun deletePersonLocally(personId: String) {
database.withTransaction {
personDao.deleteById(personId)
paymentDao.deletePaymentsForPerson(personId)
syncQueueDao.insert(SyncQueue(entityId = personId, type = SyncType.PERSON, operation = SyncOperation.DELETE))
}
}
@@ -6,72 +6,50 @@ import com.oqba26.monthlypaymentapp.data.model.AuthResponse
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person
import com.oqba26.monthlypaymentapp.data.remote.ApiClient
import io.github.jan.supabase.gotrue.auth
import io.github.jan.supabase.gotrue.providers.builtin.Email
import io.github.jan.supabase.postgrest.from
import io.github.jan.supabase.postgrest.query.Order
import io.github.jan.supabase.realtime.PostgresAction
import io.github.jan.supabase.realtime.channel
import io.github.jan.supabase.realtime.postgresChangeFlow
import io.github.jan.supabase.realtime.realtime
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.UUID
class NetworkRepository {
class NetworkRepository(
private val settingsRepository: SettingsRepository? = null
) {
private val supabase = ApiClient.client
private val pocketBase = ApiClient.pocketBase
private val _personsFlow = MutableStateFlow<List<Person>>(emptyList())
private val _paymentsFlow = MutableStateFlow<List<PaymentRecord>>(emptyList())
private val repositoryScope = CoroutineScope(Dispatchers.IO)
/**
* تا وقتی حداقل یک [refresh] موفق انجام نشده باشد `false` است.
*
* چرا لازم است: مقدار اولیه‌ی `_personsFlow`/`_paymentsFlow` لیست خالی است و از لیست خالیِ
* واقعی (کاربری که هنوز داده‌ای ثبت نکرده) قابل تشخیص نیست. merge کردن روی آن لیست خالیِ
* قالبی، همه‌ی داده‌ی محلی را پاک می‌کند. مصرف‌کننده باید منتظر `true` بماند.
*/
private val _hasServerData = MutableStateFlow(false)
fun hasServerDataFlow(): Flow<Boolean> = _hasServerData.asStateFlow()
init {
settingsRepository?.authTokenFlow?.let { authFlow ->
repositoryScope.launch {
authFlow.collect { token ->
pocketBase.authToken = token
}
}
}
}
// --- Realtime Operations ---
fun observeRealtimeChanges() {
repositoryScope.launch {
try {
// اطمینان از اتصال قبل از اشتراک در کانال
supabase.realtime.connect()
val myChannel = supabase.channel("db-changes")
// گوش دادن به تغییرات جدول اشخاص
myChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
table = "persons"
}.collect {
Log.d("Supabase-Realtime", "Change detected in persons table")
pocketBase.listenRealtime(listOf("persons", "payments")).collect { event ->
Log.d("PocketBase-Realtime", "Change detected in PocketBase: $event")
refresh()
}
// گوش دادن به تغییرات جدول پرداخت‌ها
launch {
myChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
table = "payments"
}.collect {
Log.d("Supabase-Realtime", "Change detected in payments table")
refresh()
}
}
myChannel.subscribe()
Log.d("Supabase-Realtime", "Subscribed to all changes")
} catch (e: Exception) {
Log.e("Supabase-Realtime", "Realtime connection error, retrying...", e)
Log.e("PocketBase-Realtime", "Realtime connection error, retrying...", e)
}
}
}
@@ -79,49 +57,33 @@ class NetworkRepository {
// --- Auth Operations ---
suspend fun register(request: AuthRequest): AuthResponse? {
return try {
supabase.auth.signUpWith(Email) {
email = request.email
password = request.password
}
val session = supabase.auth.currentSessionOrNull()
AuthResponse(token = session?.accessToken ?: "success", userId = session?.user?.id)
pocketBase.createUser("users", request.email, request.password)
val authResp = pocketBase.authWithPassword("users", request.email, request.password)
val userId = authResp.record?.get("id")?.toString()?.replace("\"", "")
AuthResponse(token = authResp.token, userId = userId)
} catch (e: Exception) {
Log.e("Supabase", "register error", e)
Log.e("PocketBase", "register error", e)
null
}
}
suspend fun login(request: AuthRequest): AuthResponse? {
return try {
supabase.auth.signInWith(Email) {
email = request.email
password = request.password
}
val session = supabase.auth.currentSessionOrNull()
AuthResponse(token = session?.accessToken ?: "", userId = session?.user?.id)
val authResp = pocketBase.authWithPassword("users", request.email, request.password)
val userId = authResp.record?.get("id")?.toString()?.replace("\"", "")
AuthResponse(token = authResp.token, userId = userId)
} catch (e: Exception) {
Log.e("Supabase", "login error", e)
Log.e("PocketBase", "login error", e)
null
}
}
// --- Person Operations ---
/**
* ارسال شخص به سرور با upsert.
*
* upsert (و نه insert) عمداً انتخاب شده تا این عملیات idempotent باشد: اگر تلاش اول
* به سرور رسیده باشد ولی پاسخش به‌خاطر timeout به ما نرسیده باشد، تلاش دوم رکورد
* تکراری نمی‌سازد. صف سینک متکی به همین خاصیت است تا بتواند بی‌خطر retry کند.
*
* اعتبارسنجی تکراری‌بودن نام اینجا انجام نمی‌شود؛ آن کار در PersonViewModel روی
* داده‌ی محلی (که همیشه کامل است) انجام می‌گیرد. کش _personsFlow ممکن است خالی یا
* قدیمی باشد و نتیجه‌ی غلط بدهد.
*/
suspend fun addPerson(person: Person): Boolean {
return try {
val trimmedName = person.name.trim()
if (trimmedName.isEmpty()) {
Log.w("Supabase", "addPerson skipped: blank name")
Log.w("PocketBase", "addPerson skipped: blank name")
return false
}
@@ -131,22 +93,22 @@ class NetworkRepository {
person.copy(name = trimmedName)
}
supabase.from("persons").upsert(personToUpsert)
pocketBase.upsertRecord("persons", "id='${personToUpsert.id}'", personToUpsert)
true
} catch (e: Exception) {
Log.e("Supabase", "addPerson error", e)
Log.e("PocketBase", "addPerson error", e)
false
}
}
suspend fun deletePersonAndPayments(personId: String): Boolean {
return try {
supabase.from("persons").delete {
filter { eq("id", personId) }
}
pocketBase.deleteRecordByFilter("payments", "personId='$personId'")
pocketBase.deleteRecordByFilter("persons", "id='$personId'")
true
} catch (e: Exception) {
Log.e("Supabase", "deletePerson error", e); false
Log.e("PocketBase", "deletePerson error", e)
false
}
}
@@ -173,68 +135,59 @@ class NetworkRepository {
}
if (isDuplicate) return 409
supabase.from("persons").update(
buildJsonObject {
put("name", trimmedName)
put("phoneNumber", phoneNumber?.trim())
put("monthlyCommitment", monthlyCommitment)
put("startMonth", startMonth)
put("startYear", startYear)
},
) {
filter { eq("id", personId) }
val updatedPerson = currentPerson?.copy(
name = trimmedName,
phoneNumber = phoneNumber?.trim(),
monthlyCommitment = monthlyCommitment,
startMonth = startMonth,
startYear = startYear
)
if (updatedPerson != null) {
pocketBase.upsertRecord("persons", "id='$personId'", updatedPerson)
}
200
} catch (e: Exception) {
Log.e("Supabase", "updatePerson error", e)
Log.e("PocketBase", "updatePerson error", e)
500
}
}
suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean {
return try {
supabase.from("persons").update(
buildJsonObject {
put("isArchived", isArchived)
}
) {
filter { eq("id", personId) }
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("Supabase", "updateArchived error", e); false
Log.e("PocketBase", "updateArchived error", e)
false
}
}
suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean {
return try {
supabase.from("persons").update(
buildJsonObject {
put("displayOrder", displayOrder)
}
) {
filter { eq("id", personId) }
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("Supabase", "updateOrder error", e); false
Log.e("PocketBase", "updateOrder error", e)
false
}
}
fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow()
// --- Payment Operations ---
/**
* ارسال پرداخت به سرور با upsert — به همان دلیل idempotent بودن که در [addPerson] توضیح داده شد.
* ضمناً همین باعث می‌شود ویرایش پرداخت (SyncOperation.UPDATE) هم درست کار کند؛ قبلاً insert
* بود و روی رکورد موجود شکست می‌خورد.
*/
suspend fun addPayment(paymentRecord: PaymentRecord): Boolean {
return try {
supabase.from("payments").upsert(paymentRecord)
pocketBase.upsertRecord("payments", "id='${paymentRecord.id}'", paymentRecord)
true
} catch (e: Exception) {
Log.e("Supabase", "addPayment error", e); false
Log.e("PocketBase", "addPayment error", e)
false
}
}
@@ -244,16 +197,14 @@ class NetworkRepository {
while (retries <= maxRetries) {
try {
supabase.from("payments").delete {
filter { eq("id", paymentId) }
}
Log.d("Supabase", "deletePayment Success: $paymentId")
pocketBase.deleteRecordByFilter("payments", "id='$paymentId'")
Log.d("PocketBase", "deletePayment Success: $paymentId")
return true
} catch (e: Exception) {
retries++
Log.e("Supabase", "deletePayment error (Attempt $retries): ${e.message}", e)
Log.e("PocketBase", "deletePayment error (Attempt $retries): ${e.message}", e)
if (retries > maxRetries) return false
kotlinx.coroutines.delay(1000L * retries) // Exponential backoff-ish
delay(1000L * retries)
}
}
return false
@@ -263,31 +214,20 @@ class NetworkRepository {
/**
* واکشی کامل داده‌ها از سرور.
*
* @return true فقط اگر **هر دو** واکشی موفق بوده باشند.
*
* دو تضمین مهم که فراخوان‌ها به آن تکیه می‌کنند:
* ۱. در صورت شکست، flow‌ها **دست‌نخورده** می‌مانند. قبلاً خطا بلعیده می‌شد و صدازننده
* نمی‌فهمید واکشی شکست خورده؛ نتیجه‌اش merge روی داده‌ی ناقص و پاک شدن داده‌ی محلی بود.
* ۲. مقداردهی flow‌ها اتمیک است — یا هر دو با هم به‌روز می‌شوند یا هیچ‌کدام. قبلاً اگر
* واکشی پرداخت‌ها شکست می‌خورد، لیست اشخاص جدید کنار لیست پرداخت‌های قدیمی می‌نشست.
*/
suspend fun refresh(): Boolean {
return try {
val persons = supabase.from("persons").select().decodeList<Person>()
val payments = supabase.from("payments").select {
order("timestamp", order = Order.DESCENDING)
}.decodeList<PaymentRecord>()
val persons = pocketBase.getRecords<Person>("persons")
val payments = pocketBase.getRecords<PaymentRecord>("payments", sort = "-timestamp")
// فقط بعد از موفقیت هر دو واکشی مقداردهی می‌کنیم
_personsFlow.value = persons
_paymentsFlow.value = payments
_hasServerData.value = true
Log.d("Supabase", "Refresh Success: ${persons.size} persons, ${payments.size} payments")
Log.d("PocketBase", "Refresh Success: ${persons.size} persons, ${payments.size} payments")
true
} catch (e: Exception) {
Log.e("Supabase", "refresh error — داده‌های محلی دست‌نخورده باقی می‌مانند", e)
Log.e("PocketBase", "refresh error", e)
false
}
}
@@ -3,6 +3,7 @@ package com.oqba26.monthlypaymentapp.data.repository
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
@@ -26,6 +27,8 @@ class SettingsRepository(context: Context) {
val REMINDER_DAY = stringPreferencesKey("reminder_day")
val CARD_NUMBERS = stringPreferencesKey("card_numbers")
val IGNORED_CONTACT_SUGGESTIONS = stringPreferencesKey("ignored_contact_suggestions")
val IS_OPERATOR_MODE = booleanPreferencesKey("is_operator_mode")
val ADMIN_PIN = stringPreferencesKey("admin_pin")
/** آخرین versionCodeی که با آن اجرا شده — مبنای تشخیص ارتقای نسخه برای گرفتن snapshot. */
val LAST_RUN_VERSION_CODE = intPreferencesKey("last_run_version_code")
@@ -116,4 +119,20 @@ class SettingsRepository(context: Context) {
preferences[IGNORED_CONTACT_SUGGESTIONS] = set.joinToString(",")
}
}
val isOperatorModeFlow: Flow<Boolean> = dataStore.data.map {
it[IS_OPERATOR_MODE] ?: false
}
val adminPinFlow: Flow<String> = dataStore.data.map {
it[ADMIN_PIN] ?: "1234"
}
suspend fun setOperatorMode(isOperator: Boolean) {
dataStore.edit { it[IS_OPERATOR_MODE] = isOperator }
}
suspend fun setAdminPin(pin: String) {
dataStore.edit { it[ADMIN_PIN] = pin }
}
}
@@ -75,13 +75,9 @@ object SyncMerger {
person.copy(id = resolvedId, name = name, needsSync = false)
}
val serverIds = resolved.map { it.id }.toSet()
return MergePlan(
upserts = resolved.filterNot { it.id in protectedIds },
deleteIds = local
.map { it.id }
.filterNot { it in serverIds || it in protectedIds }
deleteIds = emptyList() // هرگز رکوردهای محلی را به خاطر غیبت در سرور حذف نمی‌کنیم
)
}
@@ -91,15 +87,12 @@ object SyncMerger {
pendingIds: Set<String>
): MergePlan<PaymentRecord> {
val protectedIds = protectedPaymentIds(local, pendingIds)
val serverIds = server.map { it.id }.toSet()
return MergePlan(
upserts = server
.filterNot { it.id in protectedIds }
.map { it.copy(needsSync = false) },
deleteIds = local
.map { it.id }
.filterNot { it in serverIds || it in protectedIds }
deleteIds = emptyList() // هرگز رکوردهای محلی را به خاطر غیبت در سرور حذف نمی‌کنیم
)
}
}
@@ -92,6 +92,15 @@ fun UpdateDialog(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = onConfirm,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.medium,
elevation = ButtonDefaults.buttonElevation(defaultElevation = 2.dp)
) {
Text("بروزرسانی")
}
if (!updateInfo.isForceUpdate) {
OutlinedButton(
onClick = onDismiss,
@@ -103,15 +112,6 @@ fun UpdateDialog(
Text("بعداً")
}
}
Button(
onClick = onConfirm,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.medium,
elevation = ButtonDefaults.buttonElevation(defaultElevation = 2.dp)
) {
Text("تایید و بروزرسانی")
}
}
}
}
@@ -1,5 +1,6 @@
package com.oqba26.monthlypaymentapp.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -38,12 +39,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.navigation.NavController
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
@Composable
fun ArchiveScreen(viewModel: PersonViewModel) {
fun ArchiveScreen(viewModel: PersonViewModel, navController: NavController) {
val uiState by viewModel.uiState.collectAsState()
var personToDelete by remember { mutableStateOf<PersonUiModel?>(null) }
var personToRestore by remember { mutableStateOf<PersonUiModel?>(null) }
@@ -63,6 +65,9 @@ fun ArchiveScreen(viewModel: PersonViewModel) {
items(uiState.archivedPersons, key = { it.id }) { person ->
ArchivedPersonListItem(
person = person,
onPersonClick = {
navController.navigate("person_detail/${person.id}")
},
onRestore = {
personToRestore = person
},
@@ -148,12 +153,14 @@ fun ArchiveScreen(viewModel: PersonViewModel) {
@Composable
fun ArchivedPersonListItem(
person: PersonUiModel,
onPersonClick: () -> Unit,
onRestore: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onPersonClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
@@ -188,4 +195,4 @@ fun ArchivedPersonListItem(
}
}
}
}
}
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button
@@ -43,7 +44,6 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -124,19 +124,20 @@ fun PersonDetailScreen(
Spacer(modifier = Modifier.height(4.dp))
val isMosque = detailState.person?.category == "mosque"
LazyColumn(modifier = Modifier.fillMaxSize().padding(bottom = 8.dp)) {
items(detailState.monthStates) { monthModel ->
MonthListItem(
month = monthModel,
personName = detailState.person?.name ?: "",
startMonth = detailState.person?.startMonth ?: 1,
isMosqueCategory = isMosque,
onHeaderClick = {
expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month
},
onEditClick = {
if (monthModel.status == MonthStatus.PAID) {
editingPayment = monthModel.payment
} else if (monthModel.status == MonthStatus.AVAILABLE) {
} else {
addingPaymentMonth = monthModel.month
}
}
@@ -171,8 +172,11 @@ fun PersonDetailScreen(
val monthName = getPersianMonthName(month)
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
val isMosque = detailState.person?.category == "mosque"
val isCurrentMonth = (month == getCurrentShamsiMonth()) && (detailState.selectedYear == getCurrentShamsiYear())
val initialDesc = if (isCurrentMonth) {
val initialDesc = if (isMosque) {
"پرداخت کمک به مسجد در تاریخ $currentDateTime ثبت شد."
} else if (isCurrentMonth) {
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
} else {
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
@@ -207,12 +211,18 @@ fun PersonDetailScreen(
initialPhone = detailState.person?.phoneNumber,
initialCommitment = detailState.person?.monthlyCommitment ?: 0.0,
initialStartMonth = detailState.person?.startMonth ?: 1,
initialStartYear = detailState.person?.startYear ?: com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear(),
initialStartYear = detailState.person?.startYear ?: getCurrentShamsiYear(),
initialIsAnonymous = detailState.person?.isAnonymous ?: false,
isMosqueCategory = currentCategory == "mosque",
onConfirm = { name, phone, commitment, month, year ->
viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone, commitment, month, year))
onConfirm = { name, phone, commitment, month, year, isAnon ->
viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone, commitment, month, year, isAnon))
editingPerson = false
},
onDelete = {
viewModel.onEvent(PersonScreenEvent.DeletePerson(personId))
editingPerson = false
navController.popBackStack()
},
onDismiss = { editingPerson = false },
onSearchContact = { name ->
contactViewModel.findSimilarContacts(name)
@@ -256,16 +266,20 @@ fun UpdatePersonDialog(
initialCommitment: Double = 0.0,
initialStartMonth: Int = 1,
initialStartYear: Int = 1403,
initialIsAnonymous: Boolean = false,
isMosqueCategory: Boolean = false,
onConfirm: (String, String, Double, Int, Int) -> Unit,
onConfirm: (String, String, Double, Int, Int, Boolean) -> Unit,
onDelete: () -> Unit,
onDismiss: () -> Unit,
onSearchContact: (String) -> List<ContactMatch>,
) {
var name by remember { mutableStateOf(initialName) }
var phoneNumber by remember { mutableStateOf(initialPhone ?: "") }
var monthlyCommitment by remember { mutableStateOf(if (initialCommitment > 0) initialCommitment.toInt().toString() else "") }
var isAnonymous by remember { mutableStateOf(initialIsAnonymous) }
var showDeleteConfirm by remember { mutableStateOf(false) }
val currentShamsiYear = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear()
val currentShamsiYear = getCurrentShamsiYear()
var startMonth by remember { mutableIntStateOf(initialStartMonth) }
var startYear by remember { mutableIntStateOf(initialStartYear) }
@@ -307,6 +321,20 @@ fun UpdatePersonDialog(
fontWeight = FontWeight.Bold
)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { isAnonymous = !isAnonymous },
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = isAnonymous,
onCheckedChange = { isAnonymous = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text("ثبت به صورت خیر ناشناس")
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
@@ -458,12 +486,12 @@ fun UpdatePersonDialog(
onClick = { expandedMonth = true },
modifier = Modifier.fillMaxWidth()
) {
Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(startMonth))
Text(getPersianMonthName(startMonth))
}
DropdownMenu(expanded = expandedMonth, onDismissRequest = { expandedMonth = false }) {
(1..12).forEach { m ->
DropdownMenuItem(
text = { Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(m)) },
text = { Text(getPersianMonthName(m)) },
onClick = { startMonth = m; expandedMonth = false }
)
}
@@ -481,7 +509,7 @@ fun UpdatePersonDialog(
onClick = {
if (name.isNotBlank()) {
val commitment = monthlyCommitment.toDoubleOrNull() ?: 0.0
onConfirm(name, phoneNumber, commitment, startMonth, startYear)
onConfirm(name, phoneNumber, commitment, startMonth, startYear, isAnonymous)
}
},
enabled = name.isNotBlank()
@@ -498,6 +526,47 @@ fun UpdatePersonDialog(
Text("لغو")
}
}
Button(
onClick = { showDeleteConfirm = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(Icons.Default.Delete, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("حذف این شخص")
}
if (showDeleteConfirm) {
Dialog(onDismissRequest = { showDeleteConfirm = false }) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp)
) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(text = "حذف شخص", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Text(text = "آیا از حذف این شخص و تمام پرداخت‌های او مطمئن هستید؟", style = MaterialTheme.typography.bodyMedium)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Button(onClick = { showDeleteConfirm = false }) { Text("لغو") }
Button(
onClick = {
showDeleteConfirm = false
onDelete()
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
) {
Text("حذف")
}
}
}
}
}
}
}
}
}
}
@@ -535,8 +604,8 @@ fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
@Composable
fun MonthListItem(
month: MonthUiModel,
personName: String,
startMonth: Int,
isMosqueCategory: Boolean = false,
onHeaderClick: () -> Unit,
onEditClick: () -> Unit
) {
@@ -544,7 +613,7 @@ fun MonthListItem(
val containerColor = when (month.status) {
MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer
MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.surface
MonthStatus.NOT_COMMITTED -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
MonthStatus.NOT_COMMITTED -> if (isMosqueCategory) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
else -> MaterialTheme.colorScheme.surfaceVariant
}
@@ -553,19 +622,27 @@ fun MonthListItem(
.padding(horizontal = 16.dp, vertical = 4.dp)
.fillMaxWidth()
.clickable {
if (month.status == MonthStatus.NOT_COMMITTED) {
val monthName = getPersianMonthName(startMonth)
Toast.makeText(
context,
"ماه‌های قبل از $monthName ماه جزو تعهدات این فرد نیست",
Toast.LENGTH_SHORT
).show()
if (month.status == MonthStatus.PAID) {
onHeaderClick()
} else if (month.status == MonthStatus.AVAILABLE) {
onEditClick()
} else if (month.status == MonthStatus.NOT_COMMITTED) {
if (isMosqueCategory) {
onEditClick()
} else {
val monthName = getPersianMonthName(startMonth)
Toast.makeText(
context,
"ماه‌های قبل از $monthName ماه جزو تعهدات این فرد نیست",
Toast.LENGTH_SHORT
).show()
}
} else {
onHeaderClick()
}
},
colors = CardDefaults.cardColors(containerColor = containerColor),
elevation = CardDefaults.cardElevation(defaultElevation = if (month.status == MonthStatus.NOT_COMMITTED) 0.dp else 1.dp)
elevation = CardDefaults.cardElevation(defaultElevation = if (month.status == MonthStatus.NOT_COMMITTED && !isMosqueCategory) 0.dp else 1.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
Row(
@@ -578,7 +655,7 @@ fun MonthListItem(
text = getPersianMonthName(month.month),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (month.status == MonthStatus.NOT_COMMITTED) Color.Gray else Color.Unspecified
color = if (month.status == MonthStatus.NOT_COMMITTED && !isMosqueCategory) Color.Gray else Color.Unspecified
)
if ((month.status == MonthStatus.PAID) && (month.payment != null)) {
Spacer(modifier = Modifier.width(8.dp))
@@ -30,7 +30,9 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Assessment
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Payments
import androidx.compose.material.icons.filled.Search
@@ -92,6 +94,7 @@ import com.oqba26.monthlypaymentapp.utils.toPersianDigits
import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch
import com.oqba26.monthlypaymentapp.viewmodel.ContactSuggestion
import com.oqba26.monthlypaymentapp.viewmodel.DashboardUiModel
import com.oqba26.monthlypaymentapp.viewmodel.FinancialReportData
import com.oqba26.monthlypaymentapp.viewmodel.PersonListType
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
@@ -112,6 +115,9 @@ fun PersonScreen(
val searchQuery by viewModel.searchQuery.collectAsState()
val uiState by viewModel.uiState.collectAsState()
val dashboardData by viewModel.dashboardData.collectAsState()
val financialReportData by viewModel.financialReportData.collectAsState()
val showFinancialReportDialog by viewModel.showFinancialReportDialog.collectAsState()
val isOperatorMode by viewModel.isOperatorMode.collectAsState()
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
val cardNumbers by viewModel.cardNumbersFlow.collectAsState(initial = emptyList())
val personForPaymentDialog by viewModel.personForPaymentDialog.collectAsState()
@@ -179,7 +185,12 @@ fun PersonScreen(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 2.dp, bottom = 0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
DashboardCard(data = dashboardData)
DashboardCard(
data = dashboardData,
financialReportData = financialReportData,
isOperatorMode = isOperatorMode,
onOpenReportClick = { viewModel.onShowFinancialReport() }
)
OutlinedTextField(
value = searchQuery,
@@ -201,10 +212,10 @@ fun PersonScreen(
if (currentCategory == "mosque" && uiState.unpaidPersons.isNotEmpty()) {
item {
Text(
text = "لیست خیرین (بدهکار)",
text = "لیست خیرین",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error
color = MaterialTheme.colorScheme.primary
)
}
}
@@ -301,17 +312,19 @@ fun PersonScreen(
// Dialogs
personForPaymentDialog?.let { person ->
val currentCategory by viewModel.currentCategory.collectAsState()
val isMosque = currentCategory == "mosque"
val currentDay = getCurrentShamsiDay()
val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear()
val targetMonth = if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1
val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear
val targetMonth = if (isMosque) currentMonth else (if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1)
val monthName = getPersianMonthName(targetMonth)
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
val isCurrentMonth = (targetMonth == currentMonth) && (targetYear == currentYear)
val initialDesc = if (isCurrentMonth) {
val isCurrentMonth = targetMonth == currentMonth
val initialDesc = if (isMosque) {
"پرداخت کمک به مسجد در تاریخ $currentDateTime ثبت شد."
} else if (isCurrentMonth) {
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
} else {
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
@@ -407,6 +420,13 @@ fun PersonScreen(
)
}
if (showFinancialReportDialog) {
FinancialReportDialog(
data = financialReportData,
onDismiss = { viewModel.onDismissFinancialReport() }
)
}
if (contactState.currentBulkIndex != -1) {
val person = contactState.bulkSmsQueue.getOrNull(contactState.currentBulkIndex)
if (person != null) {
@@ -1050,7 +1070,10 @@ fun SelectCardDialog(
@Composable
fun DashboardCard(
data: DashboardUiModel
data: DashboardUiModel,
financialReportData: FinancialReportData,
isOperatorMode: Boolean = false,
onOpenReportClick: () -> Unit
) {
val animatedProgress by animateFloatAsState(
targetValue = data.progress,
@@ -1065,29 +1088,236 @@ fun DashboardCard(
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("وضعیت پرداخت‌ها", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
Text("وضعیت پرداخت‌های ماه جاری", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("باقیمانده: ${(data.totalCount - data.paidCount).toString().toPersianDigits()}", style = MaterialTheme.typography.bodySmall)
}
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.fillMaxWidth().height(8.dp).clip(MaterialTheme.shapes.medium)
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(MaterialTheme.shapes.medium)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("مجموع درآمد ماه", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Text("${formatNumberAsPersian(data.totalIncome)} تومان", style = MaterialTheme.typography.bodyMedium)
Text("مجموع درآمد ماه جاری", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Text("${formatNumberAsPersian(data.totalIncome)} تومان", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
}
if (financialReportData.totalOverdueDebt > 0 || financialReportData.debtors.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(vertical = 2.dp))
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🔴 مجموع بدهی‌های معوقه:",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
Text(
text = "${formatNumberAsPersian(financialReportData.totalOverdueDebt)} تومان",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
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)
}
}
}
}
}
@Composable
fun FinancialReportDialog(
data: FinancialReportData,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Card(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 600.dp),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "📊 گزارش آمار و درآمدهای مالی",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "بستن")
}
}
Spacer(modifier = Modifier.height(8.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f))
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("💰 کل درآمدهای تمام زمان‌ها:", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
Text("${formatNumberAsPersian(data.totalAllTimeIncome)} تومان", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("🔴 کل بدهی‌های معوقه فعلی:", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.error)
Text("${formatNumberAsPersian(data.totalOverdueDebt)} تومان (${data.totalDebtorsCount.toString().toPersianDigits()} نفر)", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "تفکیک درآمد ماهانه ماه‌های گذشته",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
if (data.monthlyReports.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Text("هیچ دریافتی تا کنون ثبت نشده است.", style = MaterialTheme.typography.bodyMedium)
}
} else {
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(data.monthlyReports) { report ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${report.monthName} ${report.year.toString().toPersianDigits()}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {
Text(
text = "${formatNumberAsPersian(report.totalAmount)} تومان (${report.paymentCount.toString().toPersianDigits()} پرداخت)",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
if (report.payerNames.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "پرداخت‌کنندگان: ${report.payerNames.joinToString("، ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
TextButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.End)
) {
Text("بستن")
}
}
}
}
}
@@ -1159,12 +1389,22 @@ fun PersonListItem(
shape = MaterialTheme.shapes.small,
modifier = Modifier.clickable { onDebtClick(person) }
) {
Text(
text = "${person.debtCount.toString().toPersianDigits()} بدهی",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = "${formatNumberAsPersian(person.totalDebtAmount)} تومان",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "${person.debtCount.toString().toPersianDigits()} ماه بدهکار",
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f),
style = MaterialTheme.typography.labelSmall
)
}
}
}
@@ -4,6 +4,7 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -19,8 +20,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -148,6 +153,16 @@ fun SettingsScreen(onLogout: () -> Unit) {
onSave = { viewModel.saveDefaultPaymentAmount(it) }
)
val isOperatorMode by viewModel.isOperatorMode.collectAsState()
val adminPin by viewModel.adminPin.collectAsState()
OperatorModeSetting(
isOperatorMode = isOperatorMode,
adminPin = adminPin,
onToggleMode = { viewModel.setOperatorMode(it) },
onChangePin = { viewModel.setAdminPin(it) }
)
Spacer(Modifier.weight(1f))
val reminderDay by viewModel.reminderDay.collectAsState()
@@ -161,6 +176,7 @@ fun SettingsScreen(onLogout: () -> Unit) {
val cardNumbers by viewModel.cardNumbers.collectAsState()
CardNumbersSetting(
cardNumbers = cardNumbers,
isOperatorMode = isOperatorMode,
onAdd = { viewModel.addCardNumber(it) },
onRemove = { viewModel.removeCardNumber(it) }
)
@@ -172,44 +188,46 @@ fun SettingsScreen(onLogout: () -> Unit) {
onFontSelected = { viewModel.onFontSelected(it) }
)
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
if (!isOperatorMode) {
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
Text(
"پشتیبان‌گیری و بازیابی",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
"پشتیبان‌گیری و بازیابی",
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)) {
OutlinedButton(
onClick = {
val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault())
val fileName = "payment_backup_${simpleDateFormat.format(Date())}.json"
backupLauncher.launch(fileName)
},
modifier = Modifier.weight(1f)
) {
Text("تهیه پشتیبان", fontSize = 13.sp)
}
OutlinedButton(
onClick = { restoreLauncher.launch(arrayOf("application/json")) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text("بازیابی فایل", fontSize = 13.sp)
}
}
// تور نجات: نسخه‌هایی که خودِ برنامه قبل از عملیات پرخطر گرفته است.
OutlinedButton(
onClick = {
val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault())
val fileName = "payment_backup_${simpleDateFormat.format(Date())}.json"
backupLauncher.launch(fileName)
viewModel.loadSnapshots()
showSnapshotDialog = true
},
modifier = Modifier.weight(1f)
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
) {
Text("تهیه پشتیبان", fontSize = 13.sp)
Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
}
OutlinedButton(
onClick = { restoreLauncher.launch(arrayOf("application/json")) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text("بازیابی فایل", fontSize = 13.sp)
}
}
// تور نجات: نسخه‌هایی که خودِ برنامه قبل از عملیات پرخطر گرفته است.
OutlinedButton(
onClick = {
viewModel.loadSnapshots()
showSnapshotDialog = true
},
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
) {
Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
}
Spacer(Modifier.weight(1f))
@@ -505,10 +523,17 @@ fun ReminderDaySetting(currentDay: Int?, onSave: (String) -> Unit) {
}
@Composable
fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
fun CardNumbersSetting(
cardNumbers: List<String>,
isOperatorMode: Boolean = false,
onAdd: (String) -> Unit,
onRemove: (String) -> Unit
) {
var isAdding by remember { mutableStateOf(false) }
var newCardText by remember { mutableStateOf("") }
var cardToRemove by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
Card(
modifier = Modifier.fillMaxWidth(),
@@ -517,9 +542,11 @@ fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRem
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("شماره کارت‌های بانکی", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
IconButton(onClick = { isAdding = true }) { Icon(Icons.Default.Add, null) }
if (!isOperatorMode) {
IconButton(onClick = { isAdding = true }) { Icon(Icons.Default.Add, null) }
}
}
if (isAdding) {
if (isAdding && !isOperatorMode) {
OutlinedTextField(
value = newCardText,
onValueChange = { newCardText = it.filter { c -> c.isDigit() } },
@@ -534,9 +561,33 @@ fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRem
}
}
cardNumbers.forEach { card ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(card.toPersianDigits(), modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge)
IconButton(onClick = { cardToRemove = card }) { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable {
clipboardManager.setText(AnnotatedString(card))
Toast.makeText(context, "شماره کارت کپی شد", Toast.LENGTH_SHORT).show()
}
.padding(vertical = 4.dp)
) {
Text(
card.toPersianDigits(),
modifier = Modifier.weight(1f),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge
)
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(card))
Toast.makeText(context, "شماره کارت کپی شد", Toast.LENGTH_SHORT).show()
}) {
Icon(Icons.Default.ContentCopy, contentDescription = "کپی شماره کارت")
}
if (!isOperatorMode) {
IconButton(onClick = { cardToRemove = card }) {
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error)
}
}
}
}
}
@@ -555,6 +606,147 @@ fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRem
}
}
@Composable
fun OperatorModeSetting(
isOperatorMode: Boolean,
adminPin: String,
onToggleMode: (Boolean) -> Unit,
onChangePin: (String) -> Unit
) {
var showPinDialog by remember { mutableStateOf(false) }
var pinAction by remember { mutableStateOf<PinAction?>(null) }
var enteredPin by remember { mutableStateOf("") }
var newPinInput by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (isOperatorMode) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text("حالت دسترسی برنامه", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
if (isOperatorMode) "وضعیت فعلی: حالت اپراتور (محدود)" else "وضعیت فعلی: حالت مدیریت (کامل)",
style = MaterialTheme.typography.bodySmall,
color = if (isOperatorMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
if (isOperatorMode) {
Button(
onClick = {
pinAction = PinAction.UNLOCK_ADMIN
enteredPin = ""
errorMessage = ""
showPinDialog = true
},
modifier = Modifier.fillMaxWidth()
) {
Text("خروج از حالت اپراتور")
}
} else {
Button(
onClick = {
onToggleMode(true)
},
modifier = Modifier.weight(1f)
) {
Text("فعال‌سازی حالت اپراتور")
}
OutlinedButton(
onClick = {
pinAction = PinAction.CHANGE_PIN
enteredPin = ""
newPinInput = ""
errorMessage = ""
showPinDialog = true
},
modifier = Modifier.weight(1f)
) {
Text("تغییر رمز مدیر")
}
}
}
}
}
if (showPinDialog) {
Dialog(onDismissRequest = { showPinDialog = false }) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
Card(modifier = Modifier.fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
if (pinAction == PinAction.UNLOCK_ADMIN) "ورود رمز مدیریت" else "تغییر رمز مدیریت",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (pinAction == PinAction.UNLOCK_ADMIN || pinAction == PinAction.CHANGE_PIN) {
OutlinedTextField(
value = enteredPin,
onValueChange = { enteredPin = it.filter { c -> c.isDigit() } },
label = { Text(if (pinAction == PinAction.CHANGE_PIN) "رمز فعلی" else "رمز عبور مدیر") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
if (pinAction == PinAction.CHANGE_PIN) {
OutlinedTextField(
value = newPinInput,
onValueChange = { newPinInput = it.filter { c -> c.isDigit() } },
label = { Text("رمز جدید") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
if (errorMessage.isNotBlank()) {
Text(errorMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { showPinDialog = false }) { Text("لغو") }
Button(onClick = {
if (pinAction == PinAction.UNLOCK_ADMIN) {
if (enteredPin == adminPin) {
onToggleMode(false)
showPinDialog = false
} else {
errorMessage = "رمز عبور اشتباه است"
}
} else if (pinAction == PinAction.CHANGE_PIN) {
if (enteredPin != adminPin) {
errorMessage = "رمز فعلی اشتباه است"
} else if (newPinInput.length < 4) {
errorMessage = "رمز جدید باید حداقل ۴ رقم باشد"
} else {
onChangePin(newPinInput)
showPinDialog = false
}
}
}) { Text("تأیید") }
}
}
}
}
}
}
}
private enum class PinAction { UNLOCK_ADMIN, CHANGE_PIN }
@Composable
fun FontSelectionDropdown(selectedFont: String, onFontSelected: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
@@ -1,34 +1,32 @@
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
@file:OptIn(InternalSerializationApi::class)
package com.oqba26.monthlypaymentapp.utils
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.os.Environment
import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import io.ktor.client.*
import io.ktor.client.statement.bodyAsText
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.utils.io.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import kotlin.time.Duration.Companion.milliseconds
@Serializable
data class UpdateInfo(
@@ -39,6 +37,12 @@ data class UpdateInfo(
val isForceUpdate: Boolean = false,
)
sealed class DownloadState {
data class Progress(val progress: Float) : DownloadState()
data class Success(val file: File) : DownloadState()
data class Error(val message: String) : DownloadState()
}
class UpdateManager(private val context: Context) {
private val updateUrl = "https://raw.githubusercontent.com/oqba26/MonthlyPaymentApp/main/update.json"
@@ -55,15 +59,16 @@ class UpdateManager(private val context: Context) {
}
suspend fun checkForUpdate(): UpdateInfo? = withContext(Dispatchers.IO) {
cancelPendingSystemDownloads()
if (!isNetworkAvailable()) return@withContext null
try {
val timestamp = System.currentTimeMillis()
val urlWithParams = if (updateUrl.contains("?")) "$updateUrl&t=$timestamp" else "$updateUrl?t=$timestamp"
val responseText: String = client.get(urlWithParams).bodyAsText()
val updateInfo: UpdateInfo = json.decodeFromString(responseText)
val pInfo = context.packageManager.getPackageInfo(context.packageName, 0)
val currentVersionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
pInfo.longVersionCode.toInt()
@@ -81,95 +86,58 @@ class UpdateManager(private val context: Context) {
null
}
fun downloadAndInstall(url: String, fileName: String): Long {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (!context.packageManager.canRequestPackageInstalls()) {
Toast.makeText(context, "لطفاً اجازه نصب برنامه‌های ناشناخته را بدهید", Toast.LENGTH_LONG).show()
val intent = Intent(android.provider.Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = "package:${context.packageName}".toUri()
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
return -1L
fun downloadApk(url: String, fileName: String): Flow<DownloadState> = flow {
try {
val destinationFile = File(
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
fileName
)
if (destinationFile.exists()) {
destinationFile.delete()
}
}
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val uri = url.toUri()
val oldFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
if (oldFile.exists()) oldFile.delete()
client.prepareGet(url) {
header(HttpHeaders.UserAgent, "Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36")
}.execute { response ->
if (!response.status.isSuccess()) {
emit(DownloadState.Error("خطا در دانلود فایل (کد ${response.status.value})"))
return@execute
}
val request = DownloadManager.Request(uri)
.setTitle("دریافت به‌روزرسانی")
.setDescription("نسخه جدید در حال دانلود است...")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, fileName)
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
val channel: ByteReadChannel = response.bodyAsChannel()
val contentLength = response.contentLength() ?: -1L
val downloadId = downloadManager.enqueue(request)
destinationFile.outputStream().use { output ->
val buffer = ByteArray(16384)
var bytesCopied = 0L
while (!channel.isClosedForRead) {
val read = channel.readAvailable(buffer, 0, buffer.size)
if (read <= 0) break
output.write(buffer, 0, read)
bytesCopied += read
val onComplete = object : BroadcastReceiver() {
override fun onReceive(receiverContext: Context, intent: Intent) {
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
if (id == downloadId) {
installApk(fileName)
try {
receiverContext.unregisterReceiver(this)
} catch (e: Exception) {
e.printStackTrace()
if (contentLength > 0) {
val progress = bytesCopied.toFloat() / contentLength.toFloat()
emit(DownloadState.Progress(progress.coerceIn(0f, 1f)))
} else {
emit(DownloadState.Progress(0f))
}
}
}
emit(DownloadState.Progress(1f))
emit(DownloadState.Success(destinationFile))
}
}
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
ContextCompat.registerReceiver(context, onComplete, filter, ContextCompat.RECEIVER_EXPORTED)
return downloadId
}
fun getDownloadProgress(downloadId: Long): Flow<Float> = flow {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
var isDownloading = true
while (isDownloading) {
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
if (cursor != null && cursor.moveToFirst()) {
val bytesDownloadedIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val bytesTotalIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
if (bytesDownloadedIndex != -1 && bytesTotalIndex != -1 && statusIndex != -1) {
val bytesDownloaded = cursor.getInt(bytesDownloadedIndex)
val bytesTotal = cursor.getInt(bytesTotalIndex)
val status = cursor.getInt(statusIndex)
if (status == DownloadManager.STATUS_SUCCESSFUL || status == DownloadManager.STATUS_FAILED) {
isDownloading = false
}
if (bytesTotal > 0) {
emit(bytesDownloaded.toFloat() / bytesTotal.toFloat())
}
}
cursor.close()
} else {
isDownloading = false
cursor?.close()
}
if (isDownloading) delay(500.milliseconds)
} catch (e: Exception) {
emit(DownloadState.Error(e.localizedMessage ?: "خطای ارتباط با سرور"))
}
}.flowOn(Dispatchers.IO)
private fun installApk(fileName: String) {
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
fun installApk(apkFile: File) {
if (!apkFile.exists()) return
try {
val contentUri = FileProvider.getUriForFile(
context,
context,
"${context.packageName}.fileprovider",
apkFile
)
@@ -179,6 +147,44 @@ class UpdateManager(private val context: Context) {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
context.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()
Toast.makeText(context, "خطا در اجرای فایل نصب: ${e.message}", Toast.LENGTH_LONG).show()
}
}
fun openInBrowser(url: String) {
try {
val intent = Intent(Intent.ACTION_VIEW, url.toUri()).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun cancelPendingSystemDownloads() {
try {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager ?: return
val query = DownloadManager.Query()
val cursor = downloadManager.query(query)
if (cursor != null) {
val idIndex = cursor.getColumnIndex(DownloadManager.COLUMN_ID)
val titleIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TITLE)
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
while (cursor.moveToNext()) {
if (idIndex != -1) {
val id = cursor.getLong(idIndex)
val title = if (titleIndex != -1) cursor.getString(titleIndex) ?: "" else ""
val status = if (statusIndex != -1) cursor.getInt(statusIndex) else -1
if (title.contains("به‌روزرسانی") || title.contains("MonthlyPaymentApp") || status == DownloadManager.STATUS_FAILED) {
downloadManager.remove(id)
}
}
}
cursor.close()
}
} catch (e: Exception) {
e.printStackTrace()
}
@@ -16,6 +16,7 @@ import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -68,9 +69,18 @@ class PersonViewModel @Inject constructor(
private val _infoMessage = MutableSharedFlow<String>()
val infoMessage = _infoMessage.asSharedFlow()
val isOperatorMode: StateFlow<Boolean> = settingsRepository.isOperatorModeFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false)
private val _uiState = MutableStateFlow(PersonListUiState())
val uiState: StateFlow<PersonListUiState> = _uiState.asStateFlow()
private val _financialReportData = MutableStateFlow(FinancialReportData())
val financialReportData: StateFlow<FinancialReportData> = _financialReportData.asStateFlow()
private val _showFinancialReportDialog = MutableStateFlow(false)
val showFinancialReportDialog: StateFlow<Boolean> = _showFinancialReportDialog.asStateFlow()
private val _showAddPersonDialog = MutableStateFlow(false)
val showAddPersonDialog: StateFlow<Boolean> = _showAddPersonDialog.asStateFlow()
@@ -92,10 +102,11 @@ class PersonViewModel @Inject constructor(
localPersonRepository.getAllPersonsFlow(),
localPersonRepository.getAllPaymentsFlow(),
_selectedYear,
_currentCategory
_currentCategory,
) { persons, allPayments, year, category ->
val person = persons.find { it.id == personId }
val personCategory = person?.category ?: category
val isMosque = (personCategory == "mosque")
val paymentsForPersonInYear = allPayments.filter {
(it.personId == personId) && (it.shamsiYear == year) && (it.category == personCategory)
}
@@ -106,12 +117,18 @@ class PersonViewModel @Inject constructor(
val payment = paymentsForPersonInYear.find { it.shamsiMonth == month }
// بررسی شروع تعهد
val isBeforeCommitment = if (person != null) {
year < person.startYear || (year == person.startYear && month < person.startMonth)
} else false
val isBeforeCommitment = (person != null) &&
(year < person.startYear || (year == person.startYear && month < person.startMonth))
val status = when {
payment != null -> MonthStatus.PAID
isMosque -> {
if (year == currentYear && month == currentMonth) {
MonthStatus.AVAILABLE
} else {
MonthStatus.NOT_COMMITTED
}
}
isBeforeCommitment -> MonthStatus.NOT_COMMITTED
year < currentYear -> MonthStatus.PAST_YEAR
year > currentYear -> MonthStatus.FUTURE_YEAR
@@ -132,33 +149,34 @@ class PersonViewModel @Inject constructor(
settingsRepository.authTokenFlow.collectLatest { token ->
if (token != null) {
networkRepository.observeRealtimeChanges()
networkRepository.refresh()
// شرط قبلی `persons.isNotEmpty()` بود که دو مشکل داشت: هم لیست خالیِ
// معتبر سرور را نادیده می‌گرفت، و هم اگر واکشی شکست می‌خورد (چون refresh
// خطا را می‌بلعید) روی داده‌ی قدیمی/ناقص merge می‌کرد.
// حالا فقط بعد از یک refresh موفق merge می‌کنیم و خودِ merge هم غیرمخرب است.
combine(
networkRepository.getPersonsFlow(),
networkRepository.getPaymentsFlow(),
networkRepository.hasServerDataFlow()
) { persons, payments, hasServerData ->
if (hasServerData) {
if (!snapshotTakenThisSession) {
// آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی
// ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد،
// کاربر بتواند از تنظیمات برش گرداند.
backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC)
snapshotTakenThisSession = true
coroutineScope {
launch {
combine(
networkRepository.getPersonsFlow(),
networkRepository.getPaymentsFlow(),
networkRepository.hasServerDataFlow()
) { persons, payments, hasServerData ->
if (hasServerData) {
if (!snapshotTakenThisSession) {
// آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی
// ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد،
// کاربر بتواند از تنظیمات برش گرداند.
backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC)
snapshotTakenThisSession = true
}
localPersonRepository.mergeFromServer(persons, payments)
true
} else false
}.collect { isUpdated ->
if (isUpdated) {
_toastMessage.emit("اطلاعات با سرور همگام‌سازی شد")
}
}
localPersonRepository.mergeFromServer(persons, payments)
true
} else false
}.collect { isUpdated ->
if (isUpdated) {
_toastMessage.emit("اطلاعات با سرور همگام‌سازی شد")
}
}
networkRepository.refresh()
}
}
}
@@ -189,18 +207,8 @@ class PersonViewModel @Inject constructor(
val uiModels = activePersons.map { person ->
val hasPaid = relevantPayments.any { it.personId == person.id }
val unpaidResult = if (category == "mosque") {
if (person.monthlyCommitment > 0) {
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
val debtStartMonth = if (person.startYear == currentYear) person.startMonth else 1
val unpaid = (debtStartMonth..debtEndMonth).filter { m ->
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category }
}
Triple(unpaid.size, unpaid.size * person.monthlyCommitment, unpaid.map { getPersianMonthName(it) } to unpaid)
} else {
Triple(0, 0.0, emptyList<String>() to emptyList<Int>())
}
val unpaidResult = if (category == "mosque" || person.isAnonymous) {
Triple(0, 0.0, emptyList<String>() to emptyList())
} else {
// Salary logic
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
@@ -210,7 +218,8 @@ class PersonViewModel @Inject constructor(
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category }
}
val defaultAmount = settingsRepository.defaultPaymentAmountFlow.first()
Triple(unpaid.size, unpaid.size * defaultAmount, unpaid.map { getPersianMonthName(it) } to unpaid)
val amountPerMonth = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultAmount
Triple(unpaid.size, unpaid.size * amountPerMonth, unpaid.map { getPersianMonthName(it) } to unpaid)
}
val (debtCount, totalDebtAmount, unpaidData) = unpaidResult
@@ -243,6 +252,53 @@ class PersonViewModel @Inject constructor(
val (paidModels, unpaidModels) = uiModels.partition { it.hasPaidThisMonth }
// محاسبه گزارشات مالی جامع
val personsMap = persons.associateBy { it.id }
val categoryPayments = allPayments.filter { it.category == category }
val totalAllTimeIncome = categoryPayments.sumOf { it.amount }
val debtorsList = unpaidModels.filter { it.debtCount > 0 }.map { personModel ->
DebtorDetail(
personId = personModel.id,
personName = personModel.name,
debtCount = personModel.debtCount,
totalDebtAmount = personModel.totalDebtAmount,
unpaidMonthsNames = personModel.unpaidMonthsNames
)
}
val totalOverdueDebt = debtorsList.sumOf { it.totalDebtAmount }
val monthlyReportsList = categoryPayments
.groupBy { Pair(it.shamsiYear, it.shamsiMonth) }
.entries
.sortedWith(compareByDescending<Map.Entry<Pair<Int, Int>, List<PaymentRecord>>> { it.key.first }.thenByDescending { it.key.second })
.map { (yearMonth, paymentGroup) ->
val (year, month) = yearMonth
val totalAmount = paymentGroup.sumOf { it.amount }
val payerNames = paymentGroup.mapNotNull { payment ->
personsMap[payment.personId]?.let { person ->
if (person.isAnonymous && category == "mosque") "خیر ناشناس (${person.name})" else person.name
}
}.distinct()
MonthlyFinancialReport(
year = year,
month = month,
monthName = getPersianMonthName(month),
totalAmount = totalAmount,
paymentCount = paymentGroup.size,
payerNames = payerNames
)
}
_financialReportData.value = FinancialReportData(
totalAllTimeIncome = totalAllTimeIncome,
totalOverdueDebt = totalOverdueDebt,
totalDebtorsCount = debtorsList.size,
debtors = debtorsList,
monthlyReports = monthlyReportsList
)
val filteredPaid = if (query.isBlank()) paidModels else paidModels.filter { it.name.contains(query, ignoreCase = true) }
val filteredUnpaid = if (query.isBlank()) unpaidModels else unpaidModels.filter { it.name.contains(query, ignoreCase = true) }
@@ -280,6 +336,8 @@ class PersonViewModel @Inject constructor(
fun onDismissAddPersonDialog() { _showAddPersonDialog.value = false }
fun onQuickPayClicked(person: PersonUiModel) { _personForPaymentDialog.value = person }
fun onDismissPaymentDialog() { _personForPaymentDialog.value = null }
fun onShowFinancialReport() { _showFinancialReportDialog.value = true }
fun onDismissFinancialReport() { _showFinancialReportDialog.value = false }
fun onEvent(event: PersonScreenEvent) {
viewModelScope.launch {
@@ -302,9 +360,9 @@ class PersonViewModel @Inject constructor(
val persons = localPersonRepository.getAllPersonsFlow().first()
val finalName = if (event.isAnonymous && event.name.isBlank()) "ناشناس" else event.name
val isDuplicate = if (!event.isAnonymous) {
persons.any { it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true) }
} else false
val isDuplicate = !event.isAnonymous && persons.any {
it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true)
}
if (isDuplicate) {
_toastMessage.emit("خطا: این نام از قبل وجود دارد.")
@@ -345,8 +403,9 @@ class PersonViewModel @Inject constructor(
val currentDay = getCurrentShamsiDay()
val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear()
val targetMonth = if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1
val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear
val isMosque = _currentCategory.value == "mosque"
val targetMonth = if (isMosque) currentMonth else (if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1)
val targetYear = if (isMosque) currentYear else (if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear)
val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description)
.copy(category = _currentCategory.value)
@@ -374,7 +433,8 @@ class PersonViewModel @Inject constructor(
is PersonScreenEvent.UpdatePerson -> {
val persons = localPersonRepository.getAllPersonsFlow().first()
val isDuplicate = persons.any { it.id != event.personId && it.category == _currentCategory.value && it.name.trim().equals(event.name.trim(), ignoreCase = true) }
val finalName = if (event.isAnonymous && event.name.isBlank()) "ناشناس" else event.name
val isDuplicate = !event.isAnonymous && persons.any { it.id != event.personId && it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true) }
if (isDuplicate) {
_toastMessage.emit("خطا: این نام از قبل وجود دارد.")
@@ -382,13 +442,15 @@ class PersonViewModel @Inject constructor(
val currentPerson = persons.find { it.id == event.personId }
if (currentPerson != null) {
val updatedPerson = currentPerson.copy(
name = event.name,
name = finalName,
phoneNumber = event.phoneNumber,
monthlyCommitment = event.monthlyCommitment,
monthlyCommitment = if (event.isAnonymous) 0.0 else event.monthlyCommitment,
startMonth = event.startMonth,
startYear = event.startYear
startYear = event.startYear,
isAnonymous = event.isAnonymous
)
localPersonRepository.updatePersonLocally(updatedPerson)
_toastMessage.emit("اطلاعات شخص با موفقیت بروز شد.")
}
}
}
@@ -498,7 +560,15 @@ class PersonViewModel @Inject constructor(
PersonScreenEvent.RefreshData -> {
_isRefreshing.value = true
networkRepository.refresh()
val success = networkRepository.refresh()
if (success) {
val persons = networkRepository.getPersonsFlow().first()
val payments = networkRepository.getPaymentsFlow().first()
localPersonRepository.mergeFromServer(persons, payments)
_toastMessage.emit("اطلاعات با سرور با موفقیت همگام‌سازی شد")
} else {
_toastMessage.emit("خطا در همگام‌سازی با سرور")
}
_isRefreshing.value = false
}
else -> {}
@@ -46,9 +46,29 @@ class SettingsViewModel @Inject constructor(
val cardNumbers: StateFlow<List<String>> = settingsRepository.cardNumbersFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), emptyList())
val isOperatorMode: StateFlow<Boolean> = settingsRepository.isOperatorModeFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false)
val adminPin: StateFlow<String> = settingsRepository.adminPinFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), "1234")
private val _toastMessage = MutableSharedFlow<String>()
val toastMessage = _toastMessage.asSharedFlow()
fun setOperatorMode(enabled: Boolean) {
viewModelScope.launch {
settingsRepository.setOperatorMode(enabled)
_toastMessage.emit(if (enabled) "حالت اپراتور (محدود) فعال شد" else "حالت مدیریت فعال شد")
}
}
fun setAdminPin(newPin: String) {
viewModelScope.launch {
settingsRepository.setAdminPin(newPin)
_toastMessage.emit("رمز مدیریت با موفقیت تغییر یافت")
}
}
fun saveReminderDay(day: String) {
viewModelScope.launch {
val dayInt = day.toIntOrNull()
@@ -84,7 +84,8 @@ sealed class PersonScreenEvent {
val phoneNumber: String?,
val monthlyCommitment: Double = 0.0,
val startMonth: Int = 1,
val startYear: Int = 1403
val startYear: Int = 1403,
val isAnonymous: Boolean = false
) : PersonScreenEvent()
data class DeletePerson(val personId: String) : PersonScreenEvent()
data class ArchivePerson(val personId: String) : PersonScreenEvent()
@@ -144,3 +145,28 @@ data class DashboardUiModel(
val totalIncome: Double = 0.0,
val progress: Float = 0f
)
data class MonthlyFinancialReport(
val year: Int,
val month: Int,
val monthName: String,
val totalAmount: Double,
val paymentCount: Int,
val payerNames: List<String>
)
data class DebtorDetail(
val personId: String,
val personName: String,
val debtCount: Int,
val totalDebtAmount: Double,
val unpaidMonthsNames: List<String> = emptyList()
)
data class FinancialReportData(
val totalAllTimeIncome: Double = 0.0,
val totalOverdueDebt: Double = 0.0,
val totalDebtorsCount: Int = 0,
val debtors: List<DebtorDetail> = emptyList(),
val monthlyReports: List<MonthlyFinancialReport> = emptyList()
)
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">194.146.68.50</domain>
<domain includeSubdomains="true">167.235.136.65</domain>
</domain-config>
</network-security-config>
@@ -70,17 +70,17 @@ class SyncMergerTest {
}
@Test
fun `شخص محلی غیر pending که در سرور نیست حذف می شود`() {
fun `شخص محلی غیر pending که در سرور نیست حذف نمی شود`() {
val local = listOf(person("a"), person("b"))
val server = listOf(person("a"))
val plan = SyncMerger.planPersons(local, server, pendingIds = emptySet())
assertEquals(listOf("b"), plan.deleteIds)
assertEquals(emptyList<String>(), plan.deleteIds)
}
@Test
fun `سرور خالی هیچ رکورد محافظت شده ای را پاک نمی کند`() {
fun `سرور خالی هیچ رکوردی را پاک نمی کند`() {
val local = listOf(
person("a", needsSync = true),
person("b"),
@@ -89,8 +89,7 @@ class SyncMergerTest {
val plan = SyncMerger.planPersons(local, server = emptyList(), pendingIds = setOf("b"))
// a با needsSync و b از طریق صف محافظت شده‌اند؛ فقط c حذف می‌شود.
assertEquals(listOf("c"), plan.deleteIds)
assertEquals(emptyList<String>(), plan.deleteIds)
assertTrue(plan.upserts.isEmpty())
}
@@ -170,17 +169,17 @@ class SyncMergerTest {
}
@Test
fun `پرداخت حذف شده در سرور از گوشی هم حذف می شود`() {
fun `پرداخت غایب در سرور حذف نمی شود`() {
val local = listOf(payment("x"), payment("y"))
val server = listOf(payment("x"))
val plan = SyncMerger.planPayments(local, server, pendingIds = emptySet())
assertEquals(listOf("y"), plan.deleteIds)
assertEquals(emptyList<String>(), plan.deleteIds)
}
@Test
fun `سرور خالی پرداخت های در صف را نگه می دارد`() {
fun `سرور خالی پرداخت ها را حذف نمی کند`() {
val local = listOf(
payment("pending", needsSync = true),
payment("synced")
@@ -188,7 +187,7 @@ class SyncMergerTest {
val plan = SyncMerger.planPayments(local, server = emptyList(), pendingIds = emptySet())
assertEquals(listOf("synced"), plan.deleteIds)
assertEquals(emptyList<String>(), plan.deleteIds)
}
@Test
+2 -6
View File
@@ -31,8 +31,7 @@ junit = "4.13.2"
junitExt = "1.1.5"
espressoCore = "3.5.1"
# Supabase & Ktor
supabase = "2.5.0"
# Ktor
ktor = "2.3.12"
[libraries]
@@ -75,10 +74,7 @@ google-material = { group = "com.google.android.material", name = "material", ve
kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
# Supabase Libraries
supabase-postgrest = { group = "io.github.jan-tennert.supabase", name = "postgrest-kt", version.ref = "supabase" }
supabase-auth = { group = "io.github.jan-tennert.supabase", name = "gotrue-kt", version.ref = "supabase" }
supabase-realtime = { group = "io.github.jan-tennert.supabase", name = "realtime-kt", version.ref = "supabase" }
# Ktor Libraries
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
+4 -4
View File
@@ -1,7 +1,7 @@
{
"versionCode": 5,
"versionName": "1.0.4",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.3/MonthlyPaymentApp-v1.0.3.apk",
"releaseNotes": "بهبود طراحی دیالوگ آپدیت و رفع مشکل برش خوردگی دکمه‌ها",
"versionCode": 9,
"versionName": "1.0.8",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.8/MonthlyPaymentApp-v1.0.8.apk",
"releaseNotes": "بهبودهای تب مسجد و افزودن حالت اپراتور (محدودسازی دسترسی)",
"isForceUpdate": false
}