"بروزرسانی دیالوگ‌ها، آرشیو و توضیحات پرداخت - ۱۴۰۴/۰۹/۰۱ (جزئیات در README.md)"

This commit is contained in:
oqba26 authored and oqba26 committed 2026-07-31 21:41:20 +03:30
1 parent ddf8a3d815
commit 502cdacb44
33 files changed
+1952 -1482

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="17" />
<bytecodeTargetLevel target="21" />
</component>
</project>
+8
View File
@@ -4,6 +4,14 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-11-20T10:05:59.804854700Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="Default" identifier="serial=192.168.1.101:40247;connection=cd7687f9" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
+2 -1
View File
@@ -6,10 +6,11 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="jbr-17" />
<option name="gradleJvm" value="jbr-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/PersianDate" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
+1 -1
View File
@@ -1,6 +1,6 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
+35
View File
@@ -0,0 +1,35 @@
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.jetbrains.kotlin.android)
}
android {
namespace = "com.github.samanzamani.persiandate"
compileSdk = 34
defaultConfig {
minSdk = 21
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20231013")
}
+9 -7
View File
@@ -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 ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Crossfade(targetState = authState, label = "AuthScreenSwitch") { state ->
when (state) {
is AuthState.Loading -> {
LoadingScreen()
is AuthState.Loading -> LoadingScreen()
is AuthState.Unauthenticated -> AuthScreen()
is AuthState.Authenticated -> AuthenticatedContent(viewModel)
}
is AuthState.Unauthenticated -> {
AuthScreen()
}
is AuthState.Authenticated -> {
AppNavigation(viewModel)
}
@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)
) {
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,21 +215,61 @@ fun MainAppHost(viewModel: PersonViewModel) {
}
@Composable
fun AppNavigation(viewModel: PersonViewModel) {
val navController = rememberNavController()
fun AppBottomNavigationBar(navController: NavController) {
val items = listOf(
Screen.Unpaid,
Screen.Paid,
Screen.Archive,
Screen.Settings
)
NavigationBar(
containerColor = MaterialTheme.colorScheme.primary // Match TopAppBar
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val currentDestination = navBackStackEntry?.destination
var showExitDialog by remember { mutableStateOf(false) }
val context = LocalContext.current
BackHandler(enabled = currentRoute == "personScreen") {
showExitDialog = true
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
}
}
)
}
}
}
if (showExitDialog) {
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 = { showExitDialog = false },
onDismissRequest = onDismiss,
title = { Text("خروج از برنامه") },
text = { Text("آیا برای خروج از برنامه مطمئن هستید؟") },
confirmButton = {
@@ -133,12 +280,14 @@ fun AppNavigation(viewModel: PersonViewModel) {
horizontalArrangement = Arrangement.SpaceBetween
) {
Button(
onClick = { (context as? ComponentActivity)?.finish() },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
onClick = onConfirmExit,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("بله")
}
Button(onClick = { showExitDialog = false }) {
Button(onClick = onDismiss) {
Text("خیر")
}
}
@@ -147,25 +296,12 @@ fun AppNavigation(viewModel: PersonViewModel) {
)
}
NavHost(navController = navController, startDestination = "personScreen") {
composable("personScreen") {
PersonScreen(
onSettingsClick = { navController.navigate("settings") },
//onExitApp = { viewModel.logout() }
)
}
composable("settings") {
SettingsScreen(onBackClick = { navController.popBackStack() })
}
}
}
@Composable
fun LoadingScreen() {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Text("در حال بررسی وضعیت...", modifier = Modifier.align(Alignment.Center).padding(top = 80.dp))
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,16 +11,17 @@ 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")
}
@@ -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
}
@@ -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
}
)
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
}
)
}
}
if (showAddPaymentDialog) {
AddPaymentDialog(
monthName = persianMonths[selectedMonthForPayment - 1],
year = selectedYear,
amount = paymentAmount,
onAmountChange = { paymentAmount = it.filter { c -> c.isDigit() } },
onConfirm = {
val amount = paymentAmount.toDoubleOrNull()
if (amount != null && amount > 0) {
viewModel.addPaymentForMonth(selectedMonthForPayment, selectedYear, amount)
showAddPaymentDialog = false
}
},
onDismiss = { showAddPaymentDialog = false }
)
}
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
}
},
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
Column(
modifier = Modifier.fillMaxSize()
) {
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
PersonDetailHeader(
name = detailState.person?.name ?: "...",
onBack = { navController.popBackStack() }
)
if (detailState.person == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
IconButton(onClick = {
val hasData = paymentHistory.any { it.shamsiYear == selectedYear - 1 }
if (hasData || selectedYear - 1 >= currentYear - 5) { // Allow going back 5 years
viewModel.changeYear(-1)
CircularProgressIndicator()
}
} 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") }
}
}
YearSelector(
year = detailState.selectedYear,
onYearChange = { offset ->
viewModel.onEvent(PersonScreenEvent.ChangeYear(offset))
}
)
@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
Spacer(modifier = Modifier.height(4.dp))
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 8.dp)
) {
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
}
items(detailState.monthStates) { monthModel ->
MonthListItem(
monthName = monthName,
payment = payment,
status = monthStatus,
onItemClick = {
when (monthStatus) {
MonthStatus.AVAILABLE -> onAvailableClick(monthNumber, selectedYear)
MonthStatus.PAID -> onPaidClick(payment!!)
else -> onDisabledClick(getMessageForStatus(monthStatus, monthName))
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 */ }
}
}
)
HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f))
}
}
}
}
infoDialogMessage?.let {
InfoDialog(message = it, onDismiss = { infoDialogMessage = null })
}
paymentToDelete?.let { payment ->
DeleteConfirmationDialog(
payment = payment,
onConfirm = {
viewModel.onEvent(PersonScreenEvent.DeletePayment(payment))
paymentToDelete = null
},
onDismiss = { paymentToDelete = null }
)
}
paymentDialogState?.let { state ->
when (state) {
is PaymentDialogState.Add -> {
AddPaymentDialog(
personName = detailState.person!!.name,
defaultAmount = defaultPaymentAmount,
onConfirm = { amount, description ->
viewModel.onEvent(
PersonScreenEvent.AddPaymentForMonth(
personId = personId,
month = state.month,
year = detailState.selectedYear,
amount = amount,
description = description
)
)
paymentDialogState = null
},
onDismiss = { paymentDialogState = null }
)
}
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
}
)
}
}
}
}
@Composable
private fun MonthListItem(
monthName: String,
payment: PaymentRecord?,
status: MonthStatus,
onItemClick: () -> Unit
private fun PersonDetailHeader(name: String, onBack: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.primary,
contentColor = Color.White
) {
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"
)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = isClickable, onClick = onItemClick)
.background(backgroundColor)
.padding(horizontal = 16.dp, vertical = 20.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
.height(44.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = monthName, 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
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "بازگشت"
)
Spacer(Modifier.width(8.dp))
Icon(Icons.Filled.Check, contentDescription = "Paid", tint = MaterialTheme.colorScheme.primary)
}
Spacer(modifier = Modifier.padding(horizontal = 4.dp))
Text(
text = name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
}
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))
}
}
}
}
}
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 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 = "سال قبل"
)
}
Text(
text = "سال ${year.toString().toPersianDigits()}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 12.dp)
)
IconButton(onClick = { onYearChange(1) }) {
Icon(
imageVector = Icons.Default.ChevronLeft,
contentDescription = "سال بعد"
)
}
}
}
}
@Suppress("UNUSED_PARAMETER")
@Composable
fun MonthListItem(
month: MonthUiModel,
isExpanded: Boolean, // فعلاً استفاده نمی‌شود
onHeaderClick: () -> Unit,
onEditClick: () -> Unit
) {
val containerColor = when (month.status) {
MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer
MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.surface
MonthStatus.PAST_YEAR, MonthStatus.FUTURE_YEAR, MonthStatus.FUTURE_MONTH ->
MaterialTheme.colorScheme.surfaceVariant
}
Card(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 4.dp)
.fillMaxWidth()
.clickable(onClick = onHeaderClick),
colors = CardDefaults.cardColors(containerColor = containerColor),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp)
) {
// ردیف بالا: نام ماه + مبلغ + دکمه
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
)
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
)
) {
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
)
}
}
}
// جزئیات پرداخت: تاریخ + (در صورت وجود) توضیحات
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
)
}
}
}
}
}
@Composable
fun DeleteConfirmationDialog(
payment: PaymentRecord,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("ثبت پرداخت برای $monthName ${year.toString().toPersianDigits()}") },
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()
)
},
confirmButton = {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(onClick = onSave) { Text("ذخیره") }
Spacer(Modifier.width(8.dp))
TextButton(onClick = onDismiss) { Text("لغو") }
}
},
dismissButton = {
TextButton(onClick = onDelete, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) { Text("حذف") }
}
)
}
@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("حذف کن") }
text = {
Text(
"آیا برای حذف پرداخت ماه ${
getPersianMonthName(payment.shamsiMonth)
} به مبلغ ${
formatNumberAsPersian(payment.amount)
} تومان مطمئن هستید؟"
)
},
dismissButton = { TextButton(onClick = onDismiss) { Text("لغو") } }
confirmButton = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
// در RTL: این دکمه می‌چسبد به سمت راست دیالوگ
Button(onClick = onDismiss) {
Text("لغو")
}
// در RTL: این دکمه می‌چسبد به سمت چپ دیالوگ
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("حذف")
}
}
},
dismissButton = {}
)
}
@Composable
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 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,39 +31,95 @@ 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(),
_selectedYear
) { persons, allPayments, year ->
val person = persons.find { it.id == personId }
val paymentsForPersonInYear = allPayments.filter {
it.personId == personId && it.shamsiYear == year
}
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)
}
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PersonDetailUiState())
init {
viewModelScope.launch {
authState.collectLatest { authState ->
if (authState is AuthState.Authenticated) {
// اولین بار دیتای سرور
networkRepository.refresh()
// گوش دادن به تغییرات اشخاص + پرداخت‌ها + سرچ
combine(
networkRepository.getPersonsFlow(),
networkRepository.getPaymentsFlow(),
@@ -71,44 +127,335 @@ class PersonViewModel(
) { 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 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)
PersonUiModel(
id = person.id,
name = person.name,
hasPaidThisMonth = hasPaid,
displayOrder = person.displayOrder,
createdAt = person.createdAt
)
}
updateDashboard(uiModels.size, paymentsThisMonth)
val filtered = if (query.isBlank()) uiModels
else uiModels.filter { it.name.contains(query, ignoreCase = true) }
val (paidModels, unpaidModels) =
uiModels.partition { it.hasPaidThisMonth }
PersonListUiState(persons = filtered, payments = allPayments)
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()
}
}
}
}
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 -> {
flowOf(PersonListUiState()) // Emit empty state if not authenticated
}
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PersonListUiState())
init {
viewModelScope.launch {
// This will trigger a refresh whenever the user becomes authenticated.
authState.collectLatest { state ->
if (state is AuthState.Authenticated) {
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("از حساب کاربری خارج شدید.")
}
// Auth Actions
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 }
+1 -1
View File
@@ -1,7 +1,7 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
}
+5 -2
View File
@@ -11,6 +11,7 @@ coreKtx = "1.13.1"
lifecycle = "2.7.0"
activityCompose = "1.9.0"
navigation = "2.7.7"
appcompat = "1.7.0"
retrofit = "2.9.0"
okhttp = "4.12.0"
@@ -21,7 +22,7 @@ datastore = "1.1.1"
desugarLibs = "2.0.4"
serialization = "1.6.3"
persianDate = "1.5.2"
persianDate = "0.2.0"
material = "1.12.0"
junit = "4.13.2"
@@ -30,6 +31,7 @@ espressoCore = "3.5.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-savedstate = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-savedstate", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
@@ -59,7 +61,7 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto
android-desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarLibs" }
persiandate = { module = "com.github.samanzamani:PersianDate", version.ref = "persianDate" }
persiandate = { module = "com.github.mfathi91:time", version.ref = "persianDate" }
google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
@@ -72,6 +74,7 @@ androidTest-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
+1 -1
View File
@@ -33,4 +33,4 @@ dependencyResolutionManagement {
}
rootProject.name = "MonthlyPaymentApp"
include(":app")
include(":app", ":PersianDate")