16 Commits
Author SHA1 Message Date
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
oqba26 83f0b82c7a بهبود طراحی دیالوگ آپدیت و رفع مشکل برش خوردگی دکمه‌ها
Android Release / build (push) Canceled after 2s
2026-08-05 03:55:53 +03:30
oqba26 5d80bb6f6a بهبود طراحی دیالوگ آپدیت و رفع مشکل برش خوردگی دکمه‌ها 2026-08-05 03:54:29 +03:30
oqba26 4dae0d4c0e بهبود طراحی دیالوگ آپدیت و رفع مشکل برش خوردگی دکمه‌ها
Android Release / build (push) Canceled after 1m7s
2026-08-04 20:03:50 +03:30
oqba26 6eb9ae8ed6 آپدیت نسخه برنامه به 1.0.3 و بروزرسانی فایل update.json 2026-08-04 19:02:01 +03:30
oqba26 ab905b14cb بهبود سیستم پرداخت دسته جمعی و اضافه شدن قابلیت نوسازی (Pull-to-Refresh) در لیست پرداخت شده‌ها 2026-08-04 18:55:41 +03:30
oqba26 96d37007fb Fix: move update check to after login and bump version to 1.0.2
Android Release / build (push) Canceled after 1m10s
2026-08-02 19:16:19 +03:30
26 changed files with 1024 additions and 513 deletions

No files matched your search

+12
View File
@@ -6,6 +6,18 @@
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DialogSelection /> <DialogSelection />
</SelectionState> </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> </selectionStates>
</component> </component>
</project> </project>
+5 -10
View File
@@ -30,8 +30,8 @@ android {
applicationId = "com.oqba26.monthlypaymentapp" applicationId = "com.oqba26.monthlypaymentapp"
minSdk = 25 minSdk = 25
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 8
versionName = "1.0" versionName = "1.0.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true } vectorDrawables { useSupportLibrary = true }
} }
@@ -55,8 +55,7 @@ android {
buildTypes { buildTypes {
debug { debug {
buildConfigField("String", "SUPABASE_URL", "\"https://ftufsygeartwukonclkz.supabase.co\"") buildConfigField("String", "POCKETBASE_URL", "\"http://194.146.68.50/\"")
buildConfigField("String", "SUPABASE_KEY", "\"sb_publishable_H4mAU1Ds-vZ22HwMSfCaBQ_QjBfmRd5\"")
} }
release { release {
val storeFileProp = getProp("RELEASE_STORE_FILE") val storeFileProp = getProp("RELEASE_STORE_FILE")
@@ -68,8 +67,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
buildConfigField("String", "SUPABASE_URL", "\"https://ftufsygeartwukonclkz.supabase.co\"") buildConfigField("String", "POCKETBASE_URL", "\"http://194.146.68.50/\"")
buildConfigField("String", "SUPABASE_KEY", "\"sb_publishable_H4mAU1Ds-vZ22HwMSfCaBQ_QjBfmRd5\"")
} }
} }
@@ -111,10 +109,7 @@ dependencies {
implementation("androidx.compose.material:material-icons-extended-android:1.6.7") implementation("androidx.compose.material:material-icons-extended-android:1.6.7")
implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6") implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6")
// Supabase // PocketBase / Ktor Network
implementation(libs.supabase.postgrest)
implementation(libs.supabase.auth)
implementation(libs.supabase.realtime)
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
+1 -1
View File
@@ -16,12 +16,12 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.MonthlyPaymentApp" android:theme="@style/Theme.MonthlyPaymentApp"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.MonthlyPaymentApp"> android:theme="@style/Theme.MonthlyPaymentApp">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -1,10 +1,13 @@
@file:OptIn(ExperimentalMaterial3Api::class) @file:OptIn(ExperimentalMaterial3Api::class)
package com.oqba26.monthlypaymentapp package com.oqba26.monthlypaymentapp
import android.Manifest import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.widget.Toast import android.widget.Toast
import androidx.core.net.toUri
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@@ -96,6 +99,7 @@ import com.oqba26.monthlypaymentapp.ui.screens.PersonDetailScreen
import com.oqba26.monthlypaymentapp.ui.screens.PersonScreen import com.oqba26.monthlypaymentapp.ui.screens.PersonScreen
import com.oqba26.monthlypaymentapp.ui.screens.SettingsScreen import com.oqba26.monthlypaymentapp.ui.screens.SettingsScreen
import com.oqba26.monthlypaymentapp.ui.theme.MonthlyPaymentManagement2Theme import com.oqba26.monthlypaymentapp.ui.theme.MonthlyPaymentManagement2Theme
import com.oqba26.monthlypaymentapp.utils.DownloadState
import com.oqba26.monthlypaymentapp.utils.UpdateInfo import com.oqba26.monthlypaymentapp.utils.UpdateInfo
import com.oqba26.monthlypaymentapp.utils.UpdateManager import com.oqba26.monthlypaymentapp.utils.UpdateManager
import com.oqba26.monthlypaymentapp.viewmodel.ContactViewModel import com.oqba26.monthlypaymentapp.viewmodel.ContactViewModel
@@ -164,17 +168,24 @@ fun MainAppHost(viewModel: PersonViewModel) {
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
delay(2000.milliseconds)
updateInfo = updateManager.checkForUpdate()
val allGranted = requiredPermissions.all { val allGranted = requiredPermissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
} }
if (!allGranted) { if (!allGranted) {
delay(2000.milliseconds)
showPermissionsDialog = true showPermissionsDialog = true
} }
} }
LaunchedEffect(authState) {
if (authState is AuthState.Authenticated) {
delay(2000.milliseconds)
updateInfo = updateManager.checkForUpdate()
} else {
updateInfo = null
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch { launch {
viewModel.toastMessage.collect { message -> viewModel.toastMessage.collect { message ->
@@ -206,24 +217,41 @@ fun MainAppHost(viewModel: PersonViewModel) {
updateInfo = info, updateInfo = info,
onDismiss = { updateInfo = null }, onDismiss = { updateInfo = null },
onConfirm = { onConfirm = {
val id = updateManager.downloadAndInstall(info.url, "MonthlyPaymentApp_v${info.versionName}.apk") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) {
if (id != -1L) { 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 isDownloading = true
downloadProgress = 0f
if (!info.isForceUpdate) {
updateInfo = null
}
scope.launch { scope.launch {
updateManager.getDownloadProgress(id).collect { progress -> val fileName = "MonthlyPaymentApp_v${info.versionName}.apk"
downloadProgress = progress updateManager.downloadApk(info.url, fileName).collect { state ->
if (progress >= 1f) { when (state) {
is DownloadState.Progress -> {
downloadProgress = state.progress
}
is DownloadState.Success -> {
isDownloading = false isDownloading = false
if (!info.isForceUpdate) { updateManager.installApk(state.file)
updateInfo = null }
is DownloadState.Error -> {
isDownloading = false
Toast.makeText(context, "خطا در دانلود: ${state.message}", Toast.LENGTH_LONG).show()
updateManager.openInBrowser(info.url)
} }
} }
} }
} }
if (!info.isForceUpdate) {
updateInfo = null
}
}
} }
) )
} }
@@ -245,15 +273,23 @@ fun MainAppHost(viewModel: PersonViewModel) {
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp) modifier = Modifier.padding(bottom = 16.dp)
) )
if (downloadProgress > 0f) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { downloadProgress }, progress = { downloadProgress },
modifier = Modifier.fillMaxWidth().height(8.dp), modifier = Modifier.fillMaxWidth().height(8.dp),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant, 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)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "${(downloadProgress * 100).toInt()}%", text = if (downloadProgress > 0f) "${(downloadProgress * 100).toInt()}%" else "در حال اتصال...",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
) )
} }
@@ -483,7 +519,7 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
) )
} }
composable(Screen.Archive.route) { composable(Screen.Archive.route) {
ArchiveScreen(viewModel) ArchiveScreen(viewModel = viewModel, navController = navController)
} }
composable(Screen.Settings.route) { composable(Screen.Settings.route) {
val authViewModel: AuthViewModel = hiltViewModel() val authViewModel: AuthViewModel = hiltViewModel()
@@ -20,8 +20,8 @@ object RepositoryModule {
@Provides @Provides
@Singleton @Singleton
fun provideNetworkRepository(): NetworkRepository { fun provideNetworkRepository(settingsRepository: SettingsRepository): NetworkRepository {
return NetworkRepository() return NetworkRepository(settingsRepository)
} }
@Provides @Provides
@@ -25,6 +25,9 @@ interface PaymentDao {
@Query("DELETE FROM payments WHERE id = :paymentId") @Query("DELETE FROM payments WHERE id = :paymentId")
suspend fun deleteById(paymentId: String) suspend fun deleteById(paymentId: String)
@Query("DELETE FROM payments WHERE personId = :personId")
suspend fun deletePaymentsForPerson(personId: String)
/** حذف گروهی — برای اعمال [com.oqba26.monthlypaymentapp.data.sync.MergePlan] در یک کوئری. */ /** حذف گروهی — برای اعمال [com.oqba26.monthlypaymentapp.data.sync.MergePlan] در یک کوئری. */
@Query("DELETE FROM payments WHERE id IN (:ids)") @Query("DELETE FROM payments WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>) 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 package com.oqba26.monthlypaymentapp.data.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import java.util.UUID import java.util.UUID
@Serializable @Serializable
@@ -24,12 +26,12 @@ data class PaymentRecord(
@SerialName("timestamp") val timestamp: Long = System.currentTimeMillis(), @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("createdAt") val createdAt: Long? = null,
@SerialName("category") val category: String = "salary", @SerialName("category") val category: String = "salary",
@kotlinx.serialization.Transient @Transient
val needsSync: Boolean = false val needsSync: Boolean = false
) )
@@ -1,11 +1,13 @@
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class) @file:OptIn(InternalSerializationApi::class)
package com.oqba26.monthlypaymentapp.data.model package com.oqba26.monthlypaymentapp.data.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable @Serializable
@Entity(tableName = "persons") @Entity(tableName = "persons")
@@ -33,6 +35,6 @@ data class Person(
@SerialName("startYear") val startYear: Int = 1403, @SerialName("startYear") val startYear: Int = 1403,
@kotlinx.serialization.Transient @Transient
val needsSync: Boolean = false val needsSync: Boolean = false
) )
@@ -1,38 +1,10 @@
@file:OptIn(io.github.jan.supabase.annotations.SupabaseInternal::class)
package com.oqba26.monthlypaymentapp.data.remote package com.oqba26.monthlypaymentapp.data.remote
import com.oqba26.monthlypaymentapp.BuildConfig 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 { object ApiClient {
val client = createSupabaseClient( val pocketBase: PocketBaseClient by lazy {
supabaseUrl = BuildConfig.SUPABASE_URL, PocketBaseClient(baseUrl = BuildConfig.POCKETBASE_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)
} }
} }
@@ -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 personPlan = SyncMerger.planPersons(localPersons, persons, pendingIds)
val paymentPlan = SyncMerger.planPayments(localPayments, payments, 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 (personPlan.upserts.isNotEmpty()) personDao.insertAll(personPlan.upserts)
if (paymentPlan.upserts.isNotEmpty()) paymentDao.insertAllPaymentRecords(paymentPlan.upserts) if (paymentPlan.upserts.isNotEmpty()) paymentDao.insertAllPaymentRecords(paymentPlan.upserts)
// خودترمیمی: اگر رکوردهایی در دیتابیس محلی هستند که در سرور وجود ندارند و در صف هم نیستند،
// آن‌ها را دوباره برای ارسال به سرور ثبت می‌کنیم تا سرور و کلاینت همگام شوند.
val serverPersonIds = persons.map { it.id }.toSet()
val 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) { suspend fun deletePersonLocally(personId: String) {
database.withTransaction { database.withTransaction {
personDao.deleteById(personId) personDao.deleteById(personId)
paymentDao.deletePaymentsForPerson(personId)
syncQueueDao.insert(SyncQueue(entityId = personId, type = SyncType.PERSON, operation = SyncOperation.DELETE)) 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.PaymentRecord
import com.oqba26.monthlypaymentapp.data.model.Person import com.oqba26.monthlypaymentapp.data.model.Person
import com.oqba26.monthlypaymentapp.data.remote.ApiClient 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.util.UUID 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 _personsFlow = MutableStateFlow<List<Person>>(emptyList())
private val _paymentsFlow = MutableStateFlow<List<PaymentRecord>>(emptyList()) private val _paymentsFlow = MutableStateFlow<List<PaymentRecord>>(emptyList())
private val repositoryScope = CoroutineScope(Dispatchers.IO) private val repositoryScope = CoroutineScope(Dispatchers.IO)
/** /**
* تا وقتی حداقل یک [refresh] موفق انجام نشده باشد `false` است. * تا وقتی حداقل یک [refresh] موفق انجام نشده باشد `false` است.
*
* چرا لازم است: مقدار اولیه‌ی `_personsFlow`/`_paymentsFlow` لیست خالی است و از لیست خالیِ
* واقعی (کاربری که هنوز داده‌ای ثبت نکرده) قابل تشخیص نیست. merge کردن روی آن لیست خالیِ
* قالبی، همه‌ی داده‌ی محلی را پاک می‌کند. مصرف‌کننده باید منتظر `true` بماند.
*/ */
private val _hasServerData = MutableStateFlow(false) private val _hasServerData = MutableStateFlow(false)
fun hasServerDataFlow(): Flow<Boolean> = _hasServerData.asStateFlow() fun hasServerDataFlow(): Flow<Boolean> = _hasServerData.asStateFlow()
init {
settingsRepository?.authTokenFlow?.let { authFlow ->
repositoryScope.launch {
authFlow.collect { token ->
pocketBase.authToken = token
}
}
}
}
// --- Realtime Operations --- // --- Realtime Operations ---
fun observeRealtimeChanges() { fun observeRealtimeChanges() {
repositoryScope.launch { repositoryScope.launch {
try { try {
// اطمینان از اتصال قبل از اشتراک در کانال pocketBase.listenRealtime(listOf("persons", "payments")).collect { event ->
supabase.realtime.connect() Log.d("PocketBase-Realtime", "Change detected in PocketBase: $event")
val myChannel = supabase.channel("db-changes")
// گوش دادن به تغییرات جدول اشخاص
myChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
table = "persons"
}.collect {
Log.d("Supabase-Realtime", "Change detected in persons table")
refresh() 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) { } 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 --- // --- Auth Operations ---
suspend fun register(request: AuthRequest): AuthResponse? { suspend fun register(request: AuthRequest): AuthResponse? {
return try { return try {
supabase.auth.signUpWith(Email) { pocketBase.createUser("users", request.email, request.password)
email = request.email val authResp = pocketBase.authWithPassword("users", request.email, request.password)
password = request.password val userId = authResp.record?.get("id")?.toString()?.replace("\"", "")
} AuthResponse(token = authResp.token, userId = userId)
val session = supabase.auth.currentSessionOrNull()
AuthResponse(token = session?.accessToken ?: "success", userId = session?.user?.id)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Supabase", "register error", e) Log.e("PocketBase", "register error", e)
null null
} }
} }
suspend fun login(request: AuthRequest): AuthResponse? { suspend fun login(request: AuthRequest): AuthResponse? {
return try { return try {
supabase.auth.signInWith(Email) { val authResp = pocketBase.authWithPassword("users", request.email, request.password)
email = request.email val userId = authResp.record?.get("id")?.toString()?.replace("\"", "")
password = request.password AuthResponse(token = authResp.token, userId = userId)
}
val session = supabase.auth.currentSessionOrNull()
AuthResponse(token = session?.accessToken ?: "", userId = session?.user?.id)
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Supabase", "login error", e) Log.e("PocketBase", "login error", e)
null null
} }
} }
// --- Person Operations --- // --- Person Operations ---
/**
* ارسال شخص به سرور با upsert.
*
* upsert (و نه insert) عمداً انتخاب شده تا این عملیات idempotent باشد: اگر تلاش اول
* به سرور رسیده باشد ولی پاسخش به‌خاطر timeout به ما نرسیده باشد، تلاش دوم رکورد
* تکراری نمی‌سازد. صف سینک متکی به همین خاصیت است تا بتواند بی‌خطر retry کند.
*
* اعتبارسنجی تکراری‌بودن نام اینجا انجام نمی‌شود؛ آن کار در PersonViewModel روی
* داده‌ی محلی (که همیشه کامل است) انجام می‌گیرد. کش _personsFlow ممکن است خالی یا
* قدیمی باشد و نتیجه‌ی غلط بدهد.
*/
suspend fun addPerson(person: Person): Boolean { suspend fun addPerson(person: Person): Boolean {
return try { return try {
val trimmedName = person.name.trim() val trimmedName = person.name.trim()
if (trimmedName.isEmpty()) { if (trimmedName.isEmpty()) {
Log.w("Supabase", "addPerson skipped: blank name") Log.w("PocketBase", "addPerson skipped: blank name")
return false return false
} }
@@ -131,22 +93,22 @@ class NetworkRepository {
person.copy(name = trimmedName) person.copy(name = trimmedName)
} }
supabase.from("persons").upsert(personToUpsert) pocketBase.upsertRecord("persons", "id='${personToUpsert.id}'", personToUpsert)
true true
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Supabase", "addPerson error", e) Log.e("PocketBase", "addPerson error", e)
false false
} }
} }
suspend fun deletePersonAndPayments(personId: String): Boolean { suspend fun deletePersonAndPayments(personId: String): Boolean {
return try { return try {
supabase.from("persons").delete { pocketBase.deleteRecordByFilter("payments", "personId='$personId'")
filter { eq("id", personId) } pocketBase.deleteRecordByFilter("persons", "id='$personId'")
}
true true
} catch (e: Exception) { } 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 if (isDuplicate) return 409
supabase.from("persons").update( val updatedPerson = currentPerson?.copy(
buildJsonObject { name = trimmedName,
put("name", trimmedName) phoneNumber = phoneNumber?.trim(),
put("phoneNumber", phoneNumber?.trim()) monthlyCommitment = monthlyCommitment,
put("monthlyCommitment", monthlyCommitment) startMonth = startMonth,
put("startMonth", startMonth) startYear = startYear
put("startYear", startYear) )
}, if (updatedPerson != null) {
) { pocketBase.upsertRecord("persons", "id='$personId'", updatedPerson)
filter { eq("id", personId) }
} }
200 200
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Supabase", "updatePerson error", e) Log.e("PocketBase", "updatePerson error", e)
500 500
} }
} }
suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean { suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean {
return try { return try {
supabase.from("persons").update( val currentPerson = _personsFlow.value.find { it.id == personId }
buildJsonObject { if (currentPerson != null) {
put("isArchived", isArchived) pocketBase.upsertRecord("persons", "id='$personId'", currentPerson.copy(isArchived = isArchived))
}
) {
filter { eq("id", personId) }
} }
true true
} catch (e: Exception) { } 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 { suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean {
return try { return try {
supabase.from("persons").update( val currentPerson = _personsFlow.value.find { it.id == personId }
buildJsonObject { if (currentPerson != null) {
put("displayOrder", displayOrder) pocketBase.upsertRecord("persons", "id='$personId'", currentPerson.copy(displayOrder = displayOrder))
}
) {
filter { eq("id", personId) }
} }
true true
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Supabase", "updateOrder error", e); false Log.e("PocketBase", "updateOrder error", e)
false
} }
} }
fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow() fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow()
// --- Payment Operations --- // --- Payment Operations ---
/**
* ارسال پرداخت به سرور با upsert — به همان دلیل idempotent بودن که در [addPerson] توضیح داده شد.
* ضمناً همین باعث می‌شود ویرایش پرداخت (SyncOperation.UPDATE) هم درست کار کند؛ قبلاً insert
* بود و روی رکورد موجود شکست می‌خورد.
*/
suspend fun addPayment(paymentRecord: PaymentRecord): Boolean { suspend fun addPayment(paymentRecord: PaymentRecord): Boolean {
return try { return try {
supabase.from("payments").upsert(paymentRecord) pocketBase.upsertRecord("payments", "id='${paymentRecord.id}'", paymentRecord)
true true
} catch (e: Exception) { } 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) { while (retries <= maxRetries) {
try { try {
supabase.from("payments").delete { pocketBase.deleteRecordByFilter("payments", "id='$paymentId'")
filter { eq("id", paymentId) } Log.d("PocketBase", "deletePayment Success: $paymentId")
}
Log.d("Supabase", "deletePayment Success: $paymentId")
return true return true
} catch (e: Exception) { } catch (e: Exception) {
retries++ 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 if (retries > maxRetries) return false
kotlinx.coroutines.delay(1000L * retries) // Exponential backoff-ish delay(1000L * retries)
} }
} }
return false return false
@@ -263,31 +214,20 @@ class NetworkRepository {
/** /**
* واکشی کامل داده‌ها از سرور. * واکشی کامل داده‌ها از سرور.
*
* @return true فقط اگر **هر دو** واکشی موفق بوده باشند.
*
* دو تضمین مهم که فراخوان‌ها به آن تکیه می‌کنند:
* ۱. در صورت شکست، flow‌ها **دست‌نخورده** می‌مانند. قبلاً خطا بلعیده می‌شد و صدازننده
* نمی‌فهمید واکشی شکست خورده؛ نتیجه‌اش merge روی داده‌ی ناقص و پاک شدن داده‌ی محلی بود.
* ۲. مقداردهی flow‌ها اتمیک است — یا هر دو با هم به‌روز می‌شوند یا هیچ‌کدام. قبلاً اگر
* واکشی پرداخت‌ها شکست می‌خورد، لیست اشخاص جدید کنار لیست پرداخت‌های قدیمی می‌نشست.
*/ */
suspend fun refresh(): Boolean { suspend fun refresh(): Boolean {
return try { return try {
val persons = supabase.from("persons").select().decodeList<Person>() val persons = pocketBase.getRecords<Person>("persons")
val payments = supabase.from("payments").select { val payments = pocketBase.getRecords<PaymentRecord>("payments", sort = "-timestamp")
order("timestamp", order = Order.DESCENDING)
}.decodeList<PaymentRecord>()
// فقط بعد از موفقیت هر دو واکشی مقداردهی می‌کنیم
_personsFlow.value = persons _personsFlow.value = persons
_paymentsFlow.value = payments _paymentsFlow.value = payments
_hasServerData.value = true _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 true
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Supabase", "refresh error — داده‌های محلی دست‌نخورده باقی می‌مانند", e) Log.e("PocketBase", "refresh error", e)
false false
} }
} }
@@ -75,13 +75,9 @@ object SyncMerger {
person.copy(id = resolvedId, name = name, needsSync = false) person.copy(id = resolvedId, name = name, needsSync = false)
} }
val serverIds = resolved.map { it.id }.toSet()
return MergePlan( return MergePlan(
upserts = resolved.filterNot { it.id in protectedIds }, upserts = resolved.filterNot { it.id in protectedIds },
deleteIds = local deleteIds = emptyList() // هرگز رکوردهای محلی را به خاطر غیبت در سرور حذف نمی‌کنیم
.map { it.id }
.filterNot { it in serverIds || it in protectedIds }
) )
} }
@@ -91,15 +87,12 @@ object SyncMerger {
pendingIds: Set<String> pendingIds: Set<String>
): MergePlan<PaymentRecord> { ): MergePlan<PaymentRecord> {
val protectedIds = protectedPaymentIds(local, pendingIds) val protectedIds = protectedPaymentIds(local, pendingIds)
val serverIds = server.map { it.id }.toSet()
return MergePlan( return MergePlan(
upserts = server upserts = server
.filterNot { it.id in protectedIds } .filterNot { it.id in protectedIds }
.map { it.copy(needsSync = false) }, .map { it.copy(needsSync = false) },
deleteIds = local deleteIds = emptyList() // هرگز رکوردهای محلی را به خاطر غیبت در سرور حذف نمی‌کنیم
.map { it.id }
.filterNot { it in serverIds || it in protectedIds }
) )
} }
} }
@@ -7,7 +7,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
@@ -38,10 +37,9 @@ fun UpdateDialog(
.padding(16.dp) .padding(16.dp)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier.padding(24.dp)
.padding(24.dp)
.verticalScroll(rememberScrollState())
) { ) {
// Header
Text( Text(
text = "به‌روزرسانی جدید موجود است", text = "به‌روزرسانی جدید موجود است",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
@@ -54,11 +52,17 @@ fun UpdateDialog(
Text( Text(
text = "نسخه ${updateInfo.versionName}", text = "نسخه ${updateInfo.versionName}",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = Color.Gray color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Content (Scrollable)
Column(
modifier = Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState())
) {
Text( Text(
text = "تغییرات این نسخه:", text = "تغییرات این نسخه:",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -70,36 +74,40 @@ fun UpdateDialog(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp) modifier = Modifier.padding(vertical = 8.dp)
) )
}
// Footer
if (updateInfo.isForceUpdate) { if (updateInfo.isForceUpdate) {
Text( Text(
text = "نصب این به‌روزرسانی برای ادامه استفاده از برنامه الزامی است.", text = "نصب این به‌روزرسانی برای ادامه استفاده از برنامه الزامی است.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp) modifier = Modifier.padding(vertical = 8.dp)
) )
} else {
Spacer(modifier = Modifier.height(16.dp))
} }
Spacer(modifier = Modifier.height(24.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Button( Button(
onClick = onConfirm, onClick = onConfirm,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small shape = MaterialTheme.shapes.medium,
elevation = ButtonDefaults.buttonElevation(defaultElevation = 2.dp)
) { ) {
Text("تایید و بروزرسانی") Text("بروزرسانی")
} }
if (!updateInfo.isForceUpdate) { if (!updateInfo.isForceUpdate) {
Button( OutlinedButton(
onClick = onDismiss, onClick = onDismiss,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
shape = MaterialTheme.shapes.small border = ButtonDefaults.outlinedButtonBorder.copy(brush = androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error)),
shape = MaterialTheme.shapes.medium
) { ) {
Text("بعداً") Text("بعداً")
} }
@@ -1,5 +1,6 @@
package com.oqba26.monthlypaymentapp.ui.screens package com.oqba26.monthlypaymentapp.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -38,12 +39,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.navigation.NavController
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
@Composable @Composable
fun ArchiveScreen(viewModel: PersonViewModel) { fun ArchiveScreen(viewModel: PersonViewModel, navController: NavController) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
var personToDelete by remember { mutableStateOf<PersonUiModel?>(null) } var personToDelete by remember { mutableStateOf<PersonUiModel?>(null) }
var personToRestore 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 -> items(uiState.archivedPersons, key = { it.id }) { person ->
ArchivedPersonListItem( ArchivedPersonListItem(
person = person, person = person,
onPersonClick = {
navController.navigate("person_detail/${person.id}")
},
onRestore = { onRestore = {
personToRestore = person personToRestore = person
}, },
@@ -148,12 +153,14 @@ fun ArchiveScreen(viewModel: PersonViewModel) {
@Composable @Composable
fun ArchivedPersonListItem( fun ArchivedPersonListItem(
person: PersonUiModel, person: PersonUiModel,
onPersonClick: () -> Unit,
onRestore: () -> Unit, onRestore: () -> Unit,
onDelete: () -> Unit onDelete: () -> Unit
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onPersonClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
@@ -4,13 +4,18 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -45,6 +50,7 @@ import org.burnoutcrew.reorderable.detectReorderAfterLongPress
import org.burnoutcrew.reorderable.rememberReorderableLazyListState import org.burnoutcrew.reorderable.rememberReorderableLazyListState
import org.burnoutcrew.reorderable.reorderable import org.burnoutcrew.reorderable.reorderable
@OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun PaidListScreen( fun PaidListScreen(
viewModel: PersonViewModel, viewModel: PersonViewModel,
@@ -53,8 +59,11 @@ fun PaidListScreen(
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val searchQuery by viewModel.searchQuery.collectAsState() val searchQuery by viewModel.searchQuery.collectAsState()
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
val isRefreshing by viewModel.isRefreshing.collectAsState()
val personToArchive = remember { mutableStateOf<PersonUiModel?>(null) } val personToArchive = remember { mutableStateOf<PersonUiModel?>(null) }
val personForBulkPayment = remember { mutableStateOf<PersonUiModel?>(null) }
val state = rememberReorderableLazyListState( val state = rememberReorderableLazyListState(
onMove = { from, to -> onMove = { from, to ->
@@ -69,8 +78,20 @@ fun PaidListScreen(
} }
) )
@OptIn(ExperimentalMaterialApi::class)
val pullRefreshState = rememberPullRefreshState(
refreshing = isRefreshing,
onRefresh = { viewModel.onEvent(PersonScreenEvent.RefreshData) }
)
Scaffold { innerPadding -> Scaffold { innerPadding ->
Column(modifier = Modifier.padding(innerPadding)) { Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.pullRefresh(pullRefreshState)
) {
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField( OutlinedTextField(
value = searchQuery, value = searchQuery,
onValueChange = { viewModel.onSearchQueryChange(it) }, onValueChange = { viewModel.onSearchQueryChange(it) },
@@ -95,7 +116,8 @@ fun PaidListScreen(
onPersonClick = { navController.navigate("person_detail/${person.id}") }, onPersonClick = { navController.navigate("person_detail/${person.id}") },
onQuickPayClick = { }, onQuickPayClick = { },
onArchiveClick = { personToArchive.value = it }, onArchiveClick = { personToArchive.value = it },
onSmsClick = { contactViewModel.sendSmsReminder(it) } onSmsClick = { contactViewModel.sendSmsReminder(it) },
onDebtClick = { personForBulkPayment.value = it }
) )
} }
} }
@@ -105,6 +127,32 @@ fun PaidListScreen(
} }
} }
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
personForBulkPayment.value?.let { person: PersonUiModel ->
BulkPaymentDialog(
availableMonths = person.unpaidMonths,
defaultAmount = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount,
onConfirm = { selected: List<Int>, amount: Double ->
viewModel.onEvent(
PersonScreenEvent.AddBulkPayments(
personId = person.id,
months = selected,
year = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear(),
amount = amount
)
)
personForBulkPayment.value = null
},
onDismiss = { personForBulkPayment.value = null }
)
}
personToArchive.value?.let { person -> personToArchive.value?.let { person ->
Dialog(onDismissRequest = { personToArchive.value = null }) { Dialog(onDismissRequest = { personToArchive.value = null }) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
@@ -25,8 +25,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight 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.Edit
import androidx.compose.material.icons.filled.Payments
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -44,14 +44,12 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -102,7 +100,6 @@ fun PersonDetailScreen(
var editingPayment by remember { mutableStateOf<PaymentRecord?>(null) } var editingPayment by remember { mutableStateOf<PaymentRecord?>(null) }
var addingPaymentMonth by remember { mutableStateOf<Int?>(null) } var addingPaymentMonth by remember { mutableStateOf<Int?>(null) }
var editingPerson by remember { mutableStateOf(value = false) } var editingPerson by remember { mutableStateOf(value = false) }
var showBulkPaymentDialog by remember { mutableStateOf(value = false) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
val displayName = detailState.person?.let { p -> val displayName = detailState.person?.let { p ->
@@ -125,31 +122,12 @@ fun PersonDetailScreen(
viewModel.onEvent(PersonScreenEvent.ChangeYear(offset)) viewModel.onEvent(PersonScreenEvent.ChangeYear(offset))
} }
val availableMonths = detailState.monthStates
.asSequence()
.filter { it.status == MonthStatus.AVAILABLE }
.map { it.month }
.toList()
if (availableMonths.isNotEmpty()) {
Button(
onClick = { showBulkPaymentDialog = true },
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.tertiary)
) {
Icon(Icons.Default.Payments, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("پرداخت دسته جمعی (${availableMonths.size.toString().toPersianDigits()} ماه)")
}
}
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
LazyColumn(modifier = Modifier.fillMaxSize().padding(bottom = 8.dp)) { LazyColumn(modifier = Modifier.fillMaxSize().padding(bottom = 8.dp)) {
items(detailState.monthStates) { monthModel -> items(detailState.monthStates) { monthModel ->
MonthListItem( MonthListItem(
month = monthModel, month = monthModel,
personName = detailState.person?.name ?: "",
startMonth = detailState.person?.startMonth ?: 1, startMonth = detailState.person?.startMonth ?: 1,
onHeaderClick = { onHeaderClick = {
expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month
@@ -228,147 +206,24 @@ fun PersonDetailScreen(
initialPhone = detailState.person?.phoneNumber, initialPhone = detailState.person?.phoneNumber,
initialCommitment = detailState.person?.monthlyCommitment ?: 0.0, initialCommitment = detailState.person?.monthlyCommitment ?: 0.0,
initialStartMonth = detailState.person?.startMonth ?: 1, 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", isMosqueCategory = currentCategory == "mosque",
onConfirm = { name, phone, commitment, month, year -> onConfirm = { name, phone, commitment, month, year, isAnon ->
viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone, commitment, month, year)) viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone, commitment, month, year, isAnon))
editingPerson = false editingPerson = false
}, },
onDelete = {
viewModel.onEvent(PersonScreenEvent.DeletePerson(personId))
editingPerson = false
navController.popBackStack()
},
onDismiss = { editingPerson = false }, onDismiss = { editingPerson = false },
onSearchContact = { name -> onSearchContact = { name ->
contactViewModel.findSimilarContacts(name) contactViewModel.findSimilarContacts(name)
} }
) )
} }
if (showBulkPaymentDialog) {
val availableMonths = detailState.monthStates
.asSequence()
.filter { it.status == MonthStatus.AVAILABLE }
.map { it.month }
.toList()
val bulkDefaultAmount = if ((detailState.person?.monthlyCommitment ?: 0.0) > 0)
detailState.person!!.monthlyCommitment
else defaultPaymentAmount
BulkPaymentDialog(
availableMonths = availableMonths,
defaultAmount = bulkDefaultAmount,
onConfirm = { selected, amount ->
viewModel.onEvent(
PersonScreenEvent.AddBulkPayments(
personId = personId,
months = selected,
year = detailState.selectedYear,
amount = amount
)
)
showBulkPaymentDialog = false
},
) { showBulkPaymentDialog = false }
}
}
@Composable
fun BulkPaymentDialog(
availableMonths: List<Int>,
defaultAmount: Double,
onConfirm: (List<Int>, Double) -> Unit,
onDismiss: () -> Unit,
) {
val selectedMonths = remember { mutableStateListOf<Int>().apply { addAll(availableMonths) } }
var amountText by remember { mutableStateOf(defaultAmount.toInt().toString()) }
Dialog(
onDismissRequest = onDismiss
) {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "پرداخت دسته جمعی",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text("ماه‌های مورد نظر را انتخاب کنید:")
LazyColumn(modifier = Modifier.heightIn(max = 250.dp)) {
items(availableMonths) { month ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (selectedMonths.contains(month)) selectedMonths.remove(month)
else selectedMonths.add(month)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selectedMonths.contains(month),
onCheckedChange = { checked ->
if (checked) selectedMonths.add(month)
else selectedMonths.remove(month)
}
)
Text(getPersianMonthName(month))
}
}
}
OutlinedTextField(
value = amountText,
onValueChange = { value ->
// اجازه ورود فقط به اعداد (چه انگلیسی چه فارسی)
val digitsOnly = value.filter { it.isDigit() || (((it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9'))) }
// تبدیل اعداد فارسی به انگلیسی برای ذخیره در استیت
amountText = digitsOnly.map {
when (it) {
in '\u0660'..'\u0669' -> ((it.code - '\u0660'.code) + '0'.code).toChar()
in '\u06f0'..'\u06f9' -> ((it.code - '\u06f0'.code) + '0'.code).toChar()
else -> it
}
}.joinToString("")
},
label = { Text("مبلغ برای هر ماه (تومان)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
visualTransformation = PersianNumberVisualTransformation()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
val finalAmount = amountText.toDoubleOrNull() ?: defaultAmount
onConfirm(selectedMonths.toList(), finalAmount)
},
enabled = selectedMonths.isNotEmpty()
) {
Text("ثبت پرداخت‌ها")
}
TextButton(onClick = onDismiss) {
Text("لغو")
}
}
}
}
}
}
} }
@Composable @Composable
@@ -406,16 +261,20 @@ fun UpdatePersonDialog(
initialCommitment: Double = 0.0, initialCommitment: Double = 0.0,
initialStartMonth: Int = 1, initialStartMonth: Int = 1,
initialStartYear: Int = 1403, initialStartYear: Int = 1403,
initialIsAnonymous: Boolean = false,
isMosqueCategory: Boolean = false, isMosqueCategory: Boolean = false,
onConfirm: (String, String, Double, Int, Int) -> Unit, onConfirm: (String, String, Double, Int, Int, Boolean) -> Unit,
onDelete: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSearchContact: (String) -> List<ContactMatch>, onSearchContact: (String) -> List<ContactMatch>,
) { ) {
var name by remember { mutableStateOf(initialName) } var name by remember { mutableStateOf(initialName) }
var phoneNumber by remember { mutableStateOf(initialPhone ?: "") } var phoneNumber by remember { mutableStateOf(initialPhone ?: "") }
var monthlyCommitment by remember { mutableStateOf(if (initialCommitment > 0) initialCommitment.toInt().toString() else "") } 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 startMonth by remember { mutableIntStateOf(initialStartMonth) }
var startYear by remember { mutableIntStateOf(initialStartYear) } var startYear by remember { mutableIntStateOf(initialStartYear) }
@@ -457,6 +316,20 @@ fun UpdatePersonDialog(
fontWeight = FontWeight.Bold 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( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
@@ -608,12 +481,12 @@ fun UpdatePersonDialog(
onClick = { expandedMonth = true }, onClick = { expandedMonth = true },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(startMonth)) Text(getPersianMonthName(startMonth))
} }
DropdownMenu(expanded = expandedMonth, onDismissRequest = { expandedMonth = false }) { DropdownMenu(expanded = expandedMonth, onDismissRequest = { expandedMonth = false }) {
(1..12).forEach { m -> (1..12).forEach { m ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(m)) }, text = { Text(getPersianMonthName(m)) },
onClick = { startMonth = m; expandedMonth = false } onClick = { startMonth = m; expandedMonth = false }
) )
} }
@@ -631,7 +504,7 @@ fun UpdatePersonDialog(
onClick = { onClick = {
if (name.isNotBlank()) { if (name.isNotBlank()) {
val commitment = monthlyCommitment.toDoubleOrNull() ?: 0.0 val commitment = monthlyCommitment.toDoubleOrNull() ?: 0.0
onConfirm(name, phoneNumber, commitment, startMonth, startYear) onConfirm(name, phoneNumber, commitment, startMonth, startYear, isAnonymous)
} }
}, },
enabled = name.isNotBlank() enabled = name.isNotBlank()
@@ -648,6 +521,47 @@ fun UpdatePersonDialog(
Text("لغو") 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("حذف")
}
}
}
}
}
}
}
} }
} }
} }
@@ -685,7 +599,6 @@ fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
@Composable @Composable
fun MonthListItem( fun MonthListItem(
month: MonthUiModel, month: MonthUiModel,
personName: String,
startMonth: Int, startMonth: Int,
onHeaderClick: () -> Unit, onHeaderClick: () -> Unit,
onEditClick: () -> Unit onEditClick: () -> Unit
@@ -83,6 +83,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
import com.oqba26.monthlypaymentapp.utils.toPersianDigits import com.oqba26.monthlypaymentapp.utils.toPersianDigits
import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch
import com.oqba26.monthlypaymentapp.viewmodel.ContactSuggestion import com.oqba26.monthlypaymentapp.viewmodel.ContactSuggestion
@@ -129,6 +134,7 @@ fun PersonScreen(
} }
var personToArchive by remember { mutableStateOf<PersonUiModel?>(null) } var personToArchive by remember { mutableStateOf<PersonUiModel?>(null) }
var personForBulkPayment by remember { mutableStateOf<PersonUiModel?>(null) }
val showBulkSelection = contactState.showBulkSmsDialog val showBulkSelection = contactState.showBulkSmsDialog
val reorderableState = rememberReorderableLazyListState( val reorderableState = rememberReorderableLazyListState(
@@ -195,10 +201,10 @@ fun PersonScreen(
if (currentCategory == "mosque" && uiState.unpaidPersons.isNotEmpty()) { if (currentCategory == "mosque" && uiState.unpaidPersons.isNotEmpty()) {
item { item {
Text( Text(
text = "لیست خیرین (بدهکار)", text = "لیست خیرین",
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.primary
) )
} }
} }
@@ -232,6 +238,9 @@ fun PersonScreen(
}, },
onSmsClick = { selectedPerson -> onSmsClick = { selectedPerson ->
contactViewModel.onSmsClicked(selectedPerson) contactViewModel.onSmsClicked(selectedPerson)
},
onDebtClick = { selectedPerson ->
personForBulkPayment = selectedPerson
} }
) )
} }
@@ -271,6 +280,9 @@ fun PersonScreen(
}, },
onSmsClick = { selectedPerson -> onSmsClick = { selectedPerson ->
contactViewModel.onSmsClicked(selectedPerson) contactViewModel.onSmsClicked(selectedPerson)
},
onDebtClick = { selectedPerson ->
personForBulkPayment = selectedPerson
} }
) )
} }
@@ -289,9 +301,24 @@ fun PersonScreen(
// Dialogs // Dialogs
personForPaymentDialog?.let { person -> personForPaymentDialog?.let { person ->
val currentDay = getCurrentShamsiDay()
val currentMonth = getCurrentShamsiMonth()
val targetMonth = 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
val initialDesc = if (isCurrentMonth) {
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
} else {
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
}
AddPaymentDialog( AddPaymentDialog(
personName = person.name, personName = person.name,
defaultAmount = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount, defaultAmount = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount,
initialDescription = initialDesc,
onConfirm = { amount, description -> onConfirm = { amount, description ->
viewModel.onEvent(PersonScreenEvent.AddQuickPayment(person.id, amount, description)) viewModel.onEvent(PersonScreenEvent.AddQuickPayment(person.id, amount, description))
viewModel.onDismissPaymentDialog() viewModel.onDismissPaymentDialog()
@@ -348,6 +375,25 @@ fun PersonScreen(
) )
} }
personForBulkPayment?.let { person: PersonUiModel ->
BulkPaymentDialog(
availableMonths = person.unpaidMonths,
defaultAmount = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultPaymentAmount,
onConfirm = { selected: List<Int>, amount: Double ->
viewModel.onEvent(
PersonScreenEvent.AddBulkPayments(
personId = person.id,
months = selected,
year = getCurrentShamsiYear(),
amount = amount
)
)
personForBulkPayment = null
},
onDismiss = { personForBulkPayment = null }
)
}
if (showBulkSelection) { if (showBulkSelection) {
val debtors = (uiState.unpaidPersons + uiState.paidPersons).filter { it.debtCount > 0 } val debtors = (uiState.unpaidPersons + uiState.paidPersons).filter { it.debtCount > 0 }
BulkSmsSelectionDialog( BulkSmsSelectionDialog(
@@ -511,8 +557,8 @@ fun AddNewPersonDialog(
var monthlyCommitment by remember { mutableStateOf("") } var monthlyCommitment by remember { mutableStateOf("") }
var initialPaymentAmount by remember { mutableStateOf("") } var initialPaymentAmount by remember { mutableStateOf("") }
val currentShamsiYear = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear() val currentShamsiYear = getCurrentShamsiYear()
val currentShamsiMonth = com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth() val currentShamsiMonth = getCurrentShamsiMonth()
var startMonth by remember { mutableIntStateOf(currentShamsiMonth) } var startMonth by remember { mutableIntStateOf(currentShamsiMonth) }
var startYear by remember { mutableIntStateOf(currentShamsiYear) } var startYear by remember { mutableIntStateOf(currentShamsiYear) }
@@ -755,14 +801,14 @@ fun AddNewPersonDialog(
onClick = { expandedMonth = true }, onClick = { expandedMonth = true },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(startMonth)) Text(getPersianMonthName(startMonth))
} }
DropdownMenu( DropdownMenu(
expanded = expandedMonth, expanded = expandedMonth,
onDismissRequest = { expandedMonth = false }) { onDismissRequest = { expandedMonth = false }) {
(1..12).forEach { m -> (1..12).forEach { m ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(com.oqba26.monthlypaymentapp.utils.getPersianMonthName(m)) }, text = { Text(getPersianMonthName(m)) },
onClick = { startMonth = m; expandedMonth = false } onClick = { startMonth = m; expandedMonth = false }
) )
} }
@@ -1058,7 +1104,8 @@ fun PersonListItem(
onLongClick: () -> Unit = {}, onLongClick: () -> Unit = {},
onQuickPayClick: (PersonUiModel) -> Unit, onQuickPayClick: (PersonUiModel) -> Unit,
onArchiveClick: (PersonUiModel) -> Unit, onArchiveClick: (PersonUiModel) -> Unit,
onSmsClick: (PersonUiModel) -> Unit onSmsClick: (PersonUiModel) -> Unit,
onDebtClick: (PersonUiModel) -> Unit
) { ) {
Card( Card(
modifier = modifier modifier = modifier
@@ -1107,7 +1154,8 @@ fun PersonListItem(
if (person.debtCount > 0) { if (person.debtCount > 0) {
Surface( Surface(
color = MaterialTheme.colorScheme.errorContainer, color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small shape = MaterialTheme.shapes.small,
modifier = Modifier.clickable { onDebtClick(person) }
) { ) {
Text( Text(
text = "${person.debtCount.toString().toPersianDigits()} بدهی", text = "${person.debtCount.toString().toPersianDigits()} بدهی",
@@ -2,26 +2,33 @@
package com.oqba26.monthlypaymentapp.ui.screens package com.oqba26.monthlypaymentapp.ui.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -37,6 +44,7 @@ import com.oqba26.monthlypaymentapp.R
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
@Composable @Composable
fun EditPaymentDialog( fun EditPaymentDialog(
@@ -333,3 +341,104 @@ fun AddPaymentDialog(
) )
} }
} }
@Composable
fun BulkPaymentDialog(
availableMonths: List<Int>,
defaultAmount: Double,
onConfirm: (List<Int>, Double) -> Unit,
onDismiss: () -> Unit,
) {
val selectedMonths = remember { mutableStateListOf<Int>().apply { addAll(availableMonths) } }
var amountText by remember { mutableStateOf(defaultAmount.toInt().toString()) }
Dialog(
onDismissRequest = onDismiss
) {
CompositionLocalProvider(
LocalLayoutDirection provides LayoutDirection.Rtl
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "پرداخت دسته جمعی",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text("ماه‌های مورد نظر را انتخاب کنید:")
LazyColumn(modifier = Modifier.heightIn(max = 250.dp)) {
items(availableMonths) { month ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
if (selectedMonths.contains(month)) selectedMonths.remove(month)
else selectedMonths.add(month)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = selectedMonths.contains(month),
onCheckedChange = { checked ->
if (checked) selectedMonths.add(month)
else selectedMonths.remove(month)
}
)
Text(getPersianMonthName(month))
}
}
}
OutlinedTextField(
value = amountText,
onValueChange = { value ->
// اجازه ورود فقط به اعداد (چه انگلیسی چه فارسی)
val digitsOnly = value.filter { it.isDigit() || (((it in '\u0660'..'\u0669') || (it in '\u06f0'..'\u06f9'))) }
// تبدیل اعداد فارسی به انگلیسی برای ذخیره در استیت
amountText = digitsOnly.map {
when (it) {
in '\u0660'..'\u0669' -> ((it.code - '\u0660'.code) + '0'.code).toChar()
in '\u06f0'..'\u06f9' -> ((it.code - '\u06f0'.code) + '0'.code).toChar()
else -> it
}
}.joinToString("")
},
label = { Text("مبلغ برای هر ماه (تومان)") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
visualTransformation = PersianNumberVisualTransformation()
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
val finalAmount = amountText.toDoubleOrNull() ?: defaultAmount
onConfirm(selectedMonths.toList(), finalAmount)
},
enabled = selectedMonths.isNotEmpty()
) {
Text("ثبت پرداخت‌ها")
}
TextButton(onClick = onDismiss) {
Text("لغو")
}
}
}
}
}
}
}
@@ -1,34 +1,32 @@
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class) @file:OptIn(InternalSerializationApi::class)
package com.oqba26.monthlypaymentapp.utils package com.oqba26.monthlypaymentapp.utils
import android.app.DownloadManager import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.statement.bodyAsText
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.* import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import io.ktor.utils.io.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.InternalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
import kotlin.time.Duration.Companion.milliseconds
@Serializable @Serializable
data class UpdateInfo( data class UpdateInfo(
@@ -39,6 +37,12 @@ data class UpdateInfo(
val isForceUpdate: Boolean = false, 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) { class UpdateManager(private val context: Context) {
private val updateUrl = "https://raw.githubusercontent.com/oqba26/MonthlyPaymentApp/main/update.json" private val updateUrl = "https://raw.githubusercontent.com/oqba26/MonthlyPaymentApp/main/update.json"
@@ -55,6 +59,7 @@ class UpdateManager(private val context: Context) {
} }
suspend fun checkForUpdate(): UpdateInfo? = withContext(Dispatchers.IO) { suspend fun checkForUpdate(): UpdateInfo? = withContext(Dispatchers.IO) {
cancelPendingSystemDownloads()
if (!isNetworkAvailable()) return@withContext null if (!isNetworkAvailable()) return@withContext null
try { try {
@@ -81,90 +86,53 @@ class UpdateManager(private val context: Context) {
null null
} }
fun downloadAndInstall(url: String, fileName: String): Long { fun downloadApk(url: String, fileName: String): Flow<DownloadState> = flow {
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
}
}
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()
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 downloadId = downloadManager.enqueue(request)
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 { try {
receiverContext.unregisterReceiver(this) val destinationFile = File(
} catch (e: Exception) { context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
e.printStackTrace() fileName
} )
} if (destinationFile.exists()) {
} destinationFile.delete()
} }
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) client.prepareGet(url) {
ContextCompat.registerReceiver(context, onComplete, filter, ContextCompat.RECEIVER_EXPORTED) 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 ->
return downloadId if (!response.status.isSuccess()) {
emit(DownloadState.Error("خطا در دانلود فایل (کد ${response.status.value})"))
return@execute
} }
fun getDownloadProgress(downloadId: Long): Flow<Float> = flow { val channel: ByteReadChannel = response.bodyAsChannel()
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val contentLength = response.contentLength() ?: -1L
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) { destinationFile.outputStream().use { output ->
val bytesDownloaded = cursor.getInt(bytesDownloadedIndex) val buffer = ByteArray(16384)
val bytesTotal = cursor.getInt(bytesTotalIndex) var bytesCopied = 0L
val status = cursor.getInt(statusIndex) while (!channel.isClosedForRead) {
val read = channel.readAvailable(buffer, 0, buffer.size)
if (read <= 0) break
output.write(buffer, 0, read)
bytesCopied += read
if (status == DownloadManager.STATUS_SUCCESSFUL || status == DownloadManager.STATUS_FAILED) { if (contentLength > 0) {
isDownloading = false val progress = bytesCopied.toFloat() / contentLength.toFloat()
} emit(DownloadState.Progress(progress.coerceIn(0f, 1f)))
if (bytesTotal > 0) {
emit(bytesDownloaded.toFloat() / bytesTotal.toFloat())
}
}
cursor.close()
} else { } else {
isDownloading = false emit(DownloadState.Progress(0f))
cursor?.close()
} }
if (isDownloading) delay(500.milliseconds) }
}
emit(DownloadState.Progress(1f))
emit(DownloadState.Success(destinationFile))
}
} catch (e: Exception) {
emit(DownloadState.Error(e.localizedMessage ?: "خطای ارتباط با سرور"))
} }
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
private fun installApk(fileName: String) { fun installApk(apkFile: File) {
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
if (!apkFile.exists()) return if (!apkFile.exists()) return
try { try {
@@ -179,6 +147,44 @@ class UpdateManager(private val context: Context) {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
} }
context.startActivity(intent) 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) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
@@ -9,6 +9,7 @@ import com.oqba26.monthlypaymentapp.data.model.Person
import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiDay
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
@@ -131,12 +132,8 @@ class PersonViewModel @Inject constructor(
settingsRepository.authTokenFlow.collectLatest { token -> settingsRepository.authTokenFlow.collectLatest { token ->
if (token != null) { if (token != null) {
networkRepository.observeRealtimeChanges() networkRepository.observeRealtimeChanges()
networkRepository.refresh()
// شرط قبلی `persons.isNotEmpty()` بود که دو مشکل داشت: هم لیست خالیِ launch {
// معتبر سرور را نادیده می‌گرفت، و هم اگر واکشی شکست می‌خورد (چون refresh
// خطا را می‌بلعید) روی داده‌ی قدیمی/ناقص merge می‌کرد.
// حالا فقط بعد از یک refresh موفق merge می‌کنیم و خودِ merge هم غیرمخرب است.
combine( combine(
networkRepository.getPersonsFlow(), networkRepository.getPersonsFlow(),
networkRepository.getPaymentsFlow(), networkRepository.getPaymentsFlow(),
@@ -159,6 +156,9 @@ class PersonViewModel @Inject constructor(
} }
} }
} }
networkRepository.refresh()
}
} }
} }
@@ -188,18 +188,8 @@ class PersonViewModel @Inject constructor(
val uiModels = activePersons.map { person -> val uiModels = activePersons.map { person ->
val hasPaid = relevantPayments.any { it.personId == person.id } val hasPaid = relevantPayments.any { it.personId == person.id }
val (debtCount, totalDebtAmount, unpaidMonths) = if (category == "mosque") { val unpaidResult = if (category == "mosque" || person.isAnonymous) {
if (person.monthlyCommitment > 0) { Triple(0, 0.0, emptyList<String>() to emptyList())
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) })
} else {
Triple(0, 0.0, emptyList())
}
} else { } else {
// Salary logic // Salary logic
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1 val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
@@ -209,9 +199,12 @@ class PersonViewModel @Inject constructor(
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category } allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category }
} }
val defaultAmount = settingsRepository.defaultPaymentAmountFlow.first() val defaultAmount = settingsRepository.defaultPaymentAmountFlow.first()
Triple(unpaid.size, unpaid.size * defaultAmount, unpaid.map { getPersianMonthName(it) }) Triple(unpaid.size, unpaid.size * defaultAmount, unpaid.map { getPersianMonthName(it) } to unpaid)
} }
val (debtCount, totalDebtAmount, unpaidData) = unpaidResult
val (unpaidMonthsNames, unpaidMonths) = unpaidData
val displayName = if (person.isAnonymous && category == "mosque") { val displayName = if (person.isAnonymous && category == "mosque") {
"خیر ناشناس (${person.name})" "خیر ناشناس (${person.name})"
} else { } else {
@@ -229,7 +222,8 @@ class PersonViewModel @Inject constructor(
phoneNumber = person.phoneNumber, phoneNumber = person.phoneNumber,
isAnonymous = person.isAnonymous, isAnonymous = person.isAnonymous,
monthlyCommitment = person.monthlyCommitment, monthlyCommitment = person.monthlyCommitment,
unpaidMonthsNames = unpaidMonths, unpaidMonthsNames = unpaidMonthsNames,
unpaidMonths = unpaidMonths,
needsSync = person.needsSync needsSync = person.needsSync
) )
} }
@@ -369,7 +363,8 @@ class PersonViewModel @Inject constructor(
is PersonScreenEvent.UpdatePerson -> { is PersonScreenEvent.UpdatePerson -> {
val persons = localPersonRepository.getAllPersonsFlow().first() 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) { if (isDuplicate) {
_toastMessage.emit("خطا: این نام از قبل وجود دارد.") _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
@@ -377,13 +372,15 @@ class PersonViewModel @Inject constructor(
val currentPerson = persons.find { it.id == event.personId } val currentPerson = persons.find { it.id == event.personId }
if (currentPerson != null) { if (currentPerson != null) {
val updatedPerson = currentPerson.copy( val updatedPerson = currentPerson.copy(
name = event.name, name = finalName,
phoneNumber = event.phoneNumber, phoneNumber = event.phoneNumber,
monthlyCommitment = event.monthlyCommitment, monthlyCommitment = if (event.isAnonymous) 0.0 else event.monthlyCommitment,
startMonth = event.startMonth, startMonth = event.startMonth,
startYear = event.startYear startYear = event.startYear,
isAnonymous = event.isAnonymous
) )
localPersonRepository.updatePersonLocally(updatedPerson) localPersonRepository.updatePersonLocally(updatedPerson)
_toastMessage.emit("اطلاعات شخص با موفقیت بروز شد.")
} }
} }
} }
@@ -440,13 +437,25 @@ class PersonViewModel @Inject constructor(
try { try {
val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == event.personId } val person = localPersonRepository.getAllPersonsFlow().first().find { it.id == event.personId }
val category = person?.category ?: _currentCategory.value val category = person?.category ?: _currentCategory.value
val currentMonth = getCurrentShamsiMonth()
val currentYear = getCurrentShamsiYear()
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
event.months.forEach { month -> event.months.forEach { month ->
val monthName = getPersianMonthName(month)
val isCurrentMonth = (month == currentMonth) && (event.year == currentYear)
val description = if (isCurrentMonth) {
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
} else {
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
}
val record = createPaymentRecord( val record = createPaymentRecord(
personId = event.personId, personId = event.personId,
month = month, month = month,
year = event.year, year = event.year,
amount = event.amount, amount = event.amount,
description = "پرداخت دسته جمعی" description = description
).copy(category = category) ).copy(category = category)
localPersonRepository.insertPaymentLocally(record) localPersonRepository.insertPaymentLocally(record)
} }
@@ -481,7 +490,15 @@ class PersonViewModel @Inject constructor(
PersonScreenEvent.RefreshData -> { PersonScreenEvent.RefreshData -> {
_isRefreshing.value = true _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 _isRefreshing.value = false
} }
else -> {} else -> {}
@@ -15,6 +15,7 @@ data class PersonUiModel(
val isAnonymous: Boolean = false, val isAnonymous: Boolean = false,
val monthlyCommitment: Double = 0.0, val monthlyCommitment: Double = 0.0,
val unpaidMonthsNames: List<String> = emptyList(), val unpaidMonthsNames: List<String> = emptyList(),
val unpaidMonths: List<Int> = emptyList(),
val needsSync: Boolean = false, val needsSync: Boolean = false,
) )
@@ -83,7 +84,8 @@ sealed class PersonScreenEvent {
val phoneNumber: String?, val phoneNumber: String?,
val monthlyCommitment: Double = 0.0, val monthlyCommitment: Double = 0.0,
val startMonth: Int = 1, val startMonth: Int = 1,
val startYear: Int = 1403 val startYear: Int = 1403,
val isAnonymous: Boolean = false
) : PersonScreenEvent() ) : PersonScreenEvent()
data class DeletePerson(val personId: String) : PersonScreenEvent() data class DeletePerson(val personId: String) : PersonScreenEvent()
data class ArchivePerson(val personId: String) : PersonScreenEvent() data class ArchivePerson(val personId: String) : PersonScreenEvent()
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<network-security-config> <network-security-config>
<base-config cleartextTrafficPermitted="true" />
<domain-config cleartextTrafficPermitted="true"> <domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">194.146.68.50</domain>
<domain includeSubdomains="true">167.235.136.65</domain> <domain includeSubdomains="true">167.235.136.65</domain>
</domain-config> </domain-config>
</network-security-config> </network-security-config>
@@ -70,17 +70,17 @@ class SyncMergerTest {
} }
@Test @Test
fun `شخص محلی غیر pending که در سرور نیست حذف می شود`() { fun `شخص محلی غیر pending که در سرور نیست حذف نمی شود`() {
val local = listOf(person("a"), person("b")) val local = listOf(person("a"), person("b"))
val server = listOf(person("a")) val server = listOf(person("a"))
val plan = SyncMerger.planPersons(local, server, pendingIds = emptySet()) val plan = SyncMerger.planPersons(local, server, pendingIds = emptySet())
assertEquals(listOf("b"), plan.deleteIds) assertEquals(emptyList<String>(), plan.deleteIds)
} }
@Test @Test
fun `سرور خالی هیچ رکورد محافظت شده ای را پاک نمی کند`() { fun `سرور خالی هیچ رکوردی را پاک نمی کند`() {
val local = listOf( val local = listOf(
person("a", needsSync = true), person("a", needsSync = true),
person("b"), person("b"),
@@ -89,8 +89,7 @@ class SyncMergerTest {
val plan = SyncMerger.planPersons(local, server = emptyList(), pendingIds = setOf("b")) val plan = SyncMerger.planPersons(local, server = emptyList(), pendingIds = setOf("b"))
// a با needsSync و b از طریق صف محافظت شده‌اند؛ فقط c حذف می‌شود. assertEquals(emptyList<String>(), plan.deleteIds)
assertEquals(listOf("c"), plan.deleteIds)
assertTrue(plan.upserts.isEmpty()) assertTrue(plan.upserts.isEmpty())
} }
@@ -170,17 +169,17 @@ class SyncMergerTest {
} }
@Test @Test
fun `پرداخت حذف شده در سرور از گوشی هم حذف می شود`() { fun `پرداخت غایب در سرور حذف نمی شود`() {
val local = listOf(payment("x"), payment("y")) val local = listOf(payment("x"), payment("y"))
val server = listOf(payment("x")) val server = listOf(payment("x"))
val plan = SyncMerger.planPayments(local, server, pendingIds = emptySet()) val plan = SyncMerger.planPayments(local, server, pendingIds = emptySet())
assertEquals(listOf("y"), plan.deleteIds) assertEquals(emptyList<String>(), plan.deleteIds)
} }
@Test @Test
fun `سرور خالی پرداخت های در صف را نگه می دارد`() { fun `سرور خالی پرداخت ها را حذف نمی کند`() {
val local = listOf( val local = listOf(
payment("pending", needsSync = true), payment("pending", needsSync = true),
payment("synced") payment("synced")
@@ -188,7 +187,7 @@ class SyncMergerTest {
val plan = SyncMerger.planPayments(local, server = emptyList(), pendingIds = emptySet()) val plan = SyncMerger.planPayments(local, server = emptyList(), pendingIds = emptySet())
assertEquals(listOf("synced"), plan.deleteIds) assertEquals(emptyList<String>(), plan.deleteIds)
} }
@Test @Test
+2 -6
View File
@@ -31,8 +31,7 @@ junit = "4.13.2"
junitExt = "1.1.5" junitExt = "1.1.5"
espressoCore = "3.5.1" espressoCore = "3.5.1"
# Supabase & Ktor # Ktor
supabase = "2.5.0"
ktor = "2.3.12" ktor = "2.3.12"
[libraries] [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" } kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
# Supabase Libraries # Ktor 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-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } 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-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" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"versionCode": 2, "versionCode": 8,
"versionName": "1.0.1", "versionName": "1.0.7",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.1/MonthlyPaymentApp-v1.0.1.apk", "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.7/MonthlyPaymentApp-v1.0.7.apk",
"releaseNotes": "اصلاحات اخیر:\n- رفع تداخل در مهاجرت دیتابیس Room.\n- مرتب‌سازی تب‌ها و بهبود چیدمان.\n- شخصی‌سازی عناوین برای صفحات آرشیو، تنظیمات و مسجد.", "releaseNotes": "مهاجرت به PocketBase، رفع خطاهای بیلد و بهبود پایداری همگام‌سازی",
"isForceUpdate": true "isForceUpdate": false
} }