Add auto-release and update system
This commit is contained in:
1 parent
502cdacb44
commit
0557679e9c
125 files changed
+6799
-1680
No files matched your search
+52
-17
@@ -1,17 +1,31 @@
|
||||
// app/build.gradle.kts
|
||||
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
// ⭐️ پلاگین Serialization فقط یک بار با alias تعریف میشود (رفع خطای تکراری)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.google.devtools.ksp)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.oqba26.monthlypaymentapp"
|
||||
compileSdk = 34
|
||||
|
||||
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||
val keystoreProperties = Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
fun getProp(name: String): String? {
|
||||
return (keystoreProperties[name] as? String) ?: System.getenv(name) ?: project.findProperty(name) as? String
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.oqba26.monthlypaymentapp"
|
||||
minSdk = 25
|
||||
@@ -26,34 +40,45 @@ android {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val storeFileProp = getProp("RELEASE_STORE_FILE")
|
||||
if (storeFileProp != null) {
|
||||
storeFile = file(storeFileProp)
|
||||
storePassword = getProp("RELEASE_STORE_PASSWORD")
|
||||
keyAlias = getProp("RELEASE_KEY_ALIAS")
|
||||
keyPassword = getProp("RELEASE_KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// ⭐️ استفاده از آدرس نهایی سرور برای Consistency
|
||||
buildConfigField("String", "API_BASE_URL", "\"http://167.235.136.65:8080/\"")
|
||||
buildConfigField("String", "SUPABASE_URL", "\"https://ftufsygeartwukonclkz.supabase.co\"")
|
||||
buildConfigField("String", "SUPABASE_KEY", "\"sb_publishable_H4mAU1Ds-vZ22HwMSfCaBQ_QjBfmRd5\"")
|
||||
}
|
||||
release {
|
||||
val storeFileProp = getProp("RELEASE_STORE_FILE")
|
||||
if (storeFileProp != null) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
// ⭐️ استفاده از آدرس نهایی سرور برای Consistency
|
||||
buildConfigField("String", "API_BASE_URL", "\"http://167.235.136.65:8080/\"")
|
||||
buildConfigField("String", "SUPABASE_URL", "\"https://ftufsygeartwukonclkz.supabase.co\"")
|
||||
buildConfigField("String", "SUPABASE_KEY", "\"sb_publishable_H4mAU1Ds-vZ22HwMSfCaBQ_QjBfmRd5\"")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
// Align with the JDK version used by Android Studio
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlinOptions { jvmTarget = "21" }
|
||||
// Ensure JDK 21
|
||||
kotlin { jvmToolchain(21) }
|
||||
|
||||
packaging {
|
||||
@@ -72,23 +97,28 @@ dependencies {
|
||||
implementation(libs.androidx.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation("androidx.compose.material:material") // برای PullRefresh
|
||||
implementation("androidx.compose.material:material")
|
||||
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)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
|
||||
// Core & Coroutines
|
||||
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
// Networking (Retrofit, OkHttp)
|
||||
// Networking
|
||||
implementation(libs.retrofit.core)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging)
|
||||
|
||||
// ⭐️ Kotlinx Serialization - حذف وابستگیهای Gson و تکراری
|
||||
implementation(libs.kotlin.serialization.json) // kotlinx.serialization.json
|
||||
// ⭐️ مبدل Retrofit برای Kotlinx Serialization (فقط این یکی لازم است)
|
||||
implementation(libs.kotlin.serialization.json)
|
||||
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
|
||||
|
||||
// Database (Room)
|
||||
@@ -103,6 +133,11 @@ dependencies {
|
||||
implementation(project(":PersianDate"))
|
||||
implementation(libs.google.material)
|
||||
|
||||
// Hilt
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
|
||||
// Desugaring
|
||||
coreLibraryDesugaring(libs.android.desugar.jdk.libs)
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package com.oqba26.monthlypaymentapp
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("com.oqba26.monthlypaymentapp", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
|
||||
<application
|
||||
android:name=".core.PaymentApplication"
|
||||
@@ -25,6 +29,16 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,32 +1,47 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package com.oqba26.monthlypaymentapp
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.automirrored.filled.Message
|
||||
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.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Paid
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
@@ -41,17 +56,25 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
@@ -61,7 +84,8 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.oqba26.monthlypaymentapp.core.PaymentApplication
|
||||
import com.oqba26.monthlypaymentapp.ui.components.PermissionsDialog
|
||||
import com.oqba26.monthlypaymentapp.ui.components.UpdateDialog
|
||||
import com.oqba26.monthlypaymentapp.ui.screens.ArchiveScreen
|
||||
import com.oqba26.monthlypaymentapp.ui.screens.AuthScreen
|
||||
import com.oqba26.monthlypaymentapp.ui.screens.PaidListScreen
|
||||
@@ -69,17 +93,25 @@ 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.UpdateInfo
|
||||
import com.oqba26.monthlypaymentapp.utils.UpdateManager
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.ContactViewModel
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.AuthViewModel
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.AuthState
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonListType
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val application = application as PaymentApplication
|
||||
val personViewModel: PersonViewModel =
|
||||
viewModel(factory = application.personViewModelFactory)
|
||||
val personViewModel: PersonViewModel = hiltViewModel()
|
||||
|
||||
MonthlyPaymentManagement2Theme {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
@@ -97,27 +129,135 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
@Composable
|
||||
fun MainAppHost(viewModel: PersonViewModel) {
|
||||
val authState by viewModel.authState.collectAsState()
|
||||
val authViewModel: AuthViewModel = hiltViewModel()
|
||||
val contactViewModel: ContactViewModel = hiltViewModel()
|
||||
val authState by authViewModel.authState.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.toastMessage.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
// Update and Permission States
|
||||
val updateManager = remember { UpdateManager(context) }
|
||||
var updateInfo by remember { mutableStateOf<UpdateInfo?>(null) }
|
||||
var downloadProgress by remember { mutableFloatStateOf(0f) }
|
||||
var isDownloading by remember { mutableStateOf(false) }
|
||||
var showPermissionsDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val requiredPermissions = mutableListOf(
|
||||
Manifest.permission.READ_CONTACTS
|
||||
).apply {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
}
|
||||
}.toTypedArray()
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { permissions ->
|
||||
val allGranted = permissions.entries.all { it.value }
|
||||
if (allGranted) {
|
||||
showPermissionsDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
Crossfade(targetState = authState, label = "AuthScreenSwitch") { state ->
|
||||
when (state) {
|
||||
is AuthState.Loading -> LoadingScreen()
|
||||
is AuthState.Unauthenticated -> AuthScreen()
|
||||
is AuthState.Authenticated -> AuthenticatedContent(viewModel)
|
||||
LaunchedEffect(Unit) {
|
||||
delay(2000.milliseconds)
|
||||
updateInfo = updateManager.checkForUpdate()
|
||||
|
||||
val allGranted = requiredPermissions.all {
|
||||
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (!allGranted) {
|
||||
showPermissionsDialog = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
viewModel.toastMessage.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
viewModel.infoMessage.collect { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI elements for update and permissions
|
||||
if (showPermissionsDialog) {
|
||||
PermissionsDialog(
|
||||
onRequestPermissions = { permissionLauncher.launch(requiredPermissions) },
|
||||
onExitClick = { (context as? ComponentActivity)?.finish() }
|
||||
)
|
||||
}
|
||||
|
||||
updateInfo?.let { info ->
|
||||
UpdateDialog(
|
||||
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) isDownloading = false
|
||||
}
|
||||
}
|
||||
updateInfo = null
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isDownloading) {
|
||||
Dialog(onDismissRequest = { }) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "در حال دریافت بهروزرسانی",
|
||||
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,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "${(downloadProgress * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// استفاده از when ساده به جای Crossfade برای جلوگیری از پرشهای لحظهای
|
||||
when (authState) {
|
||||
is AuthState.Loading -> LoadingScreen()
|
||||
is AuthState.Unauthenticated -> AuthScreen()
|
||||
is AuthState.Authenticated -> AuthenticatedContent(viewModel, contactViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun AuthenticatedContent(viewModel: PersonViewModel) {
|
||||
fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactViewModel) {
|
||||
val navController = rememberNavController()
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
@@ -141,31 +281,138 @@ fun AuthenticatedContent(viewModel: PersonViewModel) {
|
||||
ExitDialog(
|
||||
onConfirmExit = { (context as? ComponentActivity)?.finish() },
|
||||
onDismiss = {
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
showExitDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
||||
var showConfirmMoveDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showConfirmMoveDialog) {
|
||||
Dialog(
|
||||
onDismissRequest = { showConfirmMoveDialog = 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 = {
|
||||
val listType = when (navController.currentBackStackEntry?.destination?.route) {
|
||||
Screen.Paid.route -> PersonListType.PAID
|
||||
else -> PersonListType.UNPAID
|
||||
}
|
||||
viewModel.onEvent(PersonScreenEvent.CommitReorder(listType))
|
||||
viewModel.onEvent(PersonScreenEvent.ClearSelection)
|
||||
showConfirmMoveDialog = false
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("بله، ذخیره کن")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { showConfirmMoveDialog = false },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text("لغو")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("مدیریت حقوق ماهانه") },
|
||||
title = {
|
||||
val titleText = when {
|
||||
isSelectionMode -> "انتخاب شدهها"
|
||||
currentRoute == Screen.Settings.route -> "تنظیمات"
|
||||
else -> "مدیریت حقوق ماهانه"
|
||||
}
|
||||
Text(titleText)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
titleContentColor = Color.White,
|
||||
actionIconContentColor = Color.White
|
||||
containerColor = if (isSelectionMode) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.primary,
|
||||
titleContentColor = if (isSelectionMode) MaterialTheme.colorScheme.onSecondaryContainer else Color.White,
|
||||
actionIconContentColor = if (isSelectionMode) MaterialTheme.colorScheme.onSecondaryContainer else Color.White
|
||||
),
|
||||
navigationIcon = {
|
||||
if (isSelectionMode) {
|
||||
IconButton(onClick = { viewModel.onEvent(PersonScreenEvent.ClearSelection) }) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Clear selection")
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (currentRoute == Screen.Unpaid.route) {
|
||||
Button(
|
||||
if (isSelectionMode) {
|
||||
val listType = when (currentRoute) {
|
||||
Screen.Paid.route -> PersonListType.PAID
|
||||
else -> PersonListType.UNPAID
|
||||
}
|
||||
IconButton(onClick = { viewModel.onEvent(PersonScreenEvent.MoveSelected(-1, listType)) }) {
|
||||
Icon(Icons.Default.ArrowUpward, contentDescription = "Move Up")
|
||||
}
|
||||
IconButton(onClick = { viewModel.onEvent(PersonScreenEvent.MoveSelected(1, listType)) }) {
|
||||
Icon(Icons.Default.ArrowDownward, contentDescription = "Move Down")
|
||||
}
|
||||
IconButton(onClick = { showConfirmMoveDialog = true }) {
|
||||
Icon(Icons.Default.Check, contentDescription = "Confirm Move", tint = Color.Green)
|
||||
}
|
||||
} else if (currentRoute == Screen.Unpaid.route) {
|
||||
FilledIconButton(
|
||||
onClick = { viewModel.onAddPersonClicked() },
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Text("افزودن شخص")
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +422,8 @@ fun AuthenticatedContent(viewModel: PersonViewModel) {
|
||||
if (showBottomBar) {
|
||||
AppBottomNavigationBar(navController = navController)
|
||||
}
|
||||
}
|
||||
},
|
||||
/* Floating Action Button removed and moved to TopAppBar actions */
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
@@ -185,17 +433,23 @@ fun AuthenticatedContent(viewModel: PersonViewModel) {
|
||||
composable(Screen.Unpaid.route) {
|
||||
PersonScreen(
|
||||
viewModel = viewModel,
|
||||
contactViewModel = contactViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
composable(Screen.Paid.route) {
|
||||
PaidListScreen(viewModel, navController)
|
||||
PaidListScreen(
|
||||
viewModel = viewModel,
|
||||
contactViewModel = contactViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
composable(Screen.Archive.route) {
|
||||
ArchiveScreen(viewModel)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(onLogout = { viewModel.logout() })
|
||||
val authViewModel: AuthViewModel = hiltViewModel()
|
||||
SettingsScreen(onLogout = { authViewModel.logout() })
|
||||
}
|
||||
composable(
|
||||
route = "person_detail/{personId}",
|
||||
@@ -206,6 +460,7 @@ fun AuthenticatedContent(viewModel: PersonViewModel) {
|
||||
PersonDetailScreen(
|
||||
personId = personId,
|
||||
viewModel = viewModel,
|
||||
contactViewModel = contactViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
@@ -268,32 +523,52 @@ sealed class Screen(
|
||||
|
||||
@Composable
|
||||
fun ExitDialog(onConfirmExit: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("خروج از برنامه") },
|
||||
text = { Text("آیا برای خروج از برنامه مطمئن هستید؟") },
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides LayoutDirection.Rtl
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = onConfirmExit,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text("بله")
|
||||
}
|
||||
Button(onClick = onDismiss) {
|
||||
Text("خیر")
|
||||
Text(
|
||||
text = stringResource(R.string.exit_app),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.exit_app_confirm),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = onConfirmExit,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.yes))
|
||||
}
|
||||
Button(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.no))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -304,4 +579,4 @@ fun LoadingScreen() {
|
||||
Text("در حال بررسی وضعیت...", modifier = Modifier.padding(top = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,7 @@
|
||||
package com.oqba26.monthlypaymentapp.core
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.oqba26.monthlypaymentapp.data.database.AppDatabase
|
||||
import com.oqba26.monthlypaymentapp.data.remote.ApiClient
|
||||
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.viewmodel.PersonViewModel
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.SettingsViewModel
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
class PaymentApplication : Application() {
|
||||
|
||||
lateinit var localPersonRepository: LocalPersonRepository
|
||||
lateinit var networkRepository: NetworkRepository
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
private lateinit var apiClient: ApiClient
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val db = AppDatabase.getInstance(this)
|
||||
|
||||
settingsRepository = SettingsRepository(this)
|
||||
apiClient = ApiClient(settingsRepository)
|
||||
|
||||
localPersonRepository = LocalPersonRepository(
|
||||
personDao = db.personDao(),
|
||||
paymentDao = db.paymentDao(),
|
||||
database = db
|
||||
)
|
||||
|
||||
networkRepository = NetworkRepository(api = apiClient.api)
|
||||
}
|
||||
|
||||
val personViewModelFactory: ViewModelProvider.Factory
|
||||
get() = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(PersonViewModel::class.java)) {
|
||||
// ⭐️ FIX: Removed the unnecessary 'localPersonRepository' argument.
|
||||
return PersonViewModel(networkRepository, settingsRepository) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
|
||||
val settingsViewModelFactory: ViewModelProvider.Factory
|
||||
get() = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) {
|
||||
return SettingsViewModel(settingsRepository, localPersonRepository) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
}
|
||||
@HiltAndroidApp
|
||||
class PaymentApplication : Application()
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.oqba26.monthlypaymentapp.core.di
|
||||
|
||||
import android.content.Context
|
||||
import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
|
||||
import com.oqba26.monthlypaymentapp.data.dao.PersonDao
|
||||
import com.oqba26.monthlypaymentapp.data.database.AppDatabase
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
|
||||
return AppDatabase.getInstance(context)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun providePersonDao(database: AppDatabase): PersonDao {
|
||||
return database.personDao()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun providePaymentDao(database: AppDatabase): PaymentDao {
|
||||
return database.paymentDao()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.oqba26.monthlypaymentapp.core.di
|
||||
|
||||
import android.content.Context
|
||||
import com.oqba26.monthlypaymentapp.data.dao.PaymentDao
|
||||
import com.oqba26.monthlypaymentapp.data.dao.PersonDao
|
||||
import com.oqba26.monthlypaymentapp.data.database.AppDatabase
|
||||
import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
|
||||
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
|
||||
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object RepositoryModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNetworkRepository(): NetworkRepository {
|
||||
return NetworkRepository()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLocalPersonRepository(
|
||||
personDao: PersonDao,
|
||||
paymentDao: PaymentDao,
|
||||
database: AppDatabase
|
||||
): LocalPersonRepository {
|
||||
return LocalPersonRepository(personDao, paymentDao, database)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSettingsRepository(@ApplicationContext context: Context): SettingsRepository {
|
||||
return SettingsRepository(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.oqba26.monthlypaymentapp.core.manager
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
|
||||
import com.oqba26.monthlypaymentapp.data.model.Person
|
||||
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
|
||||
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
|
||||
import com.oqba26.monthlypaymentapp.utils.toPersianDigits
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ExportManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
fun exportYearlyReport(year: Int, persons: List<Person>, payments: List<PaymentRecord>): Boolean {
|
||||
val csvContent = StringBuilder()
|
||||
csvContent.append("نام,")
|
||||
(1..12).forEach { csvContent.append("${getPersianMonthName(it)},") }
|
||||
csvContent.append("مجموع\n")
|
||||
|
||||
persons.filter { !it.isArchived }.forEach { person ->
|
||||
csvContent.append("${person.name},")
|
||||
var total = 0.0
|
||||
(1..12).forEach { month ->
|
||||
val payment = payments.find { it.personId == person.id && it.shamsiMonth == month }
|
||||
if (payment != null) {
|
||||
csvContent.append("${payment.amount},")
|
||||
total += payment.amount
|
||||
} else {
|
||||
csvContent.append("-,")
|
||||
}
|
||||
}
|
||||
csvContent.append("$total\n")
|
||||
}
|
||||
|
||||
return try {
|
||||
val fileName = "Report_$year.csv"
|
||||
val file = File(context.cacheDir, fileName)
|
||||
FileOutputStream(file).use { it.write(csvContent.toString().toByteArray()) }
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/csv"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
val chooser = Intent.createChooser(intent, "اشتراکگذاری گزارش سال $year")
|
||||
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(chooser)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun shareReceipt(payment: PaymentRecord, personName: String) {
|
||||
val message = """
|
||||
📜 رسید پرداخت ماهانه
|
||||
👤 نام: $personName
|
||||
📅 بابت: ${getPersianMonthName(payment.shamsiMonth)} ${payment.shamsiYear.toString().toPersianDigits()}
|
||||
💰 مبلغ: ${formatNumberAsPersian(payment.amount)} تومان
|
||||
📝 توضیحات: ${payment.description ?: "---"}
|
||||
✅ با موفقیت ثبت شد.
|
||||
""".trimIndent()
|
||||
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, message)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
val chooser = Intent.createChooser(intent, "اشتراکگذاری رسید")
|
||||
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(chooser)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.oqba26.monthlypaymentapp.core.manager
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SmsManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
fun sendSmsReminder(person: PersonUiModel, selectedCard: String? = null) {
|
||||
val intent = getSmsIntent(person, selectedCard) ?: return
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun getSmsIntent(person: PersonUiModel, selectedCard: String? = null): Intent? {
|
||||
if (person.phoneNumber.isNullOrBlank()) return null
|
||||
|
||||
val monthsText = if (person.unpaidMonthsNames.size == 1) {
|
||||
"بابت ماه ${person.unpaidMonthsNames.first()}"
|
||||
} else {
|
||||
"بابت ماههای ${person.unpaidMonthsNames.joinToString("، ")}"
|
||||
}
|
||||
|
||||
var message = """
|
||||
سلام ${person.name} عزیز،
|
||||
وقت بخیر. پرداخت حقوق شما $monthsText هنوز ثبت نشده است.
|
||||
مبلغ بدهی: ${formatNumberAsPersian(person.totalDebtAmount)} تومان
|
||||
لطفاً در اسرع وقت بررسی نمایید. باتشکر.
|
||||
""".trimIndent()
|
||||
|
||||
if (!selectedCard.isNullOrBlank()) {
|
||||
message += "\nشماره کارت جهت واریز:\n$selectedCard"
|
||||
}
|
||||
|
||||
return Intent(Intent.ACTION_VIEW).apply {
|
||||
data = "sms:${person.phoneNumber}".toUri()
|
||||
putExtra("sms_body", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ interface PaymentDao {
|
||||
@Query("SELECT * FROM payments ORDER BY timestamp DESC")
|
||||
fun getAllPaymentsFlow(): Flow<List<PaymentRecord>>
|
||||
|
||||
@Query("SELECT * FROM payments ORDER BY timestamp DESC")
|
||||
suspend fun getAllPayments(): List<PaymentRecord>
|
||||
|
||||
@Query("SELECT * FROM payments WHERE personId = :personId ORDER BY timestamp DESC")
|
||||
fun getPaymentsForPersonFlow(personId: String): Flow<List<PaymentRecord>>
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ interface PersonDao {
|
||||
@Query("SELECT * FROM persons ORDER BY displayOrder ASC")
|
||||
fun getAllPersonsFlow(): Flow<List<Person>>
|
||||
|
||||
@Query("SELECT * FROM persons ORDER BY displayOrder ASC")
|
||||
suspend fun getAllPersons(): List<Person>
|
||||
|
||||
@Query("SELECT * FROM persons WHERE id = :id LIMIT 1")
|
||||
fun getPersonByIdFlow(id: String): Flow<Person?>
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.oqba26.monthlypaymentapp.data.model
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
// مدل برای ارسال به سرور هنگام ورود/ثبت نام
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Serializable
|
||||
data class LoginRequest(
|
||||
val username: String, // یا email
|
||||
@@ -10,6 +14,7 @@ data class LoginRequest(
|
||||
)
|
||||
|
||||
// مدلی که سرور پس از ورود موفق برمیگرداند
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@Serializable
|
||||
data class LoginResponse(
|
||||
val token: String, // توکن دسترسی (JWT)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
|
||||
|
||||
package com.oqba26.monthlypaymentapp.data.model
|
||||
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@OptIn(InternalSerializationApi::class) // <<< CRITICAL FIX: Added the required Opt-In annotation
|
||||
@Serializable
|
||||
data class AuthRequest(
|
||||
val username: String,
|
||||
val email: String,
|
||||
val password: String
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
|
||||
package com.oqba26.monthlypaymentapp.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.oqba26.monthlypaymentapp.data.model
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
@kotlinx.serialization.Serializable
|
||||
data class BackupData(
|
||||
val persons: List<Person>,
|
||||
|
||||
@@ -4,16 +4,27 @@ package com.oqba26.monthlypaymentapp.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
@Entity(tableName = "payments")
|
||||
data class PaymentRecord(
|
||||
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
||||
val personId: String,
|
||||
val amount: Double,
|
||||
val shamsiYear: Int,
|
||||
val shamsiMonth: Int,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val description: String = ""
|
||||
)
|
||||
@PrimaryKey
|
||||
@SerialName("id") val id: String = UUID.randomUUID().toString(),
|
||||
|
||||
@SerialName("personId") val personId: String,
|
||||
|
||||
@SerialName("amount") val amount: Double,
|
||||
|
||||
@SerialName("shamsiYear") val shamsiYear: Int,
|
||||
|
||||
@SerialName("shamsiMonth") val shamsiMonth: Int,
|
||||
|
||||
@SerialName("timestamp") val timestamp: Long = System.currentTimeMillis(),
|
||||
|
||||
@SerialName("description") val description: String? = "", // ⭐️ تغییر به String? برای پذیرش مقادیر null
|
||||
|
||||
@SerialName("createdAt") val createdAt: Long? = null
|
||||
)
|
||||
@@ -1,18 +1,25 @@
|
||||
@file:OptIn(kotlinx.serialization.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 kotlin.OptIn // CORRECTED IMPORT
|
||||
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
@Serializable
|
||||
@Entity(tableName = "persons")
|
||||
data class Person(
|
||||
@PrimaryKey val id: String = "",
|
||||
val name: String,
|
||||
val isArchived: Boolean = false,
|
||||
val displayOrder: Long = 0L,
|
||||
val createdAt: Long = 0L
|
||||
@PrimaryKey
|
||||
@SerialName("id") val id: String = "",
|
||||
|
||||
@SerialName("name") val name: String,
|
||||
|
||||
@SerialName("isArchived") val isArchived: Boolean = false,
|
||||
|
||||
@SerialName("displayOrder") val displayOrder: Long? = 0L,
|
||||
|
||||
@SerialName("createdAt") val createdAt: Long? = 0L,
|
||||
|
||||
@SerialName("phoneNumber") val phoneNumber: String? = ""
|
||||
)
|
||||
@@ -1,44 +1,38 @@
|
||||
@file:OptIn(io.github.jan.supabase.annotations.SupabaseInternal::class)
|
||||
|
||||
package com.oqba26.monthlypaymentapp.data.remote
|
||||
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import com.oqba26.monthlypaymentapp.BuildConfig
|
||||
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
|
||||
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
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
|
||||
// ⭐️ تغییر به کلاس برای تزریق SettingsRepository
|
||||
class ApiClient(settingsRepository: SettingsRepository) {
|
||||
object ApiClient {
|
||||
|
||||
// تنظیمات Kotlinx.Serialization
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
val client = createSupabaseClient(
|
||||
supabaseUrl = BuildConfig.SUPABASE_URL,
|
||||
supabaseKey = BuildConfig.SUPABASE_KEY
|
||||
) {
|
||||
defaultSerializer = KotlinXSerializer(Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
encodeDefaults = true
|
||||
})
|
||||
|
||||
httpConfig {
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 30000
|
||||
connectTimeoutMillis = 30000
|
||||
socketTimeoutMillis = 30000
|
||||
}
|
||||
}
|
||||
|
||||
install(Postgrest)
|
||||
install(Auth)
|
||||
install(Realtime)
|
||||
}
|
||||
|
||||
// ⭐️ ایجاد اینترسپتور احراز هویت
|
||||
private val authInterceptor = AuthInterceptor(settingsRepository)
|
||||
|
||||
private val logging = HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
|
||||
// ⭐️ افزودن AuthInterceptor به OkHttpClient
|
||||
private val client: OkHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.build()
|
||||
|
||||
val api: ApiService by lazy {
|
||||
val contentType = "application/json".toMediaType()
|
||||
|
||||
Retrofit.Builder()
|
||||
.baseUrl("http://167.235.136.65:8080/") // آدرس سرور
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory(contentType))
|
||||
.build()
|
||||
.create(ApiService::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.oqba26.monthlypaymentapp.data.remote
|
||||
|
||||
import com.oqba26.monthlypaymentapp.data.model.AuthRequest
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
@Suppress("unused")
|
||||
class AuthInterceptor(
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : Interceptor {
|
||||
|
||||
+7
-5
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.oqba26.monthlypaymentapp.data.repository
|
||||
|
||||
import androidx.room.withTransaction
|
||||
@@ -35,7 +37,7 @@ class LocalPersonRepository(
|
||||
currentByName.containsKey(norm) -> currentByName[norm]!!.id
|
||||
else -> UUID.randomUUID().toString()
|
||||
}
|
||||
Person(id = resolvedId, name = name)
|
||||
p.copy(id = resolvedId, name = name)
|
||||
}
|
||||
|
||||
personDao.insertAll(safePersons)
|
||||
@@ -60,7 +62,7 @@ class LocalPersonRepository(
|
||||
currentByName.containsKey(norm) -> currentByName[norm]!!.id
|
||||
else -> UUID.randomUUID().toString()
|
||||
}
|
||||
Person(id = resolvedId, name = name)
|
||||
p.copy(id = resolvedId, name = name)
|
||||
}
|
||||
|
||||
// ۲. حذف کامل تمام دادههای محلی
|
||||
@@ -75,8 +77,8 @@ class LocalPersonRepository(
|
||||
|
||||
// Backup/Restore
|
||||
suspend fun getDataForBackup(): BackupData {
|
||||
val persons = getAllPersonsFlow().first()
|
||||
val payments = getAllPaymentsFlow().first()
|
||||
val persons = personDao.getAllPersons()
|
||||
val payments = paymentDao.getAllPayments()
|
||||
return BackupData(persons = persons, payments = payments)
|
||||
}
|
||||
|
||||
@@ -88,7 +90,7 @@ class LocalPersonRepository(
|
||||
val safePersons = backupData.persons.map { p ->
|
||||
val name = p.name.trim()
|
||||
val id = p.id.ifBlank { UUID.randomUUID().toString() }
|
||||
Person(id = id, name = name)
|
||||
p.copy(id = id, name = name)
|
||||
}
|
||||
|
||||
personDao.insertAll(safePersons)
|
||||
|
||||
+137
-55
@@ -5,38 +5,87 @@ import com.oqba26.monthlypaymentapp.data.model.AuthRequest
|
||||
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.ApiService
|
||||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import retrofit2.HttpException
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
class NetworkRepository(private val api: ApiService) {
|
||||
class NetworkRepository {
|
||||
|
||||
private val supabase = ApiClient.client
|
||||
private val _personsFlow = MutableStateFlow<List<Person>>(emptyList())
|
||||
private val _paymentsFlow = MutableStateFlow<List<PaymentRecord>>(emptyList())
|
||||
private val repositoryScope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
// --- Realtime Operations ---
|
||||
fun observeRealtimeChanges() {
|
||||
repositoryScope.launch {
|
||||
try {
|
||||
supabase.realtime.connect()
|
||||
val myChannel = supabase.channel("db-changes")
|
||||
|
||||
// گوش دادن به تغییرات جدول اشخاص
|
||||
launch {
|
||||
myChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
|
||||
table = "persons"
|
||||
}.collect {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// گوش دادن به تغییرات جدول پرداختها
|
||||
launch {
|
||||
myChannel.postgresChangeFlow<PostgresAction>(schema = "public") {
|
||||
table = "payments"
|
||||
}.collect {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
myChannel.subscribe()
|
||||
} catch (e: Exception) {
|
||||
Log.e("Supabase", "Realtime connection error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth Operations ---
|
||||
suspend fun register(request: AuthRequest): AuthResponse? {
|
||||
return try {
|
||||
api.register(request)
|
||||
} catch (e: HttpException) {
|
||||
Log.e("API", "register HttpException: ${e.code()}", e)
|
||||
null
|
||||
supabase.auth.signUpWith(Email) {
|
||||
email = request.email
|
||||
password = request.password
|
||||
}
|
||||
val session = supabase.auth.currentSessionOrNull()
|
||||
AuthResponse(token = session?.accessToken ?: "success", userId = session?.user?.id)
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "register error", e)
|
||||
Log.e("Supabase", "register error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(request: AuthRequest): AuthResponse? {
|
||||
return try {
|
||||
api.login(request)
|
||||
} catch (e: HttpException) {
|
||||
Log.e("API", "login HttpException: ${e.code()}", e)
|
||||
null
|
||||
supabase.auth.signInWith(Email) {
|
||||
email = request.email
|
||||
password = request.password
|
||||
}
|
||||
val session = supabase.auth.currentSessionOrNull()
|
||||
AuthResponse(token = session?.accessToken ?: "", userId = session?.user?.id)
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "login error", e)
|
||||
Log.e("Supabase", "login error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -44,61 +93,87 @@ class NetworkRepository(private val api: ApiService) {
|
||||
// --- Person Operations ---
|
||||
suspend fun addPerson(person: Person): Int? {
|
||||
return try {
|
||||
val trimmed = person.name.trim()
|
||||
if (trimmed.isEmpty()) return 400
|
||||
val resp = api.addPerson(person.copy(name = trimmed))
|
||||
resp.code()
|
||||
} catch (e: HttpException) {
|
||||
e.code()
|
||||
val trimmedName = person.name.trim()
|
||||
if (trimmedName.isEmpty()) return 400
|
||||
|
||||
// بررسی تکراری بودن نام در لیست موجود
|
||||
val isDuplicate = _personsFlow.value.any { it.name.trim().equals(trimmedName, ignoreCase = true) }
|
||||
if (isDuplicate) return 409
|
||||
|
||||
val personToInsert = if (person.id.isEmpty()) {
|
||||
person.copy(id = UUID.randomUUID().toString(), name = trimmedName, createdAt = System.currentTimeMillis())
|
||||
} else {
|
||||
person.copy(name = trimmedName)
|
||||
}
|
||||
|
||||
supabase.from("persons").insert(personToInsert)
|
||||
201
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "addPerson error", e)
|
||||
Log.e("Supabase", "addPerson error", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deletePersonAndPayments(personId: String): Boolean {
|
||||
return try {
|
||||
api.deletePersonAndPayments(personId).isSuccessful
|
||||
supabase.from("persons").delete {
|
||||
filter { eq("id", personId) }
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "deletePersonAndPayments error", e); false
|
||||
Log.e("Supabase", "deletePerson error", e); false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePerson(personId: String, name: String): Boolean {
|
||||
suspend fun updatePerson(personId: String, name: String, phoneNumber: String?): Int {
|
||||
return try {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return false
|
||||
// Note: displayOrder is not updated here, only the name.
|
||||
val personUpdate = Person(id = personId, name = trimmed, displayOrder = 0)
|
||||
api.updatePerson(personId, personUpdate).isSuccessful
|
||||
val trimmedName = name.trim()
|
||||
if (trimmedName.isEmpty()) return 400
|
||||
|
||||
// بررسی تکراری بودن نام (به جز خودِ این شخص)
|
||||
val isDuplicate = _personsFlow.value.any {
|
||||
it.id != personId && it.name.trim().equals(trimmedName, ignoreCase = true)
|
||||
}
|
||||
if (isDuplicate) return 409
|
||||
|
||||
supabase.from("persons").update(
|
||||
mapOf(
|
||||
"name" to trimmedName,
|
||||
"phoneNumber" to phoneNumber?.trim()
|
||||
)
|
||||
) {
|
||||
filter { eq("id", personId) }
|
||||
}
|
||||
200
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "updatePerson error", e); false
|
||||
Log.e("Supabase", "updatePerson error", e)
|
||||
500
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean {
|
||||
return try {
|
||||
api.updatePersonArchivedStatus(personId, mapOf("isArchived" to isArchived)).isSuccessful
|
||||
supabase.from("persons").update(
|
||||
mapOf("isArchived" to isArchived)
|
||||
) {
|
||||
filter { eq("id", personId) }
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "updatePersonArchivedStatus error", e); false
|
||||
Log.e("Supabase", "updateArchived error", e); false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean {
|
||||
return try {
|
||||
api.updatePersonDisplayOrder(personId, mapOf("displayOrder" to displayOrder)).isSuccessful
|
||||
supabase.from("persons").update(
|
||||
mapOf("displayOrder" to displayOrder)
|
||||
) {
|
||||
filter { eq("id", personId) }
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "updatePersonDisplayOrder error", e); false
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
suspend fun rebalanceDisplayOrders(): Boolean {
|
||||
return try {
|
||||
api.rebalanceDisplayOrders().isSuccessful
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "rebalanceDisplayOrders error", e)
|
||||
false
|
||||
Log.e("Supabase", "updateOrder error", e); false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,17 +182,21 @@ class NetworkRepository(private val api: ApiService) {
|
||||
// --- Payment Operations ---
|
||||
suspend fun addPayment(paymentRecord: PaymentRecord): Boolean {
|
||||
return try {
|
||||
api.addPayment(paymentRecord).isSuccessful
|
||||
supabase.from("payments").insert(paymentRecord)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "addPayment error", e); false
|
||||
Log.e("Supabase", "addPayment error", e); false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deletePayment(paymentId: String): Boolean {
|
||||
return try {
|
||||
api.deletePayment(paymentId).isSuccessful
|
||||
supabase.from("payments").delete {
|
||||
filter { eq("id", paymentId) }
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "deletePayment error", e); false
|
||||
Log.e("Supabase", "deletePayment error", e); false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,16 +204,19 @@ class NetworkRepository(private val api: ApiService) {
|
||||
|
||||
suspend fun refresh() {
|
||||
try {
|
||||
val persons = api.getPersons()
|
||||
// دریافت و تبدیل خودکار لیست اشخاص
|
||||
val persons = supabase.from("persons").select().decodeList<Person>()
|
||||
_personsFlow.value = persons
|
||||
|
||||
// دریافت و تبدیل خودکار لیست پرداختها
|
||||
val payments = supabase.from("payments").select {
|
||||
order("timestamp", order = Order.DESCENDING)
|
||||
}.decodeList<PaymentRecord>()
|
||||
_paymentsFlow.value = payments
|
||||
|
||||
Log.d("Supabase", "Refresh Success: ${persons.size} persons, ${payments.size} payments")
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "refresh persons error", e)
|
||||
}
|
||||
try {
|
||||
val payments = api.getPayments(null)
|
||||
_paymentsFlow.value = payments.sortedByDescending { it.timestamp }
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "refresh payments error", e)
|
||||
Log.e("Supabase", "refresh error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,37 @@ class SettingsRepository(context: Context) {
|
||||
val SELECTED_FONT_NAME = stringPreferencesKey("selected_font_name")
|
||||
val AUTH_TOKEN_KEY = stringPreferencesKey("auth_token")
|
||||
val USER_ID_KEY = stringPreferencesKey("user_id")
|
||||
val REMINDER_DAY = stringPreferencesKey("reminder_day")
|
||||
val CARD_NUMBERS = stringPreferencesKey("card_numbers")
|
||||
val IGNORED_CONTACT_SUGGESTIONS = stringPreferencesKey("ignored_contact_suggestions")
|
||||
|
||||
const val FALLBACK_AMOUNT = 200000.0
|
||||
const val DEFAULT_FONT = "Estedad"
|
||||
}
|
||||
|
||||
val reminderDayFlow: Flow<Int?> = dataStore.data.map {
|
||||
it[REMINDER_DAY]?.toIntOrNull()
|
||||
}
|
||||
|
||||
val cardNumbersFlow: Flow<List<String>> = dataStore.data.map {
|
||||
val raw = it[CARD_NUMBERS] ?: ""
|
||||
if (raw.isBlank()) emptyList() else raw.split("|")
|
||||
}
|
||||
|
||||
suspend fun saveCardNumbers(cards: List<String>) {
|
||||
dataStore.edit { it[CARD_NUMBERS] = cards.joinToString("|") }
|
||||
}
|
||||
|
||||
suspend fun saveReminderDay(day: Int?) {
|
||||
dataStore.edit { preferences ->
|
||||
if (day != null) {
|
||||
preferences[REMINDER_DAY] = day.toString()
|
||||
} else {
|
||||
preferences.remove(REMINDER_DAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val defaultPaymentAmountFlow: Flow<Double> = dataStore.data.map {
|
||||
it[DEFAULT_PAYMENT_AMOUNT] ?: FALLBACK_AMOUNT
|
||||
}
|
||||
@@ -65,4 +91,18 @@ class SettingsRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ignoredContactSuggestionsFlow: Flow<Set<String>> = dataStore.data.map {
|
||||
val raw = it[IGNORED_CONTACT_SUGGESTIONS] ?: ""
|
||||
if (raw.isBlank()) emptySet() else raw.split(",").toSet()
|
||||
}
|
||||
|
||||
suspend fun ignoreContactSuggestion(personId: String) {
|
||||
dataStore.edit { preferences ->
|
||||
val current = preferences[IGNORED_CONTACT_SUGGESTIONS] ?: ""
|
||||
val set = if (current.isBlank()) mutableSetOf() else current.split(",").toMutableSet()
|
||||
set.add(personId)
|
||||
preferences[IGNORED_CONTACT_SUGGESTIONS] = set.joinToString(",")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.oqba26.monthlypaymentapp.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun PermissionsDialog(
|
||||
onRequestPermissions: () -> Unit,
|
||||
onExitClick: () -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = { }) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp)) {
|
||||
Text(
|
||||
text = "دسترسیهای الزامی",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "کاربر گرامی، برای استفاده از برنامه باید دسترسیهای زیر را تایید کنید:\n\n" +
|
||||
"۱. مخاطبین: برای مدیریت پرداختهای افراد\n" +
|
||||
"۲. حافظه: برای ذخیره فایلهای پشتیبان\n\n" +
|
||||
"توجه: در صورت عدم تایید، برنامه بسته خواهد شد.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = onRequestPermissions,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Text("متوجه شدم؛ اعطای دسترسی", color = MaterialTheme.colorScheme.onPrimary)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextButton(
|
||||
onClick = onExitClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("خروج", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.oqba26.monthlypaymentapp.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.oqba26.monthlypaymentapp.utils.UpdateInfo
|
||||
|
||||
@Composable
|
||||
fun UpdateDialog(
|
||||
updateInfo: UpdateInfo,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = { if (!updateInfo.isForceUpdate) onDismiss() },
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = !updateInfo.isForceUpdate,
|
||||
dismissOnClickOutside = !updateInfo.isForceUpdate
|
||||
)
|
||||
) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp,
|
||||
modifier = Modifier
|
||||
.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)
|
||||
) {
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text("تایید و بروزرسانی")
|
||||
}
|
||||
|
||||
if (!updateInfo.isForceUpdate) {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text("بعداً")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.oqba26.monthlypaymentapp.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -11,21 +12,32 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
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 com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
|
||||
@@ -33,6 +45,8 @@ import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
|
||||
@Composable
|
||||
fun ArchiveScreen(viewModel: PersonViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
var personToDelete by remember { mutableStateOf<PersonUiModel?>(null) }
|
||||
var personToRestore by remember { mutableStateOf<PersonUiModel?>(null) }
|
||||
|
||||
if (uiState.archivedPersons.isEmpty()) {
|
||||
Box(
|
||||
@@ -50,19 +64,135 @@ fun ArchiveScreen(viewModel: PersonViewModel) {
|
||||
ArchivedPersonListItem(
|
||||
person = person,
|
||||
onRestore = {
|
||||
viewModel.onEvent(PersonScreenEvent.RestorePerson(person.id))
|
||||
personToRestore = person
|
||||
},
|
||||
onDelete = {
|
||||
personToDelete = person
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// دیالوگ تایید حذف قطعی
|
||||
personToDelete?.let { person ->
|
||||
Dialog(
|
||||
onDismissRequest = { personToDelete = null }
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides LayoutDirection.Rtl
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
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 = "آیا از حذف قطعی ${person.name} و تمامی سوابق پرداخت او مطمئن هستید؟ این عمل غیرقابل بازگشت است.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = { personToDelete = null }
|
||||
) {
|
||||
Text("لغو")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.onEvent(PersonScreenEvent.DeletePerson(person.id))
|
||||
personToDelete = null
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("حذف قطعی")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// دیالوگ تایید بازیابی
|
||||
personToRestore?.let { person ->
|
||||
Dialog(
|
||||
onDismissRequest = { personToRestore = null }
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides LayoutDirection.Rtl
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
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 = "آیا از بازیابی ${person.name} به لیست اصلی مطمئن هستید؟",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = { personToRestore = null }
|
||||
) {
|
||||
Text("لغو")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.onEvent(PersonScreenEvent.RestorePerson(person.id))
|
||||
personToRestore = null
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("بازیابی")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArchivedPersonListItem(
|
||||
person: PersonUiModel,
|
||||
onRestore: () -> Unit
|
||||
onRestore: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -74,18 +204,31 @@ fun ArchivedPersonListItem(
|
||||
Text(
|
||||
text = person.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onRestore,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Restore, contentDescription = "بازیابی")
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("بازیابی")
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "حذف قطعی",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = onRestore,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Restore, contentDescription = "بازیابی")
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("بازیابی")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -27,28 +27,20 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.oqba26.monthlypaymentapp.core.PaymentApplication
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.oqba26.monthlypaymentapp.data.model.AuthRequest
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.AuthState
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.AuthViewModel
|
||||
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
viewModel: PersonViewModel = viewModel(
|
||||
// استفاده از Factory که در PaymentApplication تعریف شده است
|
||||
factory = (LocalContext.current.applicationContext as PaymentApplication).personViewModelFactory
|
||||
)
|
||||
viewModel: AuthViewModel = hiltViewModel()
|
||||
) {
|
||||
// مشاهده وضعیت لودینگ از ViewModel
|
||||
val authState by viewModel.authState.collectAsState()
|
||||
val isLoading = authState is AuthState.Loading
|
||||
|
||||
// مدیریت وضعیت نمایش فرم (ورود یا ثبت نام)
|
||||
var isLoginMode by remember { mutableStateOf(true) }
|
||||
|
||||
Column(
|
||||
@@ -67,8 +59,8 @@ fun AuthScreen(
|
||||
AuthForm(
|
||||
isLoginMode = isLoginMode,
|
||||
isLoading = isLoading,
|
||||
onAuthenticate = { username, password ->
|
||||
val request = AuthRequest(username, password)
|
||||
onAuthenticate = { email, password ->
|
||||
val request = AuthRequest(email, password)
|
||||
if (isLoginMode) {
|
||||
viewModel.login(request)
|
||||
} else {
|
||||
@@ -79,7 +71,6 @@ fun AuthScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// دکمه تغییر وضعیت: رفتن به ثبت نام یا ورود
|
||||
TextButton(onClick = { isLoginMode = !isLoginMode }, enabled = !isLoading) {
|
||||
Text(
|
||||
text = if (isLoginMode) "حساب کاربری ندارید؟ ثبت نام کنید." else "قبلاً ثبت نام کردهاید؟ وارد شوید."
|
||||
@@ -94,17 +85,17 @@ private fun AuthForm(
|
||||
isLoading: Boolean,
|
||||
onAuthenticate: (String, String) -> Unit
|
||||
) {
|
||||
var username by remember { mutableStateOf("") }
|
||||
var email by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
val buttonText = if (isLoginMode) "ورود" else "ثبت نام"
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// فیلد نام کاربری
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text("نام کاربری") },
|
||||
leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text("ایمیل") },
|
||||
leadingIcon = { Icon(Icons.Default.Email, contentDescription = null) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(0.8f),
|
||||
enabled = !isLoading
|
||||
@@ -112,7 +103,6 @@ private fun AuthForm(
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// فیلد رمز عبور
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
@@ -127,11 +117,10 @@ private fun AuthForm(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// دکمه ورود/ثبت نام
|
||||
Button(
|
||||
onClick = { onAuthenticate(username, password) },
|
||||
onClick = { onAuthenticate(email, password) },
|
||||
modifier = Modifier.fillMaxWidth(0.8f).height(50.dp),
|
||||
enabled = username.isNotBlank() && password.isNotBlank() && !isLoading
|
||||
enabled = email.isNotBlank() && password.isNotBlank() && !isLoading
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
@@ -144,4 +133,4 @@ private fun AuthForm(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,12 @@ 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.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -20,14 +21,21 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
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.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.ContactViewModel
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonListType
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
|
||||
@@ -38,47 +46,37 @@ import org.burnoutcrew.reorderable.rememberReorderableLazyListState
|
||||
import org.burnoutcrew.reorderable.reorderable
|
||||
|
||||
@Composable
|
||||
fun PaidListScreen(viewModel: PersonViewModel, navController: NavController) {
|
||||
fun PaidListScreen(
|
||||
viewModel: PersonViewModel,
|
||||
contactViewModel: ContactViewModel = hiltViewModel(),
|
||||
navController: NavController
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
|
||||
// شخصی که قرار است در این صفحه با تأیید کاربر به آرشیو منتقل شود
|
||||
val personToArchive = remember { mutableStateOf<PersonUiModel?>(null) }
|
||||
|
||||
val state = rememberReorderableLazyListState(
|
||||
onMove = { from, to ->
|
||||
if (searchQuery.isBlank()) {
|
||||
viewModel.onEvent(
|
||||
PersonScreenEvent.MovePersonNew(
|
||||
fromIndex = from.index,
|
||||
toIndex = to.index,
|
||||
listType = PersonListType.PAID
|
||||
)
|
||||
)
|
||||
viewModel.onEvent(PersonScreenEvent.MovePersonNew(from.index, to.index, PersonListType.PAID))
|
||||
}
|
||||
},
|
||||
onDragEnd = { _, _ ->
|
||||
if (searchQuery.isBlank()) {
|
||||
viewModel.onEvent(
|
||||
PersonScreenEvent.CommitReorder(
|
||||
listType = PersonListType.PAID
|
||||
)
|
||||
)
|
||||
viewModel.onEvent(PersonScreenEvent.CommitReorder(PersonListType.PAID))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Scaffold { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding)) {
|
||||
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { viewModel.onSearchQueryChange(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
label = { Text("جستجوی نام...") },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
@@ -86,84 +84,47 @@ fun PaidListScreen(viewModel: PersonViewModel, navController: NavController) {
|
||||
state = state.listState,
|
||||
modifier = Modifier.reorderable(state)
|
||||
) {
|
||||
itemsIndexed(
|
||||
uiState.paidPersons,
|
||||
key = { _, person -> person.id }
|
||||
) { index, person ->
|
||||
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
|
||||
) {
|
||||
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}")
|
||||
},
|
||||
// در لیست پرداخت شدهها، دکمه پرداخت سریع کاری نمیکند
|
||||
onPersonClick = { navController.navigate("person_detail/${person.id}") },
|
||||
onQuickPayClick = { },
|
||||
onArchiveClick = { selectedPerson ->
|
||||
personToArchive.value = selectedPerson
|
||||
}
|
||||
onArchiveClick = { personToArchive.value = it },
|
||||
onSmsClick = { contactViewModel.sendSmsReminder(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// دیالوگ تأیید انتقال به آرشیو برای لیست پرداختشدهها
|
||||
personToArchive.value?.let { person ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { personToArchive.value = null },
|
||||
title = { Text("انتقال به آرشیو") },
|
||||
text = { Text("آیا از انتقال ${person.name} به آرشیو مطمئن هستید؟") },
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// در RTL: این دکمه کاملاً به سمت راست دیالوگ میچسبد
|
||||
Button(onClick = { personToArchive.value = null }) {
|
||||
Text("لغو")
|
||||
}
|
||||
|
||||
// در RTL: این دکمه کاملاً به سمت چپ دیالوگ میچسبد
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.onEvent(
|
||||
PersonScreenEvent.ArchivePerson(person.id)
|
||||
)
|
||||
personToArchive.value = null
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text("آرشیو")
|
||||
Dialog(onDismissRequest = { personToArchive.value = null }) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(16.dp), 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 = "آیا از انتقال ${person.name} به آرشیو مطمئن هستید؟", style = MaterialTheme.typography.bodyMedium)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(onClick = { personToArchive.value = null }) { Text("لغو") }
|
||||
Button(onClick = {
|
||||
viewModel.onEvent(PersonScreenEvent.ArchivePerson(person.id))
|
||||
personToArchive.value = null
|
||||
}, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) {
|
||||
Text("آرشیو")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
@file:Suppress("AssignedValueIsNeverRead")
|
||||
|
||||
package com.oqba26.monthlypaymentapp.ui.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -11,45 +14,66 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.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.material3.AlertDialog
|
||||
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
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.navigation.NavController
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.ContactViewModel
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
|
||||
import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation
|
||||
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
|
||||
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDate
|
||||
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianTime
|
||||
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDateTime
|
||||
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.MonthStatus
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.MonthUiModel
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
|
||||
@@ -59,7 +83,8 @@ import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
|
||||
fun PersonDetailScreen(
|
||||
personId: String,
|
||||
viewModel: PersonViewModel,
|
||||
navController: NavController
|
||||
contactViewModel: ContactViewModel = hiltViewModel(),
|
||||
navController: NavController,
|
||||
) {
|
||||
LaunchedEffect(personId) {
|
||||
viewModel.onEvent(PersonScreenEvent.SelectPerson(personId))
|
||||
@@ -67,31 +92,22 @@ fun PersonDetailScreen(
|
||||
|
||||
val detailState by viewModel.personDetailState.collectAsState()
|
||||
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
|
||||
val context = LocalContext.current
|
||||
|
||||
var expandedMonth by remember { mutableStateOf<Int?>(null) }
|
||||
var paymentDialogState by remember { mutableStateOf<PaymentDialogState?>(null) }
|
||||
var infoDialogMessage by remember { mutableStateOf<String?>(null) }
|
||||
var paymentToDelete by remember { mutableStateOf<PaymentRecord?>(null) }
|
||||
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(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.infoMessage.collect { message ->
|
||||
infoDialogMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
PersonDetailHeader(
|
||||
name = detailState.person?.name ?: "...",
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
) { editingPerson = true }
|
||||
|
||||
if (detailState.person == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
@@ -102,34 +118,36 @@ fun PersonDetailScreen(
|
||||
}
|
||||
)
|
||||
|
||||
val availableMonths = detailState.monthStates
|
||||
.filter { it.status == MonthStatus.AVAILABLE }
|
||||
.map { it.month }
|
||||
|
||||
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))
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = 8.dp)
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(bottom = 8.dp)) {
|
||||
items(detailState.monthStates) { monthModel ->
|
||||
MonthListItem(
|
||||
month = monthModel,
|
||||
isExpanded = expandedMonth == monthModel.month,
|
||||
onHeaderClick = {
|
||||
expandedMonth =
|
||||
if (expandedMonth == monthModel.month) null else monthModel.month
|
||||
expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month
|
||||
},
|
||||
onEditClick = {
|
||||
when (monthModel.status) {
|
||||
MonthStatus.PAID -> {
|
||||
paymentDialogState =
|
||||
PaymentDialogState.Edit(monthModel.payment!!)
|
||||
}
|
||||
|
||||
MonthStatus.AVAILABLE -> {
|
||||
paymentDialogState =
|
||||
PaymentDialogState.Add(monthModel.month)
|
||||
}
|
||||
|
||||
else -> { /* Do nothing for other states */ }
|
||||
if (monthModel.status == MonthStatus.PAID) {
|
||||
editingPayment = monthModel.payment
|
||||
} else if (monthModel.status == MonthStatus.AVAILABLE) {
|
||||
addingPaymentMonth = monthModel.month
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -138,95 +156,388 @@ fun PersonDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
infoDialogMessage?.let {
|
||||
InfoDialog(message = it, onDismiss = { infoDialogMessage = null })
|
||||
}
|
||||
|
||||
paymentToDelete?.let { payment ->
|
||||
DeleteConfirmationDialog(
|
||||
payment = payment,
|
||||
onConfirm = {
|
||||
viewModel.onEvent(PersonScreenEvent.DeletePayment(payment))
|
||||
paymentToDelete = null
|
||||
editingPayment?.let { payment ->
|
||||
EditPaymentDialog(
|
||||
paymentRecord = payment,
|
||||
onConfirm = { amount, desc ->
|
||||
viewModel.onEvent(PersonScreenEvent.UpdatePayment(payment, amount, desc))
|
||||
editingPayment = null
|
||||
},
|
||||
onDismiss = { paymentToDelete = null }
|
||||
onDelete = {
|
||||
viewModel.onEvent(PersonScreenEvent.DeletePayment(payment))
|
||||
editingPayment = null
|
||||
},
|
||||
onShare = { p ->
|
||||
viewModel.shareReceipt(p, detailState.person?.name ?: "")
|
||||
},
|
||||
onDismiss = {
|
||||
editingPayment = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
paymentDialogState?.let { state ->
|
||||
when (state) {
|
||||
is PaymentDialogState.Add -> {
|
||||
AddPaymentDialog(
|
||||
personName = detailState.person!!.name,
|
||||
defaultAmount = defaultPaymentAmount,
|
||||
onConfirm = { amount, description ->
|
||||
viewModel.onEvent(
|
||||
PersonScreenEvent.AddPaymentForMonth(
|
||||
personId = personId,
|
||||
month = state.month,
|
||||
year = detailState.selectedYear,
|
||||
amount = amount,
|
||||
description = description
|
||||
)
|
||||
)
|
||||
paymentDialogState = null
|
||||
},
|
||||
onDismiss = { paymentDialogState = null }
|
||||
)
|
||||
}
|
||||
// دیالوگ افزودن (اصلاح شده)
|
||||
addingPaymentMonth?.let { month ->
|
||||
val monthName = getPersianMonthName(month)
|
||||
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
|
||||
|
||||
val isCurrentMonth = (month == getCurrentShamsiMonth()) && (detailState.selectedYear == getCurrentShamsiYear())
|
||||
val initialDesc = if (isCurrentMonth) {
|
||||
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
|
||||
} else {
|
||||
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
|
||||
}
|
||||
|
||||
is PaymentDialogState.Edit -> {
|
||||
val payment = state.payment
|
||||
AddPaymentDialog(
|
||||
personName = detailState.person!!.name,
|
||||
defaultAmount = payment.amount,
|
||||
initialDescription = payment.description,
|
||||
onConfirm = { amount, description ->
|
||||
viewModel.onEvent(
|
||||
PersonScreenEvent.UpdatePayment(
|
||||
payment = payment,
|
||||
newAmount = amount,
|
||||
newDescription = description
|
||||
)
|
||||
)
|
||||
paymentDialogState = null
|
||||
},
|
||||
onDismiss = { paymentDialogState = null },
|
||||
onDelete = {
|
||||
paymentToDelete = payment
|
||||
paymentDialogState = null
|
||||
}
|
||||
AddPaymentDialog(
|
||||
personName = detailState.person?.name ?: "",
|
||||
defaultAmount = defaultPaymentAmount,
|
||||
initialDescription = initialDesc,
|
||||
onConfirm = { amount, description ->
|
||||
viewModel.onEvent(
|
||||
PersonScreenEvent.AddPaymentForMonth(
|
||||
personId = personId,
|
||||
month = month,
|
||||
year = detailState.selectedYear,
|
||||
amount = amount,
|
||||
description = description
|
||||
)
|
||||
)
|
||||
addingPaymentMonth = null
|
||||
},
|
||||
onDismiss = {
|
||||
addingPaymentMonth = null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (editingPerson) {
|
||||
UpdatePersonDialog(
|
||||
initialName = detailState.person?.name ?: "",
|
||||
initialPhone = detailState.person?.phoneNumber,
|
||||
onConfirm = { name, phone ->
|
||||
viewModel.onEvent(PersonScreenEvent.UpdatePerson(personId, name, phone))
|
||||
editingPerson = false
|
||||
},
|
||||
onDismiss = { editingPerson = false },
|
||||
onSearchContact = { name ->
|
||||
contactViewModel.findSimilarContacts(name)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showBulkPaymentDialog) {
|
||||
val availableMonths = detailState.monthStates
|
||||
.filter { it.status == MonthStatus.AVAILABLE }
|
||||
.map { it.month }
|
||||
|
||||
BulkPaymentDialog(
|
||||
availableMonths = availableMonths,
|
||||
defaultAmount = defaultPaymentAmount,
|
||||
onConfirm = { selected, amount ->
|
||||
viewModel.onEvent(
|
||||
PersonScreenEvent.AddBulkPayments(
|
||||
personId = personId,
|
||||
months = selected,
|
||||
year = detailState.selectedYear,
|
||||
amount = amount
|
||||
)
|
||||
)
|
||||
showBulkPaymentDialog = false
|
||||
},
|
||||
onDismiss = { 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 = { amountText = it },
|
||||
label = { Text("مبلغ برای هر ماه (تومان)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
visualTransformation = PersianDigitsTransformation()
|
||||
)
|
||||
|
||||
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
|
||||
private fun PersonDetailHeader(name: String, onBack: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
contentColor = Color.White
|
||||
) {
|
||||
private fun PersonDetailHeader(
|
||||
name: String,
|
||||
onBack: () -> Unit,
|
||||
onEdit: () -> Unit
|
||||
) {
|
||||
Surface(color = MaterialTheme.colorScheme.primary, contentColor = Color.White) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp).padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "بازگشت"
|
||||
)
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "بازگشت")
|
||||
}
|
||||
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
text = name,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
IconButton(onClick = onEdit) {
|
||||
Icon(imageVector = Icons.Default.Edit, contentDescription = "ویرایش شخص")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdatePersonDialog(
|
||||
initialName: String,
|
||||
initialPhone: String?,
|
||||
onConfirm: (String, String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onSearchContact: (String) -> List<ContactMatch>
|
||||
) {
|
||||
var name by remember { mutableStateOf(initialName) }
|
||||
var phoneNumber by remember { mutableStateOf(initialPhone ?: "") }
|
||||
var similarContacts by remember { mutableStateOf<List<ContactMatch>>(emptyList()) }
|
||||
val context = LocalContext.current
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
val matches = onSearchContact(name)
|
||||
if (matches.isNotEmpty()) {
|
||||
similarContacts = matches
|
||||
} else {
|
||||
Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(context, "دسترسی به مخاطبین داده نشد", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
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(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "ویرایش اطلاعات شخص",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("نام شخص") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides LayoutDirection.Ltr
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = phoneNumber,
|
||||
onValueChange = { phoneNumber = it },
|
||||
label = { Text("شماره موبایل") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
visualTransformation = PersianDigitsTransformation(),
|
||||
placeholder = { Text("۰۹---------", textAlign = androidx.compose.ui.text.style.TextAlign.Right) }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (name.isBlank()) {
|
||||
Toast.makeText(context, "ابتدا نام را وارد کنید", Toast.LENGTH_SHORT).show()
|
||||
return@IconButton
|
||||
}
|
||||
when (PackageManager.PERMISSION_GRANTED) {
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) -> {
|
||||
val matches = onSearchContact(name)
|
||||
if (matches.isNotEmpty()) {
|
||||
similarContacts = matches
|
||||
} else {
|
||||
Toast.makeText(context, "مخاطبی با این نام پیدا نشد", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Search, contentDescription = "جستجو در مخاطبین")
|
||||
}
|
||||
}
|
||||
|
||||
if (similarContacts.isNotEmpty()) {
|
||||
Text(
|
||||
"موارد مشابه پیدا شده:",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.heightIn(max = 200.dp)
|
||||
) {
|
||||
items(similarContacts) { match ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
phoneNumber = match.phoneNumber
|
||||
similarContacts = emptyList()
|
||||
}
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = phoneNumber == match.phoneNumber,
|
||||
onClick = {
|
||||
phoneNumber = match.phoneNumber
|
||||
similarContacts = emptyList()
|
||||
}
|
||||
)
|
||||
Column {
|
||||
Text(match.nameInContacts, style = MaterialTheme.typography.bodySmall)
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
|
||||
Text(
|
||||
match.phoneNumber.toPersianDigits(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (name.isNotBlank()) {
|
||||
onConfirm(name, phoneNumber)
|
||||
}
|
||||
},
|
||||
enabled = name.isNotBlank()
|
||||
) {
|
||||
Text("ذخیره تغییرات")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("لغو")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,208 +550,74 @@ fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
IconButton(onClick = { onYearChange(-1) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = "سال قبل"
|
||||
)
|
||||
Icon(imageVector = Icons.Default.ChevronRight, contentDescription = "سال قبل")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "سال ${year.toString().toPersianDigits()}",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
IconButton(onClick = { onYearChange(1) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronLeft,
|
||||
contentDescription = "سال بعد"
|
||||
)
|
||||
Icon(imageVector = Icons.Default.ChevronLeft, contentDescription = "سال بعد")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@Composable
|
||||
fun MonthListItem(
|
||||
month: MonthUiModel,
|
||||
isExpanded: Boolean, // فعلاً استفاده نمیشود
|
||||
onHeaderClick: () -> Unit,
|
||||
onEditClick: () -> Unit
|
||||
) {
|
||||
val containerColor = when (month.status) {
|
||||
MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer
|
||||
MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.surface
|
||||
MonthStatus.PAST_YEAR, MonthStatus.FUTURE_YEAR, MonthStatus.FUTURE_MONTH ->
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onHeaderClick),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp).fillMaxWidth().clickable(onClick = onHeaderClick),
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp)
|
||||
) {
|
||||
// ردیف بالا: نام ماه + مبلغ + دکمه
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// نام ماه + مبلغ در صورت پرداخت
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = getPersianMonthName(month.month),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = getPersianMonthName(month.month), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
if (month.status == MonthStatus.PAID && month.payment != null) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "${formatNumberAsPersian(month.payment.amount)} تومان",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(text = "${formatNumberAsPersian(month.payment.amount)} ت", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
// دکمه سمت چپ
|
||||
when (month.status) {
|
||||
MonthStatus.PAID -> {
|
||||
Button(
|
||||
onClick = onEditClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Text("ویرایش")
|
||||
}
|
||||
}
|
||||
|
||||
MonthStatus.AVAILABLE -> {
|
||||
Button(
|
||||
onClick = onEditClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text("پرداخت")
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
text = "غیرفعال",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
if (month.status == MonthStatus.PAID || month.status == MonthStatus.AVAILABLE) {
|
||||
Button(
|
||||
onClick = onEditClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (month.status == MonthStatus.PAID) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text(if (month.status == MonthStatus.PAID) "ویرایش" else "پرداخت")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// جزئیات پرداخت: تاریخ + (در صورت وجود) توضیحات
|
||||
month.payment?.let { payment ->
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Text(
|
||||
text = "ثبت شده در: " +
|
||||
"${formatTimestampToPersianDate(payment.timestamp)} " +
|
||||
"ساعت ${formatTimestampToPersianTime(payment.timestamp)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
|
||||
)
|
||||
|
||||
if (payment.description.isNotBlank()) {
|
||||
if (month.status == MonthStatus.PAID && month.payment != null) {
|
||||
val desc = month.payment.description ?: ""
|
||||
if (desc.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "توضیحات: ${payment.description}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Text(text = desc, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteConfirmationDialog(
|
||||
payment: PaymentRecord,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("تایید حذف") },
|
||||
text = {
|
||||
Text(
|
||||
"آیا برای حذف پرداخت ماه ${
|
||||
getPersianMonthName(payment.shamsiMonth)
|
||||
} به مبلغ ${
|
||||
formatNumberAsPersian(payment.amount)
|
||||
} تومان مطمئن هستید؟"
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
// در RTL: این دکمه میچسبد به سمت راست دیالوگ
|
||||
Button(onClick = onDismiss) {
|
||||
Text("لغو")
|
||||
}
|
||||
|
||||
// در RTL: این دکمه میچسبد به سمت چپ دیالوگ
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("حذف")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoDialog(message: String, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("توجه") },
|
||||
text = { Text(message) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("باشه")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
sealed class PaymentDialogState {
|
||||
data class Add(val month: Int) : PaymentDialogState()
|
||||
data class Edit(val payment: PaymentRecord) : PaymentDialogState()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,3 @@
|
||||
@file:Suppress("AssignedValueIsNeverRead")
|
||||
|
||||
package com.oqba26.monthlypaymentapp.ui.screens
|
||||
|
||||
import android.widget.Toast
|
||||
@@ -11,16 +9,18 @@ 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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
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.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
@@ -33,10 +33,10 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
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
|
||||
@@ -47,15 +47,22 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.oqba26.monthlypaymentapp.core.PaymentApplication
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.oqba26.monthlypaymentapp.utils.PersianDigitsTransformation
|
||||
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
|
||||
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
|
||||
import com.oqba26.monthlypaymentapp.utils.toPersianDigits
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.SettingsViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.oqba26.monthlypaymentapp.R
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
@@ -66,8 +73,7 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun SettingsScreen(onLogout: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as PaymentApplication
|
||||
val viewModel: SettingsViewModel = viewModel(factory = app.settingsViewModelFactory)
|
||||
val viewModel: SettingsViewModel = hiltViewModel()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val defaultAmount by viewModel.defaultPaymentAmount.collectAsState()
|
||||
@@ -93,17 +99,9 @@ fun SettingsScreen(onLogout: () -> Unit) {
|
||||
writer.write(jsonString)
|
||||
}
|
||||
}
|
||||
Toast.makeText(
|
||||
context,
|
||||
"پشتیبانگیری با موفقیت انجام شد",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
Toast.makeText(context, "پشتیبانگیری با موفقیت انجام شد", Toast.LENGTH_LONG).show()
|
||||
} catch (_: Exception) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"خطا در ایجاد فایل پشتیبان",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
Toast.makeText(context, "خطا در ایجاد فایل پشتیبان", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,186 +125,169 @@ fun SettingsScreen(onLogout: () -> Unit) {
|
||||
}
|
||||
)
|
||||
|
||||
Scaffold { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text(
|
||||
"تنظیمات عمومی",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
DefaultAmountSetting(
|
||||
defaultAmount = defaultAmount,
|
||||
onSave = { viewModel.saveDefaultPaymentAmount(it) }
|
||||
)
|
||||
DefaultAmountSetting(
|
||||
defaultAmount = defaultAmount,
|
||||
onSave = { viewModel.saveDefaultPaymentAmount(it) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
FontSelectionDropdown(
|
||||
selectedFont = selectedFont,
|
||||
onFontSelected = { viewModel.onFontSelected(it) }
|
||||
)
|
||||
val reminderDay by viewModel.reminderDay.collectAsState()
|
||||
ReminderDaySetting(
|
||||
currentDay = reminderDay,
|
||||
onSave = { viewModel.saveReminderDay(it) }
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 20.dp))
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
Text(
|
||||
"پشتیبانگیری و بازیابی",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
val cardNumbers by viewModel.cardNumbers.collectAsState()
|
||||
CardNumbersSetting(
|
||||
cardNumbers = cardNumbers,
|
||||
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))
|
||||
|
||||
Text(
|
||||
"پشتیبانگیری و بازیابی",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val simpleDateFormat =
|
||||
SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault())
|
||||
val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault())
|
||||
val fileName = "payment_backup_${simpleDateFormat.format(Date())}.json"
|
||||
backupLauncher.launch(fileName)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("تهیه نسخه پشتیبان")
|
||||
Text("تهیه پشتیبان", fontSize = 13.sp)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { restoreLauncher.launch(arrayOf("application/json")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
) {
|
||||
Text("بازیابی اطلاعات از فایل")
|
||||
Text("بازیابی فایل", fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 20.dp))
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
Button(
|
||||
onClick = onLogout,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = "خروج"
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("خروج از حساب کاربری")
|
||||
}
|
||||
Button(
|
||||
onClick = onLogout,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.logout))
|
||||
}
|
||||
}
|
||||
|
||||
backupJsonToRestore?.let { json ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { backupJsonToRestore = null },
|
||||
title = { Text("هشدار جدی!") },
|
||||
text = {
|
||||
Text(
|
||||
"آیا مطمئن هستید؟ با بازیابی اطلاعات، تمام دادههای فعلی برنامه (اشخاص و پرداختها) برای همیشه حذف شده و اطلاعات فایل پشتیبان جایگزین آن خواهد شد. این عمل غیرقابل بازگشت است."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.restoreFromBackupJson(json)
|
||||
backupJsonToRestore = null
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Dialog(onDismissRequest = { backupJsonToRestore = null }) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
) {
|
||||
Text("بله، بازیابی کن")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { backupJsonToRestore = null }) {
|
||||
Text("لغو")
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.restore_warning),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.restore_warning_text),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.restoreFromBackupJson(json)
|
||||
backupJsonToRestore = null
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||
) { Text(stringResource(R.string.yes)) }
|
||||
|
||||
Button(onClick = { backupJsonToRestore = null }) { Text(stringResource(R.string.cancel)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DefaultAmountSetting(
|
||||
defaultAmount: Double,
|
||||
onSave: (String) -> Unit
|
||||
) {
|
||||
fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
|
||||
var isEditing by remember { mutableStateOf(false) }
|
||||
var amountText by remember(defaultAmount, isEditing) {
|
||||
mutableStateOf(defaultAmount.toLong().toString())
|
||||
}
|
||||
var amountText by remember(defaultAmount, isEditing) { mutableStateOf(defaultAmount.toLong().toString()) }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(0.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = isEditing,
|
||||
label = "EditAmountAnimation",
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) { editing ->
|
||||
AnimatedContent(targetState = isEditing, label = "EditAmount") { editing ->
|
||||
if (editing) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = amountText,
|
||||
onValueChange = {
|
||||
amountText = it.filter { c -> c.isDigit() }
|
||||
},
|
||||
onValueChange = { amountText = it.filter { c -> c.isDigit() } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("مبلغ پیش فرض پرداخت (تومان)") },
|
||||
label = { Text("مبلغ پیشفرض (تومان)") },
|
||||
visualTransformation = PersianNumberVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number
|
||||
)
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextButton(onClick = { isEditing = false }) {
|
||||
Text("لغو")
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Button(onClick = {
|
||||
onSave(amountText)
|
||||
isEditing = false
|
||||
}) {
|
||||
Text("ذخیره")
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = { isEditing = false }) { Text("لغو") }
|
||||
Button(onClick = { onSave(amountText); isEditing = false }) { Text("ذخیره") }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"مبلغ پیش فرض پرداخت",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
"${formatNumberAsPersian(defaultAmount)} تومان",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { isEditing = true }) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = "Edit Amount")
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("مبلغ پیش فرض پرداخت", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("${formatNumberAsPersian(defaultAmount)} تومان", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
IconButton(onClick = { isEditing = true }) { Icon(Icons.Filled.Edit, null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,35 +295,98 @@ fun DefaultAmountSetting(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FontSelectionDropdown(
|
||||
selectedFont: String,
|
||||
onFontSelected: (String) -> Unit
|
||||
) {
|
||||
fun ReminderDaySetting(currentDay: Int?, onSave: (String) -> Unit) {
|
||||
var isEditing by remember { mutableStateOf(false) }
|
||||
var dayText by remember(currentDay, isEditing) { mutableStateOf(currentDay?.toString() ?: "") }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
|
||||
) {
|
||||
AnimatedContent(targetState = isEditing, label = "EditReminder") { editing ->
|
||||
if (editing) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = dayText,
|
||||
onValueChange = { dayText = it.filter { c -> c.isDigit() }.take(2) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("روز یادآور (۱ تا ۳۱)") },
|
||||
visualTransformation = PersianDigitsTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = { isEditing = false }) { Text("لغو") }
|
||||
Button(onClick = { onSave(dayText); isEditing = false }) { Text("ذخیره") }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("روز یادآور ماهانه", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(if (currentDay != null) "روز ${currentDay.toString().toPersianDigits()} هر ماه" else "غیرفعال", fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
IconButton(onClick = { isEditing = true }) { Icon(Icons.Filled.Edit, null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
|
||||
var isAdding by remember { mutableStateOf(false) }
|
||||
var newCardText by remember { mutableStateOf("") }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
|
||||
) {
|
||||
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 (isAdding) {
|
||||
OutlinedTextField(
|
||||
value = newCardText,
|
||||
onValueChange = { newCardText = it.filter { c -> c.isDigit() } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("شماره ۱۶ رقمی") },
|
||||
visualTransformation = PersianDigitsTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
TextButton(onClick = { isAdding = false; newCardText = "" }) { Text("لغو") }
|
||||
Button(onClick = { if (newCardText.length >= 16) { onAdd(newCardText); isAdding = false; newCardText = "" } }) { Text("افزودن") }
|
||||
}
|
||||
}
|
||||
cardNumbers.forEach { card ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(card.toPersianDigits(), modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge)
|
||||
IconButton(onClick = { onRemove(card) }) { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FontSelectionDropdown(selectedFont: String, onFontSelected: (String) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val fonts = listOf("Estedad", "Vazirmatn", "BYekan", "Sahel", "IranianSans")
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { expanded = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("فونت برنامه: $selectedFont")
|
||||
Icon(Icons.Filled.ArrowDropDown, contentDescription = "Select Font")
|
||||
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("فونت برنامه: $selectedFont", fontSize = 14.sp)
|
||||
Icon(Icons.Filled.ArrowDropDown, null)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
fonts.forEach { fontName ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(fontName) },
|
||||
onClick = {
|
||||
onFontSelected(fontName)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(text = { Text(fontName) }, onClick = { onFontSelected(fontName); expanded = false })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,27 +2,38 @@
|
||||
|
||||
package com.oqba26.monthlypaymentapp.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
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.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.oqba26.monthlypaymentapp.R
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
|
||||
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
|
||||
|
||||
@@ -31,78 +42,204 @@ fun EditPaymentDialog(
|
||||
paymentRecord: PaymentRecord,
|
||||
onConfirm: (amount: Double, description: String) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onShare: (PaymentRecord) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var amountText by remember { mutableStateOf(paymentRecord.amount.toLong().toString()) }
|
||||
var description by remember { mutableStateOf(paymentRecord.description) }
|
||||
var description by remember { mutableStateOf(paymentRecord.description ?: "") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("ویرایش پرداخت") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = amountText,
|
||||
onValueChange = { value ->
|
||||
amountText = value.filter { c -> c.isDigit() }
|
||||
},
|
||||
label = { Text("مبلغ") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
visualTransformation = PersianNumberVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("توضیحات (اختیاری)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
},
|
||||
// تمام دکمهها را خودمان داخل confirmButton میچینیم
|
||||
confirmButton = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
) {
|
||||
// در RTL: Start = راست، End = چپ
|
||||
|
||||
// راست: ذخیره
|
||||
Button(
|
||||
onClick = {
|
||||
val amount = amountText.toDoubleOrNull() ?: paymentRecord.amount
|
||||
onConfirm(amount, description)
|
||||
},
|
||||
modifier = Modifier.align(androidx.compose.ui.Alignment.CenterStart)
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text("ذخیره")
|
||||
}
|
||||
|
||||
// وسط: لغو
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.align(androidx.compose.ui.Alignment.Center),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
Text(
|
||||
text = "ویرایش پرداخت",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
) {
|
||||
Text("لغو")
|
||||
}
|
||||
|
||||
// چپ: حذف
|
||||
Button(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.align(androidx.compose.ui.Alignment.CenterEnd),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
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("مبلغ") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
visualTransformation = PersianNumberVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
) {
|
||||
Text("حذف")
|
||||
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("توضیحات (اختیاری)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { onShare(paymentRecord) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiary
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("اشتراکگذاری رسید")
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
val amount = amountText.toDoubleOrNull() ?: paymentRecord.amount
|
||||
onConfirm(amount, description)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("ذخیره")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text("لغو")
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Default.Delete,
|
||||
contentDescription = "حذف",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AddPaymentDialog(
|
||||
personName: String,
|
||||
defaultAmount: Double,
|
||||
initialDescription: String = "",
|
||||
onConfirm: (amount: Double, description: String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onDelete: (() -> Unit)? = null
|
||||
) {
|
||||
var rawAmount by remember { mutableStateOf(defaultAmount.toLong().toString()) }
|
||||
var description by remember { mutableStateOf(initialDescription) }
|
||||
|
||||
val title =
|
||||
if (onDelete != null) "ویرایش پرداخت برای $personName" else "ثبت پرداخت برای $personName"
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = rawAmount,
|
||||
onValueChange = { value ->
|
||||
// اجازه ورود فقط به اعداد (چه انگلیسی چه فارسی)
|
||||
val digitsOnly = value.filter { it.isDigit() || it in '\u0660'..'\u0669' || it in '\u06f0'..'\u06f9' }
|
||||
// تبدیل اعداد فارسی به انگلیسی برای ذخیره در استیت
|
||||
rawAmount = 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("مبلغ (به تومان)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
visualTransformation = PersianNumberVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("توضیحات (اختیاری)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
val amountAsDouble = rawAmount.toDoubleOrNull() ?: defaultAmount
|
||||
onConfirm(amountAsDouble, description)
|
||||
},
|
||||
enabled = rawAmount.isNotBlank()
|
||||
) {
|
||||
val label = if (onDelete != null) "ویرایش" else "ثبت"
|
||||
Text(label)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (onDelete != null) {
|
||||
Button(
|
||||
onClick = onDelete,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("حذف")
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text("لغو")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.oqba26.monthlypaymentapp.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
+28
-11
@@ -14,7 +14,7 @@ class PersianNumberVisualTransformation : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
val originalText = text.text
|
||||
if (originalText.isBlank()) {
|
||||
return TransformedText(text, OffsetMapping.Identity)
|
||||
return TransformedText(AnnotatedString(""), OffsetMapping.Identity)
|
||||
}
|
||||
|
||||
val number = originalText.toLongOrNull() ?: 0L
|
||||
@@ -22,21 +22,28 @@ class PersianNumberVisualTransformation : VisualTransformation {
|
||||
|
||||
val offsetMapping = object : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int {
|
||||
try {
|
||||
val thousandsSeparators = formattedText.count { it == '٬' }
|
||||
return (offset + thousandsSeparators).coerceIn(0, formattedText.length)
|
||||
} catch (e: Exception) {
|
||||
return formattedText.length
|
||||
if (offset == 0) return 0
|
||||
var transformedOffset = 0
|
||||
var originalOffset = 0
|
||||
for (char in formattedText) {
|
||||
if (char != '٬') {
|
||||
originalOffset++
|
||||
}
|
||||
transformedOffset++
|
||||
if (originalOffset == offset) break
|
||||
}
|
||||
return transformedOffset
|
||||
}
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
try {
|
||||
val separatorsBeforeCursor = formattedText.substring(0, offset).count { it == '٬' }
|
||||
return (offset - separatorsBeforeCursor).coerceIn(0, originalText.length)
|
||||
} catch (e: Exception) {
|
||||
return originalText.length
|
||||
if (offset == 0) return 0
|
||||
var originalOffset = 0
|
||||
for (i in 0 until offset.coerceAtMost(formattedText.length)) {
|
||||
if (formattedText[i] != '٬') {
|
||||
originalOffset++
|
||||
}
|
||||
}
|
||||
return originalOffset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,3 +53,13 @@ class PersianNumberVisualTransformation : VisualTransformation {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class PersianDigitsTransformation : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
val transformed = text.text.toPersianDigits()
|
||||
return TransformedText(
|
||||
text = AnnotatedString(transformed),
|
||||
offsetMapping = OffsetMapping.Identity
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.Locale
|
||||
|
||||
fun getCurrentShamsiYear(): Int = PersianDate().shYear
|
||||
fun getCurrentShamsiMonth(): Int = PersianDate().shMonth
|
||||
fun getCurrentShamsiDay(): Int = PersianDate().shDay
|
||||
|
||||
fun formatTimestampToPersianDate(timestamp: Long): String {
|
||||
val persianDate = PersianDate(Date(timestamp))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.oqba26.monthlypaymentapp.utils
|
||||
|
||||
// An extension function to convert any Int to a Persian-digit String.
|
||||
@Suppress("unused")
|
||||
fun Int.toPersianDigits(): String {
|
||||
return this.toString().toPersianDigits()
|
||||
}
|
||||
@@ -23,3 +24,56 @@ fun String.toPersianDigits(): String {
|
||||
}
|
||||
}.joinToString("")
|
||||
}
|
||||
|
||||
fun String.normalizePhoneNumber(): String {
|
||||
// فقط اعداد را نگه دار
|
||||
val digitsOnly = this.filter { it.isDigit() }
|
||||
|
||||
// اگر با 98 شروع میشد، آن را به 0 تبدیل کن (فرمت رایج موبایل در ایران)
|
||||
return when {
|
||||
digitsOnly.startsWith("98") -> "0${digitsOnly.substring(2)}"
|
||||
!digitsOnly.startsWith("0") && digitsOnly.length == 10 -> "0$digitsOnly"
|
||||
else -> digitsOnly
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* الگوریتم لوناشتاین برای محاسبه شباهت دو رشته
|
||||
* مقدار برگشتی 0 یعنی کاملاً مشابه و هرچه بیشتر باشد شباهت کمتر است
|
||||
*/
|
||||
fun calculateLevenshteinDistance(s1: String, s2: String): Int {
|
||||
val dp = Array(s1.length + 1) { IntArray(s2.length + 1) }
|
||||
|
||||
for (i in 0..s1.length) dp[i][0] = i
|
||||
for (j in 0..s2.length) dp[0][j] = j
|
||||
|
||||
for (i in 1..s1.length) {
|
||||
for (j in 1..s2.length) {
|
||||
val cost = if (s1[i - 1] == s2[j - 1]) 0 else 1
|
||||
dp[i][j] = minOf(
|
||||
dp[i - 1][j] + 1,
|
||||
dp[i][j - 1] + 1,
|
||||
dp[i - 1][j - 1] + cost
|
||||
)
|
||||
}
|
||||
}
|
||||
return dp[s1.length][s2.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* بررسی شباهت دو نام به صورت هوشمند
|
||||
*/
|
||||
fun areNamesSimilar(name1: String, name2: String, threshold: Double = 0.4): Boolean {
|
||||
val n1 = name1.lowercase().trim()
|
||||
val n2 = name2.lowercase().trim()
|
||||
|
||||
// اگر یکی شامل دیگری باشد
|
||||
if (n1.contains(n2) || n2.contains(n1)) return true
|
||||
|
||||
val distance = calculateLevenshteinDistance(n1, n2)
|
||||
val maxLength = maxOf(n1.length, n2.length)
|
||||
if (maxLength == 0) return true
|
||||
|
||||
val normalizedDistance = distance.toDouble() / maxLength
|
||||
return normalizedDistance <= threshold
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
@file:OptIn(kotlinx.serialization.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.serialization.kotlinx.json.*
|
||||
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.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Serializable
|
||||
data class UpdateInfo(
|
||||
val versionCode: Int,
|
||||
val versionName: String,
|
||||
val url: String,
|
||||
val releaseNotes: String,
|
||||
val isForceUpdate: Boolean = false,
|
||||
)
|
||||
|
||||
class UpdateManager(private val context: Context) {
|
||||
|
||||
private val updateUrl = "https://raw.githubusercontent.com/oqba26/MonthlyPaymentApp/main/update.json"
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
private val client = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(json)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkForUpdate(): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||
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()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
pInfo.versionCode
|
||||
}
|
||||
|
||||
if (updateInfo.versionCode > currentVersionCode) {
|
||||
return@withContext updateInfo
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
val uri = url.toUri()
|
||||
|
||||
val oldFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
|
||||
if (oldFile.exists()) oldFile.delete()
|
||||
|
||||
val request = DownloadManager.Request(uri)
|
||||
.setTitle("دریافت بهروزرسانی")
|
||||
.setDescription("نسخه جدید در حال دانلود است...")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, fileName)
|
||||
.setAllowedOverMetered(true)
|
||||
.setAllowedOverRoaming(true)
|
||||
|
||||
val downloadId = downloadManager.enqueue(request)
|
||||
|
||||
val onComplete = object : BroadcastReceiver() {
|
||||
override fun onReceive(receiverContext: Context, intent: Intent) {
|
||||
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L)
|
||||
if (id == downloadId) {
|
||||
installApk(fileName)
|
||||
try {
|
||||
receiverContext.unregisterReceiver(this)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private fun installApk(fileName: String) {
|
||||
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), fileName)
|
||||
if (!apkFile.exists()) return
|
||||
|
||||
try {
|
||||
val contentUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
apkFile
|
||||
)
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(contentUri, "application/vnd.android.package-archive")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isNetworkAvailable(): Boolean {
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return when {
|
||||
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true
|
||||
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.oqba26.monthlypaymentapp.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.oqba26.monthlypaymentapp.data.model.AuthRequest
|
||||
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
|
||||
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface AuthState {
|
||||
data object Loading : AuthState
|
||||
data object Authenticated : AuthState
|
||||
data object Unauthenticated : AuthState
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _toastMessage = MutableSharedFlow<String>()
|
||||
val toastMessage = _toastMessage.asSharedFlow()
|
||||
|
||||
val authState: StateFlow<AuthState> = settingsRepository.authTokenFlow
|
||||
.map { token ->
|
||||
if (token != null) AuthState.Authenticated else AuthState.Unauthenticated
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AuthState.Loading)
|
||||
|
||||
fun login(request: AuthRequest) = viewModelScope.launch {
|
||||
val response = networkRepository.login(request)
|
||||
if (response != null && response.token.isNotBlank()) {
|
||||
settingsRepository.saveAuthData(response.token, response.userId)
|
||||
_toastMessage.emit("ورود موفقیتآمیز بود")
|
||||
networkRepository.refresh()
|
||||
} else {
|
||||
_toastMessage.emit("نام کاربری یا رمز عبور اشتباه است.")
|
||||
}
|
||||
}
|
||||
|
||||
fun register(request: AuthRequest) = viewModelScope.launch {
|
||||
val response = networkRepository.register(request)
|
||||
if (response != null && response.token.isNotBlank()) {
|
||||
settingsRepository.saveAuthData(response.token, response.userId)
|
||||
_toastMessage.emit("ثبت نام و ورود موفقیتآمیز بود")
|
||||
networkRepository.refresh()
|
||||
} else {
|
||||
_toastMessage.emit("خطا در ثبت نام.")
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() = viewModelScope.launch {
|
||||
settingsRepository.saveAuthData(null, null)
|
||||
_toastMessage.emit("از حساب کاربری خارج شدید.")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.oqba26.monthlypaymentapp.viewmodel
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.ContactsContract
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.oqba26.monthlypaymentapp.core.manager.SmsManager
|
||||
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
|
||||
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
|
||||
import com.oqba26.monthlypaymentapp.utils.normalizePhoneNumber
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ContactUiState(
|
||||
val contactSuggestions: List<ContactSuggestion> = emptyList(),
|
||||
val bulkSmsQueue: List<PersonUiModel> = emptyList(),
|
||||
val currentBulkIndex: Int = -1,
|
||||
val selectedCardForBulk: String? = null,
|
||||
val showBulkSmsDialog: Boolean = false,
|
||||
val personForSmsDialog: PersonUiModel? = null,
|
||||
val bulkSmsQueueForDialog: List<PersonUiModel> = emptyList()
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ContactViewModel @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val smsManager: SmsManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ContactUiState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
private val _toastMessage = MutableSharedFlow<String>()
|
||||
val toastMessage = _toastMessage.asSharedFlow()
|
||||
|
||||
fun onBulkSmsClicked() {
|
||||
_uiState.update { it.copy(showBulkSmsDialog = true) }
|
||||
}
|
||||
|
||||
fun onBulkSelectionConfirmed(selected: List<PersonUiModel>) {
|
||||
_uiState.update { it.copy(showBulkSmsDialog = false, bulkSmsQueueForDialog = selected) }
|
||||
}
|
||||
|
||||
fun onDismissBulkSmsDialog() {
|
||||
_uiState.update { it.copy(showBulkSmsDialog = false) }
|
||||
}
|
||||
|
||||
fun onDismissSmsDialog() {
|
||||
_uiState.update { it.copy(personForSmsDialog = null, bulkSmsQueueForDialog = emptyList()) }
|
||||
}
|
||||
|
||||
fun onSmsClicked(person: PersonUiModel) {
|
||||
_uiState.update { it.copy(personForSmsDialog = person) }
|
||||
}
|
||||
|
||||
fun sendSmsReminder(person: PersonUiModel, selectedCard: String? = null) {
|
||||
smsManager.sendSmsReminder(person, selectedCard)
|
||||
}
|
||||
|
||||
fun startBulkSms(selected: List<PersonUiModel>, selectedCard: String? = null) {
|
||||
if (selected.isEmpty()) return
|
||||
_uiState.update { it.copy(bulkSmsQueue = selected, currentBulkIndex = 0, selectedCardForBulk = selectedCard) }
|
||||
}
|
||||
|
||||
fun processNextBulkSms() {
|
||||
val state = _uiState.value
|
||||
if (state.currentBulkIndex in state.bulkSmsQueue.indices) {
|
||||
val person = state.bulkSmsQueue[state.currentBulkIndex]
|
||||
val intent = smsManager.getSmsIntent(person, state.selectedCardForBulk)
|
||||
if (intent != null) {
|
||||
// We still need to start the activity. Since we have application context in manager, it uses FLAG_ACTIVITY_NEW_TASK.
|
||||
smsManager.sendSmsReminder(person, state.selectedCardForBulk)
|
||||
_uiState.update { it.copy(currentBulkIndex = it.currentBulkIndex + 1) }
|
||||
} else {
|
||||
skipBulkSms()
|
||||
processNextBulkSms()
|
||||
}
|
||||
} else {
|
||||
cancelBulkSms()
|
||||
viewModelScope.launch { _toastMessage.emit("ارسال گروهی به پایان رسید.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun skipBulkSms() {
|
||||
_uiState.update {
|
||||
val nextIndex = it.currentBulkIndex + 1
|
||||
if (nextIndex >= it.bulkSmsQueue.size) {
|
||||
it.copy(bulkSmsQueue = emptyList(), currentBulkIndex = -1)
|
||||
} else {
|
||||
it.copy(currentBulkIndex = nextIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelBulkSms() {
|
||||
_uiState.update { it.copy(bulkSmsQueue = emptyList(), currentBulkIndex = -1) }
|
||||
}
|
||||
|
||||
fun checkContactsForMissingNumbers(persons: List<PersonUiModel>) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return
|
||||
|
||||
viewModelScope.launch {
|
||||
val ignoredIds = settingsRepository.ignoredContactSuggestionsFlow.first()
|
||||
val personsWithoutPhone = persons.filter {
|
||||
it.phoneNumber.isNullOrBlank() && !ignoredIds.contains(it.id)
|
||||
}
|
||||
|
||||
if (personsWithoutPhone.isEmpty()) {
|
||||
_uiState.update { it.copy(contactSuggestions = emptyList()) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val suggestions = mutableListOf<ContactSuggestion>()
|
||||
personsWithoutPhone.forEach { person ->
|
||||
val matches = findSimilarContacts(person.name)
|
||||
if (matches.isNotEmpty()) {
|
||||
suggestions.add(ContactSuggestion(person.id, person.name, matches))
|
||||
}
|
||||
}
|
||||
_uiState.update { it.copy(contactSuggestions = suggestions) }
|
||||
}
|
||||
}
|
||||
|
||||
fun findSimilarContacts(name: String): List<ContactMatch> {
|
||||
val matches = mutableListOf<ContactMatch>()
|
||||
val contentResolver = context.contentResolver
|
||||
val uri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI
|
||||
val projection = arrayOf(
|
||||
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
|
||||
ContactsContract.CommonDataKinds.Phone.NUMBER
|
||||
)
|
||||
|
||||
contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
|
||||
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
|
||||
while (cursor.moveToNext()) {
|
||||
if (nameIndex != -1 && numberIndex != -1) {
|
||||
val contactName = cursor.getString(nameIndex) ?: ""
|
||||
val rawNumber = cursor.getString(numberIndex) ?: ""
|
||||
|
||||
if (com.oqba26.monthlypaymentapp.utils.areNamesSimilar(name, contactName)) {
|
||||
matches.add(ContactMatch(contactName, rawNumber.normalizePhoneNumber()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches.distinctBy { it.phoneNumber }
|
||||
}
|
||||
|
||||
fun confirmContactSuggestion(personId: String, personName: String, phoneNumber: String) {
|
||||
viewModelScope.launch {
|
||||
val statusCode = networkRepository.updatePerson(personId, personName, phoneNumber)
|
||||
if (statusCode == 200) {
|
||||
networkRepository.refresh()
|
||||
_uiState.update { state ->
|
||||
state.copy(contactSuggestions = state.contactSuggestions.filter { it.personId != personId })
|
||||
}
|
||||
} else if (statusCode == 409) {
|
||||
_toastMessage.emit("خطا: این نام از قبل وجود دارد.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissContactSuggestion(suggestion: ContactSuggestion) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.ignoreContactSuggestion(suggestion.personId)
|
||||
_uiState.update { state ->
|
||||
state.copy(contactSuggestions = state.contactSuggestions.filter { it.personId != suggestion.personId })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
package com.oqba26.monthlypaymentapp.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.oqba26.monthlypaymentapp.data.model.AuthRequest
|
||||
import com.oqba26.monthlypaymentapp.core.manager.ExportManager
|
||||
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
|
||||
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.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -18,35 +24,27 @@ import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface AuthState {
|
||||
data object Loading : AuthState
|
||||
data object Authenticated : AuthState
|
||||
data object Unauthenticated : AuthState
|
||||
}
|
||||
|
||||
enum class PersonListType {
|
||||
UNPAID,
|
||||
PAID
|
||||
}
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PersonViewModel(
|
||||
@HiltViewModel
|
||||
class PersonViewModel @Inject constructor(
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val settingsRepository: SettingsRepository
|
||||
private val localPersonRepository: LocalPersonRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val exportManager: ExportManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
@Suppress("unused")
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
private val _selectedPersonId = MutableStateFlow<String?>(null)
|
||||
@@ -56,6 +54,7 @@ class PersonViewModel(
|
||||
val dashboardData: StateFlow<DashboardUiModel> = _dashboardData.asStateFlow()
|
||||
|
||||
val defaultPaymentAmountFlow = settingsRepository.defaultPaymentAmountFlow
|
||||
val cardNumbersFlow = settingsRepository.cardNumbersFlow
|
||||
|
||||
private val _toastMessage = MutableSharedFlow<String>()
|
||||
val toastMessage = _toastMessage.asSharedFlow()
|
||||
@@ -72,12 +71,11 @@ class PersonViewModel(
|
||||
private val _personForPaymentDialog = MutableStateFlow<PersonUiModel?>(null)
|
||||
val personForPaymentDialog: StateFlow<PersonUiModel?> = _personForPaymentDialog.asStateFlow()
|
||||
|
||||
private val _selectedIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val selectedIds: StateFlow<Set<String>> = _selectedIds.asStateFlow()
|
||||
|
||||
val authState: StateFlow<AuthState> = settingsRepository.authTokenFlow
|
||||
.map { token ->
|
||||
if (token != null) AuthState.Authenticated else AuthState.Unauthenticated
|
||||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AuthState.Loading)
|
||||
private val _isSelectionMode = MutableStateFlow(false)
|
||||
val isSelectionMode: StateFlow<Boolean> = _isSelectionMode.asStateFlow()
|
||||
|
||||
val personDetailState: StateFlow<PersonDetailUiState> = _selectedPersonId
|
||||
.flatMapLatest { personId ->
|
||||
@@ -85,8 +83,8 @@ class PersonViewModel(
|
||||
flowOf(PersonDetailUiState())
|
||||
} else {
|
||||
combine(
|
||||
networkRepository.getPersonsFlow(),
|
||||
networkRepository.getPaymentsFlow(),
|
||||
localPersonRepository.getAllPersonsFlow(),
|
||||
localPersonRepository.getAllPaymentsFlow(),
|
||||
_selectedYear
|
||||
) { persons, allPayments, year ->
|
||||
val person = persons.find { it.id == personId }
|
||||
@@ -95,12 +93,13 @@ class PersonViewModel(
|
||||
}
|
||||
val currentYear = getCurrentShamsiYear()
|
||||
val currentMonth = getCurrentShamsiMonth()
|
||||
val currentDay = getCurrentShamsiDay()
|
||||
val monthStates = (1..12).map { month ->
|
||||
val payment = paymentsForPersonInYear.find { it.shamsiMonth == month }
|
||||
val status = when {
|
||||
year < currentYear -> MonthStatus.PAST_YEAR
|
||||
year > currentYear -> MonthStatus.FUTURE_YEAR
|
||||
month > currentMonth -> MonthStatus.FUTURE_MONTH
|
||||
month > currentMonth || (month == currentMonth && currentDay < 20) -> MonthStatus.FUTURE_MONTH
|
||||
payment != null -> MonthStatus.PAID
|
||||
else -> MonthStatus.AVAILABLE
|
||||
}
|
||||
@@ -113,189 +112,152 @@ class PersonViewModel(
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PersonDetailUiState())
|
||||
|
||||
init {
|
||||
// ۱. منطق همگامسازی شبکه به دیتابیس محلی
|
||||
viewModelScope.launch {
|
||||
authState.collectLatest { authState ->
|
||||
if (authState is AuthState.Authenticated) {
|
||||
// اولین بار دیتای سرور
|
||||
settingsRepository.authTokenFlow.collectLatest { token ->
|
||||
if (token != null) {
|
||||
networkRepository.observeRealtimeChanges()
|
||||
networkRepository.refresh()
|
||||
|
||||
// گوش دادن به تغییرات اشخاص + پرداختها + سرچ
|
||||
combine(
|
||||
networkRepository.getPersonsFlow(),
|
||||
networkRepository.getPaymentsFlow(),
|
||||
_searchQuery
|
||||
) { persons, allPayments, query ->
|
||||
val currentMonth = getCurrentShamsiMonth()
|
||||
val currentYear = getCurrentShamsiYear()
|
||||
|
||||
val paymentsThisMonth = allPayments.filter {
|
||||
it.shamsiMonth == currentMonth && it.shamsiYear == currentYear
|
||||
networkRepository.getPaymentsFlow()
|
||||
) { persons, payments ->
|
||||
if (persons.isNotEmpty()) {
|
||||
localPersonRepository.syncAll(persons, payments)
|
||||
true
|
||||
} else false
|
||||
}.collect { isUpdated ->
|
||||
if (isUpdated) {
|
||||
_toastMessage.emit("اطلاعات با سرور همگامسازی شد")
|
||||
}
|
||||
|
||||
val activePersons = persons
|
||||
.filter { !it.isArchived }
|
||||
.sortedWith(compareBy({ it.displayOrder }, { it.createdAt }))
|
||||
|
||||
val uiModels = activePersons.map { person ->
|
||||
val hasPaid = paymentsThisMonth.any { it.personId == person.id }
|
||||
PersonUiModel(
|
||||
id = person.id,
|
||||
name = person.name,
|
||||
hasPaidThisMonth = hasPaid,
|
||||
displayOrder = person.displayOrder,
|
||||
createdAt = person.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
updateDashboard(uiModels.size, paymentsThisMonth)
|
||||
|
||||
val (paidModels, unpaidModels) =
|
||||
uiModels.partition { it.hasPaidThisMonth }
|
||||
|
||||
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) }
|
||||
|
||||
val archivedPersons = persons
|
||||
.filter { it.isArchived }
|
||||
.sortedBy { it.displayOrder }
|
||||
.map {
|
||||
PersonUiModel(
|
||||
id = it.id,
|
||||
name = it.name,
|
||||
hasPaidThisMonth = false,
|
||||
displayOrder = it.displayOrder,
|
||||
createdAt = it.createdAt
|
||||
)
|
||||
}
|
||||
|
||||
PersonListUiState(
|
||||
unpaidPersons = filteredUnpaid,
|
||||
paidPersons = filteredPaid,
|
||||
archivedPersons = archivedPersons,
|
||||
payments = allPayments
|
||||
)
|
||||
}.collect { newUiState ->
|
||||
_uiState.value = newUiState
|
||||
}
|
||||
} else {
|
||||
_uiState.value = PersonListUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ۲. منطق نمایش UI
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
localPersonRepository.getAllPersonsFlow(),
|
||||
localPersonRepository.getAllPaymentsFlow(),
|
||||
_searchQuery
|
||||
) { persons, allPayments, query ->
|
||||
val currentMonth = getCurrentShamsiMonth()
|
||||
val currentYear = getCurrentShamsiYear()
|
||||
val currentDay = getCurrentShamsiDay()
|
||||
|
||||
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 relevantPayments = allPayments.filter {
|
||||
it.shamsiMonth == targetMonth && it.shamsiYear == targetYear
|
||||
}
|
||||
|
||||
val activePersons = persons
|
||||
.filter { !it.isArchived }
|
||||
.sortedWith(compareBy({ it.displayOrder }, { it.createdAt }))
|
||||
|
||||
val uiModels = activePersons.map { person ->
|
||||
val hasPaid = relevantPayments.any { it.personId == person.id }
|
||||
val debtEndMonth = if (currentDay >= 20) currentMonth else currentMonth - 1
|
||||
val unpaidMonthsInCurrentYear = (1..debtEndMonth).filter { m ->
|
||||
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m }
|
||||
}
|
||||
|
||||
PersonUiModel(
|
||||
id = person.id,
|
||||
name = person.name,
|
||||
hasPaidThisMonth = hasPaid,
|
||||
displayOrder = person.displayOrder,
|
||||
createdAt = person.createdAt,
|
||||
debtCount = unpaidMonthsInCurrentYear.size,
|
||||
totalDebtAmount = unpaidMonthsInCurrentYear.size * (settingsRepository.defaultPaymentAmountFlow.first()),
|
||||
phoneNumber = person.phoneNumber,
|
||||
unpaidMonthsNames = unpaidMonthsInCurrentYear.map { getPersianMonthName(it) }
|
||||
)
|
||||
}
|
||||
|
||||
updateDashboard(uiModels.size, relevantPayments)
|
||||
|
||||
val (paidModels, unpaidModels) = uiModels.partition { it.hasPaidThisMonth }
|
||||
|
||||
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) }
|
||||
|
||||
val archivedPersons = persons.filter { it.isArchived }.map {
|
||||
PersonUiModel(it.id, it.name, false, it.displayOrder, it.createdAt)
|
||||
}
|
||||
|
||||
PersonListUiState(
|
||||
unpaidPersons = filteredUnpaid,
|
||||
paidPersons = filteredPaid,
|
||||
archivedPersons = archivedPersons,
|
||||
payments = allPayments
|
||||
)
|
||||
}.collect { newUiState ->
|
||||
_uiState.value = newUiState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSearchQueryChange(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
||||
fun onAddPersonClicked() {
|
||||
_showAddPersonDialog.value = true
|
||||
}
|
||||
|
||||
fun onDismissAddPersonDialog() {
|
||||
_showAddPersonDialog.value = false
|
||||
}
|
||||
|
||||
fun onQuickPayClicked(person: PersonUiModel) {
|
||||
_personForPaymentDialog.value = person
|
||||
}
|
||||
|
||||
fun onDismissPaymentDialog() {
|
||||
_personForPaymentDialog.value = null
|
||||
}
|
||||
|
||||
fun onSearchQueryChange(query: String) { _searchQuery.value = query }
|
||||
fun onAddPersonClicked() { _showAddPersonDialog.value = true }
|
||||
fun onDismissAddPersonDialog() { _showAddPersonDialog.value = false }
|
||||
fun onQuickPayClicked(person: PersonUiModel) { _personForPaymentDialog.value = person }
|
||||
fun onDismissPaymentDialog() { _personForPaymentDialog.value = null }
|
||||
|
||||
fun onEvent(event: PersonScreenEvent) {
|
||||
viewModelScope.launch {
|
||||
when (event) {
|
||||
is PersonScreenEvent.MovePersonNew -> {
|
||||
// فقط جابهجا کردن لیست لوکال (بدون شبکه)
|
||||
val fromIndex = event.fromIndex
|
||||
val toIndex = event.toIndex
|
||||
val currentListState = _uiState.value
|
||||
|
||||
val list = when (event.listType) {
|
||||
PersonListType.UNPAID -> currentListState.unpaidPersons
|
||||
PersonListType.PAID -> currentListState.paidPersons
|
||||
}
|
||||
|
||||
val list = if (event.listType == PersonListType.UNPAID) _uiState.value.unpaidPersons else _uiState.value.paidPersons
|
||||
if (fromIndex !in list.indices || toIndex !in list.indices) return@launch
|
||||
|
||||
val reorderedList = list.toMutableList().apply {
|
||||
add(toIndex, removeAt(fromIndex))
|
||||
}
|
||||
|
||||
val reorderedList = list.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
|
||||
_uiState.update {
|
||||
when (event.listType) {
|
||||
PersonListType.UNPAID -> it.copy(unpaidPersons = reorderedList)
|
||||
PersonListType.PAID -> it.copy(paidPersons = reorderedList)
|
||||
}
|
||||
if (event.listType == PersonListType.UNPAID) it.copy(unpaidPersons = reorderedList)
|
||||
else it.copy(paidPersons = reorderedList)
|
||||
}
|
||||
}
|
||||
|
||||
is PersonScreenEvent.AddPerson -> {
|
||||
if (event.name.isNotBlank()) {
|
||||
val person = Person(name = event.name, displayOrder = 0)
|
||||
val person = Person(name = event.name, phoneNumber = event.phoneNumber, displayOrder = 0)
|
||||
val statusCode = networkRepository.addPerson(person)
|
||||
when (statusCode) {
|
||||
409 -> {
|
||||
_toastMessage.emit("خطا: این نام از قبل وجود دارد.")
|
||||
}
|
||||
null, !in 200..299 -> {
|
||||
_toastMessage.emit("خطا در افزودن شخص.")
|
||||
}
|
||||
else -> {
|
||||
networkRepository.refresh()
|
||||
}
|
||||
}
|
||||
if (statusCode == 409) _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
|
||||
else if (statusCode != 200 && statusCode != 201) _toastMessage.emit("خطا در افزودن شخص.")
|
||||
else networkRepository.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
is PersonScreenEvent.AddQuickPayment -> {
|
||||
val record = createPaymentRecord(
|
||||
event.personId,
|
||||
getCurrentShamsiMonth(),
|
||||
getCurrentShamsiYear(),
|
||||
event.amount,
|
||||
event.description
|
||||
)
|
||||
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
|
||||
|
||||
if (networkRepository.addPayment(record)) {
|
||||
networkRepository.refresh()
|
||||
} else {
|
||||
_toastMessage.emit("خطا در ثبت پرداخت. لطفا دوباره تلاش کنید.")
|
||||
}
|
||||
val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description)
|
||||
if (networkRepository.addPayment(record)) networkRepository.refresh()
|
||||
else _toastMessage.emit("خطا در ثبت پرداخت.")
|
||||
}
|
||||
|
||||
else -> handleOtherEvents(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleOtherEvents(event: PersonScreenEvent) {
|
||||
viewModelScope.launch {
|
||||
when (event) {
|
||||
is PersonScreenEvent.SelectPerson -> {
|
||||
_selectedPersonId.value = event.personId
|
||||
_selectedYear.value = getCurrentShamsiYear()
|
||||
}
|
||||
|
||||
is PersonScreenEvent.DeletePerson -> {
|
||||
if (networkRepository.deletePersonAndPayments(event.personId)) {
|
||||
networkRepository.refresh()
|
||||
}
|
||||
if (networkRepository.deletePersonAndPayments(event.personId)) networkRepository.refresh()
|
||||
}
|
||||
|
||||
is PersonScreenEvent.ArchivePerson -> {
|
||||
if (networkRepository.updatePersonArchivedStatus(event.personId, true)) {
|
||||
_toastMessage.emit("شخص به آرشیو انتقال یافت.")
|
||||
networkRepository.refresh()
|
||||
} else {
|
||||
_toastMessage.emit("خطا در آرشیو کردن.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,105 +265,73 @@ class PersonViewModel(
|
||||
if (networkRepository.updatePersonArchivedStatus(event.personId, false)) {
|
||||
_toastMessage.emit("شخص از آرشیو بازیابی شد.")
|
||||
networkRepository.refresh()
|
||||
} else {
|
||||
_toastMessage.emit("خطا در بازیابی.")
|
||||
}
|
||||
}
|
||||
|
||||
is PersonScreenEvent.UpdatePerson -> {
|
||||
if (networkRepository.updatePerson(event.personId, event.name)) {
|
||||
networkRepository.refresh()
|
||||
}
|
||||
val statusCode = networkRepository.updatePerson(event.personId, event.name, event.phoneNumber)
|
||||
if (statusCode == 409) _toastMessage.emit("خطا: این نام از قبل وجود دارد.")
|
||||
else if (statusCode == 200) networkRepository.refresh()
|
||||
else _toastMessage.emit("خطا در ویرایش اطلاعات.")
|
||||
}
|
||||
|
||||
is PersonScreenEvent.Login -> login(AuthRequest(event.username, event.password))
|
||||
|
||||
is PersonScreenEvent.Register -> register(AuthRequest(event.username, event.password))
|
||||
|
||||
is PersonScreenEvent.ChangeYear -> {
|
||||
when (event.offset) {
|
||||
1 -> {
|
||||
if (_selectedYear.value < getCurrentShamsiYear()) {
|
||||
_selectedYear.update { it + 1 }
|
||||
} else {
|
||||
_infoMessage.emit("امکان مشاهده سال آینده وجود ندارد.")
|
||||
}
|
||||
}
|
||||
|
||||
-1 -> {
|
||||
val personId = _selectedPersonId.value ?: return@launch
|
||||
val hasPaymentsInPast = uiState.value.payments.any {
|
||||
it.personId == personId && it.shamsiYear < _selectedYear.value
|
||||
}
|
||||
if (hasPaymentsInPast) {
|
||||
_selectedYear.update { it - 1 }
|
||||
} else {
|
||||
_infoMessage.emit("هیچ پرداختی برای سالهای گذشته ثبت نشده است.")
|
||||
}
|
||||
}
|
||||
val currentYear = getCurrentShamsiYear()
|
||||
if (event.offset == 1) {
|
||||
if (_selectedYear.value < currentYear) _selectedYear.update { it + 1 }
|
||||
else _infoMessage.emit("امکان مشاهده سال آینده وجود ندارد.")
|
||||
} else {
|
||||
_selectedYear.update { it - 1 }
|
||||
}
|
||||
}
|
||||
|
||||
is PersonScreenEvent.AddPaymentForMonth -> {
|
||||
val record = createPaymentRecord(
|
||||
event.personId,
|
||||
event.month,
|
||||
event.year,
|
||||
event.amount,
|
||||
event.description
|
||||
)
|
||||
if (networkRepository.addPayment(record)) {
|
||||
networkRepository.refresh()
|
||||
} else {
|
||||
_toastMessage.emit("خطا در ثبت پرداخت")
|
||||
}
|
||||
val record = createPaymentRecord(event.personId, event.month, event.year, event.amount, event.description)
|
||||
if (networkRepository.addPayment(record)) networkRepository.refresh()
|
||||
else _toastMessage.emit("خطا در ثبت پرداخت")
|
||||
}
|
||||
|
||||
is PersonScreenEvent.UpdatePayment -> {
|
||||
val updatedRecord = event.payment.copy(
|
||||
amount = event.newAmount,
|
||||
description = event.newDescription,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
if (networkRepository.addPayment(updatedRecord)) {
|
||||
networkRepository.refresh()
|
||||
} else {
|
||||
_toastMessage.emit("خطا در ویرایش پرداخت")
|
||||
}
|
||||
val updatedRecord = event.payment.copy(amount = event.newAmount, description = event.newDescription, timestamp = System.currentTimeMillis())
|
||||
if (networkRepository.addPayment(updatedRecord)) networkRepository.refresh()
|
||||
else _toastMessage.emit("خطا در ویرایش پرداخت")
|
||||
}
|
||||
|
||||
is PersonScreenEvent.DeletePayment -> {
|
||||
if (networkRepository.deletePayment(event.payment.id)) {
|
||||
networkRepository.refresh()
|
||||
} else {
|
||||
_toastMessage.emit("خطا در حذف پرداخت")
|
||||
if (networkRepository.deletePayment(event.payment.id)) networkRepository.refresh()
|
||||
else _toastMessage.emit("خطا در حذف پرداخت")
|
||||
}
|
||||
|
||||
is PersonScreenEvent.ToggleSelection -> {
|
||||
_selectedIds.update { current ->
|
||||
if (current.contains(event.personId)) {
|
||||
val newSet = current - event.personId
|
||||
if (newSet.isEmpty()) _isSelectionMode.value = false
|
||||
newSet
|
||||
} else {
|
||||
_isSelectionMode.value = true
|
||||
current + event.personId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is PersonScreenEvent.ClearSelection -> {
|
||||
_selectedIds.value = emptySet()
|
||||
_isSelectionMode.value = false
|
||||
}
|
||||
|
||||
is PersonScreenEvent.ExportYearlyReport -> exportYearlyReport(event.year, event.context)
|
||||
|
||||
is PersonScreenEvent.CommitReorder -> {
|
||||
// بعد از اتمام درگ: ترتیب فعلی لیست را به عنوان displayOrder جدید ذخیره کن
|
||||
val currentListState = _uiState.value
|
||||
val list = when (event.listType) {
|
||||
PersonListType.UNPAID -> currentListState.unpaidPersons
|
||||
PersonListType.PAID -> currentListState.paidPersons
|
||||
}
|
||||
|
||||
val list = if (event.listType == PersonListType.UNPAID) _uiState.value.unpaidPersons else _uiState.value.paidPersons
|
||||
if (list.size < 2) return@launch
|
||||
|
||||
try {
|
||||
list.forEachIndexed { index, person ->
|
||||
val newOrder = (index + 1) * 10_000L
|
||||
if (person.displayOrder != newOrder) {
|
||||
val success = networkRepository.updatePersonDisplayOrder(
|
||||
person.id,
|
||||
newOrder
|
||||
)
|
||||
if (!success) {
|
||||
throw Exception("update failed")
|
||||
}
|
||||
networkRepository.updatePersonDisplayOrder(person.id, newOrder)
|
||||
}
|
||||
}
|
||||
// بعد از ذخیره، از سرور رفرش کن تا همهچیز باهم سینک بشه
|
||||
networkRepository.refresh()
|
||||
} catch (_: Exception) {
|
||||
_toastMessage.emit("خطا در ذخیره ترتیب")
|
||||
@@ -414,109 +344,33 @@ class PersonViewModel(
|
||||
networkRepository.refresh()
|
||||
_isRefreshing.value = false
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPaymentRecord(
|
||||
personId: String,
|
||||
month: Int,
|
||||
year: Int,
|
||||
amount: Double,
|
||||
description: String
|
||||
): PaymentRecord {
|
||||
return PaymentRecord(
|
||||
personId = personId,
|
||||
amount = amount,
|
||||
shamsiYear = year,
|
||||
shamsiMonth = month,
|
||||
description = description
|
||||
)
|
||||
private fun exportYearlyReport(year: Int, context: Context) {
|
||||
viewModelScope.launch {
|
||||
val persons = localPersonRepository.getAllPersonsFlow().first()
|
||||
val payments = localPersonRepository.getAllPaymentsFlow().first().filter { it.shamsiYear == year }
|
||||
if (!exportManager.exportYearlyReport(year, persons, payments)) {
|
||||
_toastMessage.emit("خطا در ایجاد گزارش")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shareReceipt(payment: PaymentRecord, personName: String) {
|
||||
exportManager.shareReceipt(payment, personName)
|
||||
}
|
||||
|
||||
private fun updateDashboard(totalPersonCount: Int, paymentsThisMonth: List<PaymentRecord>) {
|
||||
val paidCount = paymentsThisMonth.map { it.personId }.distinct().size
|
||||
val totalIncome = paymentsThisMonth.sumOf { it.amount }
|
||||
val progress =
|
||||
if (totalPersonCount > 0) paidCount.toFloat() / totalPersonCount.toFloat() else 0f
|
||||
_dashboardData.value =
|
||||
DashboardUiModel(paidCount, totalPersonCount, totalIncome, progress)
|
||||
val progress = if (totalPersonCount > 0) paidCount.toFloat() / totalPersonCount.toFloat() else 0f
|
||||
_dashboardData.value = DashboardUiModel(paidCount, totalPersonCount, totalIncome, progress)
|
||||
}
|
||||
|
||||
fun logout() = viewModelScope.launch {
|
||||
settingsRepository.saveAuthData(null, null)
|
||||
_toastMessage.emit("از حساب کاربری خارج شدید.")
|
||||
}
|
||||
|
||||
fun login(request: AuthRequest) = viewModelScope.launch {
|
||||
val response = networkRepository.login(request)
|
||||
if (response != null && response.token.isNotBlank()) {
|
||||
settingsRepository.saveAuthData(response.token, response.userId)
|
||||
_toastMessage.emit("ورود موفقیتآمیز بود")
|
||||
networkRepository.refresh()
|
||||
} else {
|
||||
_toastMessage.emit("نام کاربری یا رمز عبور اشتباه است.")
|
||||
}
|
||||
}
|
||||
|
||||
fun register(request: AuthRequest) = viewModelScope.launch {
|
||||
val response = networkRepository.register(request)
|
||||
if (response != null && response.token.isNotBlank()) {
|
||||
settingsRepository.saveAuthData(response.token, response.userId)
|
||||
_toastMessage.emit("ثبت نام و ورود موفقیتآمیز بود")
|
||||
networkRepository.refresh()
|
||||
} else {
|
||||
_toastMessage.emit("خطا در ثبت نام.")
|
||||
}
|
||||
private fun createPaymentRecord(personId: String, month: Int, year: Int, amount: Double, description: String): PaymentRecord {
|
||||
return PaymentRecord(personId = personId, amount = amount, shamsiYear = year, shamsiMonth = month, description = description)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class PersonScreenEvent {
|
||||
data object RefreshData : PersonScreenEvent()
|
||||
data class AddPerson(val name: String) : PersonScreenEvent()
|
||||
data class UpdatePerson(val personId: String, val name: String) : PersonScreenEvent()
|
||||
data class DeletePerson(val personId: String) : PersonScreenEvent()
|
||||
data class ArchivePerson(val personId: String) : PersonScreenEvent()
|
||||
data class RestorePerson(val personId: String) : PersonScreenEvent()
|
||||
|
||||
// درگ اند دراپ
|
||||
data class MovePersonNew(
|
||||
val fromIndex: Int,
|
||||
val toIndex: Int,
|
||||
val listType: PersonListType
|
||||
) : PersonScreenEvent()
|
||||
|
||||
// پایان درگ: ذخیره ترتیب
|
||||
data class CommitReorder(
|
||||
val listType: PersonListType
|
||||
) : PersonScreenEvent()
|
||||
|
||||
data class AddQuickPayment(
|
||||
val personId: String,
|
||||
val amount: Double,
|
||||
val description: String
|
||||
) : PersonScreenEvent()
|
||||
|
||||
data class Login(val username: String, val password: String) : PersonScreenEvent()
|
||||
data class Register(val username: String, val password: String) : PersonScreenEvent()
|
||||
|
||||
data class SelectPerson(val personId: String) : PersonScreenEvent()
|
||||
data class ChangeYear(val offset: Int) : PersonScreenEvent()
|
||||
data class AddPaymentForMonth(
|
||||
val personId: String,
|
||||
val month: Int,
|
||||
val year: Int,
|
||||
val amount: Double,
|
||||
val description: String
|
||||
) : PersonScreenEvent()
|
||||
|
||||
data class UpdatePayment(
|
||||
val payment: PaymentRecord,
|
||||
val newAmount: Double,
|
||||
val newDescription: String
|
||||
) : PersonScreenEvent()
|
||||
|
||||
data class DeletePayment(val payment: PaymentRecord) : PersonScreenEvent()
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.oqba26.monthlypaymentapp.data.model.BackupData
|
||||
import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository
|
||||
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -14,7 +16,8 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class SettingsViewModel(
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val localPersonRepository: LocalPersonRepository
|
||||
) : ViewModel() {
|
||||
@@ -32,9 +35,49 @@ class SettingsViewModel(
|
||||
val selectedFont: StateFlow<String> = settingsRepository.selectedFontFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), SettingsRepository.DEFAULT_FONT)
|
||||
|
||||
val reminderDay: StateFlow<Int?> = settingsRepository.reminderDayFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null)
|
||||
|
||||
val cardNumbers: StateFlow<List<String>> = settingsRepository.cardNumbersFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), emptyList())
|
||||
|
||||
private val _toastMessage = MutableSharedFlow<String>()
|
||||
val toastMessage = _toastMessage.asSharedFlow()
|
||||
|
||||
fun saveReminderDay(day: String) {
|
||||
viewModelScope.launch {
|
||||
val dayInt = day.toIntOrNull()
|
||||
if (dayInt == null || dayInt in 1..31) {
|
||||
settingsRepository.saveReminderDay(dayInt)
|
||||
_toastMessage.emit(if (dayInt == null) "یادآور غیرفعال شد" else "یادآور برای روز $day ماه تنظیم شد")
|
||||
} else {
|
||||
_toastMessage.emit("روز وارد شده نامعتبر است (۱ تا ۳۱)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addCardNumber(cardNumber: String) {
|
||||
viewModelScope.launch {
|
||||
if (cardNumber.isBlank()) return@launch
|
||||
val current = cardNumbers.value.toMutableList()
|
||||
if (!current.contains(cardNumber)) {
|
||||
current.add(cardNumber)
|
||||
settingsRepository.saveCardNumbers(current)
|
||||
_toastMessage.emit("شماره کارت اضافه شد")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCardNumber(cardNumber: String) {
|
||||
viewModelScope.launch {
|
||||
val current = cardNumbers.value.toMutableList()
|
||||
if (current.remove(cardNumber)) {
|
||||
settingsRepository.saveCardNumbers(current)
|
||||
_toastMessage.emit("شماره کارت حذف شد")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveDefaultPaymentAmount(amount: String) {
|
||||
viewModelScope.launch {
|
||||
val amountAsDouble = amount.toDoubleOrNull() ?: return@launch
|
||||
@@ -61,7 +104,7 @@ class SettingsViewModel(
|
||||
val backupData = json.decodeFromString<BackupData>(jsonString)
|
||||
localPersonRepository.restoreBackup(backupData)
|
||||
_toastMessage.emit("اطلاعات با موفقیت بازیابی شد! لطفاً برنامه را مجدد اجرا کنید.")
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
_toastMessage.emit("خطا در بازیابی اطلاعات: فایل نامعتبر است.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.oqba26.monthlypaymentapp.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
|
||||
import com.oqba26.monthlypaymentapp.data.model.Person
|
||||
|
||||
@@ -7,8 +8,12 @@ data class PersonUiModel(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val hasPaidThisMonth: Boolean,
|
||||
val displayOrder: Long, // FIX: Changed from Int to Long
|
||||
val createdAt: Long
|
||||
val displayOrder: Long?,
|
||||
val createdAt: Long?,
|
||||
val debtCount: Int = 0,
|
||||
val totalDebtAmount: Double = 0.0,
|
||||
val phoneNumber: String? = null,
|
||||
val unpaidMonthsNames: List<String> = emptyList()
|
||||
)
|
||||
|
||||
data class PersonListUiState(
|
||||
@@ -16,6 +21,21 @@ data class PersonListUiState(
|
||||
val paidPersons: List<PersonUiModel> = emptyList(),
|
||||
val archivedPersons: List<PersonUiModel> = emptyList(),
|
||||
val payments: List<PaymentRecord> = emptyList(),
|
||||
val bulkSmsQueue: List<PersonUiModel> = emptyList(),
|
||||
val currentBulkIndex: Int = -1,
|
||||
val selectedCardForBulk: String? = null,
|
||||
val contactSuggestions: List<ContactSuggestion> = emptyList()
|
||||
)
|
||||
|
||||
data class ContactSuggestion(
|
||||
val personId: String,
|
||||
val personName: String,
|
||||
val matches: List<ContactMatch>
|
||||
)
|
||||
|
||||
data class ContactMatch(
|
||||
val nameInContacts: String,
|
||||
val phoneNumber: String
|
||||
)
|
||||
|
||||
data class PersonDetailUiState(
|
||||
@@ -38,6 +58,68 @@ enum class MonthStatus {
|
||||
FUTURE_YEAR
|
||||
}
|
||||
|
||||
enum class PersonListType {
|
||||
UNPAID,
|
||||
PAID
|
||||
}
|
||||
|
||||
sealed class PersonScreenEvent {
|
||||
data object RefreshData : PersonScreenEvent()
|
||||
data class AddPerson(val name: String, val phoneNumber: String?, val context: Context) : PersonScreenEvent()
|
||||
data class UpdatePerson(val personId: String, val name: String, val phoneNumber: String?) : PersonScreenEvent()
|
||||
data class DeletePerson(val personId: String) : PersonScreenEvent()
|
||||
data class ArchivePerson(val personId: String) : PersonScreenEvent()
|
||||
data class RestorePerson(val personId: String) : PersonScreenEvent()
|
||||
|
||||
data class MovePersonNew(
|
||||
val fromIndex: Int,
|
||||
val toIndex: Int,
|
||||
val listType: PersonListType
|
||||
) : PersonScreenEvent()
|
||||
|
||||
data class CommitReorder(
|
||||
val listType: PersonListType
|
||||
) : PersonScreenEvent()
|
||||
|
||||
data class AddQuickPayment(
|
||||
val personId: String,
|
||||
val amount: Double,
|
||||
val description: String
|
||||
) : PersonScreenEvent()
|
||||
|
||||
data class SelectPerson(val personId: String) : PersonScreenEvent()
|
||||
data class ChangeYear(val offset: Int) : PersonScreenEvent()
|
||||
data class AddPaymentForMonth(
|
||||
val personId: String,
|
||||
val month: Int,
|
||||
val year: Int,
|
||||
val amount: Double,
|
||||
val description: String
|
||||
) : PersonScreenEvent()
|
||||
|
||||
data class UpdatePayment(
|
||||
val payment: PaymentRecord,
|
||||
val newAmount: Double,
|
||||
val newDescription: String
|
||||
) : PersonScreenEvent()
|
||||
|
||||
data class DeletePayment(val payment: PaymentRecord) : PersonScreenEvent()
|
||||
data class AddBulkPayments(
|
||||
val personId: String,
|
||||
val months: List<Int>,
|
||||
val year: Int,
|
||||
val amount: Double
|
||||
) : PersonScreenEvent()
|
||||
data class ExportYearlyReport(val year: Int, val context: Context) : PersonScreenEvent()
|
||||
|
||||
data class ToggleSelection(val personId: String) : PersonScreenEvent()
|
||||
data class MoveSelected(val direction: Int, val listType: PersonListType) : PersonScreenEvent()
|
||||
data object ClearSelection : PersonScreenEvent()
|
||||
|
||||
data class ConfirmContactSuggestion(val personId: String, val personName: String, val phoneNumber: String) : PersonScreenEvent()
|
||||
data class DismissContactSuggestion(val suggestion: ContactSuggestion) : PersonScreenEvent()
|
||||
}
|
||||
|
||||
data class DashboardUiModel(
|
||||
val paidCount: Int = 0,
|
||||
val totalCount: Int = 0,
|
||||
|
||||
@@ -1,3 +1,33 @@
|
||||
<resources>
|
||||
<string name="app_name">مدیریت حقوق ماهانه</string>
|
||||
<string name="archive_person">آرشیو شخص</string>
|
||||
<string name="archive_person_confirm">آیا از انتقال %1$s به آرشیو مطمئن هستید؟</string>
|
||||
<string name="delete_person">حذف قطعی شخص</string>
|
||||
<string name="delete_person_confirm">آیا از حذف قطعی %1$s و تمامی سوابق پرداخت او مطمئن هستید؟ این عمل غیرقابل بازگشت است.</string>
|
||||
<string name="restore_person">بازیابی شخص</string>
|
||||
<string name="restore_person_confirm">آیا از بازیابی %1$s به لیست اصلی مطمئن هستید؟</string>
|
||||
<string name="exit_app">خروج از برنامه</string>
|
||||
<string name="exit_app_confirm">آیا برای خروج از برنامه مطمئن هستید؟</string>
|
||||
<string name="yes">بله</string>
|
||||
<string name="no">خیر</string>
|
||||
<string name="cancel">لغو</string>
|
||||
<string name="save">ذخیره</string>
|
||||
<string name="add">افزودن</string>
|
||||
<string name="edit">ویرایش</string>
|
||||
<string name="settings">تنظیمات</string>
|
||||
<string name="backup">پشتیبانگیری</string>
|
||||
<string name="restore">بازیابی</string>
|
||||
<string name="backup_success">پشتیبانگیری با موفقیت انجام شد</string>
|
||||
<string name="backup_error">خطا در ایجاد فایل پشتیبان</string>
|
||||
<string name="restore_warning">هشدار بازیابی</string>
|
||||
<string name="restore_warning_text">با بازیابی، تمام دادههای فعلی حذف و اطلاعات فایل جایگزین میشود. مطمئن هستید؟</string>
|
||||
<string name="logout">خروج از حساب کاربری</string>
|
||||
<string name="default_payment_amount">مبلغ پیشفرض پرداخت</string>
|
||||
<string name="reminder_day">روز یادآور ماهانه</string>
|
||||
<string name="bank_cards">شماره کارتهای بانکی</string>
|
||||
<string name="add_person">افزودن شخص جدید</string>
|
||||
<string name="bulk_sms">ارسال گروهی پیامک</string>
|
||||
<string name="unpaid">پرداخت نشده</string>
|
||||
<string name="paid">پرداخت شده</string>
|
||||
<string name="archived">آرشیو شده</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-files-path name="my_downloads" path="Download/" />
|
||||
</paths>
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.oqba26.monthlypaymentapp
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user