Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcf4d02caf | ||
|
|
6166a2f249 | ||
|
|
6213bbf30f | ||
|
|
2c52c2c538 | ||
|
|
a7620302e9 | ||
|
|
af0e7a76de | ||
|
|
44fc513589 |
No files matched your search
@@ -46,3 +46,30 @@ jobs:
|
||||
files: app/build/outputs/apk/release/MonthlyPaymentApp-${{ github.ref_name }}.apk
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update update.json after release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG_NAME="${{ github.ref_name }}"
|
||||
VERSION_NAME="${TAG_NAME#v}"
|
||||
VERSION_CODE=$(grep 'versionCode' app/build.gradle.kts | head -n 1 | awk '{print $3}')
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
|
||||
cat <<EOF > update.json
|
||||
{
|
||||
"versionCode": ${VERSION_CODE},
|
||||
"versionName": "${VERSION_NAME}",
|
||||
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/${TAG_NAME}/MonthlyPaymentApp-${TAG_NAME}.apk",
|
||||
"releaseNotes": "بهبودهای پایداری و عملکرد همگامسازی",
|
||||
"isForceUpdate": false
|
||||
}
|
||||
EOF
|
||||
|
||||
git add update.json
|
||||
git commit -m "auto-update update.json for release ${TAG_NAME}" || true
|
||||
git push origin main
|
||||
@@ -30,8 +30,8 @@ android {
|
||||
applicationId = "com.oqba26.monthlypaymentapp"
|
||||
minSdk = 25
|
||||
targetSdk = 34
|
||||
versionCode = 8
|
||||
versionName = "1.0.7"
|
||||
versionCode = 13
|
||||
versionName = "1.1.2"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables { useSupportLibrary = true }
|
||||
}
|
||||
@@ -76,7 +76,13 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlinOptions { jvmTarget = "21" }
|
||||
kotlinOptions {
|
||||
jvmTarget = "21"
|
||||
freeCompilerArgs += listOf(
|
||||
"-opt-in=kotlinx.serialization.InternalSerializationApi",
|
||||
"-opt-in=kotlinx.serialization.ExperimentalSerializationApi"
|
||||
)
|
||||
}
|
||||
kotlin { jvmToolchain(21) }
|
||||
|
||||
packaging {
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Assessment
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Paid
|
||||
@@ -457,18 +458,34 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
|
||||
IconButton(onClick = { showConfirmMoveDialog = true }) {
|
||||
Icon(Icons.Default.Check, contentDescription = "Confirm Move", tint = Color.Green)
|
||||
}
|
||||
} else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) {
|
||||
FilledIconButton(
|
||||
onClick = { viewModel.onAddPersonClicked() },
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "افزودن شخص")
|
||||
} else if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Paid.route || currentRoute == Screen.Mosque.route) {
|
||||
if (currentRoute == Screen.Unpaid.route || currentRoute == Screen.Mosque.route) {
|
||||
FilledIconButton(
|
||||
onClick = { viewModel.onAddPersonClicked() },
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "افزودن شخص")
|
||||
}
|
||||
FilledIconButton(
|
||||
onClick = { contactViewModel.onBulkSmsClicked() },
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = Color.White
|
||||
),
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Message,
|
||||
contentDescription = "Bulk SMS",
|
||||
modifier = Modifier.graphicsLayer(scaleX = -1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
FilledIconButton(
|
||||
onClick = { contactViewModel.onBulkSmsClicked() },
|
||||
onClick = { viewModel.onShowFinancialReport() },
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = Color.White
|
||||
@@ -476,9 +493,8 @@ fun AuthenticatedContent(viewModel: PersonViewModel, contactViewModel: ContactVi
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Message,
|
||||
contentDescription = "Bulk SMS",
|
||||
modifier = Modifier.graphicsLayer(scaleX = -1f)
|
||||
imageVector = Icons.Default.Assessment,
|
||||
contentDescription = "گزارشات مالی"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:OptIn(InternalSerializationApi::class)
|
||||
|
||||
package com.oqba26.monthlypaymentapp.data.remote
|
||||
|
||||
import android.util.Log
|
||||
@@ -5,6 +7,7 @@ import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.timeout
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
@@ -25,6 +28,7 @@ import io.ktor.utils.io.readUTF8Line
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.serialization.InternalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -347,6 +351,10 @@ class PocketBaseClient(
|
||||
authToken?.let { token ->
|
||||
header(HttpHeaders.Authorization, "Bearer $token")
|
||||
}
|
||||
timeout {
|
||||
requestTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
|
||||
socketTimeoutMillis = HttpTimeout.INFINITE_TIMEOUT_MS
|
||||
}
|
||||
}.execute { response ->
|
||||
val channel: ByteReadChannel = response.bodyAsChannel()
|
||||
var currentEvent = ""
|
||||
@@ -362,6 +370,7 @@ class PocketBaseClient(
|
||||
val connectObj = json.decodeFromString<PocketBaseConnectEvent>(dataJson)
|
||||
subscribeRealtime(cleanBaseUrl, connectObj.clientId, subscriptions)
|
||||
Log.d(TAG, "اتصال ریلتایم برقرار شد. ClientId: ${connectObj.clientId}")
|
||||
emit("PB_CONNECT")
|
||||
} else if (currentEvent.isNotEmpty()) {
|
||||
emit(currentEvent)
|
||||
}
|
||||
|
||||
+15
-37
@@ -14,9 +14,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class NetworkRepository(
|
||||
private val settingsRepository: SettingsRepository? = null
|
||||
settingsRepository: SettingsRepository? = null
|
||||
) {
|
||||
|
||||
private val pocketBase = ApiClient.pocketBase
|
||||
@@ -43,13 +44,16 @@ class NetworkRepository(
|
||||
// --- Realtime Operations ---
|
||||
fun observeRealtimeChanges() {
|
||||
repositoryScope.launch {
|
||||
try {
|
||||
pocketBase.listenRealtime(listOf("persons", "payments")).collect { event ->
|
||||
Log.d("PocketBase-Realtime", "Change detected in PocketBase: $event")
|
||||
refresh()
|
||||
while (true) {
|
||||
try {
|
||||
pocketBase.listenRealtime(listOf("persons", "payments")).collect { event ->
|
||||
Log.d("PocketBase-Realtime", "Change detected in PocketBase: $event")
|
||||
refresh()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("PocketBase-Realtime", "Realtime connection error, retrying...", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("PocketBase-Realtime", "Realtime connection error, retrying...", e)
|
||||
delay(3000.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,31 +156,7 @@ class NetworkRepository(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePersonArchivedStatus(personId: String, isArchived: Boolean): Boolean {
|
||||
return try {
|
||||
val currentPerson = _personsFlow.value.find { it.id == personId }
|
||||
if (currentPerson != null) {
|
||||
pocketBase.upsertRecord("persons", "id='$personId'", currentPerson.copy(isArchived = isArchived))
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("PocketBase", "updateArchived error", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePersonDisplayOrder(personId: String, displayOrder: Long): Boolean {
|
||||
return try {
|
||||
val currentPerson = _personsFlow.value.find { it.id == personId }
|
||||
if (currentPerson != null) {
|
||||
pocketBase.upsertRecord("persons", "id='$personId'", currentPerson.copy(displayOrder = displayOrder))
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e("PocketBase", "updateOrder error", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun getPersonsFlow(): Flow<List<Person>> = _personsFlow.asStateFlow()
|
||||
|
||||
@@ -192,19 +172,17 @@ class NetworkRepository(
|
||||
}
|
||||
|
||||
suspend fun deletePayment(paymentId: String): Boolean {
|
||||
var retries = 0
|
||||
val maxRetries = 2
|
||||
|
||||
while (retries <= maxRetries) {
|
||||
for (retries in 1..maxRetries) {
|
||||
try {
|
||||
pocketBase.deleteRecordByFilter("payments", "id='$paymentId'")
|
||||
Log.d("PocketBase", "deletePayment Success: $paymentId")
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
retries++
|
||||
Log.e("PocketBase", "deletePayment error (Attempt $retries): ${e.message}", e)
|
||||
if (retries > maxRetries) return false
|
||||
delay(1000L * retries)
|
||||
if (retries < maxRetries) {
|
||||
delay((1000L * retries).milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
@@ -26,6 +27,8 @@ class SettingsRepository(context: Context) {
|
||||
val REMINDER_DAY = stringPreferencesKey("reminder_day")
|
||||
val CARD_NUMBERS = stringPreferencesKey("card_numbers")
|
||||
val IGNORED_CONTACT_SUGGESTIONS = stringPreferencesKey("ignored_contact_suggestions")
|
||||
val IS_OPERATOR_MODE = booleanPreferencesKey("is_operator_mode")
|
||||
val ADMIN_PIN = stringPreferencesKey("admin_pin")
|
||||
|
||||
/** آخرین versionCodeی که با آن اجرا شده — مبنای تشخیص ارتقای نسخه برای گرفتن snapshot. */
|
||||
val LAST_RUN_VERSION_CODE = intPreferencesKey("last_run_version_code")
|
||||
@@ -116,4 +119,20 @@ class SettingsRepository(context: Context) {
|
||||
preferences[IGNORED_CONTACT_SUGGESTIONS] = set.joinToString(",")
|
||||
}
|
||||
}
|
||||
|
||||
val isOperatorModeFlow: Flow<Boolean> = dataStore.data.map {
|
||||
it[IS_OPERATOR_MODE] ?: false
|
||||
}
|
||||
|
||||
val adminPinFlow: Flow<String> = dataStore.data.map {
|
||||
it[ADMIN_PIN] ?: "1234"
|
||||
}
|
||||
|
||||
suspend fun setOperatorMode(isOperator: Boolean) {
|
||||
dataStore.edit { it[IS_OPERATOR_MODE] = isOperator }
|
||||
}
|
||||
|
||||
suspend fun setAdminPin(pin: String) {
|
||||
dataStore.edit { it[ADMIN_PIN] = pin }
|
||||
}
|
||||
}
|
||||
@@ -124,18 +124,20 @@ fun PersonDetailScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
val isMosque = detailState.person?.category == "mosque"
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(bottom = 8.dp)) {
|
||||
items(detailState.monthStates) { monthModel ->
|
||||
MonthListItem(
|
||||
month = monthModel,
|
||||
startMonth = detailState.person?.startMonth ?: 1,
|
||||
isMosqueCategory = isMosque,
|
||||
onHeaderClick = {
|
||||
expandedMonth = if (expandedMonth == monthModel.month) null else monthModel.month
|
||||
},
|
||||
onEditClick = {
|
||||
if (monthModel.status == MonthStatus.PAID) {
|
||||
editingPayment = monthModel.payment
|
||||
} else if (monthModel.status == MonthStatus.AVAILABLE) {
|
||||
} else {
|
||||
addingPaymentMonth = monthModel.month
|
||||
}
|
||||
}
|
||||
@@ -170,8 +172,11 @@ fun PersonDetailScreen(
|
||||
val monthName = getPersianMonthName(month)
|
||||
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
|
||||
|
||||
val isMosque = detailState.person?.category == "mosque"
|
||||
val isCurrentMonth = (month == getCurrentShamsiMonth()) && (detailState.selectedYear == getCurrentShamsiYear())
|
||||
val initialDesc = if (isCurrentMonth) {
|
||||
val initialDesc = if (isMosque) {
|
||||
"پرداخت کمک به مسجد در تاریخ $currentDateTime ثبت شد."
|
||||
} else if (isCurrentMonth) {
|
||||
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
|
||||
} else {
|
||||
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
|
||||
@@ -600,6 +605,7 @@ fun YearSelector(year: Int, onYearChange: (Int) -> Unit) {
|
||||
fun MonthListItem(
|
||||
month: MonthUiModel,
|
||||
startMonth: Int,
|
||||
isMosqueCategory: Boolean = false,
|
||||
onHeaderClick: () -> Unit,
|
||||
onEditClick: () -> Unit
|
||||
) {
|
||||
@@ -607,7 +613,7 @@ fun MonthListItem(
|
||||
val containerColor = when (month.status) {
|
||||
MonthStatus.PAID -> MaterialTheme.colorScheme.primaryContainer
|
||||
MonthStatus.AVAILABLE -> MaterialTheme.colorScheme.surface
|
||||
MonthStatus.NOT_COMMITTED -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
MonthStatus.NOT_COMMITTED -> if (isMosqueCategory) MaterialTheme.colorScheme.surface else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
|
||||
@@ -616,19 +622,27 @@ fun MonthListItem(
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
if (month.status == MonthStatus.NOT_COMMITTED) {
|
||||
val monthName = getPersianMonthName(startMonth)
|
||||
Toast.makeText(
|
||||
context,
|
||||
"ماههای قبل از $monthName ماه جزو تعهدات این فرد نیست",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
if (month.status == MonthStatus.PAID) {
|
||||
onHeaderClick()
|
||||
} else if (month.status == MonthStatus.AVAILABLE) {
|
||||
onEditClick()
|
||||
} else if (month.status == MonthStatus.NOT_COMMITTED) {
|
||||
if (isMosqueCategory) {
|
||||
onEditClick()
|
||||
} else {
|
||||
val monthName = getPersianMonthName(startMonth)
|
||||
Toast.makeText(
|
||||
context,
|
||||
"ماههای قبل از $monthName ماه جزو تعهدات این فرد نیست",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} else {
|
||||
onHeaderClick()
|
||||
}
|
||||
},
|
||||
colors = CardDefaults.cardColors(containerColor = containerColor),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = if (month.status == MonthStatus.NOT_COMMITTED) 0.dp else 1.dp)
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = if (month.status == MonthStatus.NOT_COMMITTED && !isMosqueCategory) 0.dp else 1.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
Row(
|
||||
@@ -641,7 +655,7 @@ fun MonthListItem(
|
||||
text = getPersianMonthName(month.month),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (month.status == MonthStatus.NOT_COMMITTED) Color.Gray else Color.Unspecified
|
||||
color = if (month.status == MonthStatus.NOT_COMMITTED && !isMosqueCategory) Color.Gray else Color.Unspecified
|
||||
)
|
||||
if ((month.status == MonthStatus.PAID) && (month.payment != null)) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Message
|
||||
import androidx.compose.material.icons.filled.Archive
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.Payments
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
@@ -92,6 +93,7 @@ import com.oqba26.monthlypaymentapp.utils.toPersianDigits
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.ContactMatch
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.ContactSuggestion
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.DashboardUiModel
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.FinancialReportData
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonListType
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonScreenEvent
|
||||
import com.oqba26.monthlypaymentapp.viewmodel.PersonUiModel
|
||||
@@ -112,6 +114,8 @@ fun PersonScreen(
|
||||
val searchQuery by viewModel.searchQuery.collectAsState()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val dashboardData by viewModel.dashboardData.collectAsState()
|
||||
val financialReportData by viewModel.financialReportData.collectAsState()
|
||||
val showFinancialReportDialog by viewModel.showFinancialReportDialog.collectAsState()
|
||||
val defaultPaymentAmount by viewModel.defaultPaymentAmountFlow.collectAsState(initial = 0.0)
|
||||
val cardNumbers by viewModel.cardNumbersFlow.collectAsState(initial = emptyList())
|
||||
val personForPaymentDialog by viewModel.personForPaymentDialog.collectAsState()
|
||||
@@ -179,7 +183,11 @@ fun PersonScreen(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 2.dp, bottom = 0.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
DashboardCard(data = dashboardData)
|
||||
DashboardCard(
|
||||
data = dashboardData,
|
||||
financialReportData = financialReportData,
|
||||
isMosqueCategory = (currentCategory == "mosque")
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
@@ -301,15 +309,19 @@ fun PersonScreen(
|
||||
|
||||
// Dialogs
|
||||
personForPaymentDialog?.let { person ->
|
||||
val currentCategory by viewModel.currentCategory.collectAsState()
|
||||
val isMosque = currentCategory == "mosque"
|
||||
val currentDay = getCurrentShamsiDay()
|
||||
val currentMonth = getCurrentShamsiMonth()
|
||||
val targetMonth = if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1
|
||||
val targetMonth = if (isMosque) currentMonth else (if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1)
|
||||
|
||||
val monthName = getPersianMonthName(targetMonth)
|
||||
val currentDateTime = formatTimestampToPersianDateTime(System.currentTimeMillis())
|
||||
|
||||
val isCurrentMonth = targetMonth == currentMonth
|
||||
val initialDesc = if (isCurrentMonth) {
|
||||
val initialDesc = if (isMosque) {
|
||||
"پرداخت کمک به مسجد در تاریخ $currentDateTime ثبت شد."
|
||||
} else if (isCurrentMonth) {
|
||||
"پرداخت برای ماه جاری در تاریخ $currentDateTime ثبت شد."
|
||||
} else {
|
||||
"پرداخت برای $monthName در تاریخ $currentDateTime ثبت شد."
|
||||
@@ -405,6 +417,13 @@ fun PersonScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showFinancialReportDialog) {
|
||||
FinancialReportDialog(
|
||||
data = financialReportData,
|
||||
onDismiss = { viewModel.onDismissFinancialReport() }
|
||||
)
|
||||
}
|
||||
|
||||
if (contactState.currentBulkIndex != -1) {
|
||||
val person = contactState.bulkSmsQueue.getOrNull(contactState.currentBulkIndex)
|
||||
if (person != null) {
|
||||
@@ -560,7 +579,7 @@ fun AddNewPersonDialog(
|
||||
val currentShamsiYear = getCurrentShamsiYear()
|
||||
val currentShamsiMonth = getCurrentShamsiMonth()
|
||||
|
||||
var startMonth by remember { mutableIntStateOf(currentShamsiMonth) }
|
||||
var startMonth by remember { mutableIntStateOf(if (isMosqueCategory) currentShamsiMonth else 1) }
|
||||
var startYear by remember { mutableIntStateOf(currentShamsiYear) }
|
||||
|
||||
var similarContacts by remember { mutableStateOf<List<ContactMatch>>(emptyList()) }
|
||||
@@ -1048,7 +1067,9 @@ fun SelectCardDialog(
|
||||
|
||||
@Composable
|
||||
fun DashboardCard(
|
||||
data: DashboardUiModel
|
||||
data: DashboardUiModel,
|
||||
financialReportData: FinancialReportData,
|
||||
isMosqueCategory: Boolean = false
|
||||
) {
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = data.progress,
|
||||
@@ -1063,29 +1084,230 @@ fun DashboardCard(
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("وضعیت پرداختها", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Text("وضعیت پرداختهای ماه جاری", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Text("باقیمانده: ${(data.totalCount - data.paidCount).toString().toPersianDigits()}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.fillMaxWidth().height(8.dp).clip(MaterialTheme.shapes.medium)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
)
|
||||
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)
|
||||
Text(
|
||||
text = if (isMosqueCategory) "کمکهای جمعآوری شده ماه جاری" else "حقوق جمعآوری شده ماه جاری",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text("${formatNumberAsPersian(data.totalIncome)} تومان", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
if (financialReportData.totalOverdueDebt > 0 || financialReportData.debtors.isNotEmpty()) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 2.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "🔴 مجموع بدهیهای معوقه:",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = "${formatNumberAsPersian(financialReportData.totalOverdueDebt)} تومان",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FinancialReportDialog(
|
||||
data: FinancialReportData,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 650.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "📊 گزارش آمار و درآمدهای مالی",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, contentDescription = "بستن")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("💰 کل درآمدهای تمام زمانها:", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text("${formatNumberAsPersian(data.totalAllTimeIncome)} تومان", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("🔴 کل بدهیهای معوقه فعلی:", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.error)
|
||||
Text("${formatNumberAsPersian(data.totalOverdueDebt)} تومان (${data.totalDebtorsCount.toString().toPersianDigits()} نفر)", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.debtors.isNotEmpty()) {
|
||||
item {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "لیست بدهکاران (${data.totalDebtorsCount.toString().toPersianDigits()} نفر):",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
val debtorNamesText = data.debtors.joinToString("، ") {
|
||||
"${it.personName} (${formatNumberAsPersian(it.totalDebtAmount)})"
|
||||
}
|
||||
Text(
|
||||
text = debtorNamesText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Column {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
Text(
|
||||
text = "تفکیک درآمد ماهانه ماههای گذشته",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (data.monthlyReports.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("هیچ دریافتی تا کنون ثبت نشده است.", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(data.monthlyReports) { report ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "${report.monthName} ${report.year.toString().toPersianDigits()}",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = "${formatNumberAsPersian(report.totalAmount)} تومان (${report.paymentCount.toString().toPersianDigits()} پرداخت)",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (report.payerNames.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "پرداختکنندگان: ${report.payerNames.joinToString("، ")}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1157,12 +1379,22 @@ fun PersonListItem(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.clickable { onDebtClick(person) }
|
||||
) {
|
||||
Text(
|
||||
text = "${person.debtCount.toString().toPersianDigits()} بدهی",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${formatNumberAsPersian(person.totalDebtAmount)} تومان",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "${person.debtCount.toString().toPersianDigits()} ماه بدهکار",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f),
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,23 +4,36 @@ import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.fillMaxHeight
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.AdminPanelSettings
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
@@ -33,6 +46,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
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
|
||||
@@ -131,16 +145,18 @@ fun SettingsScreen(onLogout: () -> Unit) {
|
||||
}
|
||||
)
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
DefaultAmountSetting(
|
||||
@@ -148,7 +164,15 @@ fun SettingsScreen(onLogout: () -> Unit) {
|
||||
onSave = { viewModel.saveDefaultPaymentAmount(it) }
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
val isOperatorMode by viewModel.isOperatorMode.collectAsState()
|
||||
val adminPin by viewModel.adminPin.collectAsState()
|
||||
|
||||
OperatorModeSetting(
|
||||
isOperatorMode = isOperatorMode,
|
||||
adminPin = adminPin,
|
||||
onToggleMode = { viewModel.setOperatorMode(it) },
|
||||
onChangePin = { viewModel.setAdminPin(it) }
|
||||
)
|
||||
|
||||
val reminderDay by viewModel.reminderDay.collectAsState()
|
||||
ReminderDaySetting(
|
||||
@@ -156,74 +180,74 @@ fun SettingsScreen(onLogout: () -> Unit) {
|
||||
onSave = { viewModel.saveReminderDay(it) }
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
val cardNumbers by viewModel.cardNumbers.collectAsState()
|
||||
CardNumbersSetting(
|
||||
cardNumbers = cardNumbers,
|
||||
isOperatorMode = isOperatorMode,
|
||||
onAdd = { viewModel.addCardNumber(it) },
|
||||
onRemove = { viewModel.removeCardNumber(it) }
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
FontSelectionDropdown(
|
||||
selectedFont = selectedFont,
|
||||
onFontSelected = { viewModel.onFontSelected(it) }
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||
if (!isOperatorMode) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
||||
|
||||
Text(
|
||||
"پشتیبانگیری و بازیابی",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Text(
|
||||
"پشتیبانگیری و بازیابی",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(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.weight(1f)
|
||||
) {
|
||||
Text("تهیه پشتیبان", fontSize = 13.sp)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { restoreLauncher.launch(arrayOf("application/json")) },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
) {
|
||||
Text("بازیابی فایل", fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// تور نجات: نسخههایی که خودِ برنامه قبل از عملیات پرخطر گرفته است.
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val simpleDateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault())
|
||||
val fileName = "payment_backup_${simpleDateFormat.format(Date())}.json"
|
||||
backupLauncher.launch(fileName)
|
||||
viewModel.loadSnapshots()
|
||||
showSnapshotDialog = true
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("تهیه پشتیبان", fontSize = 13.sp)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { restoreLauncher.launch(arrayOf("application/json")) },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
) {
|
||||
Text("بازیابی فایل", fontSize = 13.sp)
|
||||
Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
// تور نجات: نسخههایی که خودِ برنامه قبل از عملیات پرخطر گرفته است.
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
viewModel.loadSnapshots()
|
||||
showSnapshotDialog = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.auto_backup_button), fontSize = 13.sp)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Button(
|
||||
onClick = onLogout,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp)
|
||||
contentPadding = PaddingValues(vertical = 12.dp)
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.logout))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (showSnapshotDialog) {
|
||||
@@ -505,10 +529,17 @@ fun ReminderDaySetting(currentDay: Int?, onSave: (String) -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRemove: (String) -> Unit) {
|
||||
fun CardNumbersSetting(
|
||||
cardNumbers: List<String>,
|
||||
isOperatorMode: Boolean = false,
|
||||
onAdd: (String) -> Unit,
|
||||
onRemove: (String) -> Unit
|
||||
) {
|
||||
var isAdding by remember { mutableStateOf(false) }
|
||||
var newCardText by remember { mutableStateOf("") }
|
||||
var cardToRemove by remember { mutableStateOf<String?>(null) }
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -517,9 +548,11 @@ fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRem
|
||||
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("شماره کارتهای بانکی", style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
IconButton(onClick = { isAdding = true }) { Icon(Icons.Default.Add, null) }
|
||||
if (!isOperatorMode) {
|
||||
IconButton(onClick = { isAdding = true }) { Icon(Icons.Default.Add, null) }
|
||||
}
|
||||
}
|
||||
if (isAdding) {
|
||||
if (isAdding && !isOperatorMode) {
|
||||
OutlinedTextField(
|
||||
value = newCardText,
|
||||
onValueChange = { newCardText = it.filter { c -> c.isDigit() } },
|
||||
@@ -534,9 +567,33 @@ fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRem
|
||||
}
|
||||
}
|
||||
cardNumbers.forEach { card ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(card.toPersianDigits(), modifier = Modifier.weight(1f), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge)
|
||||
IconButton(onClick = { cardToRemove = card }) { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
clipboardManager.setText(AnnotatedString(card))
|
||||
Toast.makeText(context, "شماره کارت کپی شد", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
card.toPersianDigits(),
|
||||
modifier = Modifier.weight(1f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
IconButton(onClick = {
|
||||
clipboardManager.setText(AnnotatedString(card))
|
||||
Toast.makeText(context, "شماره کارت کپی شد", Toast.LENGTH_SHORT).show()
|
||||
}) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "کپی شماره کارت")
|
||||
}
|
||||
if (!isOperatorMode) {
|
||||
IconButton(onClick = { cardToRemove = card }) {
|
||||
Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -555,6 +612,195 @@ fun CardNumbersSetting(cardNumbers: List<String>, onAdd: (String) -> Unit, onRem
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OperatorModeSetting(
|
||||
isOperatorMode: Boolean,
|
||||
adminPin: String,
|
||||
onToggleMode: (Boolean) -> Unit,
|
||||
onChangePin: (String) -> Unit
|
||||
) {
|
||||
var showPinDialog by remember { mutableStateOf(false) }
|
||||
var pinAction by remember { mutableStateOf<PinAction?>(null) }
|
||||
var enteredPin by remember { mutableStateOf("") }
|
||||
var newPinInput by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf("") }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isOperatorMode) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (isOperatorMode) Icons.Default.Lock else Icons.Default.AdminPanelSettings,
|
||||
contentDescription = null,
|
||||
tint = if (isOperatorMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "حالت دسترسی برنامه",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
color = if (isOperatorMode) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isOperatorMode) "اپراتور (محدود)" else "مدیریت (کامل)",
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isOperatorMode) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(thickness = 0.5.dp)
|
||||
|
||||
if (isOperatorMode) {
|
||||
Button(
|
||||
onClick = {
|
||||
pinAction = PinAction.UNLOCK_ADMIN
|
||||
enteredPin = ""
|
||||
errorMessage = ""
|
||||
showPinDialog = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
|
||||
) {
|
||||
Icon(Icons.Default.LockOpen, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("خروج از حالت اپراتور (ورود رمز مدیر)", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = { onToggleMode(true) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Lock, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("فعالسازی حالت اپراتور (محدودسازی)", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
pinAction = PinAction.CHANGE_PIN
|
||||
enteredPin = ""
|
||||
newPinInput = ""
|
||||
errorMessage = ""
|
||||
showPinDialog = true
|
||||
}
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Edit,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "تغییر رمز عبور مدیریت",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showPinDialog) {
|
||||
Dialog(onDismissRequest = { showPinDialog = false }) {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(16.dp), shape = RoundedCornerShape(16.dp)) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
if (pinAction == PinAction.UNLOCK_ADMIN) "ورود رمز مدیریت" else "تغییر رمز مدیریت",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
if (pinAction == PinAction.UNLOCK_ADMIN || pinAction == PinAction.CHANGE_PIN) {
|
||||
OutlinedTextField(
|
||||
value = enteredPin,
|
||||
onValueChange = { enteredPin = it.filter { c -> c.isDigit() } },
|
||||
label = { Text(if (pinAction == PinAction.CHANGE_PIN) "رمز فعلی" else "رمز عبور مدیر") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
if (pinAction == PinAction.CHANGE_PIN) {
|
||||
OutlinedTextField(
|
||||
value = newPinInput,
|
||||
onValueChange = { newPinInput = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("رمز جدید") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
if (errorMessage.isNotBlank()) {
|
||||
Text(errorMessage, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
|
||||
TextButton(onClick = { showPinDialog = false }) { Text("لغو") }
|
||||
Button(onClick = {
|
||||
if (pinAction == PinAction.UNLOCK_ADMIN) {
|
||||
if (enteredPin == adminPin) {
|
||||
onToggleMode(false)
|
||||
showPinDialog = false
|
||||
} else {
|
||||
errorMessage = "رمز عبور اشتباه است"
|
||||
}
|
||||
} else if (pinAction == PinAction.CHANGE_PIN) {
|
||||
if (enteredPin != adminPin) {
|
||||
errorMessage = "رمز فعلی اشتباه است"
|
||||
} else if (newPinInput.length < 4) {
|
||||
errorMessage = "رمز جدید باید حداقل ۴ رقم باشد"
|
||||
} else {
|
||||
onChangePin(newPinInput)
|
||||
showPinDialog = false
|
||||
}
|
||||
}
|
||||
}) { Text("تأیید") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class PinAction { UNLOCK_ADMIN, CHANGE_PIN }
|
||||
|
||||
@Composable
|
||||
fun FontSelectionDropdown(selectedFont: String, onFontSelected: (String) -> Unit) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.oqba26.monthlypaymentapp.utils.getCurrentShamsiYear
|
||||
import com.oqba26.monthlypaymentapp.utils.getPersianMonthName
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -68,9 +69,18 @@ class PersonViewModel @Inject constructor(
|
||||
private val _infoMessage = MutableSharedFlow<String>()
|
||||
val infoMessage = _infoMessage.asSharedFlow()
|
||||
|
||||
val isOperatorMode: StateFlow<Boolean> = settingsRepository.isOperatorModeFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false)
|
||||
|
||||
private val _uiState = MutableStateFlow(PersonListUiState())
|
||||
val uiState: StateFlow<PersonListUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val _financialReportData = MutableStateFlow(FinancialReportData())
|
||||
val financialReportData: StateFlow<FinancialReportData> = _financialReportData.asStateFlow()
|
||||
|
||||
private val _showFinancialReportDialog = MutableStateFlow(false)
|
||||
val showFinancialReportDialog: StateFlow<Boolean> = _showFinancialReportDialog.asStateFlow()
|
||||
|
||||
private val _showAddPersonDialog = MutableStateFlow(false)
|
||||
val showAddPersonDialog: StateFlow<Boolean> = _showAddPersonDialog.asStateFlow()
|
||||
|
||||
@@ -92,10 +102,11 @@ class PersonViewModel @Inject constructor(
|
||||
localPersonRepository.getAllPersonsFlow(),
|
||||
localPersonRepository.getAllPaymentsFlow(),
|
||||
_selectedYear,
|
||||
_currentCategory
|
||||
_currentCategory,
|
||||
) { persons, allPayments, year, category ->
|
||||
val person = persons.find { it.id == personId }
|
||||
val personCategory = person?.category ?: category
|
||||
val isMosque = (personCategory == "mosque")
|
||||
val paymentsForPersonInYear = allPayments.filter {
|
||||
(it.personId == personId) && (it.shamsiYear == year) && (it.category == personCategory)
|
||||
}
|
||||
@@ -106,12 +117,18 @@ class PersonViewModel @Inject constructor(
|
||||
val payment = paymentsForPersonInYear.find { it.shamsiMonth == month }
|
||||
|
||||
// بررسی شروع تعهد
|
||||
val isBeforeCommitment = if (person != null) {
|
||||
year < person.startYear || (year == person.startYear && month < person.startMonth)
|
||||
} else false
|
||||
val isBeforeCommitment = (person != null) &&
|
||||
(year < person.startYear || (year == person.startYear && month < person.startMonth))
|
||||
|
||||
val status = when {
|
||||
payment != null -> MonthStatus.PAID
|
||||
isMosque -> {
|
||||
if (year == currentYear && month == currentMonth) {
|
||||
MonthStatus.AVAILABLE
|
||||
} else {
|
||||
MonthStatus.NOT_COMMITTED
|
||||
}
|
||||
}
|
||||
isBeforeCommitment -> MonthStatus.NOT_COMMITTED
|
||||
year < currentYear -> MonthStatus.PAST_YEAR
|
||||
year > currentYear -> MonthStatus.FUTURE_YEAR
|
||||
@@ -133,26 +150,28 @@ class PersonViewModel @Inject constructor(
|
||||
if (token != null) {
|
||||
networkRepository.observeRealtimeChanges()
|
||||
|
||||
launch {
|
||||
combine(
|
||||
networkRepository.getPersonsFlow(),
|
||||
networkRepository.getPaymentsFlow(),
|
||||
networkRepository.hasServerDataFlow()
|
||||
) { persons, payments, hasServerData ->
|
||||
if (hasServerData) {
|
||||
if (!snapshotTakenThisSession) {
|
||||
// آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی
|
||||
// ذخیره میشود تا اگر با وجود همهی محافظتها چیزی خراب شد،
|
||||
// کاربر بتواند از تنظیمات برش گرداند.
|
||||
backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC)
|
||||
snapshotTakenThisSession = true
|
||||
coroutineScope {
|
||||
launch {
|
||||
combine(
|
||||
networkRepository.getPersonsFlow(),
|
||||
networkRepository.getPaymentsFlow(),
|
||||
networkRepository.hasServerDataFlow()
|
||||
) { persons, payments, hasServerData ->
|
||||
if (hasServerData) {
|
||||
if (!snapshotTakenThisSession) {
|
||||
// آخرین خط دفاع: قبل از اولین ادغام هر اجرا، وضعیت فعلی
|
||||
// ذخیره میشود تا اگر با وجود همهی محافظتها چیزی خراب شد،
|
||||
// کاربر بتواند از تنظیمات برش گرداند.
|
||||
backupManager.createSnapshot(BackupManager.REASON_BEFORE_SYNC)
|
||||
snapshotTakenThisSession = true
|
||||
}
|
||||
localPersonRepository.mergeFromServer(persons, payments)
|
||||
true
|
||||
} else false
|
||||
}.collect { isUpdated ->
|
||||
if (isUpdated) {
|
||||
_toastMessage.emit("اطلاعات با سرور همگامسازی شد")
|
||||
}
|
||||
localPersonRepository.mergeFromServer(persons, payments)
|
||||
true
|
||||
} else false
|
||||
}.collect { isUpdated ->
|
||||
if (isUpdated) {
|
||||
_toastMessage.emit("اطلاعات با سرور همگامسازی شد")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +218,8 @@ class PersonViewModel @Inject constructor(
|
||||
allPayments.none { it.personId == person.id && it.shamsiYear == currentYear && it.shamsiMonth == m && it.category == category }
|
||||
}
|
||||
val defaultAmount = settingsRepository.defaultPaymentAmountFlow.first()
|
||||
Triple(unpaid.size, unpaid.size * defaultAmount, unpaid.map { getPersianMonthName(it) } to unpaid)
|
||||
val amountPerMonth = if (person.monthlyCommitment > 0) person.monthlyCommitment else defaultAmount
|
||||
Triple(unpaid.size, unpaid.size * amountPerMonth, unpaid.map { getPersianMonthName(it) } to unpaid)
|
||||
}
|
||||
|
||||
val (debtCount, totalDebtAmount, unpaidData) = unpaidResult
|
||||
@@ -232,6 +252,53 @@ class PersonViewModel @Inject constructor(
|
||||
|
||||
val (paidModels, unpaidModels) = uiModels.partition { it.hasPaidThisMonth }
|
||||
|
||||
// محاسبه گزارشات مالی جامع
|
||||
val personsMap = persons.associateBy { it.id }
|
||||
val categoryPayments = allPayments.filter { it.category == category }
|
||||
val totalAllTimeIncome = categoryPayments.sumOf { it.amount }
|
||||
|
||||
val debtorsList = unpaidModels.filter { it.debtCount > 0 }.map { personModel ->
|
||||
DebtorDetail(
|
||||
personId = personModel.id,
|
||||
personName = personModel.name,
|
||||
debtCount = personModel.debtCount,
|
||||
totalDebtAmount = personModel.totalDebtAmount,
|
||||
unpaidMonthsNames = personModel.unpaidMonthsNames
|
||||
)
|
||||
}
|
||||
val totalOverdueDebt = debtorsList.sumOf { it.totalDebtAmount }
|
||||
|
||||
val monthlyReportsList = categoryPayments
|
||||
.groupBy { Pair(it.shamsiYear, it.shamsiMonth) }
|
||||
.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<Pair<Int, Int>, List<PaymentRecord>>> { it.key.first }.thenByDescending { it.key.second })
|
||||
.map { (yearMonth, paymentGroup) ->
|
||||
val (year, month) = yearMonth
|
||||
val totalAmount = paymentGroup.sumOf { it.amount }
|
||||
val payerNames = paymentGroup.mapNotNull { payment ->
|
||||
personsMap[payment.personId]?.let { person ->
|
||||
if (person.isAnonymous && category == "mosque") "خیر ناشناس (${person.name})" else person.name
|
||||
}
|
||||
}.distinct()
|
||||
|
||||
MonthlyFinancialReport(
|
||||
year = year,
|
||||
month = month,
|
||||
monthName = getPersianMonthName(month),
|
||||
totalAmount = totalAmount,
|
||||
paymentCount = paymentGroup.size,
|
||||
payerNames = payerNames
|
||||
)
|
||||
}
|
||||
|
||||
_financialReportData.value = FinancialReportData(
|
||||
totalAllTimeIncome = totalAllTimeIncome,
|
||||
totalOverdueDebt = totalOverdueDebt,
|
||||
totalDebtorsCount = debtorsList.size,
|
||||
debtors = debtorsList,
|
||||
monthlyReports = monthlyReportsList
|
||||
)
|
||||
|
||||
val filteredPaid = if (query.isBlank()) paidModels else paidModels.filter { it.name.contains(query, ignoreCase = true) }
|
||||
val filteredUnpaid = if (query.isBlank()) unpaidModels else unpaidModels.filter { it.name.contains(query, ignoreCase = true) }
|
||||
|
||||
@@ -269,6 +336,8 @@ class PersonViewModel @Inject constructor(
|
||||
fun onDismissAddPersonDialog() { _showAddPersonDialog.value = false }
|
||||
fun onQuickPayClicked(person: PersonUiModel) { _personForPaymentDialog.value = person }
|
||||
fun onDismissPaymentDialog() { _personForPaymentDialog.value = null }
|
||||
fun onShowFinancialReport() { _showFinancialReportDialog.value = true }
|
||||
fun onDismissFinancialReport() { _showFinancialReportDialog.value = false }
|
||||
|
||||
fun onEvent(event: PersonScreenEvent) {
|
||||
viewModelScope.launch {
|
||||
@@ -291,9 +360,9 @@ class PersonViewModel @Inject constructor(
|
||||
val persons = localPersonRepository.getAllPersonsFlow().first()
|
||||
val finalName = if (event.isAnonymous && event.name.isBlank()) "ناشناس" else event.name
|
||||
|
||||
val isDuplicate = if (!event.isAnonymous) {
|
||||
persons.any { it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true) }
|
||||
} else false
|
||||
val isDuplicate = !event.isAnonymous && persons.any {
|
||||
it.category == _currentCategory.value && it.name.trim().equals(finalName.trim(), ignoreCase = true)
|
||||
}
|
||||
|
||||
if (isDuplicate) {
|
||||
_toastMessage.emit("خطا: این نام از قبل وجود دارد.")
|
||||
@@ -334,8 +403,9 @@ class PersonViewModel @Inject constructor(
|
||||
val currentDay = getCurrentShamsiDay()
|
||||
val currentMonth = getCurrentShamsiMonth()
|
||||
val currentYear = getCurrentShamsiYear()
|
||||
val targetMonth = if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1
|
||||
val targetYear = if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear
|
||||
val isMosque = _currentCategory.value == "mosque"
|
||||
val targetMonth = if (isMosque) currentMonth else (if (currentDay >= 20) currentMonth else if (currentMonth == 1) 12 else currentMonth - 1)
|
||||
val targetYear = if (isMosque) currentYear else (if (currentDay < 20 && currentMonth == 1) currentYear - 1 else currentYear)
|
||||
|
||||
val record = createPaymentRecord(event.personId, targetMonth, targetYear, event.amount, event.description)
|
||||
.copy(category = _currentCategory.value)
|
||||
|
||||
@@ -46,9 +46,29 @@ class SettingsViewModel @Inject constructor(
|
||||
val cardNumbers: StateFlow<List<String>> = settingsRepository.cardNumbersFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), emptyList())
|
||||
|
||||
val isOperatorMode: StateFlow<Boolean> = settingsRepository.isOperatorModeFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), false)
|
||||
|
||||
val adminPin: StateFlow<String> = settingsRepository.adminPinFlow
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), "1234")
|
||||
|
||||
private val _toastMessage = MutableSharedFlow<String>()
|
||||
val toastMessage = _toastMessage.asSharedFlow()
|
||||
|
||||
fun setOperatorMode(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setOperatorMode(enabled)
|
||||
_toastMessage.emit(if (enabled) "حالت اپراتور (محدود) فعال شد" else "حالت مدیریت فعال شد")
|
||||
}
|
||||
}
|
||||
|
||||
fun setAdminPin(newPin: String) {
|
||||
viewModelScope.launch {
|
||||
settingsRepository.setAdminPin(newPin)
|
||||
_toastMessage.emit("رمز مدیریت با موفقیت تغییر یافت")
|
||||
}
|
||||
}
|
||||
|
||||
fun saveReminderDay(day: String) {
|
||||
viewModelScope.launch {
|
||||
val dayInt = day.toIntOrNull()
|
||||
|
||||
@@ -145,3 +145,28 @@ data class DashboardUiModel(
|
||||
val totalIncome: Double = 0.0,
|
||||
val progress: Float = 0f
|
||||
)
|
||||
|
||||
data class MonthlyFinancialReport(
|
||||
val year: Int,
|
||||
val month: Int,
|
||||
val monthName: String,
|
||||
val totalAmount: Double,
|
||||
val paymentCount: Int,
|
||||
val payerNames: List<String>
|
||||
)
|
||||
|
||||
data class DebtorDetail(
|
||||
val personId: String,
|
||||
val personName: String,
|
||||
val debtCount: Int,
|
||||
val totalDebtAmount: Double,
|
||||
val unpaidMonthsNames: List<String> = emptyList()
|
||||
)
|
||||
|
||||
data class FinancialReportData(
|
||||
val totalAllTimeIncome: Double = 0.0,
|
||||
val totalOverdueDebt: Double = 0.0,
|
||||
val totalDebtorsCount: Int = 0,
|
||||
val debtors: List<DebtorDetail> = emptyList(),
|
||||
val monthlyReports: List<MonthlyFinancialReport> = emptyList()
|
||||
)
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"versionCode": 8,
|
||||
"versionName": "1.0.7",
|
||||
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.0.7/MonthlyPaymentApp-v1.0.7.apk",
|
||||
"releaseNotes": "مهاجرت به PocketBase، رفع خطاهای بیلد و بهبود پایداری همگامسازی",
|
||||
"versionCode": 13,
|
||||
"versionName": "1.1.2",
|
||||
"url": "https://github.com/oqba26/MonthlyPaymentApp/releases/download/v1.1.2/MonthlyPaymentApp-v1.1.2.apk",
|
||||
"releaseNotes": "بهبودهای پایداری و عملکرد همگامسازی",
|
||||
"isForceUpdate": false
|
||||
}
|
||||
Reference in new issue
Block a user