From 502cdacb44ce3442649074f74df46731740d4b88 Mon Sep 17 00:00:00 2001 From: oqba26 Date: Sat, 22 Nov 2025 06:57:54 +0330 Subject: [PATCH] =?UTF-8?q?"=D8=A8=D8=B1=D9=88=D8=B2=D8=B1=D8=B3=D8=A7?= =?UTF-8?q?=D9=86=DB=8C=20=D8=AF=DB=8C=D8=A7=D9=84=D9=88=DA=AF=E2=80=8C?= =?UTF-8?q?=D9=87=D8=A7=D8=8C=20=D8=A2=D8=B1=D8=B4=DB=8C=D9=88=20=D9=88=20?= =?UTF-8?q?=D8=AA=D9=88=D8=B6=DB=8C=D8=AD=D8=A7=D8=AA=20=D9=BE=D8=B1=D8=AF?= =?UTF-8?q?=D8=A7=D8=AE=D8=AA=20-=20=DB=B1=DB=B4=DB=B0=DB=B4/=DB=B0=DB=B9/?= =?UTF-8?q?=DB=B0=DB=B1=20(=D8=AC=D8=B2=D8=A6=DB=8C=D8=A7=D8=AA=20=D8=AF?= =?UTF-8?q?=D8=B1=20README.md)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/compiler.xml | 2 +- .idea/deploymentTargetSelector.xml | 8 + .idea/gradle.xml | 3 +- .idea/misc.xml | 2 +- PersianDate/build.gradle.kts | 35 + app/build.gradle.kts | 16 +- .../oqba26/monthlypaymentapp/MainActivity.kt | 268 ++++- .../monthlypaymentapp/data/dao/PersonDao.kt | 8 +- .../data/database/AppDatabase.kt | 6 +- .../data/model/PaymentRecord.kt | 5 +- .../monthlypaymentapp/data/model/Person.kt | 13 +- .../data/remote/ApiClient.kt | 3 +- .../data/remote/ApiService.kt | 10 + .../data/remote/AuthInterceptor.kt | 17 +- .../data/repository/NetworkRepository.kt | 54 +- .../data/repository/SettingsRepository.kt | 8 +- .../ui/components/AddPaymentDialog.kt | 82 -- .../ui/screens/ArchiveScreen.kt | 91 ++ .../ui/screens/PaidListScreen.kt | 169 +++ .../ui/screens/PersonDetailScreen.kt | 634 +++++----- .../ui/screens/PersonScreen.kt | 1066 ++++++----------- .../ui/screens/SettingsScreen.kt | 189 ++- .../ui/screens/SharedComponents.kt | 108 ++ .../monthlypaymentapp/utils/DateUtils.kt | 24 +- .../monthlypaymentapp/utils/ShamsiUtils.kt | 26 +- .../monthlypaymentapp/viewmodel/ApiModels.kt | 17 + .../viewmodel/PersonDetailViewModel.kt | 97 -- .../viewmodel/PersonViewModel.kt | 550 +++++++-- .../viewmodel/PersonViewModelFactory.kt | 22 - .../monthlypaymentapp/viewmodel/UiModels.kt | 36 +- build.gradle.kts | 4 +- gradle/libs.versions.toml | 7 +- settings.gradle.kts | 2 +- 33 files changed, 2026 insertions(+), 1556 deletions(-) create mode 100644 PersianDate/build.gradle.kts delete mode 100644 app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/AddPaymentDialog.kt create mode 100644 app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/ArchiveScreen.kt create mode 100644 app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PaidListScreen.kt create mode 100644 app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SharedComponents.kt create mode 100644 app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/ApiModels.kt delete mode 100644 app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonDetailViewModel.kt delete mode 100644 app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModelFactory.kt diff --git a/.idea/compiler.xml b/.idea/compiler.xml index b589d56..b86273d 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index b268ef3..defe597 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,14 @@ diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 5ef3273..0f2d2fa 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -6,10 +6,11 @@ diff --git a/.idea/misc.xml b/.idea/misc.xml index 8978d23..b2c751a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,6 @@ - + diff --git a/PersianDate/build.gradle.kts b/PersianDate/build.gradle.kts new file mode 100644 index 0000000..266caca --- /dev/null +++ b/PersianDate/build.gradle.kts @@ -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") +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 22bff53..2ced059 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,14 +47,14 @@ android { } compileOptions { - // هم‌تراز با JDK 17 - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + // Align with the JDK version used by Android Studio + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 isCoreLibraryDesugaringEnabled = true } - kotlinOptions { jvmTarget = "17" } - // اطمینان از JDK 17 - kotlin { jvmToolchain(17) } + kotlinOptions { jvmTarget = "21" } + // Ensure JDK 21 + kotlin { jvmToolchain(21) } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } @@ -72,7 +72,9 @@ dependencies { implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) implementation(libs.androidx.navigation.compose) + implementation("androidx.compose.material:material") // برای PullRefresh implementation("androidx.compose.material:material-icons-extended-android:1.6.7") + implementation("org.burnoutcrew.composereorderable:reorderable:0.9.6") // Core & Coroutines implementation(libs.androidx.lifecycle.viewmodel.savedstate) @@ -98,7 +100,7 @@ dependencies { implementation(libs.androidx.datastore.preferences) // Utility - implementation(libs.persiandate) + implementation(project(":PersianDate")) implementation(libs.google.material) // Desugaring diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt index bf16292..1f58764 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.oqba26.monthlypaymentapp import android.os.Bundle @@ -9,18 +11,31 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Paid +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -31,17 +46,26 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.oqba26.monthlypaymentapp.core.PaymentApplication +import com.oqba26.monthlypaymentapp.ui.screens.ArchiveScreen import com.oqba26.monthlypaymentapp.ui.screens.AuthScreen +import com.oqba26.monthlypaymentapp.ui.screens.PaidListScreen +import com.oqba26.monthlypaymentapp.ui.screens.PersonDetailScreen import com.oqba26.monthlypaymentapp.ui.screens.PersonScreen import com.oqba26.monthlypaymentapp.ui.screens.SettingsScreen import com.oqba26.monthlypaymentapp.ui.theme.MonthlyPaymentManagement2Theme @@ -54,7 +78,8 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { val application = application as PaymentApplication - val personViewModel: PersonViewModel = viewModel(factory = application.personViewModelFactory) + val personViewModel: PersonViewModel = + viewModel(factory = application.personViewModelFactory) MonthlyPaymentManagement2Theme { CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { @@ -81,26 +106,108 @@ fun MainAppHost(viewModel: PersonViewModel) { } } - Scaffold( - modifier = Modifier.fillMaxSize() - ) { paddingValues -> + Crossfade(targetState = authState, label = "AuthScreenSwitch") { state -> + when (state) { + is AuthState.Loading -> LoadingScreen() + is AuthState.Unauthenticated -> AuthScreen() + is AuthState.Authenticated -> AuthenticatedContent(viewModel) + } + } +} - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) + +@Composable +fun AuthenticatedContent(viewModel: PersonViewModel) { + val navController = rememberNavController() + var showExitDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route ?: "" + + val showBottomBar = currentRoute in listOf( + Screen.Unpaid.route, + Screen.Paid.route, + Screen.Archive.route, + Screen.Settings.route + ) + + // هندل کردن دکمه Back + BackHandler { + showExitDialog = true + } + + if (showExitDialog) { + ExitDialog( + onConfirmExit = { (context as? ComponentActivity)?.finish() }, + onDismiss = { + @Suppress("AssignedValueIsNeverRead") + showExitDialog = false + } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("مدیریت حقوق ماهانه") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = Color.White, + actionIconContentColor = Color.White + ), + actions = { + if (currentRoute == Screen.Unpaid.route) { + Button( + onClick = { viewModel.onAddPersonClicked() }, + modifier = Modifier.padding(horizontal = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary + ) + ) { + Text("افزودن شخص") + } + } + } + ) + }, + bottomBar = { + if (showBottomBar) { + AppBottomNavigationBar(navController = navController) + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = Screen.Unpaid.route, + modifier = Modifier.padding(innerPadding) ) { - Crossfade(targetState = authState, label = "AuthScreenSwitch") { state -> - when (state) { - is AuthState.Loading -> { - LoadingScreen() - } - is AuthState.Unauthenticated -> { - AuthScreen() - } - is AuthState.Authenticated -> { - AppNavigation(viewModel) - } + composable(Screen.Unpaid.route) { + PersonScreen( + viewModel = viewModel, + navController = navController + ) + } + composable(Screen.Paid.route) { + PaidListScreen(viewModel, navController) + } + composable(Screen.Archive.route) { + ArchiveScreen(viewModel) + } + composable(Screen.Settings.route) { + SettingsScreen(onLogout = { viewModel.logout() }) + } + composable( + route = "person_detail/{personId}", + arguments = listOf(navArgument("personId") { type = NavType.StringType }) + ) { backStackEntry -> + val personId = backStackEntry.arguments?.getString("personId") + if (personId != null) { + PersonDetailScreen( + personId = personId, + viewModel = viewModel, + navController = navController + ) } } } @@ -108,64 +215,93 @@ fun MainAppHost(viewModel: PersonViewModel) { } @Composable -fun AppNavigation(viewModel: PersonViewModel) { - val navController = rememberNavController() - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = navBackStackEntry?.destination?.route +fun AppBottomNavigationBar(navController: NavController) { + val items = listOf( + Screen.Unpaid, + Screen.Paid, + Screen.Archive, + Screen.Settings + ) - var showExitDialog by remember { mutableStateOf(false) } - val context = LocalContext.current + NavigationBar( + containerColor = MaterialTheme.colorScheme.primary // Match TopAppBar + ) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination - BackHandler(enabled = currentRoute == "personScreen") { - showExitDialog = true - } - - if (showExitDialog) { - AlertDialog( - onDismissRequest = { showExitDialog = false }, - title = { Text("خروج از برنامه") }, - text = { Text("آیا برای خروج از برنامه مطمئن هستید؟") }, - confirmButton = { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(all = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Button( - onClick = { (context as? ComponentActivity)?.finish() }, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) - ) { - Text("بله") - } - Button(onClick = { showExitDialog = false }) { - Text("خیر") + items.forEach { screen -> + NavigationBarItem( + colors = NavigationBarItemDefaults.colors( + selectedTextColor = Color.White, + selectedIconColor = Color.White, + indicatorColor = MaterialTheme.colorScheme.primaryContainer, + unselectedTextColor = Color.White.copy(alpha = 0.7f), + unselectedIconColor = Color.White.copy(alpha = 0.7f) + ), + icon = { Icon(screen.icon, contentDescription = screen.title) }, + label = { Text(screen.title) }, + selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true } } - }, - dismissButton = {} - ) - } - - - NavHost(navController = navController, startDestination = "personScreen") { - composable("personScreen") { - PersonScreen( - onSettingsClick = { navController.navigate("settings") }, - //onExitApp = { viewModel.logout() } ) } - composable("settings") { - SettingsScreen(onBackClick = { navController.popBackStack() }) - } } } +sealed class Screen( + val route: String, + val title: String, + val icon: androidx.compose.ui.graphics.vector.ImageVector +) { + data object Unpaid : Screen("unpaid_list", "پرداخت نشده", Icons.AutoMirrored.Filled.List) + data object Paid : Screen("paid_list", "پرداخت شده", Icons.Default.Paid) + data object Archive : Screen("archive", "آرشیو", Icons.Default.Archive) + data object Settings : Screen("settings", "تنظیمات", Icons.Default.Settings) +} + +@Composable +fun ExitDialog(onConfirmExit: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("خروج از برنامه") }, + text = { Text("آیا برای خروج از برنامه مطمئن هستید؟") }, + confirmButton = { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(all = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button( + onClick = onConfirmExit, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("بله") + } + Button(onClick = onDismiss) { + Text("خیر") + } + } + }, + dismissButton = {} + ) +} @Composable fun LoadingScreen() { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - Text("در حال بررسی وضعیت...", modifier = Modifier.align(Alignment.Center).padding(top = 80.dp)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Text("در حال بررسی وضعیت...", modifier = Modifier.padding(top = 16.dp)) + } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PersonDao.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PersonDao.kt index 122d14c..4f89f75 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PersonDao.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PersonDao.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow @Dao interface PersonDao { - @Query("SELECT * FROM persons ORDER BY name COLLATE NOCASE ASC") + @Query("SELECT * FROM persons ORDER BY displayOrder ASC") fun getAllPersonsFlow(): Flow> @Query("SELECT * FROM persons WHERE id = :id LIMIT 1") @@ -19,6 +19,12 @@ interface PersonDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(persons: List) + @Query("UPDATE persons SET isArchived = :isArchived WHERE id = :personId") + suspend fun updateArchivedStatus(personId: String, isArchived: Boolean) + + @Query("UPDATE persons SET displayOrder = :displayOrder WHERE id = :personId") + suspend fun updateDisplayOrder(personId: String, displayOrder: Int) + @Query("DELETE FROM persons") suspend fun deleteAll() } \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/database/AppDatabase.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/database/AppDatabase.kt index 201a674..cf154dc 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/database/AppDatabase.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/database/AppDatabase.kt @@ -1,3 +1,5 @@ +@file:Suppress("unused") + package com.oqba26.monthlypaymentapp.data.database import android.content.Context @@ -14,7 +16,7 @@ import java.util.UUID @Database( entities = [Person::class, PaymentRecord::class], - version = 11, // حتماً نسخه را بالا ببری که اسکیمای جدید اعمال شود + version = 13, // حتماً نسخه را بالا ببری که اسکیمای جدید اعمال شود exportSchema = false ) abstract class AppDatabase : RoomDatabase() { @@ -27,7 +29,7 @@ abstract class AppDatabase : RoomDatabase() { // تمیزکاری قبل از درج بکاپ val safePersons = backupData.persons.map { p -> val name = p.name.trim() - val id = if (p.id.isNotBlank()) p.id else UUID.randomUUID().toString() + val id = p.id.ifBlank { UUID.randomUUID().toString() } Person(id = id, name = name) } personDao().deleteAll() diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt index ba03b54..71c6955 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt @@ -1,3 +1,5 @@ +@file:OptIn(kotlinx.serialization.InternalSerializationApi::class) + package com.oqba26.monthlypaymentapp.data.model import androidx.room.Entity @@ -12,5 +14,6 @@ data class PaymentRecord( val amount: Double, val shamsiYear: Int, val shamsiMonth: Int, - val timestamp: Long = System.currentTimeMillis() + val timestamp: Long = System.currentTimeMillis(), + val description: String = "" ) \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt index 1078cfd..838bdb5 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt @@ -2,14 +2,17 @@ package com.oqba26.monthlypaymentapp.data.model import androidx.room.Entity import androidx.room.PrimaryKey +import kotlinx.serialization.InternalSerializationApi import kotlinx.serialization.Serializable -import java.util.UUID +import kotlin.OptIn // CORRECTED IMPORT +@OptIn(InternalSerializationApi::class) @Serializable @Entity(tableName = "persons") data class Person( - @PrimaryKey val id: String = UUID.randomUUID().toString(), + @PrimaryKey val id: String = "", val name: String, - val userId: String? = null, - val createdAt: Long = System.currentTimeMillis() -) \ No newline at end of file + val isArchived: Boolean = false, + val displayOrder: Long = 0L, + val createdAt: Long = 0L +) diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt index 5fca29d..e0d17b6 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt @@ -22,8 +22,7 @@ class ApiClient(settingsRepository: SettingsRepository) { private val authInterceptor = AuthInterceptor(settingsRepository) private val logging = HttpLoggingInterceptor().apply { - level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY - else HttpLoggingInterceptor.Level.NONE + level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE } // ⭐️ افزودن AuthInterceptor به OkHttpClient diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt index 273c0c7..514441d 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt @@ -8,6 +8,7 @@ import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path @@ -33,6 +34,15 @@ interface ApiService { @PUT("persons/{id}") suspend fun updatePerson(@Path("id") personId: String, @Body person: Person): Response + @PATCH("persons/{id}/archived") + suspend fun updatePersonArchivedStatus(@Path("id") personId: String, @Body isArchived: Map): Response + + @PATCH("persons/{id}/display-order") + suspend fun updatePersonDisplayOrder(@Path("id") personId: String, @Body displayOrder: Map): Response + + @POST("persons/rebalance") + suspend fun rebalanceDisplayOrders(): Response + @DELETE("persons/{id}") suspend fun deletePersonAndPayments(@Path("id") personId: String): Response diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt index e48fbb5..75e8644 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt @@ -1,7 +1,7 @@ package com.oqba26.monthlypaymentapp.data.remote import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository -import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response @@ -11,19 +11,20 @@ class AuthInterceptor( ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val originalRequest = chain.request() - - // توکن را به صورت Blocking از DataStore می‌خوانیم + // Get the token from DataStore, blocking the current thread. + // This is acceptable for an I/O-bound operation in an interceptor. val authToken = runBlocking { - settingsRepository.authTokenFlow.firstOrNull() + settingsRepository.authTokenFlow.first() // Use first() to wait for a value } + val originalRequest = chain.request() val requestBuilder = originalRequest.newBuilder() - if (authToken != null) { - // اضافه کردن توکن به هدر (فرمت استاندارد Bearer) + + // Add the Authorization header only if the token is not null or blank + if (!authToken.isNullOrBlank()) { requestBuilder.header("Authorization", "Bearer $authToken") } return chain.proceed(requestBuilder.build()) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt index 2599026..0ddc4c6 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt @@ -45,23 +45,20 @@ class NetworkRepository(private val api: ApiService) { suspend fun addPerson(person: Person): Int? { return try { val trimmed = person.name.trim() - if (trimmed.isEmpty()) return 400 // Bad request from client side + if (trimmed.isEmpty()) return 400 val resp = api.addPerson(person.copy(name = trimmed)) - if (resp.isSuccessful) refresh() - resp.code() // Return the actual HTTP status code + resp.code() } catch (e: HttpException) { - e.code() // Return the HTTP error code (e.g., 409) + e.code() } catch (e: Exception) { Log.e("API", "addPerson error", e) - null // Return null for non-HTTP errors + null } } suspend fun deletePersonAndPayments(personId: String): Boolean { return try { - val resp = api.deletePersonAndPayments(personId) - if (resp.isSuccessful) refresh() - resp.isSuccessful + api.deletePersonAndPayments(personId).isSuccessful } catch (e: Exception) { Log.e("API", "deletePersonAndPayments error", e); false } @@ -71,23 +68,46 @@ class NetworkRepository(private val api: ApiService) { return try { val trimmed = name.trim() if (trimmed.isEmpty()) return false - val personUpdate = Person(id = personId, name = trimmed) - val resp = api.updatePerson(personId, personUpdate) - if (resp.isSuccessful) refresh() - resp.isSuccessful + // Note: displayOrder is not updated here, only the name. + val personUpdate = Person(id = personId, name = trimmed, displayOrder = 0) + api.updatePerson(personId, personUpdate).isSuccessful } catch (e: Exception) { Log.e("API", "updatePerson error", e); false } } + suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean { + return try { + api.updatePersonArchivedStatus(personId, mapOf("isArchived" to isArchived)).isSuccessful + } catch (e: Exception) { + Log.e("API", "updatePersonArchivedStatus error", e); false + } + } + + suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean { + return try { + api.updatePersonDisplayOrder(personId, mapOf("displayOrder" to displayOrder)).isSuccessful + } catch (e: Exception) { + Log.e("API", "updatePersonDisplayOrder error", e); false + } + } + + @Suppress("unused") + suspend fun rebalanceDisplayOrders(): Boolean { + return try { + api.rebalanceDisplayOrders().isSuccessful + } catch (e: Exception) { + Log.e("API", "rebalanceDisplayOrders error", e) + false + } + } + fun getPersonsFlow(): Flow> = _personsFlow.asStateFlow() // --- Payment Operations --- suspend fun addPayment(paymentRecord: PaymentRecord): Boolean { return try { - val resp = api.addPayment(paymentRecord) - if (resp.isSuccessful) refresh() - resp.isSuccessful + api.addPayment(paymentRecord).isSuccessful } catch (e: Exception) { Log.e("API", "addPayment error", e); false } @@ -95,9 +115,7 @@ class NetworkRepository(private val api: ApiService) { suspend fun deletePayment(paymentId: String): Boolean { return try { - val resp = api.deletePayment(paymentId) - if (resp.isSuccessful) refresh() - resp.isSuccessful + api.deletePayment(paymentId).isSuccessful } catch (e: Exception) { Log.e("API", "deletePayment error", e); false } diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt index ff7d943..c71b034 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt @@ -19,7 +19,6 @@ class SettingsRepository(context: Context) { companion object { val DEFAULT_PAYMENT_AMOUNT = doublePreferencesKey("default_payment_amount") val SELECTED_FONT_NAME = stringPreferencesKey("selected_font_name") - // ⭐️ جدید: کلیدهای مربوط به احراز هویت val AUTH_TOKEN_KEY = stringPreferencesKey("auth_token") val USER_ID_KEY = stringPreferencesKey("user_id") @@ -27,7 +26,6 @@ class SettingsRepository(context: Context) { const val DEFAULT_FONT = "Estedad" } - // --- جریان‌های تنظیمات عمومی --- val defaultPaymentAmountFlow: Flow = dataStore.data.map { it[DEFAULT_PAYMENT_AMOUNT] ?: FALLBACK_AMOUNT } @@ -44,19 +42,15 @@ class SettingsRepository(context: Context) { dataStore.edit { it[SELECTED_FONT_NAME] = fontName } } - - // --- جریان‌ها و متدهای مربوط به احراز هویت --- val authTokenFlow: Flow = dataStore.data.map { it[AUTH_TOKEN_KEY] } + @Suppress("unused") val userIdFlow: Flow = dataStore.data.map { it[USER_ID_KEY] } - /** - * توکن و ID کاربر را ذخیره می‌کند. برای پاک کردن، null ارسال کنید. - */ suspend fun saveAuthData(token: String?, userId: String?) { dataStore.edit { preferences -> if (token != null) { diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/AddPaymentDialog.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/AddPaymentDialog.kt deleted file mode 100644 index c62c0ee..0000000 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/AddPaymentDialog.kt +++ /dev/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("تأیید") - } - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/ArchiveScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/ArchiveScreen.kt new file mode 100644 index 0000000..dafdecf --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/ArchiveScreen.kt @@ -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("بازیابی") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PaidListScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PaidListScreen.kt new file mode 100644 index 0000000..2aeb0fe --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PaidListScreen.kt @@ -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(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 = {} + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt index 6462701..8698166 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt @@ -1,430 +1,446 @@ +@file:Suppress("AssignedValueIsNeverRead") + package com.oqba26.monthlypaymentapp.ui.screens -import android.widget.Toast -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController import com.oqba26.monthlypaymentapp.data.model.PaymentRecord -import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian -import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth -import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear +import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianDate +import com.oqba26.monthlypaymentapp.utils.formatTimestampToPersianTime +import com.oqba26.monthlypaymentapp.utils.getPersianMonthName import com.oqba26.monthlypaymentapp.utils.toPersianDigits import com.oqba26.monthlypaymentapp.viewmodel.MonthStatus -import com.oqba26.monthlypaymentapp.viewmodel.PersonDetailViewModel -import kotlinx.coroutines.flow.collectLatest +import com.oqba26.monthlypaymentapp.viewmodel.MonthUiModel +import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent +import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PersonDetailScreen( - onBackClick: () -> Unit + personId: String, + viewModel: PersonViewModel, + navController: NavController ) { - val viewModel: PersonDetailViewModel = viewModel(factory = PersonDetailViewModel.Factory) - val context = LocalContext.current - val person by viewModel.person.collectAsState() - val selectedYear by viewModel.selectedYear.collectAsState() - val paymentHistory by viewModel.paymentHistory.collectAsState() - val defaultPaymentAmount by viewModel.defaultPaymentAmount.collectAsState() + LaunchedEffect(personId) { + viewModel.onEvent(PersonScreenEvent.SelectPerson(personId)) + } - var paymentToEdit by remember { mutableStateOf(null) } - var showAddPaymentDialog by remember { mutableStateOf(false) } - var showEditPaymentDialog by remember { mutableStateOf(false) } - var showDeleteConfirmDialog by remember { mutableStateOf(false) } - var showMessageDialog by remember { mutableStateOf(false) } - var dialogMessage by remember { mutableStateOf("") } + val detailState by viewModel.personDetailState.collectAsState() + val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0) - var paymentAmount by remember { mutableStateOf("") } - var selectedMonthForPayment by remember { mutableIntStateOf(1) } + var expandedMonth by remember { mutableStateOf(null) } + var paymentDialogState by remember { mutableStateOf(null) } + var infoDialogMessage by remember { mutableStateOf(null) } + var paymentToDelete by remember { mutableStateOf(null) } - val currentYear = getCurrentShamsiYear() - val currentMonth = getCurrentShamsiMonth() - val persianMonths = listOf("فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند") - - LaunchedEffect(key1 = true) { - viewModel.toastMessage.collectLatest { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + LaunchedEffect(Unit) { + viewModel.infoMessage.collect { message -> + infoDialogMessage = message } } - Scaffold( - topBar = { - TopAppBar( - title = { Text(person?.name ?: "...") }, - navigationIcon = { IconButton(onClick = onBackClick) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) - } - ) { innerPadding -> - Column(modifier = Modifier.padding(innerPadding).padding(top = 16.dp)) { - YearNavigator( - modifier = Modifier.padding(horizontal = 16.dp), - viewModel = viewModel, - selectedYear = selectedYear, - paymentHistory = paymentHistory, - currentYear = currentYear, - showMessage = { msg -> - dialogMessage = msg - showMessageDialog = true + Column( + modifier = Modifier.fillMaxSize() + ) { + PersonDetailHeader( + name = detailState.person?.name ?: "...", + onBack = { navController.popBackStack() } + ) + + if (detailState.person == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + YearSelector( + year = detailState.selectedYear, + onYearChange = { offset -> + viewModel.onEvent(PersonScreenEvent.ChangeYear(offset)) } ) - Spacer(Modifier.height(20.dp)) - MonthsList( - paymentHistory = paymentHistory, - selectedYear = selectedYear, - currentYear = currentYear, - currentMonth = currentMonth, - persianMonths = persianMonths, - onAvailableClick = { month, _ -> - selectedMonthForPayment = month - paymentAmount = defaultPaymentAmount.toLong().toString() - showAddPaymentDialog = true - }, - onPaidClick = { payment -> - paymentToEdit = payment - paymentAmount = payment.amount.toLong().toString() - showEditPaymentDialog = true - }, - onDisabledClick = { message -> - dialogMessage = message - showMessageDialog = true + + Spacer(modifier = Modifier.height(4.dp)) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 8.dp) + ) { + items(detailState.monthStates) { monthModel -> + MonthListItem( + month = monthModel, + isExpanded = expandedMonth == monthModel.month, + onHeaderClick = { + expandedMonth = + if (expandedMonth == monthModel.month) null else monthModel.month + }, + onEditClick = { + when (monthModel.status) { + MonthStatus.PAID -> { + paymentDialogState = + PaymentDialogState.Edit(monthModel.payment!!) + } + + MonthStatus.AVAILABLE -> { + paymentDialogState = + PaymentDialogState.Add(monthModel.month) + } + + else -> { /* Do nothing for other states */ } + } + } + ) } - ) + } } } - if (showAddPaymentDialog) { - AddPaymentDialog( - monthName = persianMonths[selectedMonthForPayment - 1], - year = selectedYear, - amount = paymentAmount, - onAmountChange = { paymentAmount = it.filter { c -> c.isDigit() } }, + infoDialogMessage?.let { + InfoDialog(message = it, onDismiss = { infoDialogMessage = null }) + } + + paymentToDelete?.let { payment -> + DeleteConfirmationDialog( + payment = payment, onConfirm = { - val amount = paymentAmount.toDoubleOrNull() - if (amount != null && amount > 0) { - viewModel.addPaymentForMonth(selectedMonthForPayment, selectedYear, amount) - showAddPaymentDialog = false - } + viewModel.onEvent(PersonScreenEvent.DeletePayment(payment)) + paymentToDelete = null }, - onDismiss = { showAddPaymentDialog = false } + onDismiss = { paymentToDelete = null } ) } - paymentToEdit?.let { payment -> - if (showEditPaymentDialog) { - EditPaymentDialog( - amount = paymentAmount, - onAmountChange = { paymentAmount = it.filter { c -> c.isDigit() } }, - onSave = { - val amount = paymentAmount.toDoubleOrNull() - if (amount != null && amount > 0) { - viewModel.updatePayment(payment, amount) - showEditPaymentDialog = false + paymentDialogState?.let { state -> + when (state) { + is PaymentDialogState.Add -> { + AddPaymentDialog( + personName = detailState.person!!.name, + defaultAmount = defaultPaymentAmount, + onConfirm = { amount, description -> + viewModel.onEvent( + PersonScreenEvent.AddPaymentForMonth( + personId = personId, + month = state.month, + year = detailState.selectedYear, + amount = amount, + description = description + ) + ) + paymentDialogState = null + }, + onDismiss = { paymentDialogState = null } + ) + } + + is PaymentDialogState.Edit -> { + val payment = state.payment + AddPaymentDialog( + personName = detailState.person!!.name, + defaultAmount = payment.amount, + initialDescription = payment.description, + onConfirm = { amount, description -> + viewModel.onEvent( + PersonScreenEvent.UpdatePayment( + payment = payment, + newAmount = amount, + newDescription = description + ) + ) + paymentDialogState = null + }, + onDismiss = { paymentDialogState = null }, + onDelete = { + paymentToDelete = payment + paymentDialogState = null } - }, - onDelete = { - showEditPaymentDialog = false - showDeleteConfirmDialog = true - }, - onDismiss = { showEditPaymentDialog = false } - ) + ) + } } - - if (showDeleteConfirmDialog) { - DeleteConfirmDialog( - onConfirm = { - viewModel.deletePayment(payment) - showDeleteConfirmDialog = false - }, - onDismiss = { showDeleteConfirmDialog = false } - ) - } - } - - if (showMessageDialog) { - MessageDialog(message = dialogMessage) { showMessageDialog = false } } } @Composable -private fun YearNavigator( - modifier: Modifier = Modifier, - viewModel: PersonDetailViewModel, - selectedYear: Int, - paymentHistory: List, - currentYear: Int, - showMessage: (String) -> Unit -) { - Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) { +private fun PersonDetailHeader(name: String, onBack: () -> Unit) { + Surface( + color = MaterialTheme.colorScheme.primary, + contentColor = Color.White + ) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .height(44.dp) + .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = { - val hasData = paymentHistory.any { it.shamsiYear == selectedYear - 1 } - if (hasData || selectedYear - 1 >= currentYear - 5) { // Allow going back 5 years - viewModel.changeYear(-1) - } else { - showMessage("هیچ داده‌ای برای سال قبل وجود ندارد") - } - }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "Previous Year") } - Text("سال ${selectedYear.toString().toPersianDigits()}", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer) - IconButton(onClick = { - if (selectedYear < currentYear) { - viewModel.changeYear(1) - } else { - showMessage("نمی‌توان به سال آینده رفت") - } - }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "Next Year") } + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "بازگشت" + ) + } + Spacer(modifier = Modifier.padding(horizontal = 4.dp)) + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) } } } @Composable -private fun MonthsList( - paymentHistory: List, - selectedYear: Int, - currentYear: Int, - currentMonth: Int, - persianMonths: List, - onAvailableClick: (month: Int, year: Int) -> Unit, - onPaidClick: (payment: PaymentRecord) -> Unit, - onDisabledClick: (message: String) -> Unit -) { - LazyColumn { - itemsIndexed(persianMonths, key = { index, _ -> index }) { index, monthName -> - val monthNumber = index + 1 - val payment = paymentHistory.find { p -> p.shamsiMonth == monthNumber && p.shamsiYear == selectedYear } - - val monthStatus = when { - payment != null -> MonthStatus.PAID - selectedYear > currentYear -> MonthStatus.FUTURE_YEAR - selectedYear < currentYear -> MonthStatus.PAST_YEAR - monthNumber > currentMonth -> MonthStatus.FUTURE_MONTH - else -> MonthStatus.AVAILABLE +fun YearSelector(year: Int, onYearChange: (Int) -> Unit) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + tonalElevation = 1.dp, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + IconButton(onClick = { onYearChange(-1) }) { + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = "سال قبل" + ) } - MonthListItem( - monthName = monthName, - payment = payment, - status = monthStatus, - onItemClick = { - when (monthStatus) { - MonthStatus.AVAILABLE -> onAvailableClick(monthNumber, selectedYear) - MonthStatus.PAID -> onPaidClick(payment!!) - else -> onDisabledClick(getMessageForStatus(monthStatus, monthName)) - } - } + Text( + text = "سال ${year.toString().toPersianDigits()}", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp) ) - HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + + IconButton(onClick = { onYearChange(1) }) { + Icon( + imageVector = Icons.Default.ChevronLeft, + contentDescription = "سال بعد" + ) + } } } } +@Suppress("UNUSED_PARAMETER") @Composable -private fun MonthListItem( - monthName: String, - payment: PaymentRecord?, - status: MonthStatus, - onItemClick: () -> Unit +fun MonthListItem( + month: MonthUiModel, + isExpanded: Boolean, // فعلاً استفاده نمی‌شود + onHeaderClick: () -> Unit, + onEditClick: () -> Unit ) { - val isClickable = status == MonthStatus.AVAILABLE || status == MonthStatus.PAID - val backgroundColor by animateColorAsState( - targetValue = if (status == MonthStatus.PAID) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f) else Color.Transparent, - animationSpec = tween(400), - label = "MonthListItemColor" - ) + val containerColor = when (month.status) { + MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer + MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.surface + MonthStatus.PAST_YEAR, MonthStatus.FUTURE_YEAR, MonthStatus.FUTURE_MONTH -> + MaterialTheme.colorScheme.surfaceVariant + } - Row( + Card( modifier = Modifier + .padding(horizontal = 16.dp, vertical = 4.dp) .fillMaxWidth() - .clickable(enabled = isClickable, onClick = onItemClick) - .background(backgroundColor) - .padding(horizontal = 16.dp, vertical = 20.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + .clickable(onClick = onHeaderClick), + colors = CardDefaults.cardColors(containerColor = containerColor), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { - Text(text = monthName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp) + ) { + // ردیف بالا: نام ماه + مبلغ + دکمه + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + // نام ماه + مبلغ در صورت پرداخت + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = getPersianMonthName(month.month), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) - AnimatedContent( - targetState = status, - transitionSpec = { fadeIn(tween(400)) togetherWith fadeOut(tween(400)) }, - label = "MonthListItemContent" - ) { targetStatus -> - when (targetStatus) { - MonthStatus.PAID -> { - payment?.let { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "${formatNumberAsPersian(it.amount)} تومان", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold + if (month.status == MonthStatus.PAID && month.payment != null) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "${formatNumberAsPersian(month.payment.amount)} تومان", + style = MaterialTheme.typography.bodyMedium + ) + } + } + + // دکمه سمت چپ + when (month.status) { + MonthStatus.PAID -> { + Button( + onClick = onEditClick, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary ) - Spacer(Modifier.width(8.dp)) - Icon(Icons.Filled.Check, contentDescription = "Paid", tint = MaterialTheme.colorScheme.primary) + ) { + Text("ویرایش") } } + + MonthStatus.AVAILABLE -> { + Button( + onClick = onEditClick, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary + ) + ) { + Text("پرداخت") + } + } + + else -> { + Text( + text = "غیرفعال", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } - MonthStatus.AVAILABLE -> { - Icon(Icons.Filled.Add, contentDescription = "Add Payment", tint = MaterialTheme.colorScheme.secondary) - } - else -> { - Icon(Icons.Filled.Close, contentDescription = "Disabled", tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)) + } + + // جزئیات پرداخت: تاریخ + (در صورت وجود) توضیحات + month.payment?.let { payment -> + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "ثبت شده در: " + + "${formatTimestampToPersianDate(payment.timestamp)} " + + "ساعت ${formatTimestampToPersianTime(payment.timestamp)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + ) + + if (payment.description.isNotBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "توضیحات: ${payment.description}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) } } } } } -private fun getMessageForStatus(status: MonthStatus, monthName: String): String { - return when (status) { - MonthStatus.FUTURE_YEAR -> "این سال هنوز نرسیده است." - MonthStatus.PAST_YEAR -> "شما در سال گذشته برای ماه $monthName پرداختی ثبت نکرده‌اید." - MonthStatus.FUTURE_MONTH -> "این ماه هنوز نرسیده است." - else -> "" - } -} - @Composable -private fun AddPaymentDialog( - monthName: String, - year: Int, - amount: String, - onAmountChange: (String) -> Unit, +fun DeleteConfirmationDialog( + payment: PaymentRecord, onConfirm: () -> Unit, onDismiss: () -> Unit ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("ثبت پرداخت برای $monthName ${year.toString().toPersianDigits()}") }, + title = { Text("تایید حذف") }, text = { - OutlinedTextField( - value = amount, - onValueChange = onAmountChange, - label = { Text("مبلغ (تومان)") }, - visualTransformation = PersianNumberVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { Button(onClick = onConfirm) { Text("ثبت") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("لغو") } } - ) -} - -@Composable -private fun EditPaymentDialog( - amount: String, - onAmountChange: (String) -> Unit, - onSave: () -> Unit, - onDelete: () -> Unit, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("ویرایش یا حذف پرداخت") }, - text = { - OutlinedTextField( - value = amount, - onValueChange = onAmountChange, - label = { Text("مبلغ جدید (تومان)") }, - visualTransformation = PersianNumberVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth() + Text( + "آیا برای حذف پرداخت ماه ${ + getPersianMonthName(payment.shamsiMonth) + } به مبلغ ${ + formatNumberAsPersian(payment.amount) + } تومان مطمئن هستید؟" ) }, confirmButton = { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - Button(onClick = onSave) { Text("ذخیره") } - Spacer(Modifier.width(8.dp)) - TextButton(onClick = onDismiss) { Text("لغو") } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // در RTL: این دکمه می‌چسبد به سمت راست دیالوگ + Button(onClick = onDismiss) { + Text("لغو") + } + + // در RTL: این دکمه می‌چسبد به سمت چپ دیالوگ + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("حذف") + } } }, - dismissButton = { - TextButton(onClick = onDelete, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) { Text("حذف") } - } + dismissButton = {} ) } @Composable -private fun DeleteConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("تایید حذف") }, - text = { Text("آیا از حذف این پرداخت مطمئن هستید؟ این عمل غیرقابل بازگشت است.") }, - confirmButton = { - Button(onClick = onConfirm, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("حذف کن") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("لغو") } } - ) -} - -@Composable -private fun MessageDialog(message: String, onDismiss: () -> Unit) { +fun InfoDialog(message: String, onDismiss: () -> Unit) { AlertDialog( onDismissRequest = onDismiss, title = { Text("توجه") }, text = { Text(message) }, - confirmButton = { TextButton(onClick = onDismiss) { Text("باشه") } } + confirmButton = { + TextButton(onClick = onDismiss) { + Text("باشه") + } + } ) } + +sealed class PaymentDialogState { + data class Add(val month: Int) : PaymentDialogState() + data class Edit(val payment: PaymentRecord) : PaymentDialogState() +} \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt index 3581e02..f19ef82 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt @@ -1,10 +1,9 @@ +@file:Suppress("AssignedValueIsNeverRead") + package com.oqba26.monthlypaymentapp.ui.screens -import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,43 +18,27 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.AddCard import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.pulltorefresh.PullToRefreshContainer -import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -63,734 +46,358 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.oqba26.monthlypaymentapp.core.PaymentApplication -import com.oqba26.monthlypaymentapp.data.model.PaymentRecord -import com.oqba26.monthlypaymentapp.utils.PersianNumberVisualTransformation +import androidx.navigation.NavController import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian -import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiMonth -import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear -import com.oqba26.monthlypaymentapp.utils.toPersianDate import com.oqba26.monthlypaymentapp.utils.toPersianDigits import com.oqba26.monthlypaymentapp.viewmodel.DashboardUiModel -import com.oqba26.monthlypaymentapp.viewmodel.MonthStatus +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 -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterialApi::class) @Composable fun PersonScreen( - onSettingsClick: () -> Unit, - //onExitApp: () -> Unit + viewModel: PersonViewModel, + navController: NavController ) { - val context = LocalContext.current - val app = context.applicationContext as PaymentApplication - val viewModel: PersonViewModel = viewModel(factory = app.personViewModelFactory) - - val uiState by viewModel.uiState.collectAsState() - val persons = uiState.persons - val allPayments = uiState.payments - val searchQuery by viewModel.searchQuery.collectAsState() + val uiState by viewModel.uiState.collectAsState() val dashboardData by viewModel.dashboardData.collectAsState() val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0) + val personForPaymentDialog by viewModel.personForPaymentDialog.collectAsState() + val isAddPersonDialogShown by viewModel.showAddPersonDialog.collectAsState() val isRefreshing by viewModel.isRefreshing.collectAsState() - var showAddPersonDialog by remember { mutableStateOf(false) } - var personToDelete by remember { mutableStateOf(null) } + // شخصی که قرار است با تأیید کاربر به آرشیو منتقل شود + var personToArchive by remember { mutableStateOf(null) } - var showDetailsSheet by remember { mutableStateOf(false) } - var selectedPerson by remember { mutableStateOf(null) } - - var personForQuickPay by remember { mutableStateOf(null) } - - val sheetState = rememberModalBottomSheetState() - val pullToRefreshState = rememberPullToRefreshState() - - if (pullToRefreshState.isRefreshing) { - LaunchedEffect(true) { - viewModel.onEvent(PersonScreenEvent.RefreshData) - } - } - - LaunchedEffect(isRefreshing) { - if (isRefreshing) { - pullToRefreshState.startRefresh() - } else { - pullToRefreshState.endRefresh() - } - } - - - Scaffold( - topBar = { - TopAppBar( - title = { Text("مدیریت حقوق ماهانه") }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primary, - titleContentColor = Color.White, - actionIconContentColor = Color.White - ), - actions = { - IconButton(onClick = onSettingsClick) { - Icon(Icons.Filled.Settings, contentDescription = "تنظیمات") - } - /*IconButton(onClick = onExitApp) { - Icon(Icons.AutoMirrored.Filled.ExitToApp, contentDescription = "خروج") - }*/ - } - ) + val reorderableState = rememberReorderableLazyListState( + onMove = { from, to -> + if (searchQuery.isBlank()) { + viewModel.onEvent( + PersonScreenEvent.MovePersonNew( + fromIndex = from.index, + toIndex = to.index, + listType = PersonListType.UNPAID + ) + ) + } }, - floatingActionButton = { - FloatingActionButton(onClick = { showAddPersonDialog = true }) { - Icon(Icons.Filled.Add, contentDescription = "افزودن شخص") + onDragEnd = { _, _ -> + if (searchQuery.isBlank()) { + viewModel.onEvent(PersonScreenEvent.CommitReorder(PersonListType.UNPAID)) } } - ) { innerPadding -> - Box(modifier = Modifier.padding(innerPadding).nestedScroll(pullToRefreshState.nestedScrollConnection)) { - Column { - DashboardCard(dashboardData) + ) + + // Pull-to-refresh + val pullRefreshState = rememberPullRefreshState( + refreshing = isRefreshing, + onRefresh = { viewModel.onEvent(PersonScreenEvent.RefreshData) } + ) + + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = 8.dp) + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + DashboardCard(data = dashboardData) 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 + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("جستجوی نام...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") } ) + } - LazyColumn(Modifier.fillMaxSize()) { - itemsIndexed(persons, key = { _, person -> person.id }) { index, person -> - PersonListItem( - person = person, - index = index + 1, - onPersonClick = { - selectedPerson = person - showDetailsSheet = true - }, - onQuickPayClick = { personForQuickPay = person }, - onDeleteClick = { personToDelete = it } + Spacer(modifier = Modifier.height(8.dp)) + + LazyColumn( + state = reorderableState.listState, + modifier = Modifier + .fillMaxSize() + .reorderable(reorderableState) + ) { + itemsIndexed(uiState.unpaidPersons, key = { _, person -> person.id }) { index, person -> + ReorderableItem(reorderableState, key = person.id) { _ -> + val dragModifier = + if (searchQuery.isBlank()) Modifier.detectReorderAfterLongPress( + reorderableState + ) else Modifier + + Box(modifier = dragModifier) { + PersonListItem( + person = person, + index = index + 1, + onPersonClick = { + navController.navigate("person_detail/${person.id}") + }, + onQuickPayClick = { viewModel.onQuickPayClicked(it) }, + onArchiveClick = { selectedPerson -> + personToArchive = selectedPerson + } + ) + } + HorizontalDivider( + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f) ) - HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) - } } } - PullToRefreshContainer( - modifier = Modifier.align(Alignment.TopCenter), - state = pullToRefreshState, + } + + // اندیکاتور رفرش + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter) + ) + + // دیالوگ پرداخت سریع + personForPaymentDialog?.let { person -> + AddPaymentDialog( + personName = person.name, + defaultAmount = defaultPaymentAmount, + onConfirm = { amount, description -> + viewModel.onEvent( + PersonScreenEvent.AddQuickPayment( + person.id, + amount, + description + ) + ) + viewModel.onDismissPaymentDialog() + }, + onDismiss = { viewModel.onDismissPaymentDialog() } ) } - } - if (showDetailsSheet) { - ModalBottomSheet( - onDismissRequest = { showDetailsSheet = false }, - sheetState = sheetState - ) { - selectedPerson?.let { - DetailsSheetContent( - person = it, - allPayments = allPayments, - defaultPaymentAmount = defaultPaymentAmount, - viewModel = viewModel - ) - } + // دیالوگ افزودن شخص جدید + if (isAddPersonDialogShown) { + AddNewPersonDialog( + onConfirm = { name -> + viewModel.onEvent(PersonScreenEvent.AddPerson(name)) + viewModel.onDismissAddPersonDialog() + }, + onDismiss = { viewModel.onDismissAddPersonDialog() } + ) } - } - personForQuickPay?.let { person -> - var amount by remember { mutableStateOf(defaultPaymentAmount.toLong().toString()) } - QuickPayDialog( - personName = person.name, - amount = amount, - onAmountChange = { amount = it.filter { c -> c.isDigit() } }, - onConfirm = { - val finalAmount = amount.toDoubleOrNull() ?: defaultPaymentAmount - viewModel.onEvent(PersonScreenEvent.AddQuickPayment(person.id, finalAmount)) - personForQuickPay = null - }, - onDismiss = { personForQuickPay = null } - ) - } - - if (showAddPersonDialog) { - var name by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = { showAddPersonDialog = false }, - title = { Text("افزودن شخص جدید") }, - text = { OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("نام") }) }, - confirmButton = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Button(onClick = { - if (name.isNotBlank()) { - viewModel.onEvent(PersonScreenEvent.AddPerson(name)) - showAddPersonDialog = false + // دیالوگ تایید انتقال به آرشیو + personToArchive?.let { person -> + AlertDialog( + onDismissRequest = { personToArchive = 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 = null }) { + Text("لغو") } - }) { Text("افزودن") } - Button( - onClick = { showAddPersonDialog = false }, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) - ) { Text("لغو") } - } - } - ) - } - - personToDelete?.let { person -> - AlertDialog( - onDismissRequest = { personToDelete = null }, - title = { Text("حذف ${person.name}") }, - text = { Text("آیا از حذف این شخص مطمئن هستید؟ تمام داده‌های پرداخت او نیز حذف خواهد شد.") }, - confirmButton = { - Button( - onClick = { - viewModel.onEvent(PersonScreenEvent.DeletePerson(person.id)) - personToDelete = null - }, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) - ) { Text("حذف") } - }, - dismissButton = { - TextButton(onClick = { personToDelete = null }) { Text("لغو") } - } - ) + // در RTL: این دکمه کاملاً به سمت چپ دیالوگ می‌چسبد + Button( + onClick = { + viewModel.onEvent( + PersonScreenEvent.ArchivePerson(person.id) + ) + personToArchive = null + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary + ) + ) { + Text("آرشیو") + } + } + }, + dismissButton = {} + ) + } } } @Composable -fun QuickPayDialog( - personName: String, - amount: String, - onAmountChange: (String) -> Unit, - onConfirm: () -> Unit, +fun AddNewPersonDialog( + onConfirm: (String) -> Unit, onDismiss: () -> Unit ) { + var name by remember { mutableStateOf("") } + AlertDialog( onDismissRequest = onDismiss, - title = { Text("پرداخت سریع برای $personName") }, + title = { Text("افزودن شخص جدید") }, text = { OutlinedTextField( - value = amount, - onValueChange = onAmountChange, - label = { Text("مبلغ (تومان)") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - visualTransformation = PersianNumberVisualTransformation(), + value = name, + onValueChange = { name = it }, + label = { Text("نام شخص") }, + singleLine = true, modifier = Modifier.fillMaxWidth() ) }, confirmButton = { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Button(onClick = onConfirm) { Text("ثبت") } - Button( - onClick = onDismiss, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) - ) { Text("لغو") } - } - } - ) -} - -@Composable -fun DetailsSheetContent( - person: PersonUiModel, - allPayments: List, - defaultPaymentAmount: Double, - viewModel: PersonViewModel -) { - val selectedYear by viewModel.selectedYear.collectAsState() - - var paymentToEdit by remember { mutableStateOf(null) } - var showPaymentInfoDialog by remember { mutableStateOf(false) } - var showAddPaymentInSheetDialog by remember { mutableStateOf(false) } - var showEditPaymentDialog by remember { mutableStateOf(false) } - var showDeleteConfirmDialog by remember { mutableStateOf(false) } - var showMessageDialog by remember { mutableStateOf(false) } - var dialogMessage by remember { mutableStateOf("") } - - var paymentAmount by remember { mutableStateOf("") } - var selectedMonthForPayment by remember { mutableIntStateOf(1) } - - val currentYear = getCurrentShamsiYear() - val currentMonth = getCurrentShamsiMonth() - val persianMonths = listOf("فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند") - - val personPaymentHistory = remember(allPayments, person.id) { - allPayments.filter { it.personId == person.id } - } - - Column(modifier = Modifier.background(MaterialTheme.colorScheme.surface).padding(16.dp)) { - Text(person.name, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 16.dp)) - YearNavigator( - modifier = Modifier.padding(top = 8.dp), - selectedYear = selectedYear, - onYearChange = { offset -> viewModel.onEvent(PersonScreenEvent.ChangeYear(offset)) }, - showMessage = { msg -> - dialogMessage = msg - showMessageDialog = true - } - ) - Spacer(Modifier.height(16.dp)) - MonthsList( - paymentHistory = personPaymentHistory, - selectedYear = selectedYear, - currentYear = currentYear, - currentMonth = currentMonth, - persianMonths = persianMonths, - onAvailableClick = { month, _ -> - selectedMonthForPayment = month - paymentAmount = defaultPaymentAmount.toLong().toString() - showAddPaymentInSheetDialog = true - }, - onPaidClick = { payment -> - paymentToEdit = payment - paymentAmount = payment.amount.toLong().toString() - showPaymentInfoDialog = true - }, - onDisabledClick = { message -> - dialogMessage = message - showMessageDialog = true - } - ) - } - - if (showAddPaymentInSheetDialog) { - 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.onEvent(PersonScreenEvent.AddPaymentForMonth(person.id, selectedMonthForPayment, selectedYear, amount)) - showAddPaymentInSheetDialog = false - } - }, - onDismiss = { showAddPaymentInSheetDialog = false } - ) - } - - paymentToEdit?.let { payment -> - if (showPaymentInfoDialog) { - PaymentInfoDialog( - payment = payment, - onEdit = { - showPaymentInfoDialog = false - showEditPaymentDialog = true - }, - onDismiss = { - showPaymentInfoDialog = false - paymentToEdit = null - } - ) - } - - if (showEditPaymentDialog) { - EditPaymentDialog( - amount = paymentAmount, - onAmountChange = { paymentAmount = it.filter { c -> c.isDigit() } }, - onSave = { - val amount = paymentAmount.toDoubleOrNull() - if (amount != null && amount > 0) { - viewModel.onEvent(PersonScreenEvent.UpdatePayment(payment, amount)) - showEditPaymentDialog = false - paymentToEdit = null - } - }, - onDelete = { - showEditPaymentDialog = false - showDeleteConfirmDialog = true - }, - onDismiss = { - showEditPaymentDialog = false - paymentToEdit = null - } - ) - } - - if (showDeleteConfirmDialog) { - DeleteConfirmDialog( - onConfirm = { - viewModel.onEvent(PersonScreenEvent.DeletePayment(payment)) - showDeleteConfirmDialog = false - paymentToEdit = null - }, - onDismiss = { - showDeleteConfirmDialog = false - paymentToEdit = null - } - ) - } - } - - if (showMessageDialog) { - MessageDialog(message = dialogMessage) { showMessageDialog = false } - } -} - -@Composable -private fun PaymentInfoDialog( - payment: PaymentRecord, - onEdit: () -> Unit, - onDismiss: () -> Unit -) { - val currentMonth = getCurrentShamsiMonth() - val persianMonths = listOf("فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند") - val monthName = persianMonths[payment.shamsiMonth - 1] - - val persianDate = payment.timestamp.toPersianDate("Y/n/j").toPersianDigits() - val persianTime = payment.timestamp.toPersianDate("H:i:s").toPersianDigits() - - val timeInfo = if (payment.shamsiMonth == currentMonth) { - "پرداخت برای $monthName در ساعت $persianTime ثبت شد" - } else { - "پرداخت برای $monthName در تاریخ $persianDate ساعت $persianTime ثبت شد" - } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("جزئیات پرداخت") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = timeInfo, - style = MaterialTheme.typography.bodyMedium - ) - Text( - text = "مبلغ: ${formatNumberAsPersian(payment.amount)} تومان", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - } - }, - confirmButton = { - Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(all = 8.dp), horizontalArrangement = Arrangement.SpaceBetween ) { Button( - onClick = onEdit, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ) - ) { Text("ویرایش") } - - Button( - onClick = onDismiss, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) - ) { Text("بستن") } - } - } - ) -} - -@Composable -private fun YearNavigator( - modifier: Modifier = Modifier, - selectedYear: Int, - onYearChange: (Int) -> Unit, - showMessage: (String) -> Unit -) { - val currentYear = getCurrentShamsiYear() - Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = { - if (selectedYear > currentYear) { - onYearChange(-1) - } else { - showMessage("زمان ثبت پرداخت حقوق برای سال‌های گذشته تمام شده است") - } - }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "Previous Year") } - Text("سال ${selectedYear.toString().toPersianDigits()}", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - IconButton(onClick = { - if (selectedYear < currentYear) { - onYearChange(1) - } else { - showMessage("زمان ثبت پرداخت حقوق برای سال‌های آینده فرا نرسیده است") - } - }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "Next Year") } - } - } -} - -@Composable -private fun MonthsList( - paymentHistory: List, - selectedYear: Int, - currentYear: Int, - currentMonth: Int, - persianMonths: List, - onAvailableClick: (month: Int, year: Int) -> Unit, - onPaidClick: (payment: PaymentRecord) -> Unit, - onDisabledClick: (message: String) -> Unit -) { - LazyColumn(modifier= Modifier.height(320.dp)) { - itemsIndexed(persianMonths, key = { index, _ -> "$selectedYear-$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 - } - - MonthListItem( - monthName = monthName, - payment = payment, - status = monthStatus, - onItemClick = { - when (monthStatus) { - MonthStatus.AVAILABLE -> onAvailableClick(monthNumber, selectedYear) - MonthStatus.PAID -> onPaidClick(payment!!) - else -> onDisabledClick(getMessageForStatus(monthStatus, monthName)) - } - } - ) - if(index < persianMonths.size - 1) { - HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f)) - } - } - } -} - -@Composable -private fun MonthListItem( - monthName: String, - payment: PaymentRecord?, - status: MonthStatus, - onItemClick: () -> Unit -) { - val backgroundColor by animateColorAsState( - targetValue = when(status) { - MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.15f) - else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - }, - animationSpec = tween(300), - label = "MonthListItemColor" - ) - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) - .clickable(onClick = onItemClick), - colors = CardDefaults.cardColors(containerColor = backgroundColor), - elevation = CardDefaults.cardElevation( - defaultElevation = if (status == MonthStatus.PAID) 2.dp else 0.dp - ), - shape = MaterialTheme.shapes.medium - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - val iconColor = when(status) { - MonthStatus.PAID -> MaterialTheme.colorScheme.primary - MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.secondary - else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) - } - - Box( - modifier = Modifier - .background( - color = iconColor.copy(alpha = 0.15f), - shape = MaterialTheme.shapes.small - ) - .padding(8.dp) + onClick = { + if (name.isNotBlank()) { + onConfirm(name) + } + }, + enabled = name.isNotBlank() ) { - Icon( - imageVector = when(status) { - MonthStatus.PAID -> Icons.Filled.Check - MonthStatus.AVAILABLE -> Icons.Filled.Add - else -> Icons.Filled.Close - }, - contentDescription = status.name, - tint = iconColor, - modifier = Modifier.width(20.dp).height(20.dp) - ) + Text("افزودن") } - - Text( - text = monthName, - style = MaterialTheme.typography.titleMedium, - fontWeight = if (status == MonthStatus.PAID) FontWeight.SemiBold else FontWeight.Normal - ) - } - - if (payment != null) { - Text( - text = "${formatNumberAsPersian(payment.amount)} تومان", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - } - } - } -} - -private fun getMessageForStatus(status: MonthStatus, monthName: String): String { - return when (status) { - MonthStatus.FUTURE_YEAR -> "این سال هنوز نرسیده است." - MonthStatus.PAST_YEAR -> "شما در سال گذشته برای ماه $monthName پرداختی ثبت نکرده‌اید." - MonthStatus.FUTURE_MONTH -> "پرداخت حقوق برای این ماه امکان‌پذیر نیست (زمانش که برسه این ماه باز می‌شه)" - else -> "" - } -} - -@Composable -private fun AddPaymentDialog( - monthName: String, - year: Int, - amount: String, - onAmountChange: (String) -> Unit, - onConfirm: () -> Unit, - onDismiss: () -> Unit -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("ثبت پرداخت برای $monthName ${year.toString().toPersianDigits()}") }, - text = { - OutlinedTextField( - value = amount, - onValueChange = onAmountChange, - label = { Text("مبلغ (تومان)") }, - visualTransformation = PersianNumberVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - suffix = { Text("تومان") } - ) - }, - confirmButton = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Button(onClick = onConfirm) { Text("ثبت") } Button( onClick = onDismiss, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error ) - ) { Text("لغو") } + ) { + Text("لغو") + } } - } + }, + dismissButton = {} ) } @Composable -private fun EditPaymentDialog( - amount: String, - onAmountChange: (String) -> Unit, - onSave: () -> Unit, - onDelete: () -> Unit, - onDismiss: () -> Unit +fun AddPaymentDialog( + personName: String, + defaultAmount: Double, + initialDescription: String = "", + onConfirm: (amount: Double, description: String) -> Unit, + onDismiss: () -> Unit, + onDelete: (() -> Unit)? = null ) { + var rawAmount by remember { mutableStateOf(defaultAmount.toLong().toString()) } + var description by remember { mutableStateOf(initialDescription) } + + val title = + if (onDelete != null) "ویرایش پرداخت برای $personName" else "ثبت پرداخت برای $personName" + AlertDialog( onDismissRequest = onDismiss, - title = { Text("ویرایش یا حذف پرداخت") }, + title = { Text(title) }, text = { - OutlinedTextField( - value = amount, - onValueChange = onAmountChange, - label = { Text("مبلغ جدید (تومان)") }, - visualTransformation = PersianNumberVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - suffix = { Text("تومان") } - ) + Column { + OutlinedTextField( + value = formatNumberAsPersian(rawAmount.toDoubleOrNull() ?: 0.0) + .removeSuffix(".00"), + onValueChange = { value -> + rawAmount = value.filter { it.isDigit() } + }, + label = { Text("مبلغ (به تومان)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("توضیحات (اختیاری)") }, + modifier = Modifier.fillMaxWidth() + ) + } }, confirmButton = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Button(onClick = onSave) { Text("ذخیره") } - - Button( - onClick = onDismiss, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.secondary - ) - ) { Text("لغو") } - - Button( - onClick = onDelete, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error - ) - ) { Text("حذف") } + if (onDelete != null) { + // حالت ویرایش: ویرایش راست، لغو وسط، حذف چپ (در RTL) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { + val amountAsDouble = rawAmount.toDoubleOrNull() ?: defaultAmount + onConfirm(amountAsDouble, description) + }, + enabled = rawAmount.isNotBlank() + ) { + Text("ویرایش") + } + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("لغو") + } + Button( + onClick = onDelete, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("حذف") + } + } + } else { + // حالت افزودن: ثبت و لغو + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = { + val amountAsDouble = rawAmount.toDoubleOrNull() ?: defaultAmount + onConfirm(amountAsDouble, description) + }, + enabled = rawAmount.isNotBlank() + ) { + Text("ثبت") + } + Button( + onClick = onDismiss, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("لغو") + } + } } - } - ) -} - -@Composable -private fun DeleteConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("تایید حذف") }, - text = { Text("آیا از حذف این پرداخت مطمئن هستید؟") }, - confirmButton = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Button( - onClick = onConfirm, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) - ) { Text("حذف کن") } - TextButton(onClick = onDismiss) { Text("لغو") } - } - } - ) -} - -@Composable -private fun MessageDialog(message: String, onDismiss: () -> Unit) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("توجه") }, - text = { Text(message) }, - confirmButton = { TextButton(onClick = onDismiss) { Text("باشه") } } + }, + dismissButton = {} ) } @@ -804,16 +411,15 @@ fun DashboardCard(data: DashboardUiModel) { Card( modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - elevation = CardDefaults.cardElevation(4.dp), + .fillMaxWidth(), + elevation = CardDefaults.cardElevation(2.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant ) ) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -827,7 +433,7 @@ fun DashboardCard(data: DashboardUiModel) { ) Text( "افراد باقیمانده: ${(data.totalCount - data.paidCount).toString().toPersianDigits()} نفر", - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyMedium ) } @@ -839,7 +445,7 @@ fun DashboardCard(data: DashboardUiModel) { .clip(MaterialTheme.shapes.medium) ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) Row( modifier = Modifier.fillMaxWidth(), @@ -866,50 +472,70 @@ fun PersonListItem( index: Int, onPersonClick: () -> Unit, onQuickPayClick: (PersonUiModel) -> Unit, - onDeleteClick: (PersonUiModel) -> Unit + onArchiveClick: (PersonUiModel) -> Unit ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .clickable { onPersonClick() } - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = 16.dp, vertical = 8.dp) ) { - Text( - text = "${index.toString().toPersianDigits()} - ", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Normal - ) - Text( - person.name, - modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold - ) - Spacer(Modifier.width(8.dp)) - - if (person.hasPaidThisMonth) { - Icon( - Icons.Filled.Check, - contentDescription = "پرداخت شده", - tint = MaterialTheme.colorScheme.primary + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${index.toString().toPersianDigits()} - ", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Normal ) - } else { - IconButton(onClick = { onQuickPayClick(person) }) { - Icon( - Icons.Filled.AddCard, - contentDescription = "پرداخت سریع", - tint = MaterialTheme.colorScheme.secondary - ) + Text( + person.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold + ) + + Spacer(Modifier.width(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (person.hasPaidThisMonth) { + Icon( + Icons.Filled.Check, + contentDescription = "پرداخت شده", + tint = MaterialTheme.colorScheme.primary + ) + + Button( + onClick = { onArchiveClick(person) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary + ) + ) { + Text("آرشیو", color = Color.White) + } + } else { + Button( + onClick = { onQuickPayClick(person) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary + ) + ) { + Text("پرداخت", color = Color.White) + } + + Button( + onClick = { onArchiveClick(person) }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary + ) + ) { + Text("آرشیو", color = Color.White) + } + } } } - - IconButton(onClick = { onDeleteClick(person) }) { - Icon( - Icons.Filled.Delete, - contentDescription = "حذف", - tint = MaterialTheme.colorScheme.error - ) - } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt index 717d94f..96599ac 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt @@ -1,3 +1,5 @@ +@file:Suppress("AssignedValueIsNeverRead") + package com.oqba26.monthlypaymentapp.ui.screens import android.widget.Toast @@ -6,8 +8,6 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.ui.graphics.Color import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -17,7 +17,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.AlertDialog @@ -27,7 +27,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -37,7 +36,6 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -65,9 +63,8 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun SettingsScreen(onBackClick: () -> Unit) { +fun SettingsScreen(onLogout: () -> Unit) { val context = LocalContext.current val app = context.applicationContext as PaymentApplication val viewModel: SettingsViewModel = viewModel(factory = app.settingsViewModelFactory) @@ -76,7 +73,7 @@ fun SettingsScreen(onBackClick: () -> Unit) { val defaultAmount by viewModel.defaultPaymentAmount.collectAsState() val selectedFont by viewModel.selectedFont.collectAsState() - var showRestoreConfirmDialog by remember { mutableStateOf(null) } + var backupJsonToRestore by remember { mutableStateOf(null) } LaunchedEffect(Unit) { viewModel.toastMessage.collect { message -> @@ -92,11 +89,21 @@ fun SettingsScreen(onBackClick: () -> Unit) { try { val jsonString = viewModel.createBackupJsonSuspend() context.contentResolver.openOutputStream(it)?.use { outputStream -> - OutputStreamWriter(outputStream).use { writer -> writer.write(jsonString) } + OutputStreamWriter(outputStream).use { writer -> + writer.write(jsonString) + } } - Toast.makeText(context, "پشتیبان‌گیری با موفقیت انجام شد", Toast.LENGTH_LONG).show() + Toast.makeText( + context, + "پشتیبان‌گیری با موفقیت انجام شد", + Toast.LENGTH_LONG + ).show() } catch (_: Exception) { - Toast.makeText(context, "خطا در ایجاد فایل پشتیبان", Toast.LENGTH_LONG).show() + Toast.makeText( + context, + "خطا در ایجاد فایل پشتیبان", + Toast.LENGTH_LONG + ).show() } } } @@ -110,7 +117,7 @@ fun SettingsScreen(onBackClick: () -> Unit) { try { context.contentResolver.openInputStream(it)?.use { inputStream -> BufferedReader(InputStreamReader(inputStream)).use { reader -> - showRestoreConfirmDialog = reader.readText() + backupJsonToRestore = reader.readText() } } } catch (_: Exception) { @@ -120,44 +127,42 @@ fun SettingsScreen(onBackClick: () -> Unit) { } ) - Scaffold( - topBar = { - TopAppBar( - title = { Text("تنظیمات") }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primary, - titleContentColor = Color.White, - navigationIconContentColor = Color.White - ) - ) - } - ) { innerPadding -> + Scaffold { innerPadding -> Column( modifier = Modifier .padding(innerPadding) - .padding(16.dp) + .padding(horizontal = 16.dp, vertical = 12.dp) ) { - Text("تنظیمات عمومی", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp)) + Text( + "تنظیمات عمومی", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 6.dp) + ) - DefaultAmountSetting(defaultAmount, onSave = { viewModel.saveDefaultPaymentAmount(it) }) + DefaultAmountSetting( + defaultAmount = defaultAmount, + onSave = { viewModel.saveDefaultPaymentAmount(it) } + ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(18.dp)) - FontSelectionDropdown(selectedFont = selectedFont) { - viewModel.onFontSelected(it) - } + FontSelectionDropdown( + selectedFont = selectedFont, + onFontSelected = { viewModel.onFontSelected(it) } + ) - HorizontalDivider(modifier = Modifier.padding(vertical = 24.dp)) + HorizontalDivider(modifier = Modifier.padding(vertical = 20.dp)) + + Text( + "پشتیبان‌گیری و بازیابی", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 6.dp) + ) - Text("پشتیبان‌گیری و بازیابی", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp)) OutlinedButton( onClick = { - val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()) + val simpleDateFormat = + SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()) val fileName = "payment_backup_${simpleDateFormat.format(Date())}.json" backupLauncher.launch(fileName) }, @@ -165,67 +170,110 @@ fun SettingsScreen(onBackClick: () -> Unit) { ) { Text("تهیه نسخه پشتیبان") } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( onClick = { restoreLauncher.launch(arrayOf("application/json")) }, modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error) + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) ) { Text("بازیابی اطلاعات از فایل") } + + HorizontalDivider(modifier = Modifier.padding(vertical = 20.dp)) + + Button( + onClick = onLogout, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Icon( + Icons.AutoMirrored.Filled.Logout, + contentDescription = "خروج" + ) + Spacer(Modifier.width(8.dp)) + Text("خروج از حساب کاربری") + } } } - showRestoreConfirmDialog?.let { + backupJsonToRestore?.let { json -> AlertDialog( - onDismissRequest = { showRestoreConfirmDialog = null }, + onDismissRequest = { backupJsonToRestore = null }, title = { Text("هشدار جدی!") }, - text = { Text("آیا مطمئن هستید؟ با بازیابی اطلاعات، تمام داده‌های فعلی برنامه (اشخاص و پرداخت‌ها) برای همیشه حذف شده و اطلاعات فایل پشتیبان جایگزین آن خواهد شد. این عمل غیرقابل بازگشت است.") }, + text = { + Text( + "آیا مطمئن هستید؟ با بازیابی اطلاعات، تمام داده‌های فعلی برنامه (اشخاص و پرداخت‌ها) برای همیشه حذف شده و اطلاعات فایل پشتیبان جایگزین آن خواهد شد. این عمل غیرقابل بازگشت است." + ) + }, confirmButton = { Button( onClick = { - viewModel.restoreFromBackupJson(it) - showRestoreConfirmDialog = null + viewModel.restoreFromBackupJson(json) + backupJsonToRestore = null }, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) ) { Text("بله، بازیابی کن") } }, dismissButton = { - TextButton(onClick = { showRestoreConfirmDialog = null }) { Text("لغو") } + TextButton(onClick = { backupJsonToRestore = null }) { + Text("لغو") + } } ) } } @Composable -fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) { +fun DefaultAmountSetting( + defaultAmount: Double, + onSave: (String) -> Unit +) { var isEditing by remember { mutableStateOf(false) } - var amountText by remember(defaultAmount, isEditing) { mutableStateOf(defaultAmount.toLong().toString()) } + var amountText by remember(defaultAmount, isEditing) { + mutableStateOf(defaultAmount.toLong().toString()) + } Card( modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(0.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + ) ) { AnimatedContent( targetState = isEditing, label = "EditAmountAnimation", - modifier = Modifier.padding(16.dp) + modifier = Modifier.padding(12.dp) ) { editing -> if (editing) { Column(horizontalAlignment = Alignment.CenterHorizontally) { OutlinedTextField( value = amountText, - onValueChange = { amountText = it.filter { c -> c.isDigit() } }, + onValueChange = { + amountText = it.filter { c -> c.isDigit() } + }, modifier = Modifier.fillMaxWidth(), label = { Text("مبلغ پیش فرض پرداخت (تومان)") }, visualTransformation = PersianNumberVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ) ) - Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { TextButton(onClick = { isEditing = false }) { Text("لغو") } @@ -239,10 +287,22 @@ fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) { } } } else { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { Column { - Text("مبلغ پیش فرض پرداخت", style = MaterialTheme.typography.bodyLarge) - Text("${formatNumberAsPersian(defaultAmount)} تومان", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + Text( + "مبلغ پیش فرض پرداخت", + style = MaterialTheme.typography.bodyLarge + ) + Text( + "${formatNumberAsPersian(defaultAmount)} تومان", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) } IconButton(onClick = { isEditing = true }) { Icon(Icons.Filled.Edit, contentDescription = "Edit Amount") @@ -253,16 +313,21 @@ fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) { } } - @Composable -fun FontSelectionDropdown(selectedFont: String, onFontSelected: (String) -> Unit) { +fun FontSelectionDropdown( + selectedFont: String, + onFontSelected: (String) -> Unit +) { var expanded by remember { mutableStateOf(false) } val fonts = listOf("Estedad", "Vazirmatn", "BYekan", "Sahel", "IranianSans") Box(modifier = Modifier.fillMaxWidth()) { - OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier.fillMaxWidth() + ) { Text("فونت برنامه: $selectedFont") - Icon(Icons.Default.ArrowDropDown, contentDescription = "Select Font") + Icon(Icons.Filled.ArrowDropDown, contentDescription = "Select Font") } DropdownMenu( expanded = expanded, diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SharedComponents.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SharedComponents.kt new file mode 100644 index 0000000..310a80c --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SharedComponents.kt @@ -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 = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/DateUtils.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/DateUtils.kt index 3362ff5..8562015 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/DateUtils.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/DateUtils.kt @@ -1,9 +1,19 @@ package com.oqba26.monthlypaymentapp.utils -import saman.zamani.persiandate.PersianDate -import saman.zamani.persiandate.PersianDateFormat - -fun Long.toPersianDate(pattern: String = "Y/M/d"): String { - val pd = PersianDate(this) // this = timestamp (ms) - return PersianDateFormat(pattern).format(pd) -} \ No newline at end of file +fun getPersianMonthName(month: Int): String { + return when (month) { + 1 -> "فروردین" + 2 -> "اردیبهشت" + 3 -> "خرداد" + 4 -> "تیر" + 5 -> "مرداد" + 6 -> "شهریور" + 7 -> "مهر" + 8 -> "آبان" + 9 -> "آذر" + 10 -> "دی" + 11 -> "بهمن" + 12 -> "اسفند" + else -> "نامشخص" + } +} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/ShamsiUtils.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/ShamsiUtils.kt index 611f224..f88716e 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/ShamsiUtils.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/ShamsiUtils.kt @@ -1,7 +1,31 @@ +@file:Suppress("unused") + package com.oqba26.monthlypaymentapp.utils +import android.icu.text.SimpleDateFormat import saman.zamani.persiandate.PersianDate +import java.util.Date +import java.util.Locale fun getCurrentShamsiYear(): Int = PersianDate().shYear +fun getCurrentShamsiMonth(): Int = PersianDate().shMonth -fun getCurrentShamsiMonth(): Int = PersianDate().shMonth \ No newline at end of file +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" +} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/ApiModels.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/ApiModels.kt new file mode 100644 index 0000000..63af448 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/ApiModels.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonDetailViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonDetailViewModel.kt deleted file mode 100644 index 1691bc0..0000000 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonDetailViewModel.kt +++ /dev/null @@ -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("personId")) - - // ⭐️ FIX: Fetch person from the network flow - val person: StateFlow = 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> = networkRepository.getPaymentsFlow() - .map { payments -> payments.filter { it.personId == personId } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - - private val _toastMessage = MutableSharedFlow() - 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 create(modelClass: Class, 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 - } - } - } -} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt index a0ad1d4..b21f84b 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt @@ -31,84 +31,431 @@ sealed interface AuthState { data object Unauthenticated : AuthState } +enum class PersonListType { + UNPAID, + PAID +} + +@OptIn(ExperimentalCoroutinesApi::class) class PersonViewModel( private val networkRepository: NetworkRepository, private val settingsRepository: SettingsRepository ) : ViewModel() { private val _searchQuery = MutableStateFlow("") - val searchQuery = _searchQuery.asStateFlow() + val searchQuery: StateFlow = _searchQuery.asStateFlow() private val _isRefreshing = MutableStateFlow(false) - val isRefreshing = _isRefreshing.asStateFlow() - - private val _expandedPersonId = MutableStateFlow(null) - val expandedPersonId = _expandedPersonId.asStateFlow() + @Suppress("unused") + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + private val _selectedPersonId = MutableStateFlow(null) private val _selectedYear = MutableStateFlow(getCurrentShamsiYear()) - val selectedYear = _selectedYear.asStateFlow() private val _dashboardData = MutableStateFlow(DashboardUiModel()) - val dashboardData = _dashboardData.asStateFlow() + val dashboardData: StateFlow = _dashboardData.asStateFlow() val defaultPaymentAmountFlow = settingsRepository.defaultPaymentAmountFlow private val _toastMessage = MutableSharedFlow() val toastMessage = _toastMessage.asSharedFlow() - val authState: StateFlow = settingsRepository.authTokenFlow.map { token -> - if (token != null) AuthState.Authenticated else AuthState.Unauthenticated - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AuthState.Loading) + private val _infoMessage = MutableSharedFlow() + val infoMessage = _infoMessage.asSharedFlow() - @OptIn(ExperimentalCoroutinesApi::class) - val uiState: StateFlow = authState.flatMapLatest { auth -> - when (auth) { - is AuthState.Authenticated -> { + private val _uiState = MutableStateFlow(PersonListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _showAddPersonDialog = MutableStateFlow(false) + val showAddPersonDialog: StateFlow = _showAddPersonDialog.asStateFlow() + + private val _personForPaymentDialog = MutableStateFlow(null) + val personForPaymentDialog: StateFlow = _personForPaymentDialog.asStateFlow() + + + val authState: StateFlow = settingsRepository.authTokenFlow + .map { token -> + if (token != null) AuthState.Authenticated else AuthState.Unauthenticated + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AuthState.Loading) + + val personDetailState: StateFlow = _selectedPersonId + .flatMapLatest { personId -> + if (personId == null) { + flowOf(PersonDetailUiState()) + } else { combine( networkRepository.getPersonsFlow(), networkRepository.getPaymentsFlow(), - _searchQuery - ) { persons, allPayments, query -> - val currentMonth = getCurrentShamsiMonth() - val currentYear = getCurrentShamsiYear() - val paymentsThisMonth = allPayments.filter { it.shamsiMonth == currentMonth && it.shamsiYear == currentYear } - - val uiModels = persons.map { person -> - val hasPaid = paymentsThisMonth.any { it.personId == person.id } - PersonUiModel(id = person.id, name = person.name, hasPaidThisMonth = hasPaid) + _selectedYear + ) { persons, allPayments, year -> + val person = persons.find { it.id == personId } + val paymentsForPersonInYear = allPayments.filter { + it.personId == personId && it.shamsiYear == year } - - updateDashboard(uiModels.size, paymentsThisMonth) - - val filtered = if (query.isBlank()) uiModels - else uiModels.filter { it.name.contains(query, ignoreCase = true) } - - PersonListUiState(persons = filtered, payments = allPayments) + val currentYear = getCurrentShamsiYear() + val currentMonth = getCurrentShamsiMonth() + val monthStates = (1..12).map { month -> + val payment = paymentsForPersonInYear.find { it.shamsiMonth == month } + val status = when { + year < currentYear -> MonthStatus.PAST_YEAR + year > currentYear -> MonthStatus.FUTURE_YEAR + month > currentMonth -> MonthStatus.FUTURE_MONTH + payment != null -> MonthStatus.PAID + else -> MonthStatus.AVAILABLE + } + MonthUiModel(month, payment, status) + } + PersonDetailUiState(person, year, monthStates) } } - else -> { - flowOf(PersonListUiState()) // Emit empty state if not authenticated - } } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PersonListUiState()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PersonDetailUiState()) init { viewModelScope.launch { - // This will trigger a refresh whenever the user becomes authenticated. - authState.collectLatest { state -> - if (state is AuthState.Authenticated) { + authState.collectLatest { authState -> + if (authState is AuthState.Authenticated) { + // اولین بار دیتای سرور networkRepository.refresh() + + // گوش دادن به تغییرات اشخاص + پرداخت‌ها + سرچ + combine( + networkRepository.getPersonsFlow(), + networkRepository.getPaymentsFlow(), + _searchQuery + ) { persons, allPayments, query -> + val currentMonth = getCurrentShamsiMonth() + val currentYear = getCurrentShamsiYear() + + val paymentsThisMonth = allPayments.filter { + it.shamsiMonth == currentMonth && it.shamsiYear == currentYear + } + + val activePersons = persons + .filter { !it.isArchived } + .sortedWith(compareBy({ it.displayOrder }, { it.createdAt })) + + val uiModels = activePersons.map { person -> + val hasPaid = paymentsThisMonth.any { it.personId == person.id } + PersonUiModel( + id = person.id, + name = person.name, + hasPaidThisMonth = hasPaid, + displayOrder = person.displayOrder, + createdAt = person.createdAt + ) + } + + updateDashboard(uiModels.size, paymentsThisMonth) + + val (paidModels, unpaidModels) = + uiModels.partition { it.hasPaidThisMonth } + + val filteredPaid = + if (query.isBlank()) paidModels + else paidModels.filter { it.name.contains(query, ignoreCase = true) } + + val filteredUnpaid = + if (query.isBlank()) unpaidModels + else unpaidModels.filter { it.name.contains(query, ignoreCase = true) } + + val archivedPersons = persons + .filter { it.isArchived } + .sortedBy { it.displayOrder } + .map { + PersonUiModel( + id = it.id, + name = it.name, + hasPaidThisMonth = false, + displayOrder = it.displayOrder, + createdAt = it.createdAt + ) + } + + PersonListUiState( + unpaidPersons = filteredUnpaid, + paidPersons = filteredPaid, + archivedPersons = archivedPersons, + payments = allPayments + ) + }.collect { newUiState -> + _uiState.value = newUiState + } + } else { + _uiState.value = PersonListUiState() } } } } - // Auth Actions + fun onSearchQueryChange(query: String) { + _searchQuery.value = query + } + + fun onAddPersonClicked() { + _showAddPersonDialog.value = true + } + + fun onDismissAddPersonDialog() { + _showAddPersonDialog.value = false + } + + fun onQuickPayClicked(person: PersonUiModel) { + _personForPaymentDialog.value = person + } + + fun onDismissPaymentDialog() { + _personForPaymentDialog.value = null + } + + + fun onEvent(event: PersonScreenEvent) { + viewModelScope.launch { + when (event) { + is PersonScreenEvent.MovePersonNew -> { + // فقط جابه‌جا کردن لیست لوکال (بدون شبکه) + val fromIndex = event.fromIndex + val toIndex = event.toIndex + val currentListState = _uiState.value + + val list = when (event.listType) { + PersonListType.UNPAID -> currentListState.unpaidPersons + PersonListType.PAID -> currentListState.paidPersons + } + + if (fromIndex !in list.indices || toIndex !in list.indices) return@launch + + val reorderedList = list.toMutableList().apply { + add(toIndex, removeAt(fromIndex)) + } + + _uiState.update { + when (event.listType) { + PersonListType.UNPAID -> it.copy(unpaidPersons = reorderedList) + PersonListType.PAID -> it.copy(paidPersons = reorderedList) + } + } + } + + is PersonScreenEvent.AddPerson -> { + if (event.name.isNotBlank()) { + val person = Person(name = event.name, displayOrder = 0) + val statusCode = networkRepository.addPerson(person) + when (statusCode) { + 409 -> { + _toastMessage.emit("خطا: این نام از قبل وجود دارد.") + } + null, !in 200..299 -> { + _toastMessage.emit("خطا در افزودن شخص.") + } + else -> { + networkRepository.refresh() + } + } + } + } + is PersonScreenEvent.AddQuickPayment -> { + val record = createPaymentRecord( + event.personId, + getCurrentShamsiMonth(), + getCurrentShamsiYear(), + event.amount, + event.description + ) + + if (networkRepository.addPayment(record)) { + networkRepository.refresh() + } else { + _toastMessage.emit("خطا در ثبت پرداخت. لطفا دوباره تلاش کنید.") + } + } + + else -> handleOtherEvents(event) + } + } + } + + private fun handleOtherEvents(event: PersonScreenEvent) { + viewModelScope.launch { + when (event) { + is PersonScreenEvent.SelectPerson -> { + _selectedPersonId.value = event.personId + _selectedYear.value = getCurrentShamsiYear() + } + + is PersonScreenEvent.DeletePerson -> { + if (networkRepository.deletePersonAndPayments(event.personId)) { + networkRepository.refresh() + } + } + + is PersonScreenEvent.ArchivePerson -> { + if (networkRepository.updatePersonArchivedStatus(event.personId, true)) { + _toastMessage.emit("شخص به آرشیو انتقال یافت.") + networkRepository.refresh() + } else { + _toastMessage.emit("خطا در آرشیو کردن.") + } + } + + is PersonScreenEvent.RestorePerson -> { + if (networkRepository.updatePersonArchivedStatus(event.personId, false)) { + _toastMessage.emit("شخص از آرشیو بازیابی شد.") + networkRepository.refresh() + } else { + _toastMessage.emit("خطا در بازیابی.") + } + } + + is PersonScreenEvent.UpdatePerson -> { + if (networkRepository.updatePerson(event.personId, event.name)) { + networkRepository.refresh() + } + } + + is PersonScreenEvent.Login -> login(AuthRequest(event.username, event.password)) + + is PersonScreenEvent.Register -> register(AuthRequest(event.username, event.password)) + + is PersonScreenEvent.ChangeYear -> { + when (event.offset) { + 1 -> { + if (_selectedYear.value < getCurrentShamsiYear()) { + _selectedYear.update { it + 1 } + } else { + _infoMessage.emit("امکان مشاهده سال آینده وجود ندارد.") + } + } + + -1 -> { + val personId = _selectedPersonId.value ?: return@launch + val hasPaymentsInPast = uiState.value.payments.any { + it.personId == personId && it.shamsiYear < _selectedYear.value + } + if (hasPaymentsInPast) { + _selectedYear.update { it - 1 } + } else { + _infoMessage.emit("هیچ پرداختی برای سال‌های گذشته ثبت نشده است.") + } + } + } + } + + is PersonScreenEvent.AddPaymentForMonth -> { + val record = createPaymentRecord( + event.personId, + event.month, + event.year, + event.amount, + event.description + ) + if (networkRepository.addPayment(record)) { + networkRepository.refresh() + } else { + _toastMessage.emit("خطا در ثبت پرداخت") + } + } + + is PersonScreenEvent.UpdatePayment -> { + val updatedRecord = event.payment.copy( + amount = event.newAmount, + description = event.newDescription, + timestamp = System.currentTimeMillis() + ) + if (networkRepository.addPayment(updatedRecord)) { + networkRepository.refresh() + } else { + _toastMessage.emit("خطا در ویرایش پرداخت") + } + } + + is PersonScreenEvent.DeletePayment -> { + if (networkRepository.deletePayment(event.payment.id)) { + networkRepository.refresh() + } else { + _toastMessage.emit("خطا در حذف پرداخت") + } + } + + is PersonScreenEvent.CommitReorder -> { + // بعد از اتمام درگ: ترتیب فعلی لیست را به عنوان displayOrder جدید ذخیره کن + val currentListState = _uiState.value + val list = when (event.listType) { + PersonListType.UNPAID -> currentListState.unpaidPersons + PersonListType.PAID -> currentListState.paidPersons + } + + if (list.size < 2) return@launch + + try { + list.forEachIndexed { index, person -> + val newOrder = (index + 1) * 10_000L + if (person.displayOrder != newOrder) { + val success = networkRepository.updatePersonDisplayOrder( + person.id, + newOrder + ) + if (!success) { + throw Exception("update failed") + } + } + } + // بعد از ذخیره، از سرور رفرش کن تا همه‌چیز باهم سینک بشه + networkRepository.refresh() + } catch (_: Exception) { + _toastMessage.emit("خطا در ذخیره ترتیب") + networkRepository.refresh() + } + } + + PersonScreenEvent.RefreshData -> { + _isRefreshing.value = true + networkRepository.refresh() + _isRefreshing.value = false + } + + else -> {} + } + } + } + + private fun createPaymentRecord( + personId: String, + month: Int, + year: Int, + amount: Double, + description: String + ): PaymentRecord { + return PaymentRecord( + personId = personId, + amount = amount, + shamsiYear = year, + shamsiMonth = month, + description = description + ) + } + + private fun updateDashboard(totalPersonCount: Int, paymentsThisMonth: List) { + val paidCount = paymentsThisMonth.map { it.personId }.distinct().size + val totalIncome = paymentsThisMonth.sumOf { it.amount } + val progress = + if (totalPersonCount > 0) paidCount.toFloat() / totalPersonCount.toFloat() else 0f + _dashboardData.value = + DashboardUiModel(paidCount, totalPersonCount, totalIncome, progress) + } + + fun logout() = viewModelScope.launch { + settingsRepository.saveAuthData(null, null) + _toastMessage.emit("از حساب کاربری خارج شدید.") + } + fun login(request: AuthRequest) = viewModelScope.launch { val response = networkRepository.login(request) if (response != null && response.token.isNotBlank()) { settingsRepository.saveAuthData(response.token, response.userId) _toastMessage.emit("ورود موفقیت‌آمیز بود") + networkRepository.refresh() } else { _toastMessage.emit("نام کاربری یا رمز عبور اشتباه است.") } @@ -119,110 +466,57 @@ class PersonViewModel( if (response != null && response.token.isNotBlank()) { settingsRepository.saveAuthData(response.token, response.userId) _toastMessage.emit("ثبت نام و ورود موفقیت‌آمیز بود") + networkRepository.refresh() } else { _toastMessage.emit("خطا در ثبت نام.") } } - - fun logout() = viewModelScope.launch { - settingsRepository.saveAuthData(null, null) - _toastMessage.emit("از حساب کاربری خارج شدید.") - } - - // UI Events - fun onSearchQueryChange(query: String) { _searchQuery.value = query } - - fun onEvent(event: PersonScreenEvent) { - viewModelScope.launch { - when (event) { - is PersonScreenEvent.AddPerson -> { - if (event.name.isNotBlank()) { - val statusCode = networkRepository.addPerson(Person(name = event.name)) - if (statusCode == 409) { - _toastMessage.emit("خطا: این نام از قبل وجود دارد.") - } else if (statusCode == null || statusCode !in 200..299) { - _toastMessage.emit("خطا در افزودن شخص.") - } - } - } - is PersonScreenEvent.DeletePerson -> { - networkRepository.deletePersonAndPayments(event.personId) - } - is PersonScreenEvent.AddQuickPayment -> { - val record = createPaymentRecord(event.personId, getCurrentShamsiMonth(), getCurrentShamsiYear(), event.amount) - networkRepository.addPayment(record) - } - is PersonScreenEvent.UpdatePerson -> { - networkRepository.updatePerson(event.personId, event.name) - } - is PersonScreenEvent.Login -> login(AuthRequest(event.username, event.password)) - is PersonScreenEvent.Register -> register(AuthRequest(event.username, event.password)) - is PersonScreenEvent.TogglePersonExpansion -> { - val currentId = _expandedPersonId.value - if (currentId == event.personId) { - _expandedPersonId.value = null - } else { - _expandedPersonId.value = event.personId - _selectedYear.value = getCurrentShamsiYear() // Reset year when opening a new item - } - } - is PersonScreenEvent.ChangeYear -> { - _selectedYear.update { it + event.offset } - } - is PersonScreenEvent.AddPaymentForMonth -> { - val record = createPaymentRecord(event.personId, event.month, event.year, event.amount) - val success = networkRepository.addPayment(record) - if (!success) _toastMessage.emit("خطا در ثبت پرداخت") - } - is PersonScreenEvent.UpdatePayment -> { - val updatedRecord = event.payment.copy(amount = event.newAmount) - val success = networkRepository.addPayment(updatedRecord) - if (!success) _toastMessage.emit("خطا در ویرایش پرداخت") - } - is PersonScreenEvent.DeletePayment -> { - val success = networkRepository.deletePayment(event.payment.id) - if (!success) _toastMessage.emit("خطا در حذف پرداخت") - } - - PersonScreenEvent.RefreshData -> { - _isRefreshing.value = true - networkRepository.refresh() - _isRefreshing.value = false - } - } - } - } - - private fun updateDashboard(totalPersonCount: Int, paymentsThisMonth: List) { - 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 = emptyList(), - val payments: List = emptyList() -) - sealed class PersonScreenEvent { data object RefreshData : PersonScreenEvent() data class AddPerson(val name: String) : PersonScreenEvent() data class UpdatePerson(val personId: String, val name: String) : PersonScreenEvent() data class DeletePerson(val personId: String) : PersonScreenEvent() - data class AddQuickPayment(val personId: String, val amount: Double) : PersonScreenEvent() + data class ArchivePerson(val personId: String) : PersonScreenEvent() + data class RestorePerson(val personId: String) : PersonScreenEvent() + + // درگ اند دراپ + data class MovePersonNew( + val fromIndex: Int, + val toIndex: Int, + val listType: PersonListType + ) : PersonScreenEvent() + + // پایان درگ: ذخیره ترتیب + data class CommitReorder( + val listType: PersonListType + ) : PersonScreenEvent() + + data class AddQuickPayment( + val personId: String, + val amount: Double, + val description: String + ) : PersonScreenEvent() + data class Login(val username: String, val password: String) : PersonScreenEvent() data class Register(val username: String, val password: String) : PersonScreenEvent() - data class TogglePersonExpansion(val personId: String) : PersonScreenEvent() - // Events for the detail section + data class SelectPerson(val personId: String) : PersonScreenEvent() data class ChangeYear(val offset: Int) : PersonScreenEvent() - data class AddPaymentForMonth(val personId: String, val month: Int, val year: Int, val amount: Double) : PersonScreenEvent() - data class UpdatePayment(val payment: PaymentRecord, val newAmount: Double) : PersonScreenEvent() + data class AddPaymentForMonth( + val personId: String, + val month: Int, + val year: Int, + val amount: Double, + val description: String + ) : PersonScreenEvent() + + data class UpdatePayment( + val payment: PaymentRecord, + val newAmount: Double, + val newDescription: String + ) : PersonScreenEvent() + data class DeletePayment(val payment: PaymentRecord) : PersonScreenEvent() } diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModelFactory.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModelFactory.kt deleted file mode 100644 index 2d1488f..0000000 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModelFactory.kt +++ /dev/null @@ -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 create(modelClass: Class): 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") - } -} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt index 72776b3..b665610 100644 --- a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt @@ -1,16 +1,46 @@ package com.oqba26.monthlypaymentapp.viewmodel +import com.oqba26.monthlypaymentapp.data.model.PaymentRecord +import com.oqba26.monthlypaymentapp.data.model.Person + data class PersonUiModel( val id: String, val name: String, - val hasPaidThisMonth: Boolean + val hasPaidThisMonth: Boolean, + val displayOrder: Long, // FIX: Changed from Int to Long + val createdAt: Long ) +data class PersonListUiState( + val unpaidPersons: List = emptyList(), + val paidPersons: List = emptyList(), + val archivedPersons: List = emptyList(), + val payments: List = emptyList(), +) + +data class PersonDetailUiState( + val person: Person? = null, + val selectedYear: Int = 0, + val monthStates: List = emptyList() +) + +data class MonthUiModel( + val month: Int, + val payment: PaymentRecord?, + val status: MonthStatus +) + +enum class MonthStatus { + PAID, + AVAILABLE, + FUTURE_MONTH, + PAST_YEAR, + FUTURE_YEAR +} + data class DashboardUiModel( val paidCount: Int = 0, val totalCount: Int = 0, val totalIncome: Double = 0.0, val progress: Float = 0f ) - -enum class MonthStatus { PAID, AVAILABLE, FUTURE_YEAR, PAST_YEAR, FUTURE_MONTH } \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 4c2d391..2977b40 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false - -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 320b8cb..783705e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ coreKtx = "1.13.1" lifecycle = "2.7.0" activityCompose = "1.9.0" navigation = "2.7.7" +appcompat = "1.7.0" retrofit = "2.9.0" okhttp = "4.12.0" @@ -21,7 +22,7 @@ datastore = "1.1.1" desugarLibs = "2.0.4" serialization = "1.6.3" -persianDate = "1.5.2" +persianDate = "0.2.0" material = "1.12.0" junit = "4.13.2" @@ -30,6 +31,7 @@ espressoCore = "3.5.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-savedstate = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-savedstate", version.ref = "lifecycle" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } @@ -59,7 +61,7 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto android-desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarLibs" } -persiandate = { module = "com.github.samanzamani:PersianDate", version.ref = "persianDate" } +persiandate = { module = "com.github.mfathi91:time", version.ref = "persianDate" } google-material = { group = "com.google.android.material", name = "material", version.ref = "material" } @@ -72,6 +74,7 @@ androidTest-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index fbd0b1d..70a3684 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,4 +33,4 @@ dependencyResolutionManagement { } rootProject.name = "MonthlyPaymentApp" -include(":app") \ No newline at end of file +include(":app", ":PersianDate") \ No newline at end of file