"بروزرسانی دیالوگها، آرشیو و توضیحات پرداخت - ۱۴۰۴/۰۹/۰۱ (جزئیات در README.md)"
This commit is contained in:
1 parent
ddf8a3d815
commit
502cdacb44
33 files changed
+2026
-1556
No files matched your search
@@ -47,14 +47,14 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
// همتراز با JDK 17
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
// Align with the JDK version used by Android Studio
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlinOptions { jvmTarget = "17" }
|
||||
// اطمینان از JDK 17
|
||||
kotlin { jvmToolchain(17) }
|
||||
kotlinOptions { jvmTarget = "21" }
|
||||
// Ensure JDK 21
|
||||
kotlin { jvmToolchain(21) }
|
||||
|
||||
packaging {
|
||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||
@@ -72,7 +72,9 @@ 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-icons-extended-android:1.6.7")
|
||||
implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6")
|
||||
|
||||
// Core & Coroutines
|
||||
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
|
||||
@@ -98,7 +100,7 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
|
||||
// Utility
|
||||
implementation(libs.persiandate)
|
||||
implementation(project(":PersianDate"))
|
||||
implementation(libs.google.material)
|
||||
|
||||
// Desugaring
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package com.oqba26.monthlypaymentapp
|
||||
|
||||
import android.os.Bundle
|
||||
@@ -9,18 +11,31 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
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.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -31,17 +46,26 @@ 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.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
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.screens.ArchiveScreen
|
||||
import com.oqba26.monthlypaymentapp.ui.screens.AuthScreen
|
||||
import com.oqba26.monthlypaymentapp.ui.screens.PaidListScreen
|
||||
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
|
||||
@@ -54,7 +78,8 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val application = application as PaymentApplication
|
||||
val personViewModel: PersonViewModel = viewModel(factory = application.personViewModelFactory)
|
||||
val personViewModel: PersonViewModel =
|
||||
viewModel(factory = application.personViewModelFactory)
|
||||
|
||||
MonthlyPaymentManagement2Theme {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
@@ -81,26 +106,108 @@ fun MainAppHost(viewModel: PersonViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { paddingValues ->
|
||||
Crossfade(targetState = authState, label = "AuthScreenSwitch") { state ->
|
||||
when (state) {
|
||||
is AuthState.Loading -> LoadingScreen()
|
||||
is AuthState.Unauthenticated -> AuthScreen()
|
||||
is AuthState.Authenticated -> AuthenticatedContent(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
|
||||
@Composable
|
||||
fun AuthenticatedContent(viewModel: PersonViewModel) {
|
||||
val navController = rememberNavController()
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route ?: ""
|
||||
|
||||
val showBottomBar = currentRoute in listOf(
|
||||
Screen.Unpaid.route,
|
||||
Screen.Paid.route,
|
||||
Screen.Archive.route,
|
||||
Screen.Settings.route
|
||||
)
|
||||
|
||||
// هندل کردن دکمه Back
|
||||
BackHandler {
|
||||
showExitDialog = true
|
||||
}
|
||||
|
||||
if (showExitDialog) {
|
||||
ExitDialog(
|
||||
onConfirmExit = { (context as? ComponentActivity)?.finish() },
|
||||
onDismiss = {
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
showExitDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("مدیریت حقوق ماهانه") },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
titleContentColor = Color.White,
|
||||
actionIconContentColor = Color.White
|
||||
),
|
||||
actions = {
|
||||
if (currentRoute == Screen.Unpaid.route) {
|
||||
Button(
|
||||
onClick = { viewModel.onAddPersonClicked() },
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text("افزودن شخص")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (showBottomBar) {
|
||||
AppBottomNavigationBar(navController = navController)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.Unpaid.route,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
Crossfade(targetState = authState, label = "AuthScreenSwitch") { state ->
|
||||
when (state) {
|
||||
is AuthState.Loading -> {
|
||||
LoadingScreen()
|
||||
}
|
||||
is AuthState.Unauthenticated -> {
|
||||
AuthScreen()
|
||||
}
|
||||
is AuthState.Authenticated -> {
|
||||
AppNavigation(viewModel)
|
||||
}
|
||||
composable(Screen.Unpaid.route) {
|
||||
PersonScreen(
|
||||
viewModel = viewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
composable(Screen.Paid.route) {
|
||||
PaidListScreen(viewModel, navController)
|
||||
}
|
||||
composable(Screen.Archive.route) {
|
||||
ArchiveScreen(viewModel)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(onLogout = { viewModel.logout() })
|
||||
}
|
||||
composable(
|
||||
route = "person_detail/{personId}",
|
||||
arguments = listOf(navArgument("personId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val personId = backStackEntry.arguments?.getString("personId")
|
||||
if (personId != null) {
|
||||
PersonDetailScreen(
|
||||
personId = personId,
|
||||
viewModel = viewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,64 +215,93 @@ fun MainAppHost(viewModel: PersonViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavigation(viewModel: PersonViewModel) {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
fun AppBottomNavigationBar(navController: NavController) {
|
||||
val items = listOf(
|
||||
Screen.Unpaid,
|
||||
Screen.Paid,
|
||||
Screen.Archive,
|
||||
Screen.Settings
|
||||
)
|
||||
|
||||
var showExitDialog by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
NavigationBar(
|
||||
containerColor = MaterialTheme.colorScheme.primary // Match TopAppBar
|
||||
) {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
BackHandler(enabled = currentRoute == "personScreen") {
|
||||
showExitDialog = true
|
||||
}
|
||||
|
||||
if (showExitDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showExitDialog = false },
|
||||
title = { Text("خروج از برنامه") },
|
||||
text = { Text("آیا برای خروج از برنامه مطمئن هستید؟") },
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(all = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Button(
|
||||
onClick = { (context as? ComponentActivity)?.finish() },
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||
) {
|
||||
Text("بله")
|
||||
}
|
||||
Button(onClick = { showExitDialog = false }) {
|
||||
Text("خیر")
|
||||
items.forEach { screen ->
|
||||
NavigationBarItem(
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedTextColor = Color.White,
|
||||
selectedIconColor = Color.White,
|
||||
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
unselectedTextColor = Color.White.copy(alpha = 0.7f),
|
||||
unselectedIconColor = Color.White.copy(alpha = 0.7f)
|
||||
),
|
||||
icon = { Icon(screen.icon, contentDescription = screen.title) },
|
||||
label = { Text(screen.title) },
|
||||
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
|
||||
onClick = {
|
||||
navController.navigate(screen.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
NavHost(navController = navController, startDestination = "personScreen") {
|
||||
composable("personScreen") {
|
||||
PersonScreen(
|
||||
onSettingsClick = { navController.navigate("settings") },
|
||||
//onExitApp = { viewModel.logout() }
|
||||
)
|
||||
}
|
||||
composable("settings") {
|
||||
SettingsScreen(onBackClick = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Screen(
|
||||
val route: String,
|
||||
val title: String,
|
||||
val icon: androidx.compose.ui.graphics.vector.ImageVector
|
||||
) {
|
||||
data object Unpaid : Screen("unpaid_list", "پرداخت نشده", Icons.AutoMirrored.Filled.List)
|
||||
data object Paid : Screen("paid_list", "پرداخت شده", Icons.Default.Paid)
|
||||
data object Archive : Screen("archive", "آرشیو", Icons.Default.Archive)
|
||||
data object Settings : Screen("settings", "تنظیمات", Icons.Default.Settings)
|
||||
}
|
||||
|
||||
@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
|
||||
) {
|
||||
Button(
|
||||
onClick = onConfirmExit,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("بله")
|
||||
}
|
||||
Button(onClick = onDismiss) {
|
||||
Text("خیر")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingScreen() {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
Text("در حال بررسی وضعیت...", modifier = Modifier.align(Alignment.Center).padding(top = 80.dp))
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator()
|
||||
Text("در حال بررسی وضعیت...", modifier = Modifier.padding(top = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
@Dao
|
||||
interface PersonDao {
|
||||
|
||||
@Query("SELECT * FROM persons ORDER BY name COLLATE NOCASE ASC")
|
||||
@Query("SELECT * FROM persons ORDER BY displayOrder ASC")
|
||||
fun getAllPersonsFlow(): Flow<List<Person>>
|
||||
|
||||
@Query("SELECT * FROM persons WHERE id = :id LIMIT 1")
|
||||
@@ -19,6 +19,12 @@ interface PersonDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(persons: List<Person>)
|
||||
|
||||
@Query("UPDATE persons SET isArchived = :isArchived WHERE id = :personId")
|
||||
suspend fun updateArchivedStatus(personId: String, isArchived: Boolean)
|
||||
|
||||
@Query("UPDATE persons SET displayOrder = :displayOrder WHERE id = :personId")
|
||||
suspend fun updateDisplayOrder(personId: String, displayOrder: Int)
|
||||
|
||||
@Query("DELETE FROM persons")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.oqba26.monthlypaymentapp.data.database
|
||||
|
||||
import android.content.Context
|
||||
@@ -14,7 +16,7 @@ import java.util.UUID
|
||||
|
||||
@Database(
|
||||
entities = [Person::class, PaymentRecord::class],
|
||||
version = 11, // حتماً نسخه را بالا ببری که اسکیمای جدید اعمال شود
|
||||
version = 13, // حتماً نسخه را بالا ببری که اسکیمای جدید اعمال شود
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
@@ -27,7 +29,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
// تمیزکاری قبل از درج بکاپ
|
||||
val safePersons = backupData.persons.map { p ->
|
||||
val name = p.name.trim()
|
||||
val id = if (p.id.isNotBlank()) p.id else UUID.randomUUID().toString()
|
||||
val id = p.id.ifBlank { UUID.randomUUID().toString() }
|
||||
Person(id = id, name = name)
|
||||
}
|
||||
personDao().deleteAll()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
|
||||
|
||||
package com.oqba26.monthlypaymentapp.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
@@ -12,5 +14,6 @@ data class PaymentRecord(
|
||||
val amount: Double,
|
||||
val shamsiYear: Int,
|
||||
val shamsiMonth: Int,
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val description: String = ""
|
||||
)
|
||||
@@ -2,14 +2,17 @@ package com.oqba26.monthlypaymentapp.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
import kotlin.OptIn // CORRECTED IMPORT
|
||||
|
||||
@OptIn(InternalSerializationApi::class)
|
||||
@Serializable
|
||||
@Entity(tableName = "persons")
|
||||
data class Person(
|
||||
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
||||
@PrimaryKey val id: String = "",
|
||||
val name: String,
|
||||
val userId: String? = null,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
val isArchived: Boolean = false,
|
||||
val displayOrder: Long = 0L,
|
||||
val createdAt: Long = 0L
|
||||
)
|
||||
@@ -22,8 +22,7 @@ class ApiClient(settingsRepository: SettingsRepository) {
|
||||
private val authInterceptor = AuthInterceptor(settingsRepository)
|
||||
|
||||
private val logging = HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
|
||||
else HttpLoggingInterceptor.Level.NONE
|
||||
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
|
||||
// ⭐️ افزودن AuthInterceptor به OkHttpClient
|
||||
|
||||
@@ -8,6 +8,7 @@ import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
@@ -33,6 +34,15 @@ interface ApiService {
|
||||
@PUT("persons/{id}")
|
||||
suspend fun updatePerson(@Path("id") personId: String, @Body person: Person): Response<Unit>
|
||||
|
||||
@PATCH("persons/{id}/archived")
|
||||
suspend fun updatePersonArchivedStatus(@Path("id") personId: String, @Body isArchived: Map<String, Boolean>): Response<Unit>
|
||||
|
||||
@PATCH("persons/{id}/display-order")
|
||||
suspend fun updatePersonDisplayOrder(@Path("id") personId: String, @Body displayOrder: Map<String, Long>): Response<Unit>
|
||||
|
||||
@POST("persons/rebalance")
|
||||
suspend fun rebalanceDisplayOrders(): Response<Unit>
|
||||
|
||||
@DELETE("persons/{id}")
|
||||
suspend fun deletePersonAndPayments(@Path("id") personId: String): Response<Unit>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.oqba26.monthlypaymentapp.data.remote
|
||||
|
||||
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
@@ -11,19 +11,20 @@ class AuthInterceptor(
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
// توکن را به صورت Blocking از DataStore میخوانیم
|
||||
// Get the token from DataStore, blocking the current thread.
|
||||
// This is acceptable for an I/O-bound operation in an interceptor.
|
||||
val authToken = runBlocking {
|
||||
settingsRepository.authTokenFlow.firstOrNull()
|
||||
settingsRepository.authTokenFlow.first() // Use first() to wait for a value
|
||||
}
|
||||
|
||||
val originalRequest = chain.request()
|
||||
val requestBuilder = originalRequest.newBuilder()
|
||||
if (authToken != null) {
|
||||
// اضافه کردن توکن به هدر (فرمت استاندارد Bearer)
|
||||
|
||||
// Add the Authorization header only if the token is not null or blank
|
||||
if (!authToken.isNullOrBlank()) {
|
||||
requestBuilder.header("Authorization", "Bearer $authToken")
|
||||
}
|
||||
|
||||
return chain.proceed(requestBuilder.build())
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-18
@@ -45,23 +45,20 @@ class NetworkRepository(private val api: ApiService) {
|
||||
suspend fun addPerson(person: Person): Int? {
|
||||
return try {
|
||||
val trimmed = person.name.trim()
|
||||
if (trimmed.isEmpty()) return 400 // Bad request from client side
|
||||
if (trimmed.isEmpty()) return 400
|
||||
val resp = api.addPerson(person.copy(name = trimmed))
|
||||
if (resp.isSuccessful) refresh()
|
||||
resp.code() // Return the actual HTTP status code
|
||||
resp.code()
|
||||
} catch (e: HttpException) {
|
||||
e.code() // Return the HTTP error code (e.g., 409)
|
||||
e.code()
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "addPerson error", e)
|
||||
null // Return null for non-HTTP errors
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deletePersonAndPayments(personId: String): Boolean {
|
||||
return try {
|
||||
val resp = api.deletePersonAndPayments(personId)
|
||||
if (resp.isSuccessful) refresh()
|
||||
resp.isSuccessful
|
||||
api.deletePersonAndPayments(personId).isSuccessful
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "deletePersonAndPayments error", e); false
|
||||
}
|
||||
@@ -71,23 +68,46 @@ class NetworkRepository(private val api: ApiService) {
|
||||
return try {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return false
|
||||
val personUpdate = Person(id = personId, name = trimmed)
|
||||
val resp = api.updatePerson(personId, personUpdate)
|
||||
if (resp.isSuccessful) refresh()
|
||||
resp.isSuccessful
|
||||
// Note: displayOrder is not updated here, only the name.
|
||||
val personUpdate = Person(id = personId, name = trimmed, displayOrder = 0)
|
||||
api.updatePerson(personId, personUpdate).isSuccessful
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "updatePerson error", e); false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean {
|
||||
return try {
|
||||
api.updatePersonArchivedStatus(personId, mapOf("isArchived" to isArchived)).isSuccessful
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "updatePersonArchivedStatus error", e); false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean {
|
||||
return try {
|
||||
api.updatePersonDisplayOrder(personId, mapOf("displayOrder" to displayOrder)).isSuccessful
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow()
|
||||
|
||||
// --- Payment Operations ---
|
||||
suspend fun addPayment(paymentRecord: PaymentRecord): Boolean {
|
||||
return try {
|
||||
val resp = api.addPayment(paymentRecord)
|
||||
if (resp.isSuccessful) refresh()
|
||||
resp.isSuccessful
|
||||
api.addPayment(paymentRecord).isSuccessful
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "addPayment error", e); false
|
||||
}
|
||||
@@ -95,9 +115,7 @@ class NetworkRepository(private val api: ApiService) {
|
||||
|
||||
suspend fun deletePayment(paymentId: String): Boolean {
|
||||
return try {
|
||||
val resp = api.deletePayment(paymentId)
|
||||
if (resp.isSuccessful) refresh()
|
||||
resp.isSuccessful
|
||||
api.deletePayment(paymentId).isSuccessful
|
||||
} catch (e: Exception) {
|
||||
Log.e("API", "deletePayment error", e); false
|
||||
}
|
||||
|
||||
+1
-7
@@ -19,7 +19,6 @@ class SettingsRepository(context: Context) {
|
||||
companion object {
|
||||
val DEFAULT_PAYMENT_AMOUNT = doublePreferencesKey("default_payment_amount")
|
||||
val SELECTED_FONT_NAME = stringPreferencesKey("selected_font_name")
|
||||
// ⭐️ جدید: کلیدهای مربوط به احراز هویت
|
||||
val AUTH_TOKEN_KEY = stringPreferencesKey("auth_token")
|
||||
val USER_ID_KEY = stringPreferencesKey("user_id")
|
||||
|
||||
@@ -27,7 +26,6 @@ class SettingsRepository(context: Context) {
|
||||
const val DEFAULT_FONT = "Estedad"
|
||||
}
|
||||
|
||||
// --- جریانهای تنظیمات عمومی ---
|
||||
val defaultPaymentAmountFlow: Flow<Double> = dataStore.data.map {
|
||||
it[DEFAULT_PAYMENT_AMOUNT] ?: FALLBACK_AMOUNT
|
||||
}
|
||||
@@ -44,19 +42,15 @@ class SettingsRepository(context: Context) {
|
||||
dataStore.edit { it[SELECTED_FONT_NAME] = fontName }
|
||||
}
|
||||
|
||||
|
||||
// --- جریانها و متدهای مربوط به احراز هویت ---
|
||||
val authTokenFlow: Flow<String?> = dataStore.data.map {
|
||||
it[AUTH_TOKEN_KEY]
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
val userIdFlow: Flow<String?> = dataStore.data.map {
|
||||
it[USER_ID_KEY]
|
||||
}
|
||||
|
||||
/**
|
||||
* توکن و ID کاربر را ذخیره میکند. برای پاک کردن، null ارسال کنید.
|
||||
*/
|
||||
suspend fun saveAuthData(token: String?, userId: String?) {
|
||||
dataStore.edit { preferences ->
|
||||
if (token != null) {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package com.oqba26.monthlypaymentapp.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun AddPaymentDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Double, String) -> Unit
|
||||
) {
|
||||
var amountText by remember { mutableStateOf("") }
|
||||
var description by remember { mutableStateOf("") }
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(shape = MaterialTheme.shapes.medium) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "افزودن پرداخت",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Right,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = amountText,
|
||||
onValueChange = { amountText = it },
|
||||
label = { Text("مبلغ (تومان)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("توضیحات") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("لغو")
|
||||
}
|
||||
TextButton(onClick = {
|
||||
val amount = amountText.toDoubleOrNull() ?: 0.0
|
||||
if (amount > 0 && description.isNotBlank()) {
|
||||
onConfirm(amount, description.trim())
|
||||
}
|
||||
}) {
|
||||
Text("تأیید")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.oqba26.monthlypaymentapp.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Restore
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
|
||||
|
||||
@Composable
|
||||
fun ArchiveScreen(viewModel: PersonViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
if (uiState.archivedPersons.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("لیست آرشیو خالی است.")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
items(uiState.archivedPersons, key = { it.id }) { person ->
|
||||
ArchivedPersonListItem(
|
||||
person = person,
|
||||
onRestore = {
|
||||
viewModel.onEvent(PersonScreenEvent.RestorePerson(person.id))
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArchivedPersonListItem(
|
||||
person: PersonUiModel,
|
||||
onRestore: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = person.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onRestore,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Restore, contentDescription = "بازیابی")
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("بازیابی")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
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.Row
|
||||
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.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.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonListType
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
|
||||
import org.burnoutcrew.reorderable.ReorderableItem
|
||||
import org.burnoutcrew.reorderable.detectReorderAfterLongPress
|
||||
import org.burnoutcrew.reorderable.rememberReorderableLazyListState
|
||||
import org.burnoutcrew.reorderable.reorderable
|
||||
|
||||
@Composable
|
||||
fun PaidListScreen(viewModel: PersonViewModel, 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
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onDragEnd = { _, _ ->
|
||||
if (searchQuery.isBlank()) {
|
||||
viewModel.onEvent(
|
||||
PersonScreenEvent.CommitReorder(
|
||||
listType = 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),
|
||||
label = { Text("جستجوی نام...") },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = "Search") },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
state = state.listState,
|
||||
modifier = Modifier.reorderable(state)
|
||||
) {
|
||||
itemsIndexed(
|
||||
uiState.paidPersons,
|
||||
key = { _, person -> person.id }
|
||||
) { index, person ->
|
||||
ReorderableItem(state, key = person.id) { _ ->
|
||||
|
||||
val rowDragModifier =
|
||||
if (searchQuery.isBlank()) {
|
||||
Modifier.detectReorderAfterLongPress(state)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = rowDragModifier
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
PersonListItem(
|
||||
person = person,
|
||||
index = index + 1,
|
||||
onPersonClick = {
|
||||
navController.navigate("person_detail/${person.id}")
|
||||
},
|
||||
// در لیست پرداخت شدهها، دکمه پرداخت سریع کاری نمیکند
|
||||
onQuickPayClick = { },
|
||||
onArchiveClick = { selectedPerson ->
|
||||
personToArchive.value = selectedPerson
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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("آرشیو")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,430 +1,446 @@
|
||||
@file:Suppress("AssignedValueIsNeverRead")
|
||||
|
||||
package com.oqba26.monthlypaymentapp.ui.screens
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ChevronLeft
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
|
||||
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
|
||||
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
|
||||
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
|
||||
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
|
||||
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDate
|
||||
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianTime
|
||||
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
|
||||
import com.oqba26.monthlypaymentapp.utils.toPersianDigits
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.MonthStatus
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonDetailViewModel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.MonthUiModel
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PersonDetailScreen(
|
||||
onBackClick: () -> Unit
|
||||
personId: String,
|
||||
viewModel: PersonViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val viewModel: PersonDetailViewModel = viewModel(factory = PersonDetailViewModel.Factory)
|
||||
val context = LocalContext.current
|
||||
val person by viewModel.person.collectAsState()
|
||||
val selectedYear by viewModel.selectedYear.collectAsState()
|
||||
val paymentHistory by viewModel.paymentHistory.collectAsState()
|
||||
val defaultPaymentAmount by viewModel.defaultPaymentAmount.collectAsState()
|
||||
LaunchedEffect(personId) {
|
||||
viewModel.onEvent(PersonScreenEvent.SelectPerson(personId))
|
||||
}
|
||||
|
||||
var paymentToEdit by remember { mutableStateOf<PaymentRecord?>(null) }
|
||||
var showAddPaymentDialog by remember { mutableStateOf(false) }
|
||||
var showEditPaymentDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
||||
var showMessageDialog by remember { mutableStateOf(false) }
|
||||
var dialogMessage by remember { mutableStateOf("") }
|
||||
val detailState by viewModel.personDetailState.collectAsState()
|
||||
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
|
||||
|
||||
var paymentAmount by remember { mutableStateOf("") }
|
||||
var selectedMonthForPayment by remember { mutableIntStateOf(1) }
|
||||
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) }
|
||||
|
||||
val currentYear = getCurrentShamsiYear()
|
||||
val currentMonth = getCurrentShamsiMonth()
|
||||
val persianMonths = listOf("فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند")
|
||||
|
||||
LaunchedEffect(key1 = true) {
|
||||
viewModel.toastMessage.collectLatest {
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.infoMessage.collect { message ->
|
||||
infoDialogMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(person?.name ?: "...") },
|
||||
navigationIcon = { IconButton(onClick = onBackClick) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding).padding(top = 16.dp)) {
|
||||
YearNavigator(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
viewModel = viewModel,
|
||||
selectedYear = selectedYear,
|
||||
paymentHistory = paymentHistory,
|
||||
currentYear = currentYear,
|
||||
showMessage = { msg ->
|
||||
dialogMessage = msg
|
||||
showMessageDialog = true
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
PersonDetailHeader(
|
||||
name = detailState.person?.name ?: "...",
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
|
||||
if (detailState.person == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
YearSelector(
|
||||
year = detailState.selectedYear,
|
||||
onYearChange = { offset ->
|
||||
viewModel.onEvent(PersonScreenEvent.ChangeYear(offset))
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
MonthsList(
|
||||
paymentHistory = paymentHistory,
|
||||
selectedYear = selectedYear,
|
||||
currentYear = currentYear,
|
||||
currentMonth = currentMonth,
|
||||
persianMonths = persianMonths,
|
||||
onAvailableClick = { month, _ ->
|
||||
selectedMonthForPayment = month
|
||||
paymentAmount = defaultPaymentAmount.toLong().toString()
|
||||
showAddPaymentDialog = true
|
||||
},
|
||||
onPaidClick = { payment ->
|
||||
paymentToEdit = payment
|
||||
paymentAmount = payment.amount.toLong().toString()
|
||||
showEditPaymentDialog = true
|
||||
},
|
||||
onDisabledClick = { message ->
|
||||
dialogMessage = message
|
||||
showMessageDialog = true
|
||||
|
||||
Spacer(modifier = Modifier.height(4.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
|
||||
},
|
||||
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 (showAddPaymentDialog) {
|
||||
AddPaymentDialog(
|
||||
monthName = persianMonths[selectedMonthForPayment - 1],
|
||||
year = selectedYear,
|
||||
amount = paymentAmount,
|
||||
onAmountChange = { paymentAmount = it.filter { c -> c.isDigit() } },
|
||||
infoDialogMessage?.let {
|
||||
InfoDialog(message = it, onDismiss = { infoDialogMessage = null })
|
||||
}
|
||||
|
||||
paymentToDelete?.let { payment ->
|
||||
DeleteConfirmationDialog(
|
||||
payment = payment,
|
||||
onConfirm = {
|
||||
val amount = paymentAmount.toDoubleOrNull()
|
||||
if (amount != null && amount > 0) {
|
||||
viewModel.addPaymentForMonth(selectedMonthForPayment, selectedYear, amount)
|
||||
showAddPaymentDialog = false
|
||||
}
|
||||
viewModel.onEvent(PersonScreenEvent.DeletePayment(payment))
|
||||
paymentToDelete = null
|
||||
},
|
||||
onDismiss = { showAddPaymentDialog = false }
|
||||
onDismiss = { paymentToDelete = null }
|
||||
)
|
||||
}
|
||||
|
||||
paymentToEdit?.let { payment ->
|
||||
if (showEditPaymentDialog) {
|
||||
EditPaymentDialog(
|
||||
amount = paymentAmount,
|
||||
onAmountChange = { paymentAmount = it.filter { c -> c.isDigit() } },
|
||||
onSave = {
|
||||
val amount = paymentAmount.toDoubleOrNull()
|
||||
if (amount != null && amount > 0) {
|
||||
viewModel.updatePayment(payment, amount)
|
||||
showEditPaymentDialog = false
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
onDelete = {
|
||||
showEditPaymentDialog = false
|
||||
showDeleteConfirmDialog = true
|
||||
},
|
||||
onDismiss = { showEditPaymentDialog = false }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteConfirmDialog) {
|
||||
DeleteConfirmDialog(
|
||||
onConfirm = {
|
||||
viewModel.deletePayment(payment)
|
||||
showDeleteConfirmDialog = false
|
||||
},
|
||||
onDismiss = { showDeleteConfirmDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showMessageDialog) {
|
||||
MessageDialog(message = dialogMessage) { showMessageDialog = false }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YearNavigator(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PersonDetailViewModel,
|
||||
selectedYear: Int,
|
||||
paymentHistory: List<PaymentRecord>,
|
||||
currentYear: Int,
|
||||
showMessage: (String) -> Unit
|
||||
) {
|
||||
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
|
||||
private fun PersonDetailHeader(name: String, onBack: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
contentColor = Color.White
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
val hasData = paymentHistory.any { it.shamsiYear == selectedYear - 1 }
|
||||
if (hasData || selectedYear - 1 >= currentYear - 5) { // Allow going back 5 years
|
||||
viewModel.changeYear(-1)
|
||||
} else {
|
||||
showMessage("هیچ دادهای برای سال قبل وجود ندارد")
|
||||
}
|
||||
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "Previous Year") }
|
||||
Text("سال ${selectedYear.toString().toPersianDigits()}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer)
|
||||
IconButton(onClick = {
|
||||
if (selectedYear < currentYear) {
|
||||
viewModel.changeYear(1)
|
||||
} else {
|
||||
showMessage("نمیتوان به سال آینده رفت")
|
||||
}
|
||||
}) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "Next Year") }
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "بازگشت"
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthsList(
|
||||
paymentHistory: List<PaymentRecord>,
|
||||
selectedYear: Int,
|
||||
currentYear: Int,
|
||||
currentMonth: Int,
|
||||
persianMonths: List<String>,
|
||||
onAvailableClick: (month: Int, year: Int) -> Unit,
|
||||
onPaidClick: (payment: PaymentRecord) -> Unit,
|
||||
onDisabledClick: (message: String) -> Unit
|
||||
) {
|
||||
LazyColumn {
|
||||
itemsIndexed(persianMonths, key = { index, _ -> index }) { index, monthName ->
|
||||
val monthNumber = index + 1
|
||||
val payment = paymentHistory.find { p -> p.shamsiMonth == monthNumber && p.shamsiYear == selectedYear }
|
||||
|
||||
val monthStatus = when {
|
||||
payment != null -> MonthStatus.PAID
|
||||
selectedYear > currentYear -> MonthStatus.FUTURE_YEAR
|
||||
selectedYear < currentYear -> MonthStatus.PAST_YEAR
|
||||
monthNumber > currentMonth -> MonthStatus.FUTURE_MONTH
|
||||
else -> MonthStatus.AVAILABLE
|
||||
fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
tonalElevation = 1.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
IconButton(onClick = { onYearChange(-1) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronRight,
|
||||
contentDescription = "سال قبل"
|
||||
)
|
||||
}
|
||||
|
||||
MonthListItem(
|
||||
monthName = monthName,
|
||||
payment = payment,
|
||||
status = monthStatus,
|
||||
onItemClick = {
|
||||
when (monthStatus) {
|
||||
MonthStatus.AVAILABLE -> onAvailableClick(monthNumber, selectedYear)
|
||||
MonthStatus.PAID -> onPaidClick(payment!!)
|
||||
else -> onDisabledClick(getMessageForStatus(monthStatus, monthName))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "سال ${year.toString().toPersianDigits()}",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
|
||||
|
||||
IconButton(onClick = { onYearChange(1) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ChevronLeft,
|
||||
contentDescription = "سال بعد"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
@Composable
|
||||
private fun MonthListItem(
|
||||
monthName: String,
|
||||
payment: PaymentRecord?,
|
||||
status: MonthStatus,
|
||||
onItemClick: () -> Unit
|
||||
fun MonthListItem(
|
||||
month: MonthUiModel,
|
||||
isExpanded: Boolean, // فعلاً استفاده نمیشود
|
||||
onHeaderClick: () -> Unit,
|
||||
onEditClick: () -> Unit
|
||||
) {
|
||||
val isClickable = status == MonthStatus.AVAILABLE || status == MonthStatus.PAID
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = if (status == MonthStatus.PAID) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f) else Color.Transparent,
|
||||
animationSpec = tween(400),
|
||||
label = "MonthListItemColor"
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
Row(
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = isClickable, onClick = onItemClick)
|
||||
.background(backgroundColor)
|
||||
.padding(horizontal = 16.dp, vertical = 20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
.clickable(onClick = onHeaderClick),
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Text(text = monthName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
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
|
||||
)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = status,
|
||||
transitionSpec = { fadeIn(tween(400)) togetherWith fadeOut(tween(400)) },
|
||||
label = "MonthListItemContent"
|
||||
) { targetStatus ->
|
||||
when (targetStatus) {
|
||||
MonthStatus.PAID -> {
|
||||
payment?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "${formatNumberAsPersian(it.amount)} تومان",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
if (month.status == MonthStatus.PAID && month.payment != null) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
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
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(Icons.Filled.Check, contentDescription = "Paid", tint = 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
|
||||
)
|
||||
}
|
||||
}
|
||||
MonthStatus.AVAILABLE -> {
|
||||
Icon(Icons.Filled.Add, contentDescription = "Add Payment", tint = MaterialTheme.colorScheme.secondary)
|
||||
}
|
||||
else -> {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Disabled", tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f))
|
||||
}
|
||||
|
||||
// جزئیات پرداخت: تاریخ + (در صورت وجود) توضیحات
|
||||
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()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "توضیحات: ${payment.description}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMessageForStatus(status: MonthStatus, monthName: String): String {
|
||||
return when (status) {
|
||||
MonthStatus.FUTURE_YEAR -> "این سال هنوز نرسیده است."
|
||||
MonthStatus.PAST_YEAR -> "شما در سال گذشته برای ماه $monthName پرداختی ثبت نکردهاید."
|
||||
MonthStatus.FUTURE_MONTH -> "این ماه هنوز نرسیده است."
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddPaymentDialog(
|
||||
monthName: String,
|
||||
year: Int,
|
||||
amount: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
fun DeleteConfirmationDialog(
|
||||
payment: PaymentRecord,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("ثبت پرداخت برای $monthName ${year.toString().toPersianDigits()}") },
|
||||
title = { Text("تایید حذف") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = amount,
|
||||
onValueChange = onAmountChange,
|
||||
label = { Text("مبلغ (تومان)") },
|
||||
visualTransformation = PersianNumberVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
},
|
||||
confirmButton = { Button(onClick = onConfirm) { Text("ثبت") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("لغو") } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditPaymentDialog(
|
||||
amount: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("ویرایش یا حذف پرداخت") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = amount,
|
||||
onValueChange = onAmountChange,
|
||||
label = { Text("مبلغ جدید (تومان)") },
|
||||
visualTransformation = PersianNumberVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
Text(
|
||||
"آیا برای حذف پرداخت ماه ${
|
||||
getPersianMonthName(payment.shamsiMonth)
|
||||
} به مبلغ ${
|
||||
formatNumberAsPersian(payment.amount)
|
||||
} تومان مطمئن هستید؟"
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
Button(onClick = onSave) { Text("ذخیره") }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TextButton(onClick = onDismiss) { Text("لغو") }
|
||||
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 = {
|
||||
TextButton(onClick = onDelete, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) { Text("حذف") }
|
||||
}
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("تایید حذف") },
|
||||
text = { Text("آیا از حذف این پرداخت مطمئن هستید؟ این عمل غیرقابل بازگشت است.") },
|
||||
confirmButton = {
|
||||
Button(onClick = onConfirm, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("حذف کن") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("لغو") } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageDialog(message: String, onDismiss: () -> Unit) {
|
||||
fun InfoDialog(message: String, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("توجه") },
|
||||
text = { Text(message) },
|
||||
confirmButton = { TextButton(onClick = onDismiss) { Text("باشه") } }
|
||||
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,3 +1,5 @@
|
||||
@file:Suppress("AssignedValueIsNeverRead")
|
||||
|
||||
package com.oqba26.monthlypaymentapp.ui.screens
|
||||
|
||||
import android.widget.Toast
|
||||
@@ -6,8 +8,6 @@ import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -17,7 +17,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -27,7 +27,6 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -37,7 +36,6 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -65,9 +63,8 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(onBackClick: () -> Unit) {
|
||||
fun SettingsScreen(onLogout: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val app = context.applicationContext as PaymentApplication
|
||||
val viewModel: SettingsViewModel = viewModel(factory = app.settingsViewModelFactory)
|
||||
@@ -76,7 +73,7 @@ fun SettingsScreen(onBackClick: () -> Unit) {
|
||||
val defaultAmount by viewModel.defaultPaymentAmount.collectAsState()
|
||||
val selectedFont by viewModel.selectedFont.collectAsState()
|
||||
|
||||
var showRestoreConfirmDialog by remember { mutableStateOf<String?>(null) }
|
||||
var backupJsonToRestore by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.toastMessage.collect { message ->
|
||||
@@ -92,11 +89,21 @@ fun SettingsScreen(onBackClick: () -> Unit) {
|
||||
try {
|
||||
val jsonString = viewModel.createBackupJsonSuspend()
|
||||
context.contentResolver.openOutputStream(it)?.use { outputStream ->
|
||||
OutputStreamWriter(outputStream).use { writer -> writer.write(jsonString) }
|
||||
OutputStreamWriter(outputStream).use { writer ->
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,7 +117,7 @@ fun SettingsScreen(onBackClick: () -> Unit) {
|
||||
try {
|
||||
context.contentResolver.openInputStream(it)?.use { inputStream ->
|
||||
BufferedReader(InputStreamReader(inputStream)).use { reader ->
|
||||
showRestoreConfirmDialog = reader.readText()
|
||||
backupJsonToRestore = reader.readText()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
@@ -120,44 +127,42 @@ fun SettingsScreen(onBackClick: () -> Unit) {
|
||||
}
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("تنظیمات") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
titleContentColor = Color.White,
|
||||
navigationIconContentColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Scaffold { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text("تنظیمات عمومی", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp))
|
||||
Text(
|
||||
"تنظیمات عمومی",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
|
||||
DefaultAmountSetting(defaultAmount, onSave = { viewModel.saveDefaultPaymentAmount(it) })
|
||||
DefaultAmountSetting(
|
||||
defaultAmount = defaultAmount,
|
||||
onSave = { viewModel.saveDefaultPaymentAmount(it) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
FontSelectionDropdown(selectedFont = selectedFont) {
|
||||
viewModel.onFontSelected(it)
|
||||
}
|
||||
FontSelectionDropdown(
|
||||
selectedFont = selectedFont,
|
||||
onFontSelected = { viewModel.onFontSelected(it) }
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 24.dp))
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 20.dp))
|
||||
|
||||
Text(
|
||||
"پشتیبانگیری و بازیابی",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 6.dp)
|
||||
)
|
||||
|
||||
Text("پشتیبانگیری و بازیابی", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 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)
|
||||
},
|
||||
@@ -165,67 +170,110 @@ fun SettingsScreen(onBackClick: () -> Unit) {
|
||||
) {
|
||||
Text("تهیه نسخه پشتیبان")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { restoreLauncher.launch(arrayOf("application/json")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("بازیابی اطلاعات از فایل")
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 20.dp))
|
||||
|
||||
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("خروج از حساب کاربری")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showRestoreConfirmDialog?.let {
|
||||
backupJsonToRestore?.let { json ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRestoreConfirmDialog = null },
|
||||
onDismissRequest = { backupJsonToRestore = null },
|
||||
title = { Text("هشدار جدی!") },
|
||||
text = { Text("آیا مطمئن هستید؟ با بازیابی اطلاعات، تمام دادههای فعلی برنامه (اشخاص و پرداختها) برای همیشه حذف شده و اطلاعات فایل پشتیبان جایگزین آن خواهد شد. این عمل غیرقابل بازگشت است.") },
|
||||
text = {
|
||||
Text(
|
||||
"آیا مطمئن هستید؟ با بازیابی اطلاعات، تمام دادههای فعلی برنامه (اشخاص و پرداختها) برای همیشه حذف شده و اطلاعات فایل پشتیبان جایگزین آن خواهد شد. این عمل غیرقابل بازگشت است."
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.restoreFromBackupJson(it)
|
||||
showRestoreConfirmDialog = null
|
||||
viewModel.restoreFromBackupJson(json)
|
||||
backupJsonToRestore = null
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("بله، بازیابی کن")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showRestoreConfirmDialog = null }) { Text("لغو") }
|
||||
TextButton(onClick = { backupJsonToRestore = null }) {
|
||||
Text("لغو")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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(16.dp)
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) { editing ->
|
||||
if (editing) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
OutlinedTextField(
|
||||
value = amountText,
|
||||
onValueChange = { amountText = it.filter { c -> c.isDigit() } },
|
||||
onValueChange = {
|
||||
amountText = it.filter { c -> c.isDigit() }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("مبلغ پیش فرض پرداخت (تومان)") },
|
||||
visualTransformation = PersianNumberVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextButton(onClick = { isEditing = false }) {
|
||||
Text("لغو")
|
||||
}
|
||||
@@ -239,10 +287,22 @@ fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column {
|
||||
Text("مبلغ پیش فرض پرداخت", style = MaterialTheme.typography.bodyLarge)
|
||||
Text("${formatNumberAsPersian(defaultAmount)} تومان", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
||||
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")
|
||||
@@ -253,16 +313,21 @@ fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun FontSelectionDropdown(selectedFont: String, onFontSelected: (String) -> Unit) {
|
||||
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()) {
|
||||
OutlinedButton(
|
||||
onClick = { expanded = true },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("فونت برنامه: $selectedFont")
|
||||
Icon(Icons.Default.ArrowDropDown, contentDescription = "Select Font")
|
||||
Icon(Icons.Filled.ArrowDropDown, contentDescription = "Select Font")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.oqba26.monthlypaymentapp.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
|
||||
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
|
||||
|
||||
@Composable
|
||||
fun EditPaymentDialog(
|
||||
paymentRecord: PaymentRecord,
|
||||
onConfirm: (amount: Double, description: String) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var amountText by remember { mutableStateOf(paymentRecord.amount.toLong().toString()) }
|
||||
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)
|
||||
) {
|
||||
// در RTL: Start = راست، End = چپ
|
||||
|
||||
// راست: ذخیره
|
||||
Button(
|
||||
onClick = {
|
||||
val amount = amountText.toDoubleOrNull() ?: paymentRecord.amount
|
||||
onConfirm(amount, description)
|
||||
},
|
||||
modifier = Modifier.align(androidx.compose.ui.Alignment.CenterStart)
|
||||
) {
|
||||
Text("ذخیره")
|
||||
}
|
||||
|
||||
// وسط: لغو
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.align(androidx.compose.ui.Alignment.Center),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary
|
||||
)
|
||||
) {
|
||||
Text("لغو")
|
||||
}
|
||||
|
||||
// چپ: حذف
|
||||
Button(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.align(androidx.compose.ui.Alignment.CenterEnd),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("حذف")
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {}
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
package com.oqba26.monthlypaymentapp.utils
|
||||
|
||||
import saman.zamani.persiandate.PersianDate
|
||||
import saman.zamani.persiandate.PersianDateFormat
|
||||
|
||||
fun Long.toPersianDate(pattern: String = "Y/M/d"): String {
|
||||
val pd = PersianDate(this) // this = timestamp (ms)
|
||||
return PersianDateFormat(pattern).format(pd)
|
||||
}
|
||||
fun getPersianMonthName(month: Int): String {
|
||||
return when (month) {
|
||||
1 -> "فروردین"
|
||||
2 -> "اردیبهشت"
|
||||
3 -> "خرداد"
|
||||
4 -> "تیر"
|
||||
5 -> "مرداد"
|
||||
6 -> "شهریور"
|
||||
7 -> "مهر"
|
||||
8 -> "آبان"
|
||||
9 -> "آذر"
|
||||
10 -> "دی"
|
||||
11 -> "بهمن"
|
||||
12 -> "اسفند"
|
||||
else -> "نامشخص"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,31 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.oqba26.monthlypaymentapp.utils
|
||||
|
||||
import android.icu.text.SimpleDateFormat
|
||||
import saman.zamani.persiandate.PersianDate
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
fun getCurrentShamsiYear(): Int = PersianDate().shYear
|
||||
fun getCurrentShamsiMonth(): Int = PersianDate().shMonth
|
||||
|
||||
fun getCurrentShamsiMonth(): Int = PersianDate().shMonth
|
||||
fun formatTimestampToPersianDate(timestamp: Long): String {
|
||||
val persianDate = PersianDate(Date(timestamp))
|
||||
val year = persianDate.shYear.toString().toPersianDigits()
|
||||
val month = persianDate.shMonth.toString().padStart(2, '0').toPersianDigits()
|
||||
val day = persianDate.shDay.toString().padStart(2, '0').toPersianDigits()
|
||||
return "$year/$month/$day"
|
||||
}
|
||||
|
||||
fun formatTimestampToPersianTime(timestamp: Long): String {
|
||||
val timeFormat = SimpleDateFormat("HH:mm", Locale.US)
|
||||
val timeString = timeFormat.format(Date(timestamp))
|
||||
return timeString.toPersianDigits()
|
||||
}
|
||||
|
||||
fun formatTimestampToPersianDateTime(timestamp: Long): String {
|
||||
val date = formatTimestampToPersianDate(timestamp)
|
||||
val time = formatTimestampToPersianTime(timestamp)
|
||||
return "$date، ساعت $time"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
|
||||
|
||||
package com.oqba26.monthlypaymentapp.viewmodel
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Suppress("unused")
|
||||
data class UpdateArchivedStatusRequest(
|
||||
val isArchived: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@Suppress("unused")
|
||||
data class UpdateDisplayOrderRequest(
|
||||
val displayOrder: Long // FIX: Changed from Int to Long
|
||||
)
|
||||
@@ -1,97 +0,0 @@
|
||||
package com.oqba26.monthlypaymentapp.viewmodel
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import com.oqba26.monthlypaymentapp.core.PaymentApplication
|
||||
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
|
||||
import com.oqba26.monthlypaymentapp.data.model.Person
|
||||
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
|
||||
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
|
||||
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PersonDetailViewModel(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val networkRepository: NetworkRepository, // ⭐️ FIX: localRepository removed
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val personId: String = requireNotNull(savedStateHandle.get<String>("personId"))
|
||||
|
||||
// ⭐️ FIX: Fetch person from the network flow
|
||||
val person: StateFlow<Person?> = networkRepository.getPersonsFlow()
|
||||
.map { persons -> persons.find { it.id == personId } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||
|
||||
private val _selectedYear = kotlinx.coroutines.flow.MutableStateFlow(getCurrentShamsiYear())
|
||||
val selectedYear = _selectedYear.asStateFlow()
|
||||
|
||||
// ⭐️ FIX: Fetch payment history from the correct network flow
|
||||
val paymentHistory: StateFlow<List<PaymentRecord>> = networkRepository.getPaymentsFlow()
|
||||
.map { payments -> payments.filter { it.personId == personId } }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
private val _toastMessage = MutableSharedFlow<String>()
|
||||
val toastMessage = _toastMessage.asSharedFlow()
|
||||
|
||||
val defaultPaymentAmount = settingsRepository.defaultPaymentAmountFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.0)
|
||||
|
||||
fun changeYear(offset: Int) { _selectedYear.update { it + offset } }
|
||||
|
||||
fun addPaymentForMonth(month: Int, year: Int, amount: Double) {
|
||||
viewModelScope.launch {
|
||||
val newPayment = PaymentRecord(
|
||||
amount = amount,
|
||||
personId = personId,
|
||||
shamsiYear = year,
|
||||
shamsiMonth = month
|
||||
)
|
||||
val ok = networkRepository.addPayment(newPayment)
|
||||
if (!ok) _toastMessage.emit("ثبت پرداخت با خطا مواجه شد.")
|
||||
}
|
||||
}
|
||||
|
||||
fun updatePayment(payment: PaymentRecord, newAmount: Double) {
|
||||
viewModelScope.launch {
|
||||
val updatedPayment = payment.copy(amount = newAmount)
|
||||
val ok = networkRepository.addPayment(updatedPayment)
|
||||
if (!ok) _toastMessage.emit("بهروزرسانی پرداخت با خطا مواجه شد.")
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePayment(payment: PaymentRecord) {
|
||||
viewModelScope.launch {
|
||||
val ok = networkRepository.deletePayment(payment.id)
|
||||
if (!ok) _toastMessage.emit("حذف پرداخت با خطا مواجه شد.")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
|
||||
val app = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) as PaymentApplication
|
||||
val saved = extras.createSavedStateHandle()
|
||||
// ⭐️ FIX: Provide the correct dependencies
|
||||
return PersonDetailViewModel(
|
||||
saved,
|
||||
app.networkRepository,
|
||||
app.settingsRepository
|
||||
) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,84 +31,431 @@ sealed interface AuthState {
|
||||
data object Unauthenticated : AuthState
|
||||
}
|
||||
|
||||
enum class PersonListType {
|
||||
UNPAID,
|
||||
PAID
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PersonViewModel(
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery = _searchQuery.asStateFlow()
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing = _isRefreshing.asStateFlow()
|
||||
|
||||
private val _expandedPersonId = MutableStateFlow<String?>(null)
|
||||
val expandedPersonId = _expandedPersonId.asStateFlow()
|
||||
@Suppress("unused")
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
private val _selectedPersonId = MutableStateFlow<String?>(null)
|
||||
private val _selectedYear = MutableStateFlow(getCurrentShamsiYear())
|
||||
val selectedYear = _selectedYear.asStateFlow()
|
||||
|
||||
private val _dashboardData = MutableStateFlow(DashboardUiModel())
|
||||
val dashboardData = _dashboardData.asStateFlow()
|
||||
val dashboardData: StateFlow<DashboardUiModel> = _dashboardData.asStateFlow()
|
||||
|
||||
val defaultPaymentAmountFlow = settingsRepository.defaultPaymentAmountFlow
|
||||
|
||||
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(5000), AuthState.Loading)
|
||||
private val _infoMessage = MutableSharedFlow<String>()
|
||||
val infoMessage = _infoMessage.asSharedFlow()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val uiState: StateFlow<PersonListUiState> = authState.flatMapLatest { auth ->
|
||||
when (auth) {
|
||||
is AuthState.Authenticated -> {
|
||||
private val _uiState = MutableStateFlow(PersonListUiState())
|
||||
val uiState: StateFlow<PersonListUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _showAddPersonDialog = MutableStateFlow(false)
|
||||
val showAddPersonDialog: StateFlow<Boolean> = _showAddPersonDialog.asStateFlow()
|
||||
|
||||
private val _personForPaymentDialog = MutableStateFlow<PersonUiModel?>(null)
|
||||
val personForPaymentDialog: StateFlow<PersonUiModel?> = _personForPaymentDialog.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)
|
||||
|
||||
val personDetailState: StateFlow<PersonDetailUiState> = _selectedPersonId
|
||||
.flatMapLatest { personId ->
|
||||
if (personId == null) {
|
||||
flowOf(PersonDetailUiState())
|
||||
} else {
|
||||
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 }
|
||||
|
||||
val uiModels = persons.map { person ->
|
||||
val hasPaid = paymentsThisMonth.any { it.personId == person.id }
|
||||
PersonUiModel(id = person.id, name = person.name, hasPaidThisMonth = hasPaid)
|
||||
_selectedYear
|
||||
) { persons, allPayments, year ->
|
||||
val person = persons.find { it.id == personId }
|
||||
val paymentsForPersonInYear = allPayments.filter {
|
||||
it.personId == personId && it.shamsiYear == year
|
||||
}
|
||||
|
||||
updateDashboard(uiModels.size, paymentsThisMonth)
|
||||
|
||||
val filtered = if (query.isBlank()) uiModels
|
||||
else uiModels.filter { it.name.contains(query, ignoreCase = true) }
|
||||
|
||||
PersonListUiState(persons = filtered, payments = allPayments)
|
||||
val currentYear = getCurrentShamsiYear()
|
||||
val currentMonth = getCurrentShamsiMonth()
|
||||
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
|
||||
payment != null -> MonthStatus.PAID
|
||||
else -> MonthStatus.AVAILABLE
|
||||
}
|
||||
MonthUiModel(month, payment, status)
|
||||
}
|
||||
PersonDetailUiState(person, year, monthStates)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
flowOf(PersonListUiState()) // Emit empty state if not authenticated
|
||||
}
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PersonListUiState())
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PersonDetailUiState())
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
// This will trigger a refresh whenever the user becomes authenticated.
|
||||
authState.collectLatest { state ->
|
||||
if (state is AuthState.Authenticated) {
|
||||
authState.collectLatest { authState ->
|
||||
if (authState is AuthState.Authenticated) {
|
||||
// اولین بار دیتای سرور
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auth Actions
|
||||
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
|
||||
}
|
||||
|
||||
if (fromIndex !in list.indices || toIndex !in list.indices) return@launch
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is PersonScreenEvent.AddPerson -> {
|
||||
if (event.name.isNotBlank()) {
|
||||
val person = Person(name = event.name, displayOrder = 0)
|
||||
val statusCode = networkRepository.addPerson(person)
|
||||
when (statusCode) {
|
||||
409 -> {
|
||||
_toastMessage.emit("خطا: این نام از قبل وجود دارد.")
|
||||
}
|
||||
null, !in 200..299 -> {
|
||||
_toastMessage.emit("خطا در افزودن شخص.")
|
||||
}
|
||||
else -> {
|
||||
networkRepository.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is PersonScreenEvent.AddQuickPayment -> {
|
||||
val record = createPaymentRecord(
|
||||
event.personId,
|
||||
getCurrentShamsiMonth(),
|
||||
getCurrentShamsiYear(),
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
is PersonScreenEvent.ArchivePerson -> {
|
||||
if (networkRepository.updatePersonArchivedStatus(event.personId, true)) {
|
||||
_toastMessage.emit("شخص به آرشیو انتقال یافت.")
|
||||
networkRepository.refresh()
|
||||
} else {
|
||||
_toastMessage.emit("خطا در آرشیو کردن.")
|
||||
}
|
||||
}
|
||||
|
||||
is PersonScreenEvent.RestorePerson -> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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("هیچ پرداختی برای سالهای گذشته ثبت نشده است.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("خطا در ثبت پرداخت")
|
||||
}
|
||||
}
|
||||
|
||||
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("خطا در ویرایش پرداخت")
|
||||
}
|
||||
}
|
||||
|
||||
is PersonScreenEvent.DeletePayment -> {
|
||||
if (networkRepository.deletePayment(event.payment.id)) {
|
||||
networkRepository.refresh()
|
||||
} else {
|
||||
_toastMessage.emit("خطا در حذف پرداخت")
|
||||
}
|
||||
}
|
||||
|
||||
is PersonScreenEvent.CommitReorder -> {
|
||||
// بعد از اتمام درگ: ترتیب فعلی لیست را به عنوان displayOrder جدید ذخیره کن
|
||||
val currentListState = _uiState.value
|
||||
val list = when (event.listType) {
|
||||
PersonListType.UNPAID -> currentListState.unpaidPersons
|
||||
PersonListType.PAID -> currentListState.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.refresh()
|
||||
} catch (_: Exception) {
|
||||
_toastMessage.emit("خطا در ذخیره ترتیب")
|
||||
networkRepository.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
PersonScreenEvent.RefreshData -> {
|
||||
_isRefreshing.value = true
|
||||
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 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)
|
||||
}
|
||||
|
||||
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("نام کاربری یا رمز عبور اشتباه است.")
|
||||
}
|
||||
@@ -119,110 +466,57 @@ class PersonViewModel(
|
||||
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("از حساب کاربری خارج شدید.")
|
||||
}
|
||||
|
||||
// UI Events
|
||||
fun onSearchQueryChange(query: String) { _searchQuery.value = query }
|
||||
|
||||
fun onEvent(event: PersonScreenEvent) {
|
||||
viewModelScope.launch {
|
||||
when (event) {
|
||||
is PersonScreenEvent.AddPerson -> {
|
||||
if (event.name.isNotBlank()) {
|
||||
val statusCode = networkRepository.addPerson(Person(name = event.name))
|
||||
if (statusCode == 409) {
|
||||
_toastMessage.emit("خطا: این نام از قبل وجود دارد.")
|
||||
} else if (statusCode == null || statusCode !in 200..299) {
|
||||
_toastMessage.emit("خطا در افزودن شخص.")
|
||||
}
|
||||
}
|
||||
}
|
||||
is PersonScreenEvent.DeletePerson -> {
|
||||
networkRepository.deletePersonAndPayments(event.personId)
|
||||
}
|
||||
is PersonScreenEvent.AddQuickPayment -> {
|
||||
val record = createPaymentRecord(event.personId, getCurrentShamsiMonth(), getCurrentShamsiYear(), event.amount)
|
||||
networkRepository.addPayment(record)
|
||||
}
|
||||
is PersonScreenEvent.UpdatePerson -> {
|
||||
networkRepository.updatePerson(event.personId, event.name)
|
||||
}
|
||||
is PersonScreenEvent.Login -> login(AuthRequest(event.username, event.password))
|
||||
is PersonScreenEvent.Register -> register(AuthRequest(event.username, event.password))
|
||||
is PersonScreenEvent.TogglePersonExpansion -> {
|
||||
val currentId = _expandedPersonId.value
|
||||
if (currentId == event.personId) {
|
||||
_expandedPersonId.value = null
|
||||
} else {
|
||||
_expandedPersonId.value = event.personId
|
||||
_selectedYear.value = getCurrentShamsiYear() // Reset year when opening a new item
|
||||
}
|
||||
}
|
||||
is PersonScreenEvent.ChangeYear -> {
|
||||
_selectedYear.update { it + event.offset }
|
||||
}
|
||||
is PersonScreenEvent.AddPaymentForMonth -> {
|
||||
val record = createPaymentRecord(event.personId, event.month, event.year, event.amount)
|
||||
val success = networkRepository.addPayment(record)
|
||||
if (!success) _toastMessage.emit("خطا در ثبت پرداخت")
|
||||
}
|
||||
is PersonScreenEvent.UpdatePayment -> {
|
||||
val updatedRecord = event.payment.copy(amount = event.newAmount)
|
||||
val success = networkRepository.addPayment(updatedRecord)
|
||||
if (!success) _toastMessage.emit("خطا در ویرایش پرداخت")
|
||||
}
|
||||
is PersonScreenEvent.DeletePayment -> {
|
||||
val success = networkRepository.deletePayment(event.payment.id)
|
||||
if (!success) _toastMessage.emit("خطا در حذف پرداخت")
|
||||
}
|
||||
|
||||
PersonScreenEvent.RefreshData -> {
|
||||
_isRefreshing.value = true
|
||||
networkRepository.refresh()
|
||||
_isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun createPaymentRecord(personId: String, month: Int, year: Int, amount: Double): PaymentRecord {
|
||||
return PaymentRecord(personId = personId, amount = amount, shamsiYear = year, shamsiMonth = month)
|
||||
}
|
||||
}
|
||||
|
||||
data class PersonListUiState(
|
||||
val persons: List<PersonUiModel> = emptyList(),
|
||||
val payments: List<PaymentRecord> = emptyList()
|
||||
)
|
||||
|
||||
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 AddQuickPayment(val personId: String, val amount: Double) : 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 TogglePersonExpansion(val personId: String) : PersonScreenEvent()
|
||||
|
||||
// Events for the detail section
|
||||
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) : PersonScreenEvent()
|
||||
data class UpdatePayment(val payment: PaymentRecord, val newAmount: Double) : 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()
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.oqba26.monthlypaymentapp.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
|
||||
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
|
||||
|
||||
// ⭐️ FIX: Removed the unnecessary 'localPersonRepository' dependency.
|
||||
class PersonViewModelFactory(
|
||||
private val networkRepository: NetworkRepository,
|
||||
private val settingsRepository: SettingsRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
if (modelClass.isAssignableFrom(PersonViewModel::class.java)) {
|
||||
// ⭐️ FIX: Call the ViewModel with the correct arguments.
|
||||
return PersonViewModel(networkRepository, settingsRepository) as T
|
||||
}
|
||||
throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,46 @@
|
||||
package com.oqba26.monthlypaymentapp.viewmodel
|
||||
|
||||
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
|
||||
import com.oqba26.monthlypaymentapp.data.model.Person
|
||||
|
||||
data class PersonUiModel(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val hasPaidThisMonth: Boolean
|
||||
val hasPaidThisMonth: Boolean,
|
||||
val displayOrder: Long, // FIX: Changed from Int to Long
|
||||
val createdAt: Long
|
||||
)
|
||||
|
||||
data class PersonListUiState(
|
||||
val unpaidPersons: List<PersonUiModel> = emptyList(),
|
||||
val paidPersons: List<PersonUiModel> = emptyList(),
|
||||
val archivedPersons: List<PersonUiModel> = emptyList(),
|
||||
val payments: List<PaymentRecord> = emptyList(),
|
||||
)
|
||||
|
||||
data class PersonDetailUiState(
|
||||
val person: Person? = null,
|
||||
val selectedYear: Int = 0,
|
||||
val monthStates: List<MonthUiModel> = emptyList()
|
||||
)
|
||||
|
||||
data class MonthUiModel(
|
||||
val month: Int,
|
||||
val payment: PaymentRecord?,
|
||||
val status: MonthStatus
|
||||
)
|
||||
|
||||
enum class MonthStatus {
|
||||
PAID,
|
||||
AVAILABLE,
|
||||
FUTURE_MONTH,
|
||||
PAST_YEAR,
|
||||
FUTURE_YEAR
|
||||
}
|
||||
|
||||
data class DashboardUiModel(
|
||||
val paidCount: Int = 0,
|
||||
val totalCount: Int = 0,
|
||||
val totalIncome: Double = 0.0,
|
||||
val progress: Float = 0f
|
||||
)
|
||||
|
||||
enum class MonthStatus { PAID, AVAILABLE, FUTURE_YEAR, PAST_YEAR, FUTURE_MONTH }
|
||||
Reference in new issue
Block a user