Add auto-release and update system

This commit is contained in:
oqba26 committed 2026-07-31 21:42:29 +03:30
1 parent 502cdacb44
commit 0557679e9c
125 files changed
+6799 -1680

No files matched your search

+52 -17
View File
@@ -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)
}
}
+14
View File
@@ -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 {
@@ -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)
@@ -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
@@ -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,
+30
View File
@@ -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>
+4
View File
@@ -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)
}
}