commit 30ef5d636d5b6d95a1e7bf3438f34cfd80741532 Author: oqba26 Date: Sun Oct 19 09:57:27 2025 +0330 Initial commit: First version of the payment management app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..eca3fe9 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,27 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..5ef3273 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..103e00c --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,32 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..148fdd2 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8978d23 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..22bff53 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,115 @@ +// app/build.gradle.kts + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) + // ⭐️ پلاگین Serialization فقط یک بار با alias تعریف می‌شود (رفع خطای تکراری) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.oqba26.monthlypaymentapp" + compileSdk = 34 + + defaultConfig { + applicationId = "com.oqba26.monthlypaymentapp" + minSdk = 25 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { useSupportLibrary = true } + } + + buildFeatures { + compose = true + buildConfig = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() + } + + buildTypes { + debug { + // ⭐️ استفاده از آدرس نهایی سرور برای Consistency + buildConfigField("String", "API_BASE_URL", "\"http://167.235.136.65:8080/\"") + } + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + // ⭐️ استفاده از آدرس نهایی سرور برای Consistency + buildConfigField("String", "API_BASE_URL", "\"http://167.235.136.65:8080/\"") + } + } + + compileOptions { + // هم‌تراز با JDK 17 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + kotlinOptions { jvmTarget = "17" } + // اطمینان از JDK 17 + kotlin { jvmToolchain(17) } + + packaging { + resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } + } +} + +dependencies { + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.navigation.compose) + implementation("androidx.compose.material:material-icons-extended-android:1.6.7") + + // Core & Coroutines + implementation(libs.androidx.lifecycle.viewmodel.savedstate) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.android) + + // Networking (Retrofit, OkHttp) + implementation(libs.retrofit.core) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + + // ⭐️ Kotlinx Serialization - حذف وابستگی‌های Gson و تکراری + implementation(libs.kotlin.serialization.json) // kotlinx.serialization.json + // ⭐️ مبدل Retrofit برای Kotlinx Serialization (فقط این یکی لازم است) + implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") + + // Database (Room) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + // DataStore + implementation(libs.androidx.datastore.preferences) + + // Utility + implementation(libs.persiandate) + implementation(libs.google.material) + + // Desugaring + coreLibraryDesugaring(libs.android.desugar.jdk.libs) + + // Testing + testImplementation(libs.test.junit) + androidTestImplementation(libs.androidTest.junit.ext) + androidTestImplementation(libs.androidTest.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidTest.compose.ui.test.junit4) + + debugImplementation(libs.androidx.ui.tooling) +} \ No newline at end of file diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..c9853f3 --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,48 @@ +{ + "project_info": { + "project_number": "302483616421", + "project_id": "monthlypaymentapp-c52fc", + "storage_bucket": "monthlypaymentapp-c52fc.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:302483616421:android:c1ba5fe18311c90a0ad34c", + "android_client_info": { + "package_name": "com.oqba26.monthlypaymentapp" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBzOX1K1dIKlaOk1VlL0fwzsmffosc4N88" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:302483616421:android:e3f9eab928a121b90ad34c", + "android_client_info": { + "package_name": "com.oqba26.monthlypaymentmanagement2" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBzOX1K1dIKlaOk1VlL0fwzsmffosc4N88" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/oqba26/monthlypaymentapp/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/oqba26/monthlypaymentapp/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..ae899d3 --- /dev/null +++ b/app/src/androidTest/java/com/oqba26/monthlypaymentapp/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.oqba26.monthlypaymentapp + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.oqba26.monthlypaymentapp", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a55be95 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt new file mode 100644 index 0000000..bf16292 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/MainActivity.kt @@ -0,0 +1,171 @@ +package com.oqba26.monthlypaymentapp + +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.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.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.oqba26.monthlypaymentapp.core.PaymentApplication +import com.oqba26.monthlypaymentapp.ui.screens.AuthScreen +import com.oqba26.monthlypaymentapp.ui.screens.PersonScreen +import com.oqba26.monthlypaymentapp.ui.screens.SettingsScreen +import com.oqba26.monthlypaymentapp.ui.theme.MonthlyPaymentManagement2Theme +import com.oqba26.monthlypaymentapp.viewmodel.AuthState +import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + val application = application as PaymentApplication + val personViewModel: PersonViewModel = viewModel(factory = application.personViewModelFactory) + + MonthlyPaymentManagement2Theme { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + MainAppHost(personViewModel) + } + } + } + } + } +} + +@Composable +fun MainAppHost(viewModel: PersonViewModel) { + val authState by viewModel.authState.collectAsState() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.toastMessage.collect { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + Scaffold( + modifier = Modifier.fillMaxSize() + ) { paddingValues -> + + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Crossfade(targetState = authState, label = "AuthScreenSwitch") { state -> + when (state) { + is AuthState.Loading -> { + LoadingScreen() + } + is AuthState.Unauthenticated -> { + AuthScreen() + } + is AuthState.Authenticated -> { + AppNavigation(viewModel) + } + } + } + } + } +} + +@Composable +fun AppNavigation(viewModel: PersonViewModel) { + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + var showExitDialog by remember { mutableStateOf(false) } + val context = LocalContext.current + + 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("خیر") + } + } + }, + dismissButton = {} + ) + } + + + NavHost(navController = navController, startDestination = "personScreen") { + composable("personScreen") { + PersonScreen( + onSettingsClick = { navController.navigate("settings") }, + //onExitApp = { viewModel.logout() } + ) + } + composable("settings") { + SettingsScreen(onBackClick = { navController.popBackStack() }) + } + } +} + + +@Composable +fun LoadingScreen() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + Text("در حال بررسی وضعیت...", modifier = Modifier.align(Alignment.Center).padding(top = 80.dp)) + } +} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/core/PaymentApplication.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/core/PaymentApplication.kt new file mode 100644 index 0000000..3d4f1d3 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/core/PaymentApplication.kt @@ -0,0 +1,59 @@ +package com.oqba26.monthlypaymentapp.core + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.oqba26.monthlypaymentapp.data.database.AppDatabase +import com.oqba26.monthlypaymentapp.data.remote.ApiClient +import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository +import com.oqba26.monthlypaymentapp.data.repository.NetworkRepository +import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository +import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel +import com.oqba26.monthlypaymentapp.viewmodel.SettingsViewModel + +class PaymentApplication : Application() { + + lateinit var localPersonRepository: LocalPersonRepository + lateinit var networkRepository: NetworkRepository + lateinit var settingsRepository: SettingsRepository + private lateinit var apiClient: ApiClient + + override fun onCreate() { + super.onCreate() + val db = AppDatabase.getInstance(this) + + settingsRepository = SettingsRepository(this) + apiClient = ApiClient(settingsRepository) + + localPersonRepository = LocalPersonRepository( + personDao = db.personDao(), + paymentDao = db.paymentDao(), + database = db + ) + + networkRepository = NetworkRepository(api = apiClient.api) + } + + val personViewModelFactory: ViewModelProvider.Factory + get() = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(PersonViewModel::class.java)) { + // ⭐️ FIX: Removed the unnecessary 'localPersonRepository' argument. + return PersonViewModel(networkRepository, settingsRepository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } + + val settingsViewModelFactory: ViewModelProvider.Factory + get() = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) { + return SettingsViewModel(settingsRepository, localPersonRepository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } +} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PaymentDao.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PaymentDao.kt new file mode 100644 index 0000000..ed6c334 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PaymentDao.kt @@ -0,0 +1,24 @@ +package com.oqba26.monthlypaymentapp.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.oqba26.monthlypaymentapp.data.model.PaymentRecord +import kotlinx.coroutines.flow.Flow + +@Dao +interface PaymentDao { + + @Query("SELECT * FROM payments ORDER BY timestamp DESC") + fun getAllPaymentsFlow(): Flow> + + @Query("SELECT * FROM payments WHERE personId = :personId ORDER BY timestamp DESC") + fun getPaymentsForPersonFlow(personId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAllPaymentRecords(payments: List) + + @Query("DELETE FROM payments") + suspend fun deleteAllPaymentRecords() +} \ 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 new file mode 100644 index 0000000..122d14c --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/dao/PersonDao.kt @@ -0,0 +1,24 @@ +package com.oqba26.monthlypaymentapp.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.oqba26.monthlypaymentapp.data.model.Person +import kotlinx.coroutines.flow.Flow + +@Dao +interface PersonDao { + + @Query("SELECT * FROM persons ORDER BY name COLLATE NOCASE ASC") + fun getAllPersonsFlow(): Flow> + + @Query("SELECT * FROM persons WHERE id = :id LIMIT 1") + fun getPersonByIdFlow(id: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(persons: List) + + @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 new file mode 100644 index 0000000..201a674 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/database/AppDatabase.kt @@ -0,0 +1,59 @@ +package com.oqba26.monthlypaymentapp.data.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.Transaction +import com.oqba26.monthlypaymentapp.data.dao.PaymentDao +import com.oqba26.monthlypaymentapp.data.dao.PersonDao +import com.oqba26.monthlypaymentapp.data.model.BackupData +import com.oqba26.monthlypaymentapp.data.model.PaymentRecord +import com.oqba26.monthlypaymentapp.data.model.Person +import java.util.UUID + +@Database( + entities = [Person::class, PaymentRecord::class], + version = 11, // حتماً نسخه را بالا ببری که اسکیمای جدید اعمال شود + exportSchema = false +) +abstract class AppDatabase : RoomDatabase() { + + abstract fun personDao(): PersonDao + abstract fun paymentDao(): PaymentDao + + @Transaction + open suspend fun performRestore(backupData: BackupData) { + // تمیزکاری قبل از درج بکاپ + val safePersons = backupData.persons.map { p -> + val name = p.name.trim() + val id = if (p.id.isNotBlank()) p.id else UUID.randomUUID().toString() + Person(id = id, name = name) + } + personDao().deleteAll() + paymentDao().deleteAllPaymentRecords() + personDao().insertAll(safePersons) + paymentDao().insertAllPaymentRecords(backupData.payments) + } + + companion object { + @Volatile + private var INSTANCE: AppDatabase? = null + + fun getInstance(context: Context): AppDatabase = getDatabase(context) + + private fun getDatabase(context: Context): AppDatabase { + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + AppDatabase::class.java, + "monthly_payment_db" + ) + .fallbackToDestructiveMigration() + .build() + INSTANCE = instance + instance + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthModels.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthModels.kt new file mode 100644 index 0000000..ab2c3d8 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthModels.kt @@ -0,0 +1,18 @@ +package com.oqba26.monthlypaymentapp.data.model + +import kotlinx.serialization.Serializable + +// مدل برای ارسال به سرور هنگام ورود/ثبت نام +@Serializable +data class LoginRequest( + val username: String, // یا email + val password: String +) + +// مدلی که سرور پس از ورود موفق برمی‌گرداند +@Serializable +data class LoginResponse( + val token: String, // توکن دسترسی (JWT) + val userId: String, // شناسه کاربر برای فیلتر کردن داده‌ها + val username: String +) \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthRequest.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthRequest.kt new file mode 100644 index 0000000..fc451fe --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthRequest.kt @@ -0,0 +1,11 @@ +package com.oqba26.monthlypaymentapp.data.model + +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.Serializable + +@OptIn(InternalSerializationApi::class) // <<< CRITICAL FIX: Added the required Opt-In annotation +@Serializable +data class AuthRequest( + val username: String, + val password: String +) diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthResponse.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthResponse.kt new file mode 100644 index 0000000..234bdc6 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/AuthResponse.kt @@ -0,0 +1,10 @@ +package com.oqba26.monthlypaymentapp.data.model + +import kotlinx.serialization.Serializable + +@Serializable +data class AuthResponse( + val token: String, + // ⭐️ سرور شما می‌تواند userId را برگرداند یا ندهد. آن را Nullable می‌کنیم. + val userId: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/BackupData.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/BackupData.kt new file mode 100644 index 0000000..9b363f8 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/BackupData.kt @@ -0,0 +1,7 @@ +package com.oqba26.monthlypaymentapp.data.model + +@kotlinx.serialization.Serializable +data class BackupData( + val persons: List, + val payments: List +) \ No newline at end of file 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 new file mode 100644 index 0000000..ba03b54 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/PaymentRecord.kt @@ -0,0 +1,16 @@ +package com.oqba26.monthlypaymentapp.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.UUID + +@kotlinx.serialization.Serializable +@Entity(tableName = "payments") +data class PaymentRecord( + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val personId: String, + val amount: Double, + val shamsiYear: Int, + val shamsiMonth: Int, + val timestamp: Long = System.currentTimeMillis() +) \ 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 new file mode 100644 index 0000000..1078cfd --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/model/Person.kt @@ -0,0 +1,15 @@ +package com.oqba26.monthlypaymentapp.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.serialization.Serializable +import java.util.UUID + +@Serializable +@Entity(tableName = "persons") +data class Person( + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val name: String, + val userId: String? = null, + val createdAt: Long = System.currentTimeMillis() +) \ No newline at end of file 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 new file mode 100644 index 0000000..5fca29d --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiClient.kt @@ -0,0 +1,45 @@ +package com.oqba26.monthlypaymentapp.data.remote + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.oqba26.monthlypaymentapp.BuildConfig +import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit + +// ⭐️ تغییر به کلاس برای تزریق SettingsRepository +class ApiClient(settingsRepository: SettingsRepository) { + + // تنظیمات Kotlinx.Serialization + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + // ⭐️ ایجاد اینترسپتور احراز هویت + private val authInterceptor = AuthInterceptor(settingsRepository) + + private val logging = HttpLoggingInterceptor().apply { + level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY + else HttpLoggingInterceptor.Level.NONE + } + + // ⭐️ افزودن AuthInterceptor به OkHttpClient + private val client: OkHttpClient = OkHttpClient.Builder() + .addInterceptor(authInterceptor) + .addInterceptor(logging) + .build() + + val api: ApiService by lazy { + val contentType = "application/json".toMediaType() + + Retrofit.Builder() + .baseUrl("http://167.235.136.65:8080/") // آدرس سرور + .client(client) + .addConverterFactory(json.asConverterFactory(contentType)) + .build() + .create(ApiService::class.java) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..273c0c7 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/ApiService.kt @@ -0,0 +1,50 @@ +package com.oqba26.monthlypaymentapp.data.remote + +import com.oqba26.monthlypaymentapp.data.model.AuthRequest +import com.oqba26.monthlypaymentapp.data.model.AuthResponse +import com.oqba26.monthlypaymentapp.data.model.PaymentRecord +import com.oqba26.monthlypaymentapp.data.model.Person +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +interface ApiService { + + // ⭐️ NEW: Auth Routes + @POST("auth/register") + suspend fun register(@Body request: AuthRequest): AuthResponse + + @POST("auth/login") + suspend fun login(@Body request: AuthRequest): AuthResponse + + // Persons (AuthInterceptor توکن را به هدر اضافه می‌کند) + @GET("persons") + suspend fun getPersons(): List + + @POST("persons") + suspend fun addPerson(@Body person: Person): Response + + // ⭐️ NEW: مسیر PUT برای ویرایش نام + @PUT("persons/{id}") + suspend fun updatePerson(@Path("id") personId: String, @Body person: Person): Response + + @DELETE("persons/{id}") + suspend fun deletePersonAndPayments(@Path("id") personId: String): Response + + // Payments + @GET("payments") + suspend fun getPayments( + @Query("personId") personId: String? = null + ): List + + @POST("payments") + suspend fun addPayment(@Body paymentRecord: PaymentRecord): Response + + @DELETE("payments/{id}") + suspend fun deletePayment(@Path("id") paymentId: String): Response +} \ No newline at end of file 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 new file mode 100644 index 0000000..e48fbb5 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/remote/AuthInterceptor.kt @@ -0,0 +1,29 @@ +package com.oqba26.monthlypaymentapp.data.remote + +import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response + +class AuthInterceptor( + private val settingsRepository: SettingsRepository +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + // توکن را به صورت Blocking از DataStore می‌خوانیم + val authToken = runBlocking { + settingsRepository.authTokenFlow.firstOrNull() + } + + val requestBuilder = originalRequest.newBuilder() + if (authToken != null) { + // اضافه کردن توکن به هدر (فرمت استاندارد Bearer) + 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/LocalPersonRepository.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt new file mode 100644 index 0000000..e8b107e --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/LocalPersonRepository.kt @@ -0,0 +1,100 @@ +package com.oqba26.monthlypaymentapp.data.repository + +import androidx.room.withTransaction +import com.oqba26.monthlypaymentapp.data.dao.PaymentDao +import com.oqba26.monthlypaymentapp.data.dao.PersonDao +import com.oqba26.monthlypaymentapp.data.database.AppDatabase +import com.oqba26.monthlypaymentapp.data.model.BackupData +import com.oqba26.monthlypaymentapp.data.model.PaymentRecord +import com.oqba26.monthlypaymentapp.data.model.Person +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import java.util.UUID + +class LocalPersonRepository( + private val personDao: PersonDao, + private val paymentDao: PaymentDao, + private val database: AppDatabase +) { + // Reads + fun getAllPersonsFlow(): Flow> = personDao.getAllPersonsFlow() + fun getAllPaymentsFlow(): Flow> = paymentDao.getAllPaymentsFlow() + fun getPersonByIdFlow(personId: String): Flow = personDao.getPersonByIdFlow(personId) + fun getPaymentsForPersonFlow(personId: String): Flow> = + paymentDao.getPaymentsForPersonFlow(personId) + + suspend fun syncPersons(persons: List) { + val current = getAllPersonsFlow().first() + val currentByName = current.associateBy { normalize(it.name) } + + val safePersons = persons.map { p -> + val name = p.name.trim() + val norm = normalize(name) + val resolvedId = when { + p.id.isNotBlank() -> p.id + currentByName.containsKey(norm) -> currentByName[norm]!!.id + else -> UUID.randomUUID().toString() + } + Person(id = resolvedId, name = name) + } + + personDao.insertAll(safePersons) + } + + suspend fun syncPayments(payments: List) = + paymentDao.insertAllPaymentRecords(payments) + + // ⭐️ تابع جدید و اصلاح شده برای همگام‌سازی کامل + suspend fun syncAll(persons: List, payments: List) { + database.withTransaction { + + // ۱. اجرای منطق حل شناسه (ID Resolution) + val current = getAllPersonsFlow().first() + val currentByName = current.associateBy { normalize(it.name) } + + val safePersons = persons.map { p -> + val name = p.name.trim() + val norm = normalize(name) + val resolvedId = when { + p.id.isNotBlank() -> p.id + currentByName.containsKey(norm) -> currentByName[norm]!!.id + else -> UUID.randomUUID().toString() + } + Person(id = resolvedId, name = name) + } + + // ۲. حذف کامل تمام داده‌های محلی + personDao.deleteAll() + paymentDao.deleteAllPaymentRecords() + + // ۳. درج داده‌های جدید + personDao.insertAll(safePersons) + paymentDao.insertAllPaymentRecords(payments) + } + } + + // Backup/Restore + suspend fun getDataForBackup(): BackupData { + val persons = getAllPersonsFlow().first() + val payments = getAllPaymentsFlow().first() + return BackupData(persons = persons, payments = payments) + } + + suspend fun restoreBackup(backupData: BackupData) { + database.withTransaction { + personDao.deleteAll() + paymentDao.deleteAllPaymentRecords() + + val safePersons = backupData.persons.map { p -> + val name = p.name.trim() + val id = p.id.ifBlank { UUID.randomUUID().toString() } + Person(id = id, name = name) + } + + personDao.insertAll(safePersons) + paymentDao.insertAllPaymentRecords(backupData.payments) + } + } + + private fun normalize(name: String): String = name.trim().lowercase() +} \ 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 new file mode 100644 index 0000000..2599026 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/NetworkRepository.kt @@ -0,0 +1,122 @@ +package com.oqba26.monthlypaymentapp.data.repository + +import android.util.Log +import com.oqba26.monthlypaymentapp.data.model.AuthRequest +import com.oqba26.monthlypaymentapp.data.model.AuthResponse +import com.oqba26.monthlypaymentapp.data.model.PaymentRecord +import com.oqba26.monthlypaymentapp.data.model.Person +import com.oqba26.monthlypaymentapp.data.remote.ApiService +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import retrofit2.HttpException + +class NetworkRepository(private val api: ApiService) { + + private val _personsFlow = MutableStateFlow>(emptyList()) + private val _paymentsFlow = MutableStateFlow>(emptyList()) + + // --- Auth Operations --- + suspend fun register(request: AuthRequest): AuthResponse? { + return try { + api.register(request) + } catch (e: HttpException) { + Log.e("API", "register HttpException: ${e.code()}", e) + null + } catch (e: Exception) { + Log.e("API", "register error", e) + null + } + } + + suspend fun login(request: AuthRequest): AuthResponse? { + return try { + api.login(request) + } catch (e: HttpException) { + Log.e("API", "login HttpException: ${e.code()}", e) + null + } catch (e: Exception) { + Log.e("API", "login error", e) + null + } + } + + // --- Person Operations --- + suspend fun addPerson(person: Person): Int? { + return try { + val trimmed = person.name.trim() + if (trimmed.isEmpty()) return 400 // Bad request from client side + val resp = api.addPerson(person.copy(name = trimmed)) + if (resp.isSuccessful) refresh() + resp.code() // Return the actual HTTP status code + } catch (e: HttpException) { + e.code() // Return the HTTP error code (e.g., 409) + } catch (e: Exception) { + Log.e("API", "addPerson error", e) + null // Return null for non-HTTP errors + } + } + + suspend fun deletePersonAndPayments(personId: String): Boolean { + return try { + val resp = api.deletePersonAndPayments(personId) + if (resp.isSuccessful) refresh() + resp.isSuccessful + } catch (e: Exception) { + Log.e("API", "deletePersonAndPayments error", e); false + } + } + + suspend fun updatePerson(personId: String, name: String): Boolean { + 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 + } catch (e: Exception) { + Log.e("API", "updatePerson 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 + } catch (e: Exception) { + Log.e("API", "addPayment error", e); false + } + } + + suspend fun deletePayment(paymentId: String): Boolean { + return try { + val resp = api.deletePayment(paymentId) + if (resp.isSuccessful) refresh() + resp.isSuccessful + } catch (e: Exception) { + Log.e("API", "deletePayment error", e); false + } + } + + fun getPaymentsFlow(): Flow> = _paymentsFlow.asStateFlow() + + suspend fun refresh() { + try { + val persons = api.getPersons() + _personsFlow.value = persons + } catch (e: Exception) { + Log.e("API", "refresh persons error", e) + } + try { + val payments = api.getPayments(null) + _paymentsFlow.value = payments.sortedByDescending { it.timestamp } + } catch (e: Exception) { + Log.e("API", "refresh payments error", e) + } + } +} 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 new file mode 100644 index 0000000..ff7d943 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/data/repository/SettingsRepository.kt @@ -0,0 +1,74 @@ +package com.oqba26.monthlypaymentapp.data.repository + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +class SettingsRepository(context: Context) { + + private val dataStore = context.dataStore + + 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") + + const val FALLBACK_AMOUNT = 200000.0 + const val DEFAULT_FONT = "Estedad" + } + + // --- جریان‌های تنظیمات عمومی --- + val defaultPaymentAmountFlow: Flow = dataStore.data.map { + it[DEFAULT_PAYMENT_AMOUNT] ?: FALLBACK_AMOUNT + } + + val selectedFontFlow: Flow = dataStore.data.map { + it[SELECTED_FONT_NAME] ?: DEFAULT_FONT + } + + suspend fun saveDefaultPaymentAmount(amount: Double) { + dataStore.edit { it[DEFAULT_PAYMENT_AMOUNT] = amount } + } + + suspend fun saveSelectedFont(fontName: String) { + dataStore.edit { it[SELECTED_FONT_NAME] = fontName } + } + + + // --- جریان‌ها و متدهای مربوط به احراز هویت --- + val authTokenFlow: Flow = dataStore.data.map { + it[AUTH_TOKEN_KEY] + } + + 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) { + preferences[AUTH_TOKEN_KEY] = token + } else { + preferences.remove(AUTH_TOKEN_KEY) + } + if (userId != null) { + preferences[USER_ID_KEY] = userId + } else { + preferences.remove(USER_ID_KEY) + } + } + } +} 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 new file mode 100644 index 0000000..c62c0ee --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/components/AddPaymentDialog.kt @@ -0,0 +1,82 @@ +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/AuthScreen.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/AuthScreen.kt new file mode 100644 index 0000000..8605c09 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/AuthScreen.kt @@ -0,0 +1,147 @@ +package com.oqba26.monthlypaymentapp.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.oqba26.monthlypaymentapp.core.PaymentApplication +import com.oqba26.monthlypaymentapp.data.model.AuthRequest +import com.oqba26.monthlypaymentapp.viewmodel.AuthState +import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel + +@Composable +fun AuthScreen( + viewModel: PersonViewModel = viewModel( + // استفاده از Factory که در PaymentApplication تعریف شده است + factory = (LocalContext.current.applicationContext as PaymentApplication).personViewModelFactory + ) +) { + // مشاهده وضعیت لودینگ از ViewModel + val authState by viewModel.authState.collectAsState() + val isLoading = authState is AuthState.Loading + + // مدیریت وضعیت نمایش فرم (ورود یا ثبت نام) + var isLoginMode by remember { mutableStateOf(true) } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = if (isLoginMode) "ورود به حساب کاربری" else "ایجاد حساب جدید", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 32.dp) + ) + + AuthForm( + isLoginMode = isLoginMode, + isLoading = isLoading, + onAuthenticate = { username, password -> + val request = AuthRequest(username, password) + if (isLoginMode) { + viewModel.login(request) + } else { + viewModel.register(request) + } + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // دکمه تغییر وضعیت: رفتن به ثبت نام یا ورود + TextButton(onClick = { isLoginMode = !isLoginMode }, enabled = !isLoading) { + Text( + text = if (isLoginMode) "حساب کاربری ندارید؟ ثبت نام کنید." else "قبلاً ثبت نام کرده‌اید؟ وارد شوید." + ) + } + } +} + +@Composable +private fun AuthForm( + isLoginMode: Boolean, + isLoading: Boolean, + onAuthenticate: (String, String) -> Unit +) { + var username by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + val buttonText = if (isLoginMode) "ورود" else "ثبت نام" + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + // فیلد نام کاربری + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text("نام کاربری") }, + leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(0.8f), + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // فیلد رمز عبور + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("رمز عبور") }, + leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true, + modifier = Modifier.fillMaxWidth(0.8f), + enabled = !isLoading + ) + + Spacer(modifier = Modifier.height(24.dp)) + + // دکمه ورود/ثبت نام + Button( + onClick = { onAuthenticate(username, password) }, + modifier = Modifier.fillMaxWidth(0.8f).height(50.dp), + enabled = username.isNotBlank() && password.isNotBlank() && !isLoading + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 3.dp + ) + } else { + Text(buttonText, style = MaterialTheme.typography.titleMedium) + } + } + } +} \ 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 new file mode 100644 index 0000000..6462701 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonDetailScreen.kt @@ -0,0 +1,430 @@ +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.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.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.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.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.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.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 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.toPersianDigits +import com.oqba26.monthlypaymentapp.viewmodel.MonthStatus +import com.oqba26.monthlypaymentapp.viewmodel.PersonDetailViewModel +import kotlinx.coroutines.flow.collectLatest + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PersonDetailScreen( + onBackClick: () -> Unit +) { + 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() + + 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("") } + + var paymentAmount by remember { mutableStateOf("") } + var selectedMonthForPayment by remember { mutableIntStateOf(1) } + + val currentYear = getCurrentShamsiYear() + val currentMonth = getCurrentShamsiMonth() + val persianMonths = listOf("فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند") + + LaunchedEffect(key1 = true) { + viewModel.toastMessage.collectLatest { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(person?.name ?: "...") }, + navigationIcon = { IconButton(onClick = onBackClick) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding).padding(top = 16.dp)) { + YearNavigator( + modifier = Modifier.padding(horizontal = 16.dp), + viewModel = viewModel, + selectedYear = selectedYear, + paymentHistory = paymentHistory, + currentYear = currentYear, + showMessage = { msg -> + dialogMessage = msg + showMessageDialog = true + } + ) + Spacer(Modifier.height(20.dp)) + MonthsList( + paymentHistory = paymentHistory, + selectedYear = selectedYear, + currentYear = currentYear, + currentMonth = currentMonth, + persianMonths = persianMonths, + onAvailableClick = { month, _ -> + selectedMonthForPayment = month + paymentAmount = defaultPaymentAmount.toLong().toString() + showAddPaymentDialog = true + }, + onPaidClick = { payment -> + paymentToEdit = payment + paymentAmount = payment.amount.toLong().toString() + showEditPaymentDialog = true + }, + onDisabledClick = { message -> + dialogMessage = message + showMessageDialog = true + } + ) + } + } + + if (showAddPaymentDialog) { + AddPaymentDialog( + monthName = persianMonths[selectedMonthForPayment - 1], + year = selectedYear, + amount = paymentAmount, + onAmountChange = { paymentAmount = it.filter { c -> c.isDigit() } }, + onConfirm = { + val amount = paymentAmount.toDoubleOrNull() + if (amount != null && amount > 0) { + viewModel.addPaymentForMonth(selectedMonthForPayment, selectedYear, amount) + showAddPaymentDialog = false + } + }, + onDismiss = { showAddPaymentDialog = false } + ) + } + + paymentToEdit?.let { payment -> + if (showEditPaymentDialog) { + EditPaymentDialog( + amount = paymentAmount, + onAmountChange = { paymentAmount = it.filter { c -> c.isDigit() } }, + onSave = { + val amount = paymentAmount.toDoubleOrNull() + if (amount != null && amount > 0) { + viewModel.updatePayment(payment, amount) + showEditPaymentDialog = false + } + }, + onDelete = { + showEditPaymentDialog = false + showDeleteConfirmDialog = true + }, + onDismiss = { showEditPaymentDialog = false } + ) + } + + if (showDeleteConfirmDialog) { + DeleteConfirmDialog( + onConfirm = { + viewModel.deletePayment(payment) + showDeleteConfirmDialog = false + }, + onDismiss = { showDeleteConfirmDialog = false } + ) + } + } + + if (showMessageDialog) { + MessageDialog(message = dialogMessage) { showMessageDialog = false } + } +} + +@Composable +private fun YearNavigator( + modifier: Modifier = Modifier, + viewModel: PersonDetailViewModel, + selectedYear: Int, + paymentHistory: List, + currentYear: Int, + showMessage: (String) -> Unit +) { + Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { + 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") } + } + } +} + +@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 + } + + MonthListItem( + monthName = monthName, + payment = payment, + status = monthStatus, + onItemClick = { + when (monthStatus) { + MonthStatus.AVAILABLE -> onAvailableClick(monthNumber, selectedYear) + MonthStatus.PAID -> onPaidClick(payment!!) + else -> onDisabledClick(getMessageForStatus(monthStatus, monthName)) + } + } + ) + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + } + } +} + +@Composable +private fun MonthListItem( + monthName: String, + payment: PaymentRecord?, + status: MonthStatus, + onItemClick: () -> Unit +) { + val isClickable = status == MonthStatus.AVAILABLE || status == MonthStatus.PAID + val backgroundColor by animateColorAsState( + targetValue = if (status == MonthStatus.PAID) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.1f) else Color.Transparent, + animationSpec = tween(400), + label = "MonthListItemColor" + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = isClickable, onClick = onItemClick) + .background(backgroundColor) + .padding(horizontal = 16.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = monthName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + + AnimatedContent( + targetState = status, + transitionSpec = { fadeIn(tween(400)) togetherWith fadeOut(tween(400)) }, + label = "MonthListItemContent" + ) { targetStatus -> + when (targetStatus) { + MonthStatus.PAID -> { + payment?.let { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "${formatNumberAsPersian(it.amount)} تومان", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + Spacer(Modifier.width(8.dp)) + Icon(Icons.Filled.Check, contentDescription = "Paid", tint = MaterialTheme.colorScheme.primary) + } + } + } + MonthStatus.AVAILABLE -> { + Icon(Icons.Filled.Add, contentDescription = "Add Payment", tint = MaterialTheme.colorScheme.secondary) + } + else -> { + Icon(Icons.Filled.Close, contentDescription = "Disabled", tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)) + } + } + } + } +} + +private fun getMessageForStatus(status: MonthStatus, monthName: String): String { + return when (status) { + MonthStatus.FUTURE_YEAR -> "این سال هنوز نرسیده است." + MonthStatus.PAST_YEAR -> "شما در سال گذشته برای ماه $monthName پرداختی ثبت نکرده‌اید." + MonthStatus.FUTURE_MONTH -> "این ماه هنوز نرسیده است." + else -> "" + } +} + +@Composable +private fun AddPaymentDialog( + monthName: String, + year: Int, + amount: String, + onAmountChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("ثبت پرداخت برای $monthName ${year.toString().toPersianDigits()}") }, + text = { + OutlinedTextField( + value = amount, + onValueChange = onAmountChange, + label = { Text("مبلغ (تومان)") }, + visualTransformation = PersianNumberVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { Button(onClick = onConfirm) { Text("ثبت") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("لغو") } } + ) +} + +@Composable +private fun EditPaymentDialog( + amount: String, + onAmountChange: (String) -> Unit, + onSave: () -> Unit, + onDelete: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("ویرایش یا حذف پرداخت") }, + text = { + OutlinedTextField( + value = amount, + onValueChange = onAmountChange, + label = { Text("مبلغ جدید (تومان)") }, + visualTransformation = PersianNumberVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Button(onClick = onSave) { Text("ذخیره") } + Spacer(Modifier.width(8.dp)) + TextButton(onClick = onDismiss) { Text("لغو") } + } + }, + dismissButton = { + TextButton(onClick = onDelete, colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) { Text("حذف") } + } + ) +} + +@Composable +private fun DeleteConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("تایید حذف") }, + text = { Text("آیا از حذف این پرداخت مطمئن هستید؟ این عمل غیرقابل بازگشت است.") }, + confirmButton = { + Button(onClick = onConfirm, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("حذف کن") } + }, + dismissButton = { 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("باشه") } } + ) +} 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 new file mode 100644 index 0000000..3581e02 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/PersonScreen.kt @@ -0,0 +1,915 @@ +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 +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.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.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 +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 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.PersonScreenEvent +import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel +import com.oqba26.monthlypaymentapp.viewmodel.PersonViewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun PersonScreen( + onSettingsClick: () -> Unit, + //onExitApp: () -> Unit +) { + 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 dashboardData by viewModel.dashboardData.collectAsState() + val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0) + val isRefreshing by viewModel.isRefreshing.collectAsState() + + var showAddPersonDialog by remember { mutableStateOf(false) } + var personToDelete 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 = "خروج") + }*/ + } + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { showAddPersonDialog = true }) { + Icon(Icons.Filled.Add, contentDescription = "افزودن شخص") + } + } + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding).nestedScroll(pullToRefreshState.nestedScrollConnection)) { + Column { + DashboardCard(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 + ) + + 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 } + ) + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)) + + } + } + } + PullToRefreshContainer( + modifier = Modifier.align(Alignment.TopCenter), + state = pullToRefreshState, + ) + } + } + + if (showDetailsSheet) { + ModalBottomSheet( + onDismissRequest = { showDetailsSheet = false }, + sheetState = sheetState + ) { + selectedPerson?.let { + DetailsSheetContent( + person = it, + allPayments = allPayments, + defaultPaymentAmount = defaultPaymentAmount, + viewModel = viewModel + ) + } + } + } + + 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 + } + }) { 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("لغو") } + } + ) + } +} + +@Composable +fun QuickPayDialog( + personName: String, + amount: String, + onAmountChange: (String) -> Unit, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("پرداخت سریع برای $personName") }, + text = { + OutlinedTextField( + value = amount, + onValueChange = onAmountChange, + label = { Text("مبلغ (تومان)") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + visualTransformation = PersianNumberVisualTransformation(), + 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(), + 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) + ) { + 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 = 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("لغو") } + } + } + ) +} + +@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(), + suffix = { Text("تومان") } + ) + }, + 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("حذف") } + } + } + ) +} + +@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("باشه") } } + ) +} + +@Composable +fun DashboardCard(data: DashboardUiModel) { + val animatedProgress by animateFloatAsState( + targetValue = data.progress, + animationSpec = tween(durationMillis = 1000), + label = "ProgressAnimation" + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + elevation = CardDefaults.cardElevation(4.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "پرداختی های این ماه", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + "افراد باقیمانده: ${(data.totalCount - data.paidCount).toString().toPersianDigits()} نفر", + style = MaterialTheme.typography.bodyLarge + ) + } + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(MaterialTheme.shapes.medium) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "مجموع درآمد ماه", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + Text( + "${formatNumberAsPersian(data.totalIncome)} تومان", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } +} + +@Composable +fun PersonListItem( + person: PersonUiModel, + index: Int, + onPersonClick: () -> Unit, + onQuickPayClick: (PersonUiModel) -> Unit, + onDeleteClick: (PersonUiModel) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onPersonClick() } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + 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 + ) + } else { + IconButton(onClick = { onQuickPayClick(person) }) { + Icon( + Icons.Filled.AddCard, + contentDescription = "پرداخت سریع", + tint = MaterialTheme.colorScheme.secondary + ) + } + } + + IconButton(onClick = { onDeleteClick(person) }) { + Icon( + Icons.Filled.Delete, + contentDescription = "حذف", + tint = MaterialTheme.colorScheme.error + ) + } + } +} 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 new file mode 100644 index 0000000..717d94f --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/screens/SettingsScreen.kt @@ -0,0 +1,283 @@ +package com.oqba26.monthlypaymentapp.ui.screens + +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +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 +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.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +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 +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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.utils.PersianNumberVisualTransformation +import com.oqba26.monthlypaymentapp.utils.formatNumberAsPersian +import com.oqba26.monthlypaymentapp.viewmodel.SettingsViewModel +import kotlinx.coroutines.launch +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(onBackClick: () -> Unit) { + val context = LocalContext.current + val app = context.applicationContext as PaymentApplication + val viewModel: SettingsViewModel = viewModel(factory = app.settingsViewModelFactory) + val coroutineScope = rememberCoroutineScope() + + val defaultAmount by viewModel.defaultPaymentAmount.collectAsState() + val selectedFont by viewModel.selectedFont.collectAsState() + + var showRestoreConfirmDialog by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.toastMessage.collect { message -> + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } + + val backupLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/json"), + onResult = { uri -> + uri?.let { + coroutineScope.launch { + try { + val jsonString = viewModel.createBackupJsonSuspend() + context.contentResolver.openOutputStream(it)?.use { outputStream -> + OutputStreamWriter(outputStream).use { writer -> writer.write(jsonString) } + } + Toast.makeText(context, "پشتیبان‌گیری با موفقیت انجام شد", Toast.LENGTH_LONG).show() + } catch (_: Exception) { + Toast.makeText(context, "خطا در ایجاد فایل پشتیبان", Toast.LENGTH_LONG).show() + } + } + } + } + ) + + val restoreLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + onResult = { uri -> + uri?.let { + try { + context.contentResolver.openInputStream(it)?.use { inputStream -> + BufferedReader(InputStreamReader(inputStream)).use { reader -> + showRestoreConfirmDialog = reader.readText() + } + } + } catch (_: Exception) { + Toast.makeText(context, "خطا در خواندن فایل", Toast.LENGTH_LONG).show() + } + } + } + ) + + 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 -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(16.dp) + ) { + Text("تنظیمات عمومی", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp)) + + DefaultAmountSetting(defaultAmount, onSave = { viewModel.saveDefaultPaymentAmount(it) }) + + Spacer(modifier = Modifier.height(24.dp)) + + FontSelectionDropdown(selectedFont = selectedFont) { + viewModel.onFontSelected(it) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 24.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 fileName = "payment_backup_${simpleDateFormat.format(Date())}.json" + backupLauncher.launch(fileName) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("تهیه نسخه پشتیبان") + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = { restoreLauncher.launch(arrayOf("application/json")) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { + Text("بازیابی اطلاعات از فایل") + } + } + } + + showRestoreConfirmDialog?.let { + AlertDialog( + onDismissRequest = { showRestoreConfirmDialog = null }, + title = { Text("هشدار جدی!") }, + text = { Text("آیا مطمئن هستید؟ با بازیابی اطلاعات، تمام داده‌های فعلی برنامه (اشخاص و پرداخت‌ها) برای همیشه حذف شده و اطلاعات فایل پشتیبان جایگزین آن خواهد شد. این عمل غیرقابل بازگشت است.") }, + confirmButton = { + Button( + onClick = { + viewModel.restoreFromBackupJson(it) + showRestoreConfirmDialog = null + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) + ) { + Text("بله، بازیابی کن") + } + }, + dismissButton = { + TextButton(onClick = { showRestoreConfirmDialog = null }) { Text("لغو") } + } + ) + } +} + +@Composable +fun DefaultAmountSetting(defaultAmount: Double, onSave: (String) -> Unit) { + var isEditing by remember { mutableStateOf(false) } + 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)) + ) { + AnimatedContent( + targetState = isEditing, + label = "EditAmountAnimation", + modifier = Modifier.padding(16.dp) + ) { editing -> + if (editing) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + OutlinedTextField( + value = amountText, + onValueChange = { amountText = it.filter { c -> c.isDigit() } }, + modifier = Modifier.fillMaxWidth(), + label = { Text("مبلغ پیش فرض پرداخت (تومان)") }, + visualTransformation = PersianNumberVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = { isEditing = false }) { + Text("لغو") + } + Spacer(modifier = Modifier.width(8.dp)) + Button(onClick = { + onSave(amountText) + isEditing = false + }) { + Text("ذخیره") + } + } + } + } else { + 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) + } + IconButton(onClick = { isEditing = true }) { + Icon(Icons.Filled.Edit, contentDescription = "Edit Amount") + } + } + } + } + } +} + + +@Composable +fun FontSelectionDropdown(selectedFont: String, onFontSelected: (String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val fonts = listOf("Estedad", "Vazirmatn", "BYekan", "Sahel", "IranianSans") + + Box(modifier = Modifier.fillMaxWidth()) { + OutlinedButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) { + Text("فونت برنامه: $selectedFont") + Icon(Icons.Default.ArrowDropDown, contentDescription = "Select Font") + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxWidth() + ) { + fonts.forEach { fontName -> + DropdownMenuItem( + text = { Text(fontName) }, + onClick = { + onFontSelected(fontName) + expanded = false + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Color.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Color.kt new file mode 100644 index 0000000..7d75135 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Color.kt @@ -0,0 +1,41 @@ +package com.oqba26.monthlypaymentapp.ui.theme + +import androidx.compose.ui.graphics.Color + +// Primary Colors - آبی مدرن +val PrimaryBlue = Color(0xFF1976D2) +val PrimaryBlueLight = Color(0xFF42A5F5) +val PrimaryBlueDark = Color(0xFF0D47A1) + +// Secondary Colors - سبز تکمیلی +val SecondaryGreen = Color(0xFF388E3C) +val SecondaryGreenLight = Color(0xFF66BB6A) +val SecondaryGreenDark = Color(0xFF1B5E20) + +// Accent Colors +val AccentOrange = Color(0xFFFF9800) +val AccentRed = Color(0xFFE53935) + +// Neutral Colors +val Gray50 = Color(0xFFFAFAFA) +val Gray100 = Color(0xFFF5F5F5) +val Gray200 = Color(0xFFEEEEEE) +val Gray300 = Color(0xFFE0E0E0) +val Gray400 = Color(0xFFBDBDBD) +val Gray500 = Color(0xFF9E9E9E) +val Gray600 = Color(0xFF757575) +val Gray700 = Color(0xFF616161) +val Gray800 = Color(0xFF424242) +val Gray900 = Color(0xFF212121) + +// Surfaces +val SurfaceLight = Color(0xFFFFFBFE) +val SurfaceDark = Color(0xFF1C1B1F) +val BackgroundLight = Color(0xFFFFFBFE) +val BackgroundDark = Color(0xFF1C1B1F) + +// Legacy Material Colors (for dark theme) +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Theme.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Theme.kt new file mode 100644 index 0000000..7f8689d --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Theme.kt @@ -0,0 +1,145 @@ +package com.oqba26.monthlypaymentapp.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp +import androidx.core.view.WindowCompat +import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository + +private val DarkColorScheme = darkColorScheme( + primary = PrimaryBlueLight, + onPrimary = Color.White, + primaryContainer = PrimaryBlueDark, + onPrimaryContainer = Color.White, + secondary = SecondaryGreenLight, + onSecondary = Color.White, + secondaryContainer = SecondaryGreenDark, + onSecondaryContainer = Color.White, + tertiary = Pink80, + onTertiary = Gray900, + background = BackgroundDark, + onBackground = Color.White, + surface = SurfaceDark, + onSurface = Color.White, + surfaceVariant = Gray800, + onSurfaceVariant = Gray300, + outline = Gray600, + outlineVariant = Gray700, + error = AccentRed, + onError = Color.White +) + +private val LightColorScheme = lightColorScheme( + primary = PrimaryBlue, + onPrimary = Color.White, + primaryContainer = PrimaryBlueLight, + onPrimaryContainer = Color.White, + secondary = SecondaryGreen, + onSecondary = Color.White, + secondaryContainer = SecondaryGreenLight, + onSecondaryContainer = Color.White, + tertiary = Pink40, + onTertiary = Color.White, + background = BackgroundLight, + onBackground = Gray900, + surface = SurfaceLight, + onSurface = Gray900, + surfaceVariant = Gray100, + onSurfaceVariant = Gray700, + surfaceContainer = Gray50, + outline = Gray400, + outlineVariant = Gray300, + error = AccentRed, + onError = Color.White, + inverseSurface = Gray900, + inverseOnSurface = Color.White +) + +@Composable +fun MonthlyPaymentManagement2Theme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, // Dynamic color can clash with custom themes + content: @Composable () -> Unit +) { + // یک بار کانتکست UI و اپ را بگیریم + val uiContext = LocalContext.current + val appContext = uiContext.applicationContext + + // Repository را با appContext بساز تا لیک نده + val settingsRepository = remember(appContext) { SettingsRepository(appContext) } + val selectedFontName by settingsRepository + .selectedFontFlow + .collectAsState(initial = SettingsRepository.DEFAULT_FONT) + + val fontFamily = when (selectedFontName) { + "Vazirmatn" -> VazirmatnFontFamily + "BYekan" -> BYekanFontFamily + "Sahel" -> SahelFontFamily + "IranianSans" -> IranianSansFontFamily + else -> EstedadFontFamily + } + + // از uiContext بیرونی استفاده کن؛ دیگه context جدید تعریف نکن + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (darkTheme) dynamicDarkColorScheme(uiContext) + else dynamicLightColorScheme(uiContext) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + val activity = view.context as? Activity + if (activity != null) { + SideEffect { + val window = activity.window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view) + .isAppearanceLightStatusBars = false // متن سفید می‌شه + } + } + } + + val typography = Typography( + displayLarge = TextStyle(fontFamily = fontFamily, fontSize = 57.sp), + displayMedium = TextStyle(fontFamily = fontFamily, fontSize = 45.sp), + displaySmall = TextStyle(fontFamily = fontFamily, fontSize = 36.sp), + headlineLarge = TextStyle(fontFamily = fontFamily, fontSize = 32.sp), + headlineMedium = TextStyle(fontFamily = fontFamily, fontSize = 28.sp), + headlineSmall = TextStyle(fontFamily = fontFamily, fontSize = 24.sp), + titleLarge = TextStyle(fontFamily = fontFamily, fontSize = 22.sp), + titleMedium = TextStyle(fontFamily = fontFamily, fontSize = 16.sp), + titleSmall = TextStyle(fontFamily = fontFamily, fontSize = 14.sp), + bodyLarge = TextStyle(fontFamily = fontFamily, fontSize = 16.sp), + bodyMedium = TextStyle(fontFamily = fontFamily, fontSize = 14.sp), + bodySmall = TextStyle(fontFamily = fontFamily, fontSize = 12.sp), + labelLarge = TextStyle(fontFamily = fontFamily, fontSize = 14.sp), + labelMedium = TextStyle(fontFamily = fontFamily, fontSize = 12.sp), + labelSmall = TextStyle(fontFamily = fontFamily, fontSize = 11.sp) + ) + + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Type.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Type.kt new file mode 100644 index 0000000..6fc34c2 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Type.kt @@ -0,0 +1,17 @@ +package com.oqba26.monthlypaymentapp.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) +) \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Typography.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Typography.kt new file mode 100644 index 0000000..a65601d --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/ui/theme/Typography.kt @@ -0,0 +1,55 @@ +package com.oqba26.monthlypaymentapp.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.oqba26.monthlypaymentapp.R + +// Corrected Font Families based on available files + +val EstedadFontFamily = FontFamily( + Font(R.font.estedad_light, FontWeight.Light), + Font(R.font.estedad_regular, FontWeight.Normal), + Font(R.font.estedad_medium, FontWeight.Medium), + Font(R.font.estedad_bold, FontWeight.Bold), + Font(R.font.estedad_black, FontWeight.Black), +) + +val VazirmatnFontFamily = FontFamily( + Font(R.font.vazirmatn_thin, FontWeight.Thin), + Font(R.font.vazirmatn_light, FontWeight.Light), + Font(R.font.vazirmatn_regular, FontWeight.Normal), + Font(R.font.vazirmatn_medium, FontWeight.Medium), + Font(R.font.vazirmatn_bold, FontWeight.Bold), + Font(R.font.vazirmatn_black, FontWeight.Black), +) + +val BYekanFontFamily = FontFamily( + Font(R.font.byekan, FontWeight.Normal), + Font(R.font.byekan_bold, FontWeight.Bold) +) + +val SahelFontFamily = FontFamily( + // Only bold and black are available for Sahel + Font(R.font.sahel_bold, FontWeight.Bold), + Font(R.font.sahel_black, FontWeight.Black) +) + +val IranianSansFontFamily = FontFamily( + Font(R.font.iraniansans, FontWeight.Normal) +) + +// Default Typography - This is now defined in Theme.kt to be dynamic +// We can keep a default here if needed for previews or other purposes. +val AppTypography = Typography( + bodyLarge = TextStyle( + fontFamily = EstedadFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/DateUtils.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/DateUtils.kt new file mode 100644 index 0000000..3362ff5 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/DateUtils.kt @@ -0,0 +1,9 @@ +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 diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/Formatters.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/Formatters.kt new file mode 100644 index 0000000..0d08920 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/Formatters.kt @@ -0,0 +1,15 @@ +package com.oqba26.monthlypaymentapp.utils + +import java.text.NumberFormat +import java.util.Locale + +private val persianLocale = Locale("fa", "IR") +private val numberFormat = NumberFormat.getNumberInstance(persianLocale) + +/** + * Formats a number into a Persian string with thousands separators. + * Example: 200000.0 -> "۲۰۰٬۰۰۰" + */ +fun formatNumberAsPersian(number: Number): String { + return numberFormat.format(number.toLong()) +} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/PersianNumberVisualTransformation.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/PersianNumberVisualTransformation.kt new file mode 100644 index 0000000..b823d96 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/PersianNumberVisualTransformation.kt @@ -0,0 +1,48 @@ +package com.oqba26.monthlypaymentapp.utils + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import java.text.NumberFormat +import java.util.Locale + +class PersianNumberVisualTransformation : VisualTransformation { + + private val numberFormat = NumberFormat.getNumberInstance(Locale("fa", "IR")) + + override fun filter(text: AnnotatedString): TransformedText { + val originalText = text.text + if (originalText.isBlank()) { + return TransformedText(text, OffsetMapping.Identity) + } + + val number = originalText.toLongOrNull() ?: 0L + val formattedText = numberFormat.format(number) + + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + try { + val thousandsSeparators = formattedText.count { it == '٬' } + return (offset + thousandsSeparators).coerceIn(0, formattedText.length) + } catch (e: Exception) { + return formattedText.length + } + } + + override fun transformedToOriginal(offset: Int): Int { + try { + val separatorsBeforeCursor = formattedText.substring(0, offset).count { it == '٬' } + return (offset - separatorsBeforeCursor).coerceIn(0, originalText.length) + } catch (e: Exception) { + return originalText.length + } + } + } + + return TransformedText( + text = AnnotatedString(formattedText), + offsetMapping = offsetMapping + ) + } +} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/ShamsiUtils.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/ShamsiUtils.kt new file mode 100644 index 0000000..611f224 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/ShamsiUtils.kt @@ -0,0 +1,7 @@ +package com.oqba26.monthlypaymentapp.utils + +import saman.zamani.persiandate.PersianDate + +fun getCurrentShamsiYear(): Int = PersianDate().shYear + +fun getCurrentShamsiMonth(): Int = PersianDate().shMonth \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/utils/StringUtils.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/StringUtils.kt new file mode 100644 index 0000000..48e798c --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/utils/StringUtils.kt @@ -0,0 +1,25 @@ +package com.oqba26.monthlypaymentapp.utils + +// An extension function to convert any Int to a Persian-digit String. +fun Int.toPersianDigits(): String { + return this.toString().toPersianDigits() +} + +// An extension function to convert a String containing English digits to Persian digits. +fun String.toPersianDigits(): String { + return this.map { + when (it) { + '0' -> '۰' + '1' -> '۱' + '2' -> '۲' + '3' -> '۳' + '4' -> '۴' + '5' -> '۵' + '6' -> '۶' + '7' -> '۷' + '8' -> '۸' + '9' -> '۹' + else -> it + } + }.joinToString("") +} diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonDetailViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonDetailViewModel.kt new file mode 100644 index 0000000..1691bc0 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonDetailViewModel.kt @@ -0,0 +1,97 @@ +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 new file mode 100644 index 0000000..a0ad1d4 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModel.kt @@ -0,0 +1,228 @@ +package com.oqba26.monthlypaymentapp.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.oqba26.monthlypaymentapp.data.model.AuthRequest +import com.oqba26.monthlypaymentapp.data.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.getCurrentShamsiMonth +import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +sealed interface AuthState { + data object Loading : AuthState + data object Authenticated : AuthState + data object Unauthenticated : AuthState +} + +class PersonViewModel( + private val networkRepository: NetworkRepository, + private val settingsRepository: SettingsRepository +) : ViewModel() { + + private val _searchQuery = MutableStateFlow("") + val searchQuery = _searchQuery.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing = _isRefreshing.asStateFlow() + + private val _expandedPersonId = MutableStateFlow(null) + val expandedPersonId = _expandedPersonId.asStateFlow() + + private val _selectedYear = MutableStateFlow(getCurrentShamsiYear()) + val selectedYear = _selectedYear.asStateFlow() + + private val _dashboardData = MutableStateFlow(DashboardUiModel()) + val dashboardData = _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) + + @OptIn(ExperimentalCoroutinesApi::class) + val uiState: StateFlow = authState.flatMapLatest { auth -> + when (auth) { + is AuthState.Authenticated -> { + 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) + } + + updateDashboard(uiModels.size, paymentsThisMonth) + + val filtered = if (query.isBlank()) uiModels + else uiModels.filter { it.name.contains(query, ignoreCase = true) } + + PersonListUiState(persons = filtered, payments = allPayments) + } + } + else -> { + flowOf(PersonListUiState()) // Emit empty state if not authenticated + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), PersonListUiState()) + + init { + viewModelScope.launch { + // This will trigger a refresh whenever the user becomes authenticated. + authState.collectLatest { state -> + if (state is AuthState.Authenticated) { + networkRepository.refresh() + } + } + } + } + + // Auth Actions + fun login(request: AuthRequest) = viewModelScope.launch { + val response = networkRepository.login(request) + if (response != null && response.token.isNotBlank()) { + settingsRepository.saveAuthData(response.token, response.userId) + _toastMessage.emit("ورود موفقیت‌آمیز بود") + } else { + _toastMessage.emit("نام کاربری یا رمز عبور اشتباه است.") + } + } + + fun register(request: AuthRequest) = viewModelScope.launch { + val response = networkRepository.register(request) + if (response != null && response.token.isNotBlank()) { + settingsRepository.saveAuthData(response.token, response.userId) + _toastMessage.emit("ثبت نام و ورود موفقیت‌آمیز بود") + } 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 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 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 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 new file mode 100644 index 0000000..2d1488f --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/PersonViewModelFactory.kt @@ -0,0 +1,22 @@ +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/SettingsViewModel.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/SettingsViewModel.kt new file mode 100644 index 0000000..6fc7b65 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/SettingsViewModel.kt @@ -0,0 +1,69 @@ +package com.oqba26.monthlypaymentapp.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.oqba26.monthlypaymentapp.data.model.BackupData +import com.oqba26.monthlypaymentapp.data.repository.LocalPersonRepository +import com.oqba26.monthlypaymentapp.data.repository.SettingsRepository +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class SettingsViewModel( + private val settingsRepository: SettingsRepository, + private val localPersonRepository: LocalPersonRepository +) : ViewModel() { + + private val json = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true + // اگر JSON قدیمی بود و کلیدی نباشد، مقدار پیش‌فرض اعمال می‌شود + } + + val defaultPaymentAmount: StateFlow = settingsRepository.defaultPaymentAmountFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), SettingsRepository.FALLBACK_AMOUNT) + + val selectedFont: StateFlow = settingsRepository.selectedFontFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), SettingsRepository.DEFAULT_FONT) + + private val _toastMessage = MutableSharedFlow() + val toastMessage = _toastMessage.asSharedFlow() + + fun saveDefaultPaymentAmount(amount: String) { + viewModelScope.launch { + val amountAsDouble = amount.toDoubleOrNull() ?: return@launch + settingsRepository.saveDefaultPaymentAmount(amountAsDouble) + _toastMessage.emit("مبلغ پیش‌فرض ذخیره شد") + } + } + + fun onFontSelected(fontName: String) { + viewModelScope.launch { + settingsRepository.saveSelectedFont(fontName) + _toastMessage.emit("فونت برنامه به $fontName تغییر یافت. برای مشاهده تغییرات برنامه را مجدد اجرا کنید.") + } + } + + suspend fun createBackupJsonSuspend(): String { + val backupData = localPersonRepository.getDataForBackup() + return json.encodeToString(backupData) + } + + fun restoreFromBackupJson(jsonString: String) { + viewModelScope.launch { + try { + val backupData = json.decodeFromString(jsonString) + localPersonRepository.restoreBackup(backupData) + _toastMessage.emit("اطلاعات با موفقیت بازیابی شد! لطفاً برنامه را مجدد اجرا کنید.") + } catch (e: Exception) { + _toastMessage.emit("خطا در بازیابی اطلاعات: فایل نامعتبر است.") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt new file mode 100644 index 0000000..72776b3 --- /dev/null +++ b/app/src/main/java/com/oqba26/monthlypaymentapp/viewmodel/UiModels.kt @@ -0,0 +1,16 @@ +package com.oqba26.monthlypaymentapp.viewmodel + +data class PersonUiModel( + val id: String, + val name: String, + val hasPaidThisMonth: Boolean +) + +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/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/font/byekan.ttf b/app/src/main/res/font/byekan.ttf new file mode 100644 index 0000000..09dab87 Binary files /dev/null and b/app/src/main/res/font/byekan.ttf differ diff --git a/app/src/main/res/font/byekan_bold.ttf b/app/src/main/res/font/byekan_bold.ttf new file mode 100644 index 0000000..e8b212c Binary files /dev/null and b/app/src/main/res/font/byekan_bold.ttf differ diff --git a/app/src/main/res/font/estedad_black.ttf b/app/src/main/res/font/estedad_black.ttf new file mode 100644 index 0000000..578db1e Binary files /dev/null and b/app/src/main/res/font/estedad_black.ttf differ diff --git a/app/src/main/res/font/estedad_bold.ttf b/app/src/main/res/font/estedad_bold.ttf new file mode 100644 index 0000000..78cdf53 Binary files /dev/null and b/app/src/main/res/font/estedad_bold.ttf differ diff --git a/app/src/main/res/font/estedad_light.ttf b/app/src/main/res/font/estedad_light.ttf new file mode 100644 index 0000000..de75bf6 Binary files /dev/null and b/app/src/main/res/font/estedad_light.ttf differ diff --git a/app/src/main/res/font/estedad_medium.ttf b/app/src/main/res/font/estedad_medium.ttf new file mode 100644 index 0000000..e01fe43 Binary files /dev/null and b/app/src/main/res/font/estedad_medium.ttf differ diff --git a/app/src/main/res/font/estedad_regular.ttf b/app/src/main/res/font/estedad_regular.ttf new file mode 100644 index 0000000..33e904c Binary files /dev/null and b/app/src/main/res/font/estedad_regular.ttf differ diff --git a/app/src/main/res/font/iraniansans.ttf b/app/src/main/res/font/iraniansans.ttf new file mode 100644 index 0000000..f3812c0 Binary files /dev/null and b/app/src/main/res/font/iraniansans.ttf differ diff --git a/app/src/main/res/font/sahel_black.ttf b/app/src/main/res/font/sahel_black.ttf new file mode 100644 index 0000000..00f05dc Binary files /dev/null and b/app/src/main/res/font/sahel_black.ttf differ diff --git a/app/src/main/res/font/sahel_bold.ttf b/app/src/main/res/font/sahel_bold.ttf new file mode 100644 index 0000000..62dc3eb Binary files /dev/null and b/app/src/main/res/font/sahel_bold.ttf differ diff --git a/app/src/main/res/font/vazirmatn_black.ttf b/app/src/main/res/font/vazirmatn_black.ttf new file mode 100644 index 0000000..4b9bd66 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_black.ttf differ diff --git a/app/src/main/res/font/vazirmatn_bold.ttf b/app/src/main/res/font/vazirmatn_bold.ttf new file mode 100644 index 0000000..efa9b09 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_bold.ttf differ diff --git a/app/src/main/res/font/vazirmatn_light.ttf b/app/src/main/res/font/vazirmatn_light.ttf new file mode 100644 index 0000000..2dfd5c3 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_light.ttf differ diff --git a/app/src/main/res/font/vazirmatn_medium.ttf b/app/src/main/res/font/vazirmatn_medium.ttf new file mode 100644 index 0000000..1e08dd5 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_medium.ttf differ diff --git a/app/src/main/res/font/vazirmatn_regular.ttf b/app/src/main/res/font/vazirmatn_regular.ttf new file mode 100644 index 0000000..64e4a81 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_regular.ttf differ diff --git a/app/src/main/res/font/vazirmatn_thin.ttf b/app/src/main/res/font/vazirmatn_thin.ttf new file mode 100644 index 0000000..b7a7d23 Binary files /dev/null and b/app/src/main/res/font/vazirmatn_thin.ttf differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..9ec299c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + مدیریت حقوق ماهانه + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2a874f0 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +