مهاجرت به PocketBase، رفع خطای بیلد و ارتقای نسخه به 1.0.7 (نسخه 8)
This commit is contained in:
1 parent
a3a8448ee5
commit
ed1d3cecbb
13 files changed
+537
-235
No files matched your search
+5
-10
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-4
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+65
-125
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user