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