Initial commit: First version of the payment management app

This commit is contained in:
oqba26 committed 2025-10-19 09:57:27 +03:30
commit 30ef5d636d
100 files changed
+4591

No files matched your search

+1
View File
@@ -0,0 +1 @@
/build
+115
View File
@@ -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)
}
+48
View File
@@ -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"
}
+21
View File
@@ -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
@@ -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)
}
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".core.PaymentApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.MonthlyPaymentApp"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.MonthlyPaymentApp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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))
}
}
@@ -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 <T : ViewModel> create(modelClass: Class<T>): 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 <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(SettingsViewModel::class.java)) {
return SettingsViewModel(settingsRepository, localPersonRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
}
@@ -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<List<PaymentRecord>>
@Query("SELECT * FROM payments WHERE personId = :personId ORDER BY timestamp DESC")
fun getPaymentsForPersonFlow(personId: String): Flow<List<PaymentRecord>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAllPaymentRecords(payments: List<PaymentRecord>)
@Query("DELETE FROM payments")
suspend fun deleteAllPaymentRecords()
}
@@ -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<List<Person>>
@Query("SELECT * FROM persons WHERE id = :id LIMIT 1")
fun getPersonByIdFlow(id: String): Flow<Person?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(persons: List<Person>)
@Query("DELETE FROM persons")
suspend fun deleteAll()
}
@@ -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
}
}
}
}
@@ -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
)
@@ -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
)
@@ -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
)
@@ -0,0 +1,7 @@
package com.oqba26.monthlypaymentapp.data.model
@kotlinx.serialization.Serializable
data class BackupData(
val persons: List<Person>,
val payments: List<PaymentRecord>
)
@@ -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()
)
@@ -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()
)
@@ -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)
}
}
@@ -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<Person>
@POST("persons")
suspend fun addPerson(@Body person: Person): Response<Unit>
// ⭐️ NEW: مسیر PUT برای ویرایش نام
@PUT("persons/{id}")
suspend fun updatePerson(@Path("id") personId: String, @Body person: Person): Response<Unit>
@DELETE("persons/{id}")
suspend fun deletePersonAndPayments(@Path("id") personId: String): Response<Unit>
// Payments
@GET("payments")
suspend fun getPayments(
@Query("personId") personId: String? = null
): List<PaymentRecord>
@POST("payments")
suspend fun addPayment(@Body paymentRecord: PaymentRecord): Response<Unit>
@DELETE("payments/{id}")
suspend fun deletePayment(@Path("id") paymentId: String): Response<Unit>
}
@@ -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())
}
}
@@ -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<List<Person>> = personDao.getAllPersonsFlow()
fun getAllPaymentsFlow(): Flow<List<PaymentRecord>> = paymentDao.getAllPaymentsFlow()
fun getPersonByIdFlow(personId: String): Flow<Person?> = personDao.getPersonByIdFlow(personId)
fun getPaymentsForPersonFlow(personId: String): Flow<List<PaymentRecord>> =
paymentDao.getPaymentsForPersonFlow(personId)
suspend fun syncPersons(persons: List<Person>) {
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<PaymentRecord>) =
paymentDao.insertAllPaymentRecords(payments)
// ⭐️ تابع جدید و اصلاح شده برای همگام‌سازی کامل
suspend fun syncAll(persons: List<Person>, payments: List<PaymentRecord>) {
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()
}
@@ -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<List<Person>>(emptyList())
private val _paymentsFlow = MutableStateFlow<List<PaymentRecord>>(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<List<Person>> = _personsFlow.asStateFlow()
// --- Payment Operations ---
suspend fun addPayment(paymentRecord: PaymentRecord): Boolean {
return try {
val resp = api.addPayment(paymentRecord)
if (resp.isSuccessful) refresh()
resp.isSuccessful
} 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<List<PaymentRecord>> = _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)
}
}
}
@@ -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<Preferences> 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<Double> = dataStore.data.map {
it[DEFAULT_PAYMENT_AMOUNT] ?: FALLBACK_AMOUNT
}
val selectedFontFlow: Flow<String> = 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<String?> = dataStore.data.map {
it[AUTH_TOKEN_KEY]
}
val userIdFlow: Flow<String?> = dataStore.data.map {
it[USER_ID_KEY]
}
/**
* توکن و ID کاربر را ذخیره می‌کند. برای پاک کردن، null ارسال کنید.
*/
suspend fun saveAuthData(token: String?, userId: String?) {
dataStore.edit { preferences ->
if (token != null) {
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)
}
}
}
}
@@ -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("تأیید")
}
}
}
}
}
}
@@ -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)
}
}
}
}
@@ -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<PaymentRecord?>(null) }
var showAddPaymentDialog by remember { mutableStateOf(false) }
var showEditPaymentDialog by remember { mutableStateOf(false) }
var showDeleteConfirmDialog by remember { mutableStateOf(false) }
var showMessageDialog by remember { mutableStateOf(false) }
var dialogMessage by remember { mutableStateOf("") }
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<PaymentRecord>,
currentYear: Int,
showMessage: (String) -> Unit
) {
Card(modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer)) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
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<PaymentRecord>,
selectedYear: Int,
currentYear: Int,
currentMonth: Int,
persianMonths: List<String>,
onAvailableClick: (month: Int, year: Int) -> Unit,
onPaidClick: (payment: PaymentRecord) -> Unit,
onDisabledClick: (message: String) -> Unit
) {
LazyColumn {
itemsIndexed(persianMonths, key = { index, _ -> index }) { index, monthName ->
val monthNumber = index + 1
val payment = paymentHistory.find { p -> p.shamsiMonth == monthNumber && p.shamsiYear == selectedYear }
val monthStatus = when {
payment != null -> MonthStatus.PAID
selectedYear > currentYear -> MonthStatus.FUTURE_YEAR
selectedYear < currentYear -> MonthStatus.PAST_YEAR
monthNumber > currentMonth -> MonthStatus.FUTURE_MONTH
else -> MonthStatus.AVAILABLE
}
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("باشه") } }
)
}
@@ -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<PersonUiModel?>(null) }
var showDetailsSheet by remember { mutableStateOf(false) }
var selectedPerson by remember { mutableStateOf<PersonUiModel?>(null) }
var personForQuickPay by remember { mutableStateOf<PersonUiModel?>(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<PaymentRecord>,
defaultPaymentAmount: Double,
viewModel: PersonViewModel
) {
val selectedYear by viewModel.selectedYear.collectAsState()
var paymentToEdit by remember { mutableStateOf<PaymentRecord?>(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<PaymentRecord>,
selectedYear: Int,
currentYear: Int,
currentMonth: Int,
persianMonths: List<String>,
onAvailableClick: (month: Int, year: Int) -> Unit,
onPaidClick: (payment: PaymentRecord) -> Unit,
onDisabledClick: (message: String) -> Unit
) {
LazyColumn(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
)
}
}
}
@@ -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<String?>(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
}
)
}
}
}
}
@@ -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)
@@ -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
)
}
@@ -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
)
)
@@ -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
)
)
@@ -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)
}
@@ -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())
}
@@ -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
)
}
}
@@ -0,0 +1,7 @@
package com.oqba26.monthlypaymentapp.utils
import saman.zamani.persiandate.PersianDate
fun getCurrentShamsiYear(): Int = PersianDate().shYear
fun getCurrentShamsiMonth(): Int = PersianDate().shMonth
@@ -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("")
}
@@ -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<String>("personId"))
// ⭐️ FIX: Fetch person from the network flow
val person: StateFlow<Person?> = networkRepository.getPersonsFlow()
.map { persons -> persons.find { it.id == personId } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
private val _selectedYear = kotlinx.coroutines.flow.MutableStateFlow(getCurrentShamsiYear())
val selectedYear = _selectedYear.asStateFlow()
// ⭐️ FIX: Fetch payment history from the correct network flow
val paymentHistory: StateFlow<List<PaymentRecord>> = networkRepository.getPaymentsFlow()
.map { payments -> payments.filter { it.personId == personId } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
private val _toastMessage = MutableSharedFlow<String>()
val toastMessage = _toastMessage.asSharedFlow()
val defaultPaymentAmount = settingsRepository.defaultPaymentAmountFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.0)
fun changeYear(offset: Int) { _selectedYear.update { it + offset } }
fun addPaymentForMonth(month: Int, year: Int, amount: Double) {
viewModelScope.launch {
val newPayment = PaymentRecord(
amount = amount,
personId = personId,
shamsiYear = year,
shamsiMonth = month
)
val ok = networkRepository.addPayment(newPayment)
if (!ok) _toastMessage.emit("ثبت پرداخت با خطا مواجه شد.")
}
}
fun updatePayment(payment: PaymentRecord, newAmount: Double) {
viewModelScope.launch {
val updatedPayment = payment.copy(amount = newAmount)
val ok = networkRepository.addPayment(updatedPayment)
if (!ok) _toastMessage.emit("به‌روزرسانی پرداخت با خطا مواجه شد.")
}
}
fun deletePayment(payment: PaymentRecord) {
viewModelScope.launch {
val ok = networkRepository.deletePayment(payment.id)
if (!ok) _toastMessage.emit("حذف پرداخت با خطا مواجه شد.")
}
}
companion object {
val Factory: ViewModelProvider.Factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val app = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]) as PaymentApplication
val saved = extras.createSavedStateHandle()
// ⭐️ FIX: Provide the correct dependencies
return PersonDetailViewModel(
saved,
app.networkRepository,
app.settingsRepository
) as T
}
}
}
}
@@ -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<String?>(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<String>()
val toastMessage = _toastMessage.asSharedFlow()
val authState: StateFlow<AuthState> = settingsRepository.authTokenFlow.map { token ->
if (token != null) AuthState.Authenticated else AuthState.Unauthenticated
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), AuthState.Loading)
@OptIn(ExperimentalCoroutinesApi::class)
val uiState: StateFlow<PersonListUiState> = 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<PaymentRecord>) {
val paidCount = paymentsThisMonth.map { it.personId }.distinct().size
val totalIncome = paymentsThisMonth.sumOf { it.amount }
val progress = if (totalPersonCount > 0) paidCount.toFloat() / totalPersonCount.toFloat() else 0f
_dashboardData.value = DashboardUiModel(paidCount, totalPersonCount, totalIncome, progress)
}
private fun createPaymentRecord(personId: String, month: Int, year: Int, amount: Double): PaymentRecord {
return PaymentRecord(personId = personId, amount = amount, shamsiYear = year, shamsiMonth = month)
}
}
data class PersonListUiState(
val persons: List<PersonUiModel> = emptyList(),
val payments: List<PaymentRecord> = emptyList()
)
sealed class PersonScreenEvent {
data object RefreshData : PersonScreenEvent()
data class AddPerson(val name: String) : PersonScreenEvent()
data class UpdatePerson(val personId: String, val name: String) : PersonScreenEvent()
data class DeletePerson(val personId: String) : PersonScreenEvent()
data class AddQuickPayment(val personId: String, val amount: Double) : PersonScreenEvent()
data class 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()
}
@@ -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 <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(PersonViewModel::class.java)) {
// ⭐️ FIX: Call the ViewModel with the correct arguments.
return PersonViewModel(networkRepository, settingsRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
@@ -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<Double> = settingsRepository.defaultPaymentAmountFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), SettingsRepository.FALLBACK_AMOUNT)
val selectedFont: StateFlow<String> = settingsRepository.selectedFontFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), SettingsRepository.DEFAULT_FONT)
private val _toastMessage = MutableSharedFlow<String>()
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<BackupData>(jsonString)
localPersonRepository.restoreBackup(backupData)
_toastMessage.emit("اطلاعات با موفقیت بازیابی شد! لطفاً برنامه را مجدد اجرا کنید.")
} catch (e: Exception) {
_toastMessage.emit("خطا در بازیابی اطلاعات: فایل نامعتبر است.")
}
}
}
}
@@ -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 }
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">مدیریت حقوق ماهانه</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.MonthlyPaymentApp" parent="Theme.Material3.DayNight.NoActionBar"/>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">167.235.136.65</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,17 @@
package com.oqba26.monthlypaymentapp
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}