From ed1d3cecbb60364206afb750b5932250908043a2 Mon Sep 17 00:00:00 2001 From: Oqba Date: Mon, 21 Sep 2026 17:50:52 +0330 Subject: [PATCH] =?UTF-8?q?=D9=85=D9=87=D8=A7=D8=AC=D8=B1=D8=AA=20=D8=A8?= =?UTF-8?q?=D9=87=20PocketBase=D8=8C=20=D8=B1=D9=81=D8=B9=20=D8=AE=D8=B7?= =?UTF-8?q?=D8=A7=DB=8C=20=D8=A8=DB=8C=D9=84=D8=AF=20=D9=88=20=D8=A7=D8=B1?= =?UTF-8?q?=D8=AA=D9=82=D8=A7=DB=8C=20=D9=86=D8=B3=D8=AE=D9=87=20=D8=A8?= =?UTF-8?q?=D9=87=201.0.7=20(=D9=86=D8=B3=D8=AE=D9=87=208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 15 +- .../core/di/RepositoryModule.kt | 4 +- .../data/model/PaymentRecord.kt | 8 +- .../monthlypaymentapp/data/model/Person.kt | 16 +- .../data/remote/ApiClient.kt | 32 +- .../data/remote/PocketBaseClient.kt | 388 ++++++++++++++++++ .../data/repository/LocalPersonRepository.kt | 20 +- .../data/repository/NetworkRepository.kt | 190 +++------ .../monthlypaymentapp/data/sync/SyncMerger.kt | 11 +- .../viewmodel/PersonViewModel.kt | 55 +-- .../data/sync/SyncMergerTest.kt | 17 +- gradle/libs.versions.toml | 8 +- update.json | 8 +- 13 files changed, 537 insertions(+), 235 deletions(-) create mode 100644 app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/PocketBaseClient.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f77192d..70a28c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { applicationId = "com.oqba26.monthlypaymentapp" minSdk = 25 targetSdk = 34 - versionCode = 7 - versionName = "1.0.6" + versionCode = 8 + versionName = "1.0.7" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } } @@ -55,8 +55,7 @@ android { buildTypes { debug { - buildConfigField("String", "SUPABASE_URL", "\"https://ftufsygeartwukonclkz.supabase.co\"") - buildConfigField("String", "SUPABASE_KEY", "\"sb_publishable_H4mAU1Ds-vZ22HwMSfCaBQ_QjBfmRd5\"") + buildConfigField("String", "POCKETBASE_URL", "\"http://194.146.68.50/\"") } release { val storeFileProp = getProp("RELEASE_STORE_FILE") @@ -68,8 +67,7 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - buildConfigField("String", "SUPABASE_URL", "\"https://ftufsygeartwukonclkz.supabase.co\"") - buildConfigField("String", "SUPABASE_KEY", "\"sb_publishable_H4mAU1Ds-vZ22HwMSfCaBQ_QjBfmRd5\"") + buildConfigField("String", "POCKETBASE_URL", "\"http://194.146.68.50/\"") } } @@ -111,10 +109,7 @@ dependencies { implementation("androidx.compose.material:material-icons-extended-android:1.6.7") implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6") - // Supabase - implementation(libs.supabase.postgrest) - implementation(libs.supabase.auth) - implementation(libs.supabase.realtime) + // PocketBase / Ktor Network implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/RepositoryModule.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/RepositoryModule.kt index 64a80d2..6a7d95b 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/RepositoryModule.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/core/di/RepositoryModule.kt @@ -20,8 +20,8 @@ object RepositoryModule { @Provides @Singleton - fun provideNetworkRepository(): NetworkRepository { - return NetworkRepository() + fun provideNetworkRepository(settingsRepository: SettingsRepository): NetworkRepository { + return NetworkRepository(settingsRepository) } @Provides diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt index ede5ac8..0614da7 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt @@ -16,19 +16,19 @@ data class PaymentRecord( @PrimaryKey @SerialName("id") val id: String = UUID.randomUUID().toString(), - @SerialName("person_id") val personId: String, + @SerialName("personId") val personId: String, @SerialName("amount") val amount: Double, - @SerialName("shamsi_year") val shamsiYear: Int, + @SerialName("shamsiYear") val shamsiYear: Int, - @SerialName("shamsi_month") val shamsiMonth: Int, + @SerialName("shamsiMonth") val shamsiMonth: Int, @SerialName("timestamp") val timestamp: Long = System.currentTimeMillis(), @SerialName("description") val description: String? = "", - @SerialName("created_at") val createdAt: Long? = null, + @SerialName("createdAt") val createdAt: Long? = null, @SerialName("category") val category: String = "salary", diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt index 26accf8..11a0133 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt @@ -17,23 +17,23 @@ data class Person( @SerialName("name") val name: String, - @SerialName("is_archived") val isArchived: Boolean = false, + @SerialName("isArchived") val isArchived: Boolean = false, - @SerialName("display_order") val displayOrder: Long? = 0L, + @SerialName("displayOrder") val displayOrder: Long? = 0L, - @SerialName("created_at") val createdAt: Long? = 0L, + @SerialName("createdAt") val createdAt: Long? = 0L, - @SerialName("phone_number") val phoneNumber: String? = "", + @SerialName("phoneNumber") val phoneNumber: String? = "", @SerialName("category") val category: String = "salary", - @SerialName("is_anonymous") val isAnonymous: Boolean = false, + @SerialName("isAnonymous") val isAnonymous: Boolean = false, - @SerialName("monthly_commitment") val monthlyCommitment: Double = 0.0, + @SerialName("monthlyCommitment") val monthlyCommitment: Double = 0.0, - @SerialName("start_month") val startMonth: Int = 1, + @SerialName("startMonth") val startMonth: Int = 1, - @SerialName("start_year") val startYear: Int = 1403, + @SerialName("startYear") val startYear: Int = 1403, @Transient val needsSync: Boolean = false diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt index 6adf906..116fcba 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt @@ -1,38 +1,10 @@ -@file:OptIn(io.github.jan.supabase.annotations.SupabaseInternal::class) - package com.oqba26.monthlypaymentapp.data.remote import com.oqba26.monthlypaymentapp.BuildConfig -import io.github.jan.supabase.createSupabaseClient -import io.github.jan.supabase.gotrue.Auth -import io.github.jan.supabase.postgrest.Postgrest -import io.github.jan.supabase.realtime.Realtime -import io.github.jan.supabase.serializer.KotlinXSerializer -import io.ktor.client.plugins.HttpTimeout -import kotlinx.serialization.json.Json object ApiClient { - val client = createSupabaseClient( - supabaseUrl = BuildConfig.SUPABASE_URL, - supabaseKey = BuildConfig.SUPABASE_KEY - ) { - defaultSerializer = KotlinXSerializer(Json { - ignoreUnknownKeys = true - isLenient = true - encodeDefaults = true - }) - - httpConfig { - install(HttpTimeout) { - requestTimeoutMillis = 60000 - connectTimeoutMillis = 60000 - socketTimeoutMillis = 60000 - } - } - - install(Postgrest) - install(Auth) - install(Realtime) + val pocketBase: PocketBaseClient by lazy { + PocketBaseClient(baseUrl = BuildConfig.POCKETBASE_URL) } } diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/PocketBaseClient.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/PocketBaseClient.kt new file mode 100644 index 0000000..01e86d6 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/PocketBaseClient.kt @@ -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( + val page: Int = 1, + val perPage: Int = 500, + val totalPages: Int = 1, + val totalItems: Int = 0, + val items: List = 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, +) + +/** + * کلاینت اختصاصی جهت ارتباط با 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(authResp) + authToken = parsed.token + return parsed + } + + /** + * دریافت لیست رکوردها از یک کالکشن + */ + suspend inline fun getRecords( + collectionName: String, + page: Int = 1, + perPage: Int = 500, + filter: String? = null, + sort: String? = null + ): List { + 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>(bodyText) + return listResp.items + } + + /** + * ایجاد رکورد جدید در PocketBase + */ + suspend inline fun 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(respText) + } + + /** + * بروزرسانی رکورد بر اساس آی‌دی PocketBase + */ + suspend inline fun 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(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 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): Flow = 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(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) { + 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)) + } + } +} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt index 77214e4..73f2bbc 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt @@ -53,12 +53,24 @@ class LocalPersonRepository( val personPlan = SyncMerger.planPersons(localPersons, persons, pendingIds) val paymentPlan = SyncMerger.planPayments(localPayments, payments, pendingIds) - // پرداخت‌ها اول حذف می‌شوند تا اگر کلید خارجی اضافه شد، ترتیب درست باشد. - if (paymentPlan.deleteIds.isNotEmpty()) paymentDao.deleteByIds(paymentPlan.deleteIds) - if (personPlan.deleteIds.isNotEmpty()) personDao.deleteByIds(personPlan.deleteIds) - if (personPlan.upserts.isNotEmpty()) personDao.insertAll(personPlan.upserts) if (paymentPlan.upserts.isNotEmpty()) paymentDao.insertAllPaymentRecords(paymentPlan.upserts) + + // خودترمیمی: اگر رکوردهایی در دیتابیس محلی هستند که در سرور وجود ندارند و در صف هم نیستند، + // آن‌ها را دوباره برای ارسال به سرور ثبت می‌کنیم تا سرور و کلاینت همگام شوند. + val serverPersonIds = persons.map { it.id }.toSet() + val missingPersons = localPersons.filter { it.id !in serverPersonIds && it.id !in pendingIds } + for (p in missingPersons) { + personDao.insertAll(listOf(p.copy(needsSync = true))) + syncQueueDao.insert(SyncQueue(entityId = p.id, type = SyncType.PERSON, operation = SyncOperation.INSERT)) + } + + val serverPaymentIds = payments.map { it.id }.toSet() + val missingPayments = localPayments.filter { it.id !in serverPaymentIds && it.id !in pendingIds } + for (p in missingPayments) { + paymentDao.insertAllPaymentRecords(listOf(p.copy(needsSync = true))) + syncQueueDao.insert(SyncQueue(entityId = p.id, type = SyncType.PAYMENT, operation = SyncOperation.INSERT)) + } } } diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt index a203280..2c3cc78 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt @@ -6,72 +6,50 @@ import com.oqba26.monthlypaymentapp.data.model.AuthResponse import com.oqba26.monthlypaymentapp.data.model.PaymentRecord import com.oqba26.monthlypaymentapp.data.model.Person import com.oqba26.monthlypaymentapp.data.remote.ApiClient -import io.github.jan.supabase.gotrue.auth -import io.github.jan.supabase.gotrue.providers.builtin.Email -import io.github.jan.supabase.postgrest.from -import io.github.jan.supabase.postgrest.query.Order -import io.github.jan.supabase.realtime.PostgresAction -import io.github.jan.supabase.realtime.channel -import io.github.jan.supabase.realtime.postgresChangeFlow -import io.github.jan.supabase.realtime.realtime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.put import java.util.UUID -class NetworkRepository { +class NetworkRepository( + private val settingsRepository: SettingsRepository? = null +) { - private val supabase = ApiClient.client + private val pocketBase = ApiClient.pocketBase private val _personsFlow = MutableStateFlow>(emptyList()) private val _paymentsFlow = MutableStateFlow>(emptyList()) private val repositoryScope = CoroutineScope(Dispatchers.IO) /** * تا وقتی حداقل یک [refresh] موفق انجام نشده باشد `false` است. - * - * چرا لازم است: مقدار اولیه‌ی `_personsFlow`/`_paymentsFlow` لیست خالی است و از لیست خالیِ - * واقعی (کاربری که هنوز داده‌ای ثبت نکرده) قابل تشخیص نیست. merge کردن روی آن لیست خالیِ - * قالبی، همه‌ی داده‌ی محلی را پاک می‌کند. مصرف‌کننده باید منتظر `true` بماند. */ private val _hasServerData = MutableStateFlow(false) fun hasServerDataFlow(): Flow = _hasServerData.asStateFlow() + init { + settingsRepository?.authTokenFlow?.let { authFlow -> + repositoryScope.launch { + authFlow.collect { token -> + pocketBase.authToken = token + } + } + } + } + // --- Realtime Operations --- fun observeRealtimeChanges() { repositoryScope.launch { try { - // اطمینان از اتصال قبل از اشتراک در کانال - supabase.realtime.connect() - - val myChannel = supabase.channel("db-changes") - - // گوش دادن به تغییرات جدول اشخاص - myChannel.postgresChangeFlow(schema = "public") { - table = "persons" - }.collect { - Log.d("Supabase-Realtime", "Change detected in persons table") + pocketBase.listenRealtime(listOf("persons", "payments")).collect { event -> + Log.d("PocketBase-Realtime", "Change detected in PocketBase: $event") refresh() } - - // گوش دادن به تغییرات جدول پرداخت‌ها - launch { - myChannel.postgresChangeFlow(schema = "public") { - table = "payments" - }.collect { - Log.d("Supabase-Realtime", "Change detected in payments table") - refresh() - } - } - - myChannel.subscribe() - Log.d("Supabase-Realtime", "Subscribed to all changes") } catch (e: Exception) { - Log.e("Supabase-Realtime", "Realtime connection error, retrying...", e) + Log.e("PocketBase-Realtime", "Realtime connection error, retrying...", e) } } } @@ -79,49 +57,33 @@ class NetworkRepository { // --- Auth Operations --- suspend fun register(request: AuthRequest): AuthResponse? { return try { - supabase.auth.signUpWith(Email) { - email = request.email - password = request.password - } - val session = supabase.auth.currentSessionOrNull() - AuthResponse(token = session?.accessToken ?: "success", userId = session?.user?.id) + pocketBase.createUser("users", request.email, request.password) + val authResp = pocketBase.authWithPassword("users", request.email, request.password) + val userId = authResp.record?.get("id")?.toString()?.replace("\"", "") + AuthResponse(token = authResp.token, userId = userId) } catch (e: Exception) { - Log.e("Supabase", "register error", e) + Log.e("PocketBase", "register error", e) null } } suspend fun login(request: AuthRequest): AuthResponse? { return try { - supabase.auth.signInWith(Email) { - email = request.email - password = request.password - } - val session = supabase.auth.currentSessionOrNull() - AuthResponse(token = session?.accessToken ?: "", userId = session?.user?.id) + val authResp = pocketBase.authWithPassword("users", request.email, request.password) + val userId = authResp.record?.get("id")?.toString()?.replace("\"", "") + AuthResponse(token = authResp.token, userId = userId) } catch (e: Exception) { - Log.e("Supabase", "login error", e) + Log.e("PocketBase", "login error", e) null } } // --- Person Operations --- - /** - * ارسال شخص به سرور با upsert. - * - * upsert (و نه insert) عمداً انتخاب شده تا این عملیات idempotent باشد: اگر تلاش اول - * به سرور رسیده باشد ولی پاسخش به‌خاطر timeout به ما نرسیده باشد، تلاش دوم رکورد - * تکراری نمی‌سازد. صف سینک متکی به همین خاصیت است تا بتواند بی‌خطر retry کند. - * - * اعتبارسنجی تکراری‌بودن نام اینجا انجام نمی‌شود؛ آن کار در PersonViewModel روی - * داده‌ی محلی (که همیشه کامل است) انجام می‌گیرد. کش _personsFlow ممکن است خالی یا - * قدیمی باشد و نتیجه‌ی غلط بدهد. - */ suspend fun addPerson(person: Person): Boolean { return try { val trimmedName = person.name.trim() if (trimmedName.isEmpty()) { - Log.w("Supabase", "addPerson skipped: blank name") + Log.w("PocketBase", "addPerson skipped: blank name") return false } @@ -131,22 +93,22 @@ class NetworkRepository { person.copy(name = trimmedName) } - supabase.from("persons").upsert(personToUpsert) + pocketBase.upsertRecord("persons", "id='${personToUpsert.id}'", personToUpsert) true } catch (e: Exception) { - Log.e("Supabase", "addPerson error", e) + Log.e("PocketBase", "addPerson error", e) false } } suspend fun deletePersonAndPayments(personId: String): Boolean { return try { - supabase.from("persons").delete { - filter { eq("id", personId) } - } + pocketBase.deleteRecordByFilter("payments", "personId='$personId'") + pocketBase.deleteRecordByFilter("persons", "id='$personId'") true } catch (e: Exception) { - Log.e("Supabase", "deletePerson error", e); false + Log.e("PocketBase", "deletePerson error", e) + false } } @@ -173,68 +135,59 @@ class NetworkRepository { } if (isDuplicate) return 409 - supabase.from("persons").update( - buildJsonObject { - put("name", trimmedName) - put("phoneNumber", phoneNumber?.trim()) - put("monthlyCommitment", monthlyCommitment) - put("startMonth", startMonth) - put("startYear", startYear) - }, - ) { - filter { eq("id", personId) } + val updatedPerson = currentPerson?.copy( + name = trimmedName, + phoneNumber = phoneNumber?.trim(), + monthlyCommitment = monthlyCommitment, + startMonth = startMonth, + startYear = startYear + ) + if (updatedPerson != null) { + pocketBase.upsertRecord("persons", "id='$personId'", updatedPerson) } 200 } catch (e: Exception) { - Log.e("Supabase", "updatePerson error", e) + Log.e("PocketBase", "updatePerson error", e) 500 } } suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean { return try { - supabase.from("persons").update( - buildJsonObject { - put("isArchived", isArchived) - } - ) { - filter { eq("id", personId) } + val currentPerson = _personsFlow.value.find { it.id == personId } + if (currentPerson != null) { + pocketBase.upsertRecord("persons", "id='$personId'", currentPerson.copy(isArchived = isArchived)) } true } catch (e: Exception) { - Log.e("Supabase", "updateArchived error", e); false + Log.e("PocketBase", "updateArchived error", e) + false } } suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean { return try { - supabase.from("persons").update( - buildJsonObject { - put("displayOrder", displayOrder) - } - ) { - filter { eq("id", personId) } + val currentPerson = _personsFlow.value.find { it.id == personId } + if (currentPerson != null) { + pocketBase.upsertRecord("persons", "id='$personId'", currentPerson.copy(displayOrder = displayOrder)) } true } catch (e: Exception) { - Log.e("Supabase", "updateOrder error", e); false + Log.e("PocketBase", "updateOrder error", e) + false } } fun getPersonsFlow(): Flow> = _personsFlow.asStateFlow() // --- Payment Operations --- - /** - * ارسال پرداخت به سرور با upsert — به همان دلیل idempotent بودن که در [addPerson] توضیح داده شد. - * ضمناً همین باعث می‌شود ویرایش پرداخت (SyncOperation.UPDATE) هم درست کار کند؛ قبلاً insert - * بود و روی رکورد موجود شکست می‌خورد. - */ suspend fun addPayment(paymentRecord: PaymentRecord): Boolean { return try { - supabase.from("payments").upsert(paymentRecord) + pocketBase.upsertRecord("payments", "id='${paymentRecord.id}'", paymentRecord) true } catch (e: Exception) { - Log.e("Supabase", "addPayment error", e); false + Log.e("PocketBase", "addPayment error", e) + false } } @@ -244,16 +197,14 @@ class NetworkRepository { while (retries <= maxRetries) { try { - supabase.from("payments").delete { - filter { eq("id", paymentId) } - } - Log.d("Supabase", "deletePayment Success: $paymentId") + pocketBase.deleteRecordByFilter("payments", "id='$paymentId'") + Log.d("PocketBase", "deletePayment Success: $paymentId") return true } catch (e: Exception) { retries++ - Log.e("Supabase", "deletePayment error (Attempt $retries): ${e.message}", e) + Log.e("PocketBase", "deletePayment error (Attempt $retries): ${e.message}", e) if (retries > maxRetries) return false - kotlinx.coroutines.delay(1000L * retries) // Exponential backoff-ish + delay(1000L * retries) } } return false @@ -263,31 +214,20 @@ class NetworkRepository { /** * واکشی کامل داده‌ها از سرور. - * - * @return true فقط اگر **هر دو** واکشی موفق بوده باشند. - * - * دو تضمین مهم که فراخوان‌ها به آن تکیه می‌کنند: - * ۱. در صورت شکست، flow‌ها **دست‌نخورده** می‌مانند. قبلاً خطا بلعیده می‌شد و صدازننده - * نمی‌فهمید واکشی شکست خورده؛ نتیجه‌اش merge روی داده‌ی ناقص و پاک شدن داده‌ی محلی بود. - * ۲. مقداردهی flow‌ها اتمیک است — یا هر دو با هم به‌روز می‌شوند یا هیچ‌کدام. قبلاً اگر - * واکشی پرداخت‌ها شکست می‌خورد، لیست اشخاص جدید کنار لیست پرداخت‌های قدیمی می‌نشست. */ suspend fun refresh(): Boolean { return try { - val persons = supabase.from("persons").select().decodeList() - val payments = supabase.from("payments").select { - order("timestamp", order = Order.DESCENDING) - }.decodeList() + val persons = pocketBase.getRecords("persons") + val payments = pocketBase.getRecords("payments", sort = "-timestamp") - // فقط بعد از موفقیت هر دو واکشی مقداردهی می‌کنیم _personsFlow.value = persons _paymentsFlow.value = payments _hasServerData.value = true - Log.d("Supabase", "Refresh Success: ${persons.size} persons, ${payments.size} payments") + Log.d("PocketBase", "Refresh Success: ${persons.size} persons, ${payments.size} payments") true } catch (e: Exception) { - Log.e("Supabase", "refresh error — داده‌های محلی دست‌نخورده باقی می‌مانند", e) + Log.e("PocketBase", "refresh error", e) false } } diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/sync/SyncMerger.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/sync/SyncMerger.kt index df8d911..6f1a351 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/sync/SyncMerger.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/sync/SyncMerger.kt @@ -75,13 +75,9 @@ object SyncMerger { person.copy(id = resolvedId, name = name, needsSync = false) } - val serverIds = resolved.map { it.id }.toSet() - return MergePlan( upserts = resolved.filterNot { it.id in protectedIds }, - deleteIds = local - .map { it.id } - .filterNot { it in serverIds || it in protectedIds } + deleteIds = emptyList() // هرگز رکوردهای محلی را به خاطر غیبت در سرور حذف نمی‌کنیم ) } @@ -91,15 +87,12 @@ object SyncMerger { pendingIds: Set ): MergePlan { val protectedIds = protectedPaymentIds(local, pendingIds) - val serverIds = server.map { it.id }.toSet() return MergePlan( upserts = server .filterNot { it.id in protectedIds } .map { it.copy(needsSync = false) }, - deleteIds = local - .map { it.id } - .filterNot { it in serverIds || it in protectedIds } + deleteIds = emptyList() // هرگز رکوردهای محلی را به خاطر غیبت در سرور حذف نمی‌کنیم ) } } diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt index 414dbcd..e2399f8 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt @@ -132,33 +132,32 @@ class PersonViewModel @Inject constructor( settingsRepository.authTokenFlow.collectLatest { token -> if (token != null) { networkRepository.observeRealtimeChanges() - networkRepository.refresh() - // شرط قبلی `persons.isNotEmpty()` بود که دو مشکل داشت: هم لیست خالیِ - // معتبر سرور را نادیده می‌گرفت، و هم اگر واکشی شکست می‌خورد (چون refresh - // خطا را می‌بلعید) روی داده‌ی قدیمی/ناقص merge می‌کرد. - // حالا فقط بعد از یک refresh موفق merge می‌کنیم و خودِ merge هم غیرمخرب است. - combine( - networkRepository.getPersonsFlow(), - networkRepository.getPaymentsFlow(), - networkRepository.hasServerDataFlow() - ) { persons, payments, hasServerData -> - if (hasServerData) { - if (!snapshotTakenThisSession) { - // آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی - // ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد، - // کاربر بتواند از تنظیمات برش گرداند. - backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC) - snapshotTakenThisSession = true + launch { + combine( + networkRepository.getPersonsFlow(), + networkRepository.getPaymentsFlow(), + networkRepository.hasServerDataFlow() + ) { persons, payments, hasServerData -> + if (hasServerData) { + if (!snapshotTakenThisSession) { + // آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی + // ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد، + // کاربر بتواند از تنظیمات برش گرداند. + backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC) + snapshotTakenThisSession = true + } + localPersonRepository.mergeFromServer(persons, payments) + true + } else false + }.collect { isUpdated -> + if (isUpdated) { + _toastMessage.emit("اطلاعات با سرور همگام‌سازی شد") } - localPersonRepository.mergeFromServer(persons, payments) - true - } else false - }.collect { isUpdated -> - if (isUpdated) { - _toastMessage.emit("اطلاعات با سرور همگام‌سازی شد") } } + + networkRepository.refresh() } } } @@ -491,7 +490,15 @@ class PersonViewModel @Inject constructor( PersonScreenEvent.RefreshData -> { _isRefreshing.value = true - networkRepository.refresh() + val success = networkRepository.refresh() + if (success) { + val persons = networkRepository.getPersonsFlow().first() + val payments = networkRepository.getPaymentsFlow().first() + localPersonRepository.mergeFromServer(persons, payments) + _toastMessage.emit("اطلاعات با سرور با موفقیت همگام‌سازی شد") + } else { + _toastMessage.emit("خطا در همگام‌سازی با سرور") + } _isRefreshing.value = false } else -> {} diff --git a/app/src/test/java/com/oqba26/monthlypaymentapp/data/sync/SyncMergerTest.kt b/app/src/test/java/com/oqba26/monthlypaymentapp/data/sync/SyncMergerTest.kt index 00e8980..c6ee09a 100644 --- a/app/src/test/java/com/oqba26/monthlypaymentapp/data/sync/SyncMergerTest.kt +++ b/app/src/test/java/com/oqba26/monthlypaymentapp/data/sync/SyncMergerTest.kt @@ -70,17 +70,17 @@ class SyncMergerTest { } @Test - fun `شخص محلی غیر pending که در سرور نیست حذف می شود`() { + fun `شخص محلی غیر pending که در سرور نیست حذف نمی شود`() { val local = listOf(person("a"), person("b")) val server = listOf(person("a")) val plan = SyncMerger.planPersons(local, server, pendingIds = emptySet()) - assertEquals(listOf("b"), plan.deleteIds) + assertEquals(emptyList(), plan.deleteIds) } @Test - fun `سرور خالی هیچ رکورد محافظت شده ای را پاک نمی کند`() { + fun `سرور خالی هیچ رکوردی را پاک نمی کند`() { val local = listOf( person("a", needsSync = true), person("b"), @@ -89,8 +89,7 @@ class SyncMergerTest { val plan = SyncMerger.planPersons(local, server = emptyList(), pendingIds = setOf("b")) - // a با needsSync و b از طریق صف محافظت شده‌اند؛ فقط c حذف می‌شود. - assertEquals(listOf("c"), plan.deleteIds) + assertEquals(emptyList(), plan.deleteIds) assertTrue(plan.upserts.isEmpty()) } @@ -170,17 +169,17 @@ class SyncMergerTest { } @Test - fun `پرداخت حذف شده در سرور از گوشی هم حذف می شود`() { + fun `پرداخت غایب در سرور حذف نمی شود`() { val local = listOf(payment("x"), payment("y")) val server = listOf(payment("x")) val plan = SyncMerger.planPayments(local, server, pendingIds = emptySet()) - assertEquals(listOf("y"), plan.deleteIds) + assertEquals(emptyList(), plan.deleteIds) } @Test - fun `سرور خالی پرداخت های در صف را نگه می دارد`() { + fun `سرور خالی پرداخت ها را حذف نمی کند`() { val local = listOf( payment("pending", needsSync = true), payment("synced") @@ -188,7 +187,7 @@ class SyncMergerTest { val plan = SyncMerger.planPayments(local, server = emptyList(), pendingIds = emptySet()) - assertEquals(listOf("synced"), plan.deleteIds) + assertEquals(emptyList(), plan.deleteIds) } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c8a3abc..ad80b50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,8 +31,7 @@ junit = "4.13.2" junitExt = "1.1.5" espressoCore = "3.5.1" -# Supabase & Ktor -supabase = "2.5.0" +# Ktor ktor = "2.3.12" [libraries] @@ -75,10 +74,7 @@ google-material = { group = "com.google.android.material", name = "material", ve kotlin-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } -# Supabase Libraries -supabase-postgrest = { group = "io.github.jan-tennert.supabase", name = "postgrest-kt", version.ref = "supabase" } -supabase-auth = { group = "io.github.jan-tennert.supabase", name = "gotrue-kt", version.ref = "supabase" } -supabase-realtime = { group = "io.github.jan-tennert.supabase", name = "realtime-kt", version.ref = "supabase" } +# Ktor Libraries ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } diff --git a/update.json b/update.json index 44c7f61..ef57166 100644 --- a/update.json +++ b/update.json @@ -1,7 +1,7 @@ { - "versionCode": 7, - "versionName": "1.0.6", - "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.6/MonthlyPaymentApp-v1.0.6.apk", - "releaseNotes": "بهینه‌سازی کدهای UI، رفع هشدارهای کامپایلر در صفحات شخص و جزئیات شخص، و بهبود پایداری برنامه", + "versionCode": 8, + "versionName": "1.0.7", + "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.7/MonthlyPaymentApp-v1.0.7.apk", + "releaseNotes": "مهاجرت به PocketBase، رفع خطاهای بیلد و بهبود پایداری همگام‌سازی", "isForceUpdate": false }