مهاجرت به PocketBase، رفع خطای بیلد و ارتقای نسخه به 1.0.7 (نسخه 8)

This commit is contained in:
Oqba committed 2026-09-21 17:50:52 +03:30
1 parent a3a8448ee5
commit ed1d3cecbb
13 files changed
+537 -235

No files matched your search

+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 = 7 versionCode = 8
versionName = "1.0.6" 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)
@@ -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
@@ -16,19 +16,19 @@ data class PaymentRecord(
@PrimaryKey @PrimaryKey
@SerialName("id") val id: String = UUID.randomUUID().toString(), @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("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("timestamp") val timestamp: Long = System.currentTimeMillis(),
@SerialName("description") val description: String? = "", @SerialName("description") val description: String? = "",
@SerialName("created_at") val createdAt: Long? = null, @SerialName("createdAt") val createdAt: Long? = null,
@SerialName("category") val category: String = "salary", @SerialName("category") val category: String = "salary",
@@ -17,23 +17,23 @@ data class Person(
@SerialName("name") val name: String, @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("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 @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))
}
} }
} }
@@ -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 }
) )
} }
} }
@@ -132,33 +132,32 @@ 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 combine(
// خطا را می‌بلعید) روی داده‌ی قدیمی/ناقص merge می‌کرد. networkRepository.getPersonsFlow(),
// حالا فقط بعد از یک refresh موفق merge می‌کنیم و خودِ merge هم غیرمخرب است. networkRepository.getPaymentsFlow(),
combine( networkRepository.hasServerDataFlow()
networkRepository.getPersonsFlow(), ) { persons, payments, hasServerData ->
networkRepository.getPaymentsFlow(), if (hasServerData) {
networkRepository.hasServerDataFlow() if (!snapshotTakenThisSession) {
) { persons, payments, hasServerData -> // آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی
if (hasServerData) { // ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد،
if (!snapshotTakenThisSession) { // کاربر بتواند از تنظیمات برش گرداند.
// آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC)
// ذخیره می‌شود تا اگر با وجود همه‌ی محافظت‌ها چیزی خراب شد، snapshotTakenThisSession = true
// کاربر بتواند از تنظیمات برش گرداند. }
backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC) localPersonRepository.mergeFromServer(persons, payments)
snapshotTakenThisSession = true 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 -> { 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 -> {}
@@ -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" }
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"versionCode": 7, "versionCode": 8,
"versionName": "1.0.6", "versionName": "1.0.7",
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.6/MonthlyPaymentApp-v1.0.6.apk", "url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.7/MonthlyPaymentApp-v1.0.7.apk",
"releaseNotes": "بهینه‌سازی کدهای UI، رفع هشدارهای کامپایلر در صفحات شخص و جزئیات شخص، و بهبود پایداری برنامه", "releaseNotes": "مهاجرت به PocketBase، رفع خطاهای بیلد و بهبود پایداری همگام‌سازی",
"isForceUpdate": false "isForceUpdate": false
} }