"بروزرسانی دیالوگها، آرشیو و توضیحات پرداخت - ۱۴۰۴/۰۹/۰۱ (جزئیات در README.md)"
This commit is contained in:
1 parent
ddf8a3d815
commit
502cdacb44
33 files changed
+1955
-1485
No files matched your search
Generated
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="CompilerConfiguration">
|
<component name="CompilerConfiguration">
|
||||||
<bytecodeTargetLevel target="17" />
|
<bytecodeTargetLevel target="21" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
Generated
+8
@@ -4,6 +4,14 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<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>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
Generated
+2
-1
@@ -6,10 +6,11 @@
|
|||||||
<GradleProjectSettings>
|
<GradleProjectSettings>
|
||||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
<option name="gradleJvm" value="jbr-17" />
|
<option name="gradleJvm" value="jbr-21" />
|
||||||
<option name="modules">
|
<option name="modules">
|
||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/PersianDate" />
|
||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<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" />
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectType">
|
<component name="ProjectType">
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -47,14 +47,14 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
// همتراز با JDK 17
|
// Align with the JDK version used by Android Studio
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
isCoreLibraryDesugaringEnabled = true
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
kotlinOptions { jvmTarget = "17" }
|
kotlinOptions { jvmTarget = "21" }
|
||||||
// اطمینان از JDK 17
|
// Ensure JDK 21
|
||||||
kotlin { jvmToolchain(17) }
|
kotlin { jvmToolchain(21) }
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||||
@@ -72,7 +72,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.ui.tooling.preview)
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
implementation(libs.androidx.material3)
|
implementation(libs.androidx.material3)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation("androidx.compose.material:material") // برای PullRefresh
|
||||||
implementation("androidx.compose.material:material-icons-extended-android:1.6.7")
|
implementation("androidx.compose.material:material-icons-extended-android:1.6.7")
|
||||||
|
implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6")
|
||||||
|
|
||||||
// Core & Coroutines
|
// Core & Coroutines
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
|
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
|
||||||
@@ -98,7 +100,7 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
// Utility
|
// Utility
|
||||||
implementation(libs.persiandate)
|
implementation(project(":PersianDate"))
|
||||||
implementation(libs.google.material)
|
implementation(libs.google.material)
|
||||||
|
|
||||||
// Desugaring
|
// Desugaring
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
package com.oqba26.monthlypaymentapp
|
package com.oqba26.monthlypaymentapp
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -9,18 +11,31 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
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.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -31,17 +46,26 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
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.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
import com.oqba26.monthlypaymentapp.core.PaymentApplication
|
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.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.PersonScreen
|
||||||
import com.oqba26.monthlypaymentapp.ui.screens.SettingsScreen
|
import com.oqba26.monthlypaymentapp.ui.screens.SettingsScreen
|
||||||
import com.oqba26.monthlypaymentapp.ui.theme.MonthlyPaymentManagement2Theme
|
import com.oqba26.monthlypaymentapp.ui.theme.MonthlyPaymentManagement2Theme
|
||||||
@@ -54,7 +78,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
val application = application as PaymentApplication
|
val application = application as PaymentApplication
|
||||||
val personViewModel: PersonViewModel = viewModel(factory = application.personViewModelFactory)
|
val personViewModel: PersonViewModel =
|
||||||
|
viewModel(factory = application.personViewModelFactory)
|
||||||
|
|
||||||
MonthlyPaymentManagement2Theme {
|
MonthlyPaymentManagement2Theme {
|
||||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
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 ->
|
Crossfade(targetState = authState, label = "AuthScreenSwitch") { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
is AuthState.Loading -> {
|
is AuthState.Loading -> LoadingScreen()
|
||||||
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
|
@Composable
|
||||||
fun AppNavigation(viewModel: PersonViewModel) {
|
fun AppBottomNavigationBar(navController: NavController) {
|
||||||
val navController = rememberNavController()
|
val items = listOf(
|
||||||
|
Screen.Unpaid,
|
||||||
|
Screen.Paid,
|
||||||
|
Screen.Archive,
|
||||||
|
Screen.Settings
|
||||||
|
)
|
||||||
|
|
||||||
|
NavigationBar(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary // Match TopAppBar
|
||||||
|
) {
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStackEntry?.destination?.route
|
val currentDestination = navBackStackEntry?.destination
|
||||||
|
|
||||||
var showExitDialog by remember { mutableStateOf(false) }
|
items.forEach { screen ->
|
||||||
val context = LocalContext.current
|
NavigationBarItem(
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
BackHandler(enabled = currentRoute == "personScreen") {
|
selectedTextColor = Color.White,
|
||||||
showExitDialog = true
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = { showExitDialog = false },
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("خروج از برنامه") },
|
title = { Text("خروج از برنامه") },
|
||||||
text = { Text("آیا برای خروج از برنامه مطمئن هستید؟") },
|
text = { Text("آیا برای خروج از برنامه مطمئن هستید؟") },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
@@ -133,39 +280,28 @@ fun AppNavigation(viewModel: PersonViewModel) {
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { (context as? ComponentActivity)?.finish() },
|
onClick = onConfirmExit,
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Text("بله")
|
Text("بله")
|
||||||
}
|
}
|
||||||
Button(onClick = { showExitDialog = false }) {
|
Button(onClick = onDismiss) {
|
||||||
Text("خیر")
|
Text("خیر")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {}
|
dismissButton = {}
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
NavHost(navController = navController, startDestination = "personScreen") {
|
|
||||||
composable("personScreen") {
|
|
||||||
PersonScreen(
|
|
||||||
onSettingsClick = { navController.navigate("settings") },
|
|
||||||
//onExitApp = { viewModel.logout() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable("settings") {
|
|
||||||
SettingsScreen(onBackClick = { navController.popBackStack() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoadingScreen() {
|
fun LoadingScreen() {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
CircularProgressIndicator()
|
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
|
@Dao
|
||||||
interface PersonDao {
|
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>>
|
fun getAllPersonsFlow(): Flow<List<Person>>
|
||||||
|
|
||||||
@Query("SELECT * FROM persons WHERE id = :id LIMIT 1")
|
@Query("SELECT * FROM persons WHERE id = :id LIMIT 1")
|
||||||
@@ -19,6 +19,12 @@ interface PersonDao {
|
|||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertAll(persons: List<Person>)
|
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")
|
@Query("DELETE FROM persons")
|
||||||
suspend fun deleteAll()
|
suspend fun deleteAll()
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("unused")
|
||||||
|
|
||||||
package com.oqba26.monthlypaymentapp.data.database
|
package com.oqba26.monthlypaymentapp.data.database
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -14,7 +16,7 @@ import java.util.UUID
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [Person::class, PaymentRecord::class],
|
entities = [Person::class, PaymentRecord::class],
|
||||||
version = 11, // حتماً نسخه را بالا ببری که اسکیمای جدید اعمال شود
|
version = 13, // حتماً نسخه را بالا ببری که اسکیمای جدید اعمال شود
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
@@ -27,7 +29,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
// تمیزکاری قبل از درج بکاپ
|
// تمیزکاری قبل از درج بکاپ
|
||||||
val safePersons = backupData.persons.map { p ->
|
val safePersons = backupData.persons.map { p ->
|
||||||
val name = p.name.trim()
|
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)
|
Person(id = id, name = name)
|
||||||
}
|
}
|
||||||
personDao().deleteAll()
|
personDao().deleteAll()
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
|
||||||
|
|
||||||
package com.oqba26.monthlypaymentapp.data.model
|
package com.oqba26.monthlypaymentapp.data.model
|
||||||
|
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
@@ -12,5 +14,6 @@ data class PaymentRecord(
|
|||||||
val amount: Double,
|
val amount: Double,
|
||||||
val shamsiYear: Int,
|
val shamsiYear: Int,
|
||||||
val shamsiMonth: 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.Entity
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
import kotlinx.serialization.InternalSerializationApi
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.util.UUID
|
import kotlin.OptIn // CORRECTED IMPORT
|
||||||
|
|
||||||
|
@OptIn(InternalSerializationApi::class)
|
||||||
@Serializable
|
@Serializable
|
||||||
@Entity(tableName = "persons")
|
@Entity(tableName = "persons")
|
||||||
data class Person(
|
data class Person(
|
||||||
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
@PrimaryKey val id: String = "",
|
||||||
val name: String,
|
val name: String,
|
||||||
val userId: String? = null,
|
val isArchived: Boolean = false,
|
||||||
val createdAt: Long = System.currentTimeMillis()
|
val displayOrder: Long = 0L,
|
||||||
|
val createdAt: Long = 0L
|
||||||
)
|
)
|
||||||
@@ -22,8 +22,7 @@ class ApiClient(settingsRepository: SettingsRepository) {
|
|||||||
private val authInterceptor = AuthInterceptor(settingsRepository)
|
private val authInterceptor = AuthInterceptor(settingsRepository)
|
||||||
|
|
||||||
private val logging = HttpLoggingInterceptor().apply {
|
private val logging = HttpLoggingInterceptor().apply {
|
||||||
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
|
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
|
||||||
else HttpLoggingInterceptor.Level.NONE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐️ افزودن AuthInterceptor به OkHttpClient
|
// ⭐️ افزودن AuthInterceptor به OkHttpClient
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import retrofit2.Response
|
|||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.DELETE
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.PATCH
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.PUT
|
import retrofit2.http.PUT
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
@@ -33,6 +34,15 @@ interface ApiService {
|
|||||||
@PUT("persons/{id}")
|
@PUT("persons/{id}")
|
||||||
suspend fun updatePerson(@Path("id") personId: String, @Body person: Person): Response<Unit>
|
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}")
|
@DELETE("persons/{id}")
|
||||||
suspend fun deletePersonAndPayments(@Path("id") personId: String): Response<Unit>
|
suspend fun deletePersonAndPayments(@Path("id") personId: String): Response<Unit>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.oqba26.monthlypaymentapp.data.remote
|
package com.oqba26.monthlypaymentapp.data.remote
|
||||||
|
|
||||||
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
|
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
@@ -11,16 +11,17 @@ class AuthInterceptor(
|
|||||||
) : Interceptor {
|
) : Interceptor {
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val originalRequest = chain.request()
|
// Get the token from DataStore, blocking the current thread.
|
||||||
|
// This is acceptable for an I/O-bound operation in an interceptor.
|
||||||
// توکن را به صورت Blocking از DataStore میخوانیم
|
|
||||||
val authToken = runBlocking {
|
val authToken = runBlocking {
|
||||||
settingsRepository.authTokenFlow.firstOrNull()
|
settingsRepository.authTokenFlow.first() // Use first() to wait for a value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val originalRequest = chain.request()
|
||||||
val requestBuilder = originalRequest.newBuilder()
|
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")
|
requestBuilder.header("Authorization", "Bearer $authToken")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-18
@@ -45,23 +45,20 @@ class NetworkRepository(private val api: ApiService) {
|
|||||||
suspend fun addPerson(person: Person): Int? {
|
suspend fun addPerson(person: Person): Int? {
|
||||||
return try {
|
return try {
|
||||||
val trimmed = person.name.trim()
|
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))
|
val resp = api.addPerson(person.copy(name = trimmed))
|
||||||
if (resp.isSuccessful) refresh()
|
resp.code()
|
||||||
resp.code() // Return the actual HTTP status code
|
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
e.code() // Return the HTTP error code (e.g., 409)
|
e.code()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("API", "addPerson error", e)
|
Log.e("API", "addPerson error", e)
|
||||||
null // Return null for non-HTTP errors
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deletePersonAndPayments(personId: String): Boolean {
|
suspend fun deletePersonAndPayments(personId: String): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val resp = api.deletePersonAndPayments(personId)
|
api.deletePersonAndPayments(personId).isSuccessful
|
||||||
if (resp.isSuccessful) refresh()
|
|
||||||
resp.isSuccessful
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("API", "deletePersonAndPayments error", e); false
|
Log.e("API", "deletePersonAndPayments error", e); false
|
||||||
}
|
}
|
||||||
@@ -71,23 +68,46 @@ class NetworkRepository(private val api: ApiService) {
|
|||||||
return try {
|
return try {
|
||||||
val trimmed = name.trim()
|
val trimmed = name.trim()
|
||||||
if (trimmed.isEmpty()) return false
|
if (trimmed.isEmpty()) return false
|
||||||
val personUpdate = Person(id = personId, name = trimmed)
|
// Note: displayOrder is not updated here, only the name.
|
||||||
val resp = api.updatePerson(personId, personUpdate)
|
val personUpdate = Person(id = personId, name = trimmed, displayOrder = 0)
|
||||||
if (resp.isSuccessful) refresh()
|
api.updatePerson(personId, personUpdate).isSuccessful
|
||||||
resp.isSuccessful
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("API", "updatePerson error", e); false
|
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()
|
fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow()
|
||||||
|
|
||||||
// --- Payment Operations ---
|
// --- Payment Operations ---
|
||||||
suspend fun addPayment(paymentRecord: PaymentRecord): Boolean {
|
suspend fun addPayment(paymentRecord: PaymentRecord): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val resp = api.addPayment(paymentRecord)
|
api.addPayment(paymentRecord).isSuccessful
|
||||||
if (resp.isSuccessful) refresh()
|
|
||||||
resp.isSuccessful
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("API", "addPayment error", e); false
|
Log.e("API", "addPayment error", e); false
|
||||||
}
|
}
|
||||||
@@ -95,9 +115,7 @@ class NetworkRepository(private val api: ApiService) {
|
|||||||
|
|
||||||
suspend fun deletePayment(paymentId: String): Boolean {
|
suspend fun deletePayment(paymentId: String): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val resp = api.deletePayment(paymentId)
|
api.deletePayment(paymentId).isSuccessful
|
||||||
if (resp.isSuccessful) refresh()
|
|
||||||
resp.isSuccessful
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("API", "deletePayment error", e); false
|
Log.e("API", "deletePayment error", e); false
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-7
@@ -19,7 +19,6 @@ class SettingsRepository(context: Context) {
|
|||||||
companion object {
|
companion object {
|
||||||
val DEFAULT_PAYMENT_AMOUNT = doublePreferencesKey("default_payment_amount")
|
val DEFAULT_PAYMENT_AMOUNT = doublePreferencesKey("default_payment_amount")
|
||||||
val SELECTED_FONT_NAME = stringPreferencesKey("selected_font_name")
|
val SELECTED_FONT_NAME = stringPreferencesKey("selected_font_name")
|
||||||
// ⭐️ جدید: کلیدهای مربوط به احراز هویت
|
|
||||||
val AUTH_TOKEN_KEY = stringPreferencesKey("auth_token")
|
val AUTH_TOKEN_KEY = stringPreferencesKey("auth_token")
|
||||||
val USER_ID_KEY = stringPreferencesKey("user_id")
|
val USER_ID_KEY = stringPreferencesKey("user_id")
|
||||||
|
|
||||||
@@ -27,7 +26,6 @@ class SettingsRepository(context: Context) {
|
|||||||
const val DEFAULT_FONT = "Estedad"
|
const val DEFAULT_FONT = "Estedad"
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- جریانهای تنظیمات عمومی ---
|
|
||||||
val defaultPaymentAmountFlow: Flow<Double> = dataStore.data.map {
|
val defaultPaymentAmountFlow: Flow<Double> = dataStore.data.map {
|
||||||
it[DEFAULT_PAYMENT_AMOUNT] ?: FALLBACK_AMOUNT
|
it[DEFAULT_PAYMENT_AMOUNT] ?: FALLBACK_AMOUNT
|
||||||
}
|
}
|
||||||
@@ -44,19 +42,15 @@ class SettingsRepository(context: Context) {
|
|||||||
dataStore.edit { it[SELECTED_FONT_NAME] = fontName }
|
dataStore.edit { it[SELECTED_FONT_NAME] = fontName }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- جریانها و متدهای مربوط به احراز هویت ---
|
|
||||||
val authTokenFlow: Flow<String?> = dataStore.data.map {
|
val authTokenFlow: Flow<String?> = dataStore.data.map {
|
||||||
it[AUTH_TOKEN_KEY]
|
it[AUTH_TOKEN_KEY]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
val userIdFlow: Flow<String?> = dataStore.data.map {
|
val userIdFlow: Flow<String?> = dataStore.data.map {
|
||||||
it[USER_ID_KEY]
|
it[USER_ID_KEY]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* توکن و ID کاربر را ذخیره میکند. برای پاک کردن، null ارسال کنید.
|
|
||||||
*/
|
|
||||||
suspend fun saveAuthData(token: String?, userId: String?) {
|
suspend fun saveAuthData(token: String?, userId: String?) {
|
||||||
dataStore.edit { preferences ->
|
dataStore.edit { preferences ->
|
||||||
if (token != null) {
|
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
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
|
import androidx.compose.material.icons.filled.ChevronLeft
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.unit.dp
|
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.data.model.PaymentRecord
|
||||||
import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation
|
|
||||||
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
|
import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian
|
||||||
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth
|
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDate
|
||||||
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
|
import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianTime
|
||||||
|
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
|
||||||
import com.oqba26.monthlypaymentapp.utils.toPersianDigits
|
import com.oqba26.monthlypaymentapp.utils.toPersianDigits
|
||||||
import com.oqba26.monthlypaymentapp.viewmodel.MonthStatus
|
import com.oqba26.monthlypaymentapp.viewmodel.MonthStatus
|
||||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonDetailViewModel
|
import com.oqba26.monthlypaymentapp.viewmodel.MonthUiModel
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
|
||||||
|
import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PersonDetailScreen(
|
fun PersonDetailScreen(
|
||||||
onBackClick: () -> Unit
|
personId: String,
|
||||||
|
viewModel: PersonViewModel,
|
||||||
|
navController: NavController
|
||||||
) {
|
) {
|
||||||
val viewModel: PersonDetailViewModel = viewModel(factory = PersonDetailViewModel.Factory)
|
LaunchedEffect(personId) {
|
||||||
val context = LocalContext.current
|
viewModel.onEvent(PersonScreenEvent.SelectPerson(personId))
|
||||||
val person by viewModel.person.collectAsState()
|
}
|
||||||
val selectedYear by viewModel.selectedYear.collectAsState()
|
|
||||||
val paymentHistory by viewModel.paymentHistory.collectAsState()
|
|
||||||
val defaultPaymentAmount by viewModel.defaultPaymentAmount.collectAsState()
|
|
||||||
|
|
||||||
var paymentToEdit by remember { mutableStateOf<PaymentRecord?>(null) }
|
val detailState by viewModel.personDetailState.collectAsState()
|
||||||
var showAddPaymentDialog by remember { mutableStateOf(false) }
|
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
|
||||||
var showEditPaymentDialog by remember { mutableStateOf(false) }
|
|
||||||
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
|
|
||||||
var showMessageDialog by remember { mutableStateOf(false) }
|
|
||||||
var dialogMessage by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
var paymentAmount by remember { mutableStateOf("") }
|
var expandedMonth by remember { mutableStateOf<Int?>(null) }
|
||||||
var selectedMonthForPayment by remember { mutableIntStateOf(1) }
|
var paymentDialogState by remember { mutableStateOf<PaymentDialogState?>(null) }
|
||||||
|
var infoDialogMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
var paymentToDelete by remember { mutableStateOf<PaymentRecord?>(null) }
|
||||||
|
|
||||||
val currentYear = getCurrentShamsiYear()
|
LaunchedEffect(Unit) {
|
||||||
val currentMonth = getCurrentShamsiMonth()
|
viewModel.infoMessage.collect { message ->
|
||||||
val persianMonths = listOf("فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند")
|
infoDialogMessage = message
|
||||||
|
|
||||||
LaunchedEffect(key1 = true) {
|
|
||||||
viewModel.toastMessage.collectLatest {
|
|
||||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Column(
|
||||||
topBar = {
|
modifier = Modifier.fillMaxSize()
|
||||||
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
|
|
||||||
) {
|
|
||||||
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
|
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = {
|
PersonDetailHeader(
|
||||||
val hasData = paymentHistory.any { it.shamsiYear == selectedYear - 1 }
|
name = detailState.person?.name ?: "...",
|
||||||
if (hasData || selectedYear - 1 >= currentYear - 5) { // Allow going back 5 years
|
onBack = { navController.popBackStack() }
|
||||||
viewModel.changeYear(-1)
|
)
|
||||||
|
|
||||||
|
if (detailState.person == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showMessage("هیچ دادهای برای سال قبل وجود ندارد")
|
YearSelector(
|
||||||
|
year = detailState.selectedYear,
|
||||||
|
onYearChange = { offset ->
|
||||||
|
viewModel.onEvent(PersonScreenEvent.ChangeYear(offset))
|
||||||
}
|
}
|
||||||
}) { 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") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
private fun MonthsList(
|
|
||||||
paymentHistory: List<PaymentRecord>,
|
|
||||||
selectedYear: Int,
|
|
||||||
currentYear: Int,
|
|
||||||
currentMonth: Int,
|
|
||||||
persianMonths: List<String>,
|
|
||||||
onAvailableClick: (month: Int, year: Int) -> Unit,
|
|
||||||
onPaidClick: (payment: PaymentRecord) -> Unit,
|
|
||||||
onDisabledClick: (message: String) -> Unit
|
|
||||||
) {
|
|
||||||
LazyColumn {
|
|
||||||
itemsIndexed(persianMonths, key = { index, _ -> index }) { index, monthName ->
|
|
||||||
val monthNumber = index + 1
|
|
||||||
val payment = paymentHistory.find { p -> p.shamsiMonth == monthNumber && p.shamsiYear == selectedYear }
|
|
||||||
|
|
||||||
val monthStatus = when {
|
|
||||||
payment != null -> MonthStatus.PAID
|
|
||||||
selectedYear > currentYear -> MonthStatus.FUTURE_YEAR
|
|
||||||
selectedYear < currentYear -> MonthStatus.PAST_YEAR
|
|
||||||
monthNumber > currentMonth -> MonthStatus.FUTURE_MONTH
|
|
||||||
else -> MonthStatus.AVAILABLE
|
|
||||||
}
|
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
) {
|
||||||
|
items(detailState.monthStates) { monthModel ->
|
||||||
MonthListItem(
|
MonthListItem(
|
||||||
monthName = monthName,
|
month = monthModel,
|
||||||
payment = payment,
|
isExpanded = expandedMonth == monthModel.month,
|
||||||
status = monthStatus,
|
onHeaderClick = {
|
||||||
onItemClick = {
|
expandedMonth =
|
||||||
when (monthStatus) {
|
if (expandedMonth == monthModel.month) null else monthModel.month
|
||||||
MonthStatus.AVAILABLE -> onAvailableClick(monthNumber, selectedYear)
|
},
|
||||||
MonthStatus.PAID -> onPaidClick(payment!!)
|
onEditClick = {
|
||||||
else -> onDisabledClick(getMessageForStatus(monthStatus, monthName))
|
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
|
@Composable
|
||||||
private fun MonthListItem(
|
private fun PersonDetailHeader(name: String, onBack: () -> Unit) {
|
||||||
monthName: String,
|
Surface(
|
||||||
payment: PaymentRecord?,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
status: MonthStatus,
|
contentColor = Color.White
|
||||||
onItemClick: () -> Unit
|
) {
|
||||||
) {
|
|
||||||
val isClickable = status == MonthStatus.AVAILABLE || status == MonthStatus.PAID
|
|
||||||
val backgroundColor by animateColorAsState(
|
|
||||||
targetValue = if (status == MonthStatus.PAID) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f) else Color.Transparent,
|
|
||||||
animationSpec = tween(400),
|
|
||||||
label = "MonthListItemColor"
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(enabled = isClickable, onClick = onItemClick)
|
.height(44.dp)
|
||||||
.background(backgroundColor)
|
.padding(horizontal = 8.dp),
|
||||||
.padding(horizontal = 16.dp, vertical = 20.dp),
|
verticalAlignment = Alignment.CenterVertically
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
) {
|
||||||
Text(text = monthName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
AnimatedContent(
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
targetState = status,
|
contentDescription = "بازگشت"
|
||||||
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
|
|
||||||
)
|
)
|
||||||
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
|
@Composable
|
||||||
private fun AddPaymentDialog(
|
fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
|
||||||
monthName: String,
|
Surface(
|
||||||
year: Int,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
amount: String,
|
tonalElevation = 1.dp,
|
||||||
onAmountChange: (String) -> Unit,
|
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,
|
onConfirm: () -> Unit,
|
||||||
onDismiss: () -> 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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("تایید حذف") },
|
title = { Text("تایید حذف") },
|
||||||
text = { Text("آیا از حذف این پرداخت مطمئن هستید؟ این عمل غیرقابل بازگشت است.") },
|
text = {
|
||||||
confirmButton = {
|
Text(
|
||||||
Button(onClick = onConfirm, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { 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
|
@Composable
|
||||||
private fun MessageDialog(message: String, onDismiss: () -> Unit) {
|
fun InfoDialog(message: String, onDismiss: () -> Unit) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text("توجه") },
|
title = { Text("توجه") },
|
||||||
text = { Text(message) },
|
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
|
package com.oqba26.monthlypaymentapp.ui.screens
|
||||||
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -6,8 +8,6 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
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.layout.width
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
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.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -27,7 +27,6 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -37,7 +36,6 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -65,9 +63,8 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onBackClick: () -> Unit) {
|
fun SettingsScreen(onLogout: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val app = context.applicationContext as PaymentApplication
|
val app = context.applicationContext as PaymentApplication
|
||||||
val viewModel: SettingsViewModel = viewModel(factory = app.settingsViewModelFactory)
|
val viewModel: SettingsViewModel = viewModel(factory = app.settingsViewModelFactory)
|
||||||
@@ -76,7 +73,7 @@ fun SettingsScreen(onBackClick: () -> Unit) {
|
|||||||
val defaultAmount by viewModel.defaultPaymentAmount.collectAsState()
|
val defaultAmount by viewModel.defaultPaymentAmount.collectAsState()
|
||||||
val selectedFont by viewModel.selectedFont.collectAsState()
|
val selectedFont by viewModel.selectedFont.collectAsState()
|
||||||
|
|
||||||
var showRestoreConfirmDialog by remember { mutableStateOf<String?>(null) }
|
var backupJsonToRestore by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.toastMessage.collect { message ->
|
viewModel.toastMessage.collect { message ->
|
||||||
@@ -92,11 +89,21 @@ fun SettingsScreen(onBackClick: () -> Unit) {
|
|||||||
try {
|
try {
|
||||||
val jsonString = viewModel.createBackupJsonSuspend()
|
val jsonString = viewModel.createBackupJsonSuspend()
|
||||||
context.contentResolver.openOutputStream(it)?.use { outputStream ->
|
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) {
|
} 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 {
|
try {
|
||||||
context.contentResolver.openInputStream(it)?.use { inputStream ->
|
context.contentResolver.openInputStream(it)?.use { inputStream ->
|
||||||
BufferedReader(InputStreamReader(inputStream)).use { reader ->
|
BufferedReader(InputStreamReader(inputStream)).use { reader ->
|
||||||
showRestoreConfirmDialog = reader.readText()
|
backupJsonToRestore = reader.readText()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
@@ -120,44 +127,42 @@ fun SettingsScreen(onBackClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Scaffold(
|
Scaffold { innerPadding ->
|
||||||
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 ->
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.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) {
|
FontSelectionDropdown(
|
||||||
viewModel.onFontSelected(it)
|
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(
|
OutlinedButton(
|
||||||
onClick = {
|
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"
|
val fileName = "payment_backup_${simpleDateFormat.format(Date())}.json"
|
||||||
backupLauncher.launch(fileName)
|
backupLauncher.launch(fileName)
|
||||||
},
|
},
|
||||||
@@ -165,67 +170,110 @@ fun SettingsScreen(onBackClick: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Text("تهیه نسخه پشتیبان")
|
Text("تهیه نسخه پشتیبان")
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { restoreLauncher.launch(arrayOf("application/json")) },
|
onClick = { restoreLauncher.launch(arrayOf("application/json")) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Text("بازیابی اطلاعات از فایل")
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = { showRestoreConfirmDialog = null },
|
onDismissRequest = { backupJsonToRestore = null },
|
||||||
title = { Text("هشدار جدی!") },
|
title = { Text("هشدار جدی!") },
|
||||||
text = { Text("آیا مطمئن هستید؟ با بازیابی اطلاعات، تمام دادههای فعلی برنامه (اشخاص و پرداختها) برای همیشه حذف شده و اطلاعات فایل پشتیبان جایگزین آن خواهد شد. این عمل غیرقابل بازگشت است.") },
|
text = {
|
||||||
|
Text(
|
||||||
|
"آیا مطمئن هستید؟ با بازیابی اطلاعات، تمام دادههای فعلی برنامه (اشخاص و پرداختها) برای همیشه حذف شده و اطلاعات فایل پشتیبان جایگزین آن خواهد شد. این عمل غیرقابل بازگشت است."
|
||||||
|
)
|
||||||
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.restoreFromBackupJson(it)
|
viewModel.restoreFromBackupJson(json)
|
||||||
showRestoreConfirmDialog = null
|
backupJsonToRestore = null
|
||||||
},
|
},
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Text("بله، بازیابی کن")
|
Text("بله، بازیابی کن")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showRestoreConfirmDialog = null }) { Text("لغو") }
|
TextButton(onClick = { backupJsonToRestore = null }) {
|
||||||
|
Text("لغو")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
|
fun DefaultAmountSetting(
|
||||||
|
defaultAmount: Double,
|
||||||
|
onSave: (String) -> Unit
|
||||||
|
) {
|
||||||
var isEditing by remember { mutableStateOf(false) }
|
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(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
elevation = CardDefaults.cardElevation(0.dp),
|
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(
|
AnimatedContent(
|
||||||
targetState = isEditing,
|
targetState = isEditing,
|
||||||
label = "EditAmountAnimation",
|
label = "EditAmountAnimation",
|
||||||
modifier = Modifier.padding(16.dp)
|
modifier = Modifier.padding(12.dp)
|
||||||
) { editing ->
|
) { editing ->
|
||||||
if (editing) {
|
if (editing) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = amountText,
|
value = amountText,
|
||||||
onValueChange = { amountText = it.filter { c -> c.isDigit() } },
|
onValueChange = {
|
||||||
|
amountText = it.filter { c -> c.isDigit() }
|
||||||
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("مبلغ پیش فرض پرداخت (تومان)") },
|
label = { Text("مبلغ پیش فرض پرداخت (تومان)") },
|
||||||
visualTransformation = PersianNumberVisualTransformation(),
|
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 }) {
|
TextButton(onClick = { isEditing = false }) {
|
||||||
Text("لغو")
|
Text("لغو")
|
||||||
}
|
}
|
||||||
@@ -239,10 +287,22 @@ fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text("مبلغ پیش فرض پرداخت", style = MaterialTheme.typography.bodyLarge)
|
Text(
|
||||||
Text("${formatNumberAsPersian(defaultAmount)} تومان", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
|
"مبلغ پیش فرض پرداخت",
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${formatNumberAsPersian(defaultAmount)} تومان",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { isEditing = true }) {
|
IconButton(onClick = { isEditing = true }) {
|
||||||
Icon(Icons.Filled.Edit, contentDescription = "Edit Amount")
|
Icon(Icons.Filled.Edit, contentDescription = "Edit Amount")
|
||||||
@@ -253,16 +313,21 @@ fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FontSelectionDropdown(selectedFont: String, onFontSelected: (String) -> Unit) {
|
fun FontSelectionDropdown(
|
||||||
|
selectedFont: String,
|
||||||
|
onFontSelected: (String) -> Unit
|
||||||
|
) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
val fonts = listOf("Estedad", "Vazirmatn", "BYekan", "Sahel", "IranianSans")
|
val fonts = listOf("Estedad", "Vazirmatn", "BYekan", "Sahel", "IranianSans")
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
|
OutlinedButton(
|
||||||
|
onClick = { expanded = true },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
Text("فونت برنامه: $selectedFont")
|
Text("فونت برنامه: $selectedFont")
|
||||||
Icon(Icons.Default.ArrowDropDown, contentDescription = "Select Font")
|
Icon(Icons.Filled.ArrowDropDown, contentDescription = "Select Font")
|
||||||
}
|
}
|
||||||
DropdownMenu(
|
DropdownMenu(
|
||||||
expanded = expanded,
|
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
|
package com.oqba26.monthlypaymentapp.utils
|
||||||
|
|
||||||
import saman.zamani.persiandate.PersianDate
|
fun getPersianMonthName(month: Int): String {
|
||||||
import saman.zamani.persiandate.PersianDateFormat
|
return when (month) {
|
||||||
|
1 -> "فروردین"
|
||||||
fun Long.toPersianDate(pattern: String = "Y/M/d"): String {
|
2 -> "اردیبهشت"
|
||||||
val pd = PersianDate(this) // this = timestamp (ms)
|
3 -> "خرداد"
|
||||||
return PersianDateFormat(pattern).format(pd)
|
4 -> "تیر"
|
||||||
|
5 -> "مرداد"
|
||||||
|
6 -> "شهریور"
|
||||||
|
7 -> "مهر"
|
||||||
|
8 -> "آبان"
|
||||||
|
9 -> "آذر"
|
||||||
|
10 -> "دی"
|
||||||
|
11 -> "بهمن"
|
||||||
|
12 -> "اسفند"
|
||||||
|
else -> "نامشخص"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,31 @@
|
|||||||
|
@file:Suppress("unused")
|
||||||
|
|
||||||
package com.oqba26.monthlypaymentapp.utils
|
package com.oqba26.monthlypaymentapp.utils
|
||||||
|
|
||||||
|
import android.icu.text.SimpleDateFormat
|
||||||
import saman.zamani.persiandate.PersianDate
|
import saman.zamani.persiandate.PersianDate
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
fun getCurrentShamsiYear(): Int = PersianDate().shYear
|
fun getCurrentShamsiYear(): Int = PersianDate().shYear
|
||||||
|
|
||||||
fun getCurrentShamsiMonth(): Int = PersianDate().shMonth
|
fun getCurrentShamsiMonth(): Int = PersianDate().shMonth
|
||||||
|
|
||||||
|
fun formatTimestampToPersianDate(timestamp: Long): String {
|
||||||
|
val persianDate = PersianDate(Date(timestamp))
|
||||||
|
val year = persianDate.shYear.toString().toPersianDigits()
|
||||||
|
val month = persianDate.shMonth.toString().padStart(2, '0').toPersianDigits()
|
||||||
|
val day = persianDate.shDay.toString().padStart(2, '0').toPersianDigits()
|
||||||
|
return "$year/$month/$day"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatTimestampToPersianTime(timestamp: Long): String {
|
||||||
|
val timeFormat = SimpleDateFormat("HH:mm", Locale.US)
|
||||||
|
val timeString = timeFormat.format(Date(timestamp))
|
||||||
|
return timeString.toPersianDigits()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatTimestampToPersianDateTime(timestamp: Long): String {
|
||||||
|
val date = formatTimestampToPersianDate(timestamp)
|
||||||
|
val time = formatTimestampToPersianTime(timestamp)
|
||||||
|
return "$date، ساعت $time"
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@file:OptIn(kotlinx.serialization.InternalSerializationApi::class)
|
||||||
|
|
||||||
|
package com.oqba26.monthlypaymentapp.viewmodel
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@Suppress("unused")
|
||||||
|
data class UpdateArchivedStatusRequest(
|
||||||
|
val isArchived: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@Suppress("unused")
|
||||||
|
data class UpdateDisplayOrderRequest(
|
||||||
|
val displayOrder: Long // FIX: Changed from Int to Long
|
||||||
|
)
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
package com.oqba26.monthlypaymentapp.viewmodel
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
|
||||||
import androidx.lifecycle.createSavedStateHandle
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.lifecycle.viewmodel.CreationExtras
|
|
||||||
import com.oqba26.monthlypaymentapp.core.PaymentApplication
|
|
||||||
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
|
|
||||||
import com.oqba26.monthlypaymentapp.data.model.Person
|
|
||||||
import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository
|
|
||||||
import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository
|
|
||||||
import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class PersonDetailViewModel(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
private val networkRepository: NetworkRepository, // ⭐️ FIX: localRepository removed
|
|
||||||
private val settingsRepository: SettingsRepository
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val personId: String = requireNotNull(savedStateHandle.get<String>("personId"))
|
|
||||||
|
|
||||||
// ⭐️ FIX: Fetch person from the network flow
|
|
||||||
val person: StateFlow<Person?> = networkRepository.getPersonsFlow()
|
|
||||||
.map { persons -> persons.find { it.id == personId } }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
|
||||||
|
|
||||||
private val _selectedYear = kotlinx.coroutines.flow.MutableStateFlow(getCurrentShamsiYear())
|
|
||||||
val selectedYear = _selectedYear.asStateFlow()
|
|
||||||
|
|
||||||
// ⭐️ FIX: Fetch payment history from the correct network flow
|
|
||||||
val paymentHistory: StateFlow<List<PaymentRecord>> = networkRepository.getPaymentsFlow()
|
|
||||||
.map { payments -> payments.filter { it.personId == personId } }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
|
||||||
|
|
||||||
private val _toastMessage = MutableSharedFlow<String>()
|
|
||||||
val toastMessage = _toastMessage.asSharedFlow()
|
|
||||||
|
|
||||||
val defaultPaymentAmount = settingsRepository.defaultPaymentAmountFlow
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.0)
|
|
||||||
|
|
||||||
fun changeYear(offset: Int) { _selectedYear.update { it + offset } }
|
|
||||||
|
|
||||||
fun addPaymentForMonth(month: Int, year: Int, amount: Double) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val newPayment = PaymentRecord(
|
|
||||||
amount = amount,
|
|
||||||
personId = personId,
|
|
||||||
shamsiYear = year,
|
|
||||||
shamsiMonth = month
|
|
||||||
)
|
|
||||||
val ok = networkRepository.addPayment(newPayment)
|
|
||||||
if (!ok) _toastMessage.emit("ثبت پرداخت با خطا مواجه شد.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updatePayment(payment: PaymentRecord, newAmount: Double) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val updatedPayment = payment.copy(amount = newAmount)
|
|
||||||
val ok = networkRepository.addPayment(updatedPayment)
|
|
||||||
if (!ok) _toastMessage.emit("بهروزرسانی پرداخت با خطا مواجه شد.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deletePayment(payment: PaymentRecord) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val ok = networkRepository.deletePayment(payment.id)
|
|
||||||
if (!ok) _toastMessage.emit("حذف پرداخت با خطا مواجه شد.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
|
|
||||||
val app = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) as PaymentApplication
|
|
||||||
val saved = extras.createSavedStateHandle()
|
|
||||||
// ⭐️ FIX: Provide the correct dependencies
|
|
||||||
return PersonDetailViewModel(
|
|
||||||
saved,
|
|
||||||
app.networkRepository,
|
|
||||||
app.settingsRepository
|
|
||||||
) as T
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,39 +31,95 @@ sealed interface AuthState {
|
|||||||
data object Unauthenticated : AuthState
|
data object Unauthenticated : AuthState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class PersonListType {
|
||||||
|
UNPAID,
|
||||||
|
PAID
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class PersonViewModel(
|
class PersonViewModel(
|
||||||
private val networkRepository: NetworkRepository,
|
private val networkRepository: NetworkRepository,
|
||||||
private val settingsRepository: SettingsRepository
|
private val settingsRepository: SettingsRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _searchQuery = MutableStateFlow("")
|
private val _searchQuery = MutableStateFlow("")
|
||||||
val searchQuery = _searchQuery.asStateFlow()
|
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||||
|
|
||||||
private val _isRefreshing = MutableStateFlow(false)
|
private val _isRefreshing = MutableStateFlow(false)
|
||||||
val isRefreshing = _isRefreshing.asStateFlow()
|
@Suppress("unused")
|
||||||
|
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||||
private val _expandedPersonId = MutableStateFlow<String?>(null)
|
|
||||||
val expandedPersonId = _expandedPersonId.asStateFlow()
|
|
||||||
|
|
||||||
|
private val _selectedPersonId = MutableStateFlow<String?>(null)
|
||||||
private val _selectedYear = MutableStateFlow(getCurrentShamsiYear())
|
private val _selectedYear = MutableStateFlow(getCurrentShamsiYear())
|
||||||
val selectedYear = _selectedYear.asStateFlow()
|
|
||||||
|
|
||||||
private val _dashboardData = MutableStateFlow(DashboardUiModel())
|
private val _dashboardData = MutableStateFlow(DashboardUiModel())
|
||||||
val dashboardData = _dashboardData.asStateFlow()
|
val dashboardData: StateFlow<DashboardUiModel> = _dashboardData.asStateFlow()
|
||||||
|
|
||||||
val defaultPaymentAmountFlow = settingsRepository.defaultPaymentAmountFlow
|
val defaultPaymentAmountFlow = settingsRepository.defaultPaymentAmountFlow
|
||||||
|
|
||||||
private val _toastMessage = MutableSharedFlow<String>()
|
private val _toastMessage = MutableSharedFlow<String>()
|
||||||
val toastMessage = _toastMessage.asSharedFlow()
|
val toastMessage = _toastMessage.asSharedFlow()
|
||||||
|
|
||||||
val authState: StateFlow<AuthState> = settingsRepository.authTokenFlow.map { token ->
|
private val _infoMessage = MutableSharedFlow<String>()
|
||||||
if (token != null) AuthState.Authenticated else AuthState.Unauthenticated
|
val infoMessage = _infoMessage.asSharedFlow()
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AuthState.Loading)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
private val _uiState = MutableStateFlow(PersonListUiState())
|
||||||
val uiState: StateFlow<PersonListUiState> = authState.flatMapLatest { auth ->
|
val uiState: StateFlow<PersonListUiState> = _uiState.asStateFlow()
|
||||||
when (auth) {
|
|
||||||
is AuthState.Authenticated -> {
|
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(
|
combine(
|
||||||
networkRepository.getPersonsFlow(),
|
networkRepository.getPersonsFlow(),
|
||||||
networkRepository.getPaymentsFlow(),
|
networkRepository.getPaymentsFlow(),
|
||||||
@@ -71,44 +127,335 @@ class PersonViewModel(
|
|||||||
) { persons, allPayments, query ->
|
) { persons, allPayments, query ->
|
||||||
val currentMonth = getCurrentShamsiMonth()
|
val currentMonth = getCurrentShamsiMonth()
|
||||||
val currentYear = getCurrentShamsiYear()
|
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 }
|
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)
|
updateDashboard(uiModels.size, paymentsThisMonth)
|
||||||
|
|
||||||
val filtered = if (query.isBlank()) uiModels
|
val (paidModels, unpaidModels) =
|
||||||
else uiModels.filter { it.name.contains(query, ignoreCase = true) }
|
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 -> {
|
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()
|
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 {
|
fun login(request: AuthRequest) = viewModelScope.launch {
|
||||||
val response = networkRepository.login(request)
|
val response = networkRepository.login(request)
|
||||||
if (response != null && response.token.isNotBlank()) {
|
if (response != null && response.token.isNotBlank()) {
|
||||||
settingsRepository.saveAuthData(response.token, response.userId)
|
settingsRepository.saveAuthData(response.token, response.userId)
|
||||||
_toastMessage.emit("ورود موفقیتآمیز بود")
|
_toastMessage.emit("ورود موفقیتآمیز بود")
|
||||||
|
networkRepository.refresh()
|
||||||
} else {
|
} else {
|
||||||
_toastMessage.emit("نام کاربری یا رمز عبور اشتباه است.")
|
_toastMessage.emit("نام کاربری یا رمز عبور اشتباه است.")
|
||||||
}
|
}
|
||||||
@@ -119,110 +466,57 @@ class PersonViewModel(
|
|||||||
if (response != null && response.token.isNotBlank()) {
|
if (response != null && response.token.isNotBlank()) {
|
||||||
settingsRepository.saveAuthData(response.token, response.userId)
|
settingsRepository.saveAuthData(response.token, response.userId)
|
||||||
_toastMessage.emit("ثبت نام و ورود موفقیتآمیز بود")
|
_toastMessage.emit("ثبت نام و ورود موفقیتآمیز بود")
|
||||||
|
networkRepository.refresh()
|
||||||
} else {
|
} else {
|
||||||
_toastMessage.emit("خطا در ثبت نام.")
|
_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 {
|
sealed class PersonScreenEvent {
|
||||||
data object RefreshData : PersonScreenEvent()
|
data object RefreshData : PersonScreenEvent()
|
||||||
data class AddPerson(val name: String) : PersonScreenEvent()
|
data class AddPerson(val name: String) : PersonScreenEvent()
|
||||||
data class UpdatePerson(val personId: String, val name: String) : PersonScreenEvent()
|
data class UpdatePerson(val personId: String, val name: String) : PersonScreenEvent()
|
||||||
data class DeletePerson(val personId: 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 Login(val username: String, val password: String) : PersonScreenEvent()
|
||||||
data class Register(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 ChangeYear(val offset: Int) : PersonScreenEvent()
|
||||||
data class AddPaymentForMonth(val personId: String, val month: Int, val year: Int, val amount: Double) : PersonScreenEvent()
|
data class AddPaymentForMonth(
|
||||||
data class UpdatePayment(val payment: PaymentRecord, val newAmount: Double) : PersonScreenEvent()
|
val personId: String,
|
||||||
|
val month: Int,
|
||||||
|
val year: Int,
|
||||||
|
val amount: Double,
|
||||||
|
val description: String
|
||||||
|
) : PersonScreenEvent()
|
||||||
|
|
||||||
|
data class UpdatePayment(
|
||||||
|
val payment: PaymentRecord,
|
||||||
|
val newAmount: Double,
|
||||||
|
val newDescription: String
|
||||||
|
) : PersonScreenEvent()
|
||||||
|
|
||||||
data class DeletePayment(val payment: PaymentRecord) : PersonScreenEvent()
|
data class 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
|
package com.oqba26.monthlypaymentapp.viewmodel
|
||||||
|
|
||||||
|
import com.oqba26.monthlypaymentapp.data.model.PaymentRecord
|
||||||
|
import com.oqba26.monthlypaymentapp.data.model.Person
|
||||||
|
|
||||||
data class PersonUiModel(
|
data class PersonUiModel(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: 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(
|
data class DashboardUiModel(
|
||||||
val paidCount: Int = 0,
|
val paidCount: Int = 0,
|
||||||
val totalCount: Int = 0,
|
val totalCount: Int = 0,
|
||||||
val totalIncome: Double = 0.0,
|
val totalIncome: Double = 0.0,
|
||||||
val progress: Float = 0f
|
val progress: Float = 0f
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class MonthStatus { PAID, AVAILABLE, FUTURE_YEAR, PAST_YEAR, FUTURE_MONTH }
|
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
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.jetbrains.kotlin.android) apply false
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.ksp) apply false
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ coreKtx = "1.13.1"
|
|||||||
lifecycle = "2.7.0"
|
lifecycle = "2.7.0"
|
||||||
activityCompose = "1.9.0"
|
activityCompose = "1.9.0"
|
||||||
navigation = "2.7.7"
|
navigation = "2.7.7"
|
||||||
|
appcompat = "1.7.0"
|
||||||
|
|
||||||
retrofit = "2.9.0"
|
retrofit = "2.9.0"
|
||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
@@ -21,7 +22,7 @@ datastore = "1.1.1"
|
|||||||
desugarLibs = "2.0.4"
|
desugarLibs = "2.0.4"
|
||||||
serialization = "1.6.3"
|
serialization = "1.6.3"
|
||||||
|
|
||||||
persianDate = "1.5.2"
|
persianDate = "0.2.0"
|
||||||
material = "1.12.0"
|
material = "1.12.0"
|
||||||
|
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
@@ -30,6 +31,7 @@ espressoCore = "3.5.1"
|
|||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-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-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" }
|
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" }
|
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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
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" }
|
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
+1
-1
@@ -33,4 +33,4 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "MonthlyPaymentApp"
|
rootProject.name = "MonthlyPaymentApp"
|
||||||
include(":app")
|
include(":app", ":PersianDate")
|
||||||
Reference in new issue
Block a user