From a0b375ea90afcfdc187f748d3a663fd50adc1562 Mon Sep 17 00:00:00 2001 From: Ishtiaque Date: Tue, 14 Apr 2026 14:07:49 +0100 Subject: [PATCH 1/6] build 6 --- app/build.gradle.kts | 4 +- .../java/com/allubie/nana/NanaApplication.kt | 19 +++++ .../allubie/nana/data/dao/TransactionDao.kt | 6 +- .../screens/finances/BudgetManagerScreen.kt | 20 +++-- .../finances/BudgetManagerViewModel.kt | 7 +- .../finances/FinancesOverviewScreen.kt | 7 +- .../finances/FinancesOverviewViewModel.kt | 8 +- .../ui/screens/finances/FinancesScreen.kt | 25 ++++-- .../ui/screens/finances/FinancesViewModel.kt | 7 +- .../finances/TransactionEditorScreen.kt | 11 --- .../finances/TransactionEditorViewModel.kt | 11 +-- .../allubie/nana/widget/BudgetStatusWidget.kt | 54 +++++++++--- .../widget/BudgetWidgetRefreshCoordinator.kt | 83 +++++++++++++++++++ .../nana/widget/BudgetWidgetRefreshWorker.kt | 25 ++++++ .../allubie/nana/widget/ChecklistWidget.kt | 2 +- gradle/libs.versions.toml | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 17 files changed, 230 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/com/allubie/nana/widget/BudgetWidgetRefreshCoordinator.kt create mode 100644 app/src/main/java/com/allubie/nana/widget/BudgetWidgetRefreshWorker.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d8210c..62a8c55 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.allubie.nana" minSdk = 26 targetSdk = 35 - versionCode = 5 - versionName = "0.9.1" + versionCode = 6 + versionName = "0.9.5" vectorDrawables { useSupportLibrary = true diff --git a/app/src/main/java/com/allubie/nana/NanaApplication.kt b/app/src/main/java/com/allubie/nana/NanaApplication.kt index 6f41bef..a458aba 100644 --- a/app/src/main/java/com/allubie/nana/NanaApplication.kt +++ b/app/src/main/java/com/allubie/nana/NanaApplication.kt @@ -1,11 +1,14 @@ package com.allubie.nana import android.app.Application +import androidx.room.InvalidationTracker import com.allubie.nana.data.BackupManager import com.allubie.nana.data.NanaDatabase import com.allubie.nana.data.PreferencesManager import com.allubie.nana.notification.NotificationHelper import com.allubie.nana.widget.WidgetRefreshWorker +import com.allubie.nana.widget.requestBudgetWidgetRefresh +import com.allubie.nana.widget.updateChecklistWidgets import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -28,6 +31,20 @@ class NanaApplication : Application() { val backupManager: BackupManager by lazy { BackupManager(this, database, preferencesManager) } + + private val budgetWidgetDbObserver = object : InvalidationTracker.Observer("transactions", "budgets") { + override fun onInvalidated(tables: Set) { + requestBudgetWidgetRefresh(this@NanaApplication) + } + } + + private val checklistWidgetDbObserver = object : InvalidationTracker.Observer("notes", "checklist_items") { + override fun onInvalidated(tables: Set) { + applicationScope.launch { + updateChecklistWidgets(this@NanaApplication) + } + } + } override fun onCreate() { super.onCreate() @@ -41,6 +58,8 @@ class NanaApplication : Application() { TimeZone.setDefault(TimeZone.getTimeZone(savedTimezone)) } + database.invalidationTracker.addObserver(budgetWidgetDbObserver) + database.invalidationTracker.addObserver(checklistWidgetDbObserver) WidgetRefreshWorker.schedule(this) } } diff --git a/app/src/main/java/com/allubie/nana/data/dao/TransactionDao.kt b/app/src/main/java/com/allubie/nana/data/dao/TransactionDao.kt index c9c33ae..ed70385 100644 --- a/app/src/main/java/com/allubie/nana/data/dao/TransactionDao.kt +++ b/app/src/main/java/com/allubie/nana/data/dao/TransactionDao.kt @@ -11,7 +11,7 @@ interface TransactionDao { @Query("SELECT * FROM transactions ORDER BY date DESC") fun getAllTransactions(): Flow> - @Query("SELECT * FROM transactions WHERE date >= :startDate AND date <= :endDate ORDER BY date DESC") + @Query("SELECT * FROM transactions WHERE date >= :startDate AND date < :endDate ORDER BY date DESC") fun getTransactionsInRange(startDate: Long, endDate: Long): Flow> @Query("SELECT * FROM transactions WHERE type = :type ORDER BY date DESC") @@ -26,10 +26,10 @@ interface TransactionDao { @Query("SELECT * FROM transactions ORDER BY date DESC LIMIT :limit") fun getRecentTransactions(limit: Int = 10): Flow> - @Query("SELECT SUM(amount) FROM transactions WHERE type = :type AND date >= :startDate AND date <= :endDate") + @Query("SELECT SUM(amount) FROM transactions WHERE type = :type AND date >= :startDate AND date < :endDate") suspend fun getTotalByTypeInRange(type: TransactionType, startDate: Long, endDate: Long): Double? - @Query("SELECT SUM(amount) FROM transactions WHERE type = :type AND category = :category AND date >= :startDate AND date <= :endDate") + @Query("SELECT SUM(amount) FROM transactions WHERE type = :type AND category = :category AND date >= :startDate AND date < :endDate") suspend fun getTotalByCategoryInRange(type: TransactionType, category: String, startDate: Long, endDate: Long): Double? @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerScreen.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerScreen.kt index 1881983..e3f5cbf 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerScreen.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel @@ -37,8 +38,7 @@ import com.allubie.nana.data.model.Budget import com.allubie.nana.data.model.BudgetPeriod import com.allubie.nana.data.model.ExpenseCategories import com.allubie.nana.util.CurrencyFormatter -import java.text.NumberFormat -import java.util.* +import java.util.Calendar import com.allubie.nana.ui.theme.* // Category colors - using theme colors @@ -130,7 +130,9 @@ fun BudgetManagerScreen( var showTotalBudgetDialog by remember { mutableStateOf(false) } val remainingBudget = totalBudget - totalSpent - val remainingPercentage = if (totalBudget > 0) ((remainingBudget / totalBudget) * 100).toInt().coerceAtLeast(0) else 0 + val remainingPercentage = if (totalBudget > 0) { + ((remainingBudget / totalBudget) * 100).coerceIn(0.0, 100.0).toInt() + } else 0 // Format currency with symbol from settings fun formatCurrency(amount: Double): String { @@ -322,7 +324,11 @@ fun BudgetManagerScreen( text = formatCurrency(remainingBudget.coerceAtLeast(0.0)), style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(8.dp)) Text( @@ -356,7 +362,9 @@ fun BudgetManagerScreen( } // Alert pill if over 80% AND budget is actually set - val usagePercentage = 100 - remainingPercentage + val usagePercentage = if (totalBudget > 0) { + ((totalSpent / totalBudget) * 100).toInt().coerceAtLeast(0) + } else 0 if (usagePercentage >= 80 && totalBudget > 0) { item { val isDark = isSystemInDarkTheme() @@ -918,4 +926,4 @@ private fun TotalBudgetDialog( } } ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerViewModel.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerViewModel.kt index 055cc6b..f319d90 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerViewModel.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerViewModel.kt @@ -13,7 +13,7 @@ import com.allubie.nana.data.dao.TransactionDao import com.allubie.nana.data.model.Budget import com.allubie.nana.data.model.BudgetPeriod import com.allubie.nana.data.model.TransactionType -import com.allubie.nana.widget.updateBudgetWidget +import com.allubie.nana.widget.requestBudgetWidgetRefresh import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.util.* @@ -108,28 +108,25 @@ class BudgetManagerViewModel( iconName = iconName ) budgetDao.insertBudget(budget) - updateBudgetWidget(application) } } fun updateBudget(budget: Budget) { viewModelScope.launch { budgetDao.updateBudget(budget) - updateBudgetWidget(application) } } fun deleteBudget(budget: Budget) { viewModelScope.launch { budgetDao.deleteBudget(budget) - updateBudgetWidget(application) } } fun setTotalBudgetLimit(amount: Double) { viewModelScope.launch { preferencesManager.setTotalBudget(amount) - updateBudgetWidget(application) + requestBudgetWidgetRefresh(application) } } diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewScreen.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewScreen.kt index 861e362..6b98531 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewScreen.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.allubie.nana.util.CurrencyFormatter +import kotlin.math.roundToInt import java.text.NumberFormat import java.util.* @@ -191,7 +192,9 @@ fun FinancesOverviewScreen( category = budget.category, budgeted = formatCurrency(budget.budgeted), actual = formatCurrency(budget.actual), - progress = (budget.actual / budget.budgeted).toFloat().coerceIn(0f, 1f), + progress = if (budget.budgeted > 0.0) { + (budget.actual / budget.budgeted).toFloat().coerceIn(0f, 1f) + } else 0f, isOverBudget = budget.actual > budget.budgeted ) } @@ -238,7 +241,7 @@ private fun CategorySpendingItem( fontWeight = FontWeight.SemiBold ) Text( - text = "${(percentage * 100).toInt()}%", + text = "${(percentage * 100).roundToInt().coerceIn(0, 100)}%", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewViewModel.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewViewModel.kt index a880864..2fec159 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewViewModel.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewViewModel.kt @@ -104,11 +104,11 @@ class FinancesOverviewViewModel( val endOfMonth = calendar.timeInMillis val totalIncome = transactions - .filter { it.type == TransactionType.INCOME && it.date in startOfMonth..endOfMonth } + .filter { it.type == TransactionType.INCOME && it.date in startOfMonth until endOfMonth } .sumOf { it.amount } val totalExpenses = transactions - .filter { it.type == TransactionType.EXPENSE && it.date in startOfMonth..endOfMonth } + .filter { it.type == TransactionType.EXPENSE && it.date in startOfMonth until endOfMonth } .sumOf { it.amount } // Build label color map @@ -116,7 +116,7 @@ class FinancesOverviewViewModel( // Group expenses by category - include custom categories val expenseTransactions = transactions - .filter { it.type == TransactionType.EXPENSE && it.date in startOfMonth..endOfMonth } + .filter { it.type == TransactionType.EXPENSE && it.date in startOfMonth until endOfMonth } val categoryBreakdown = expenseTransactions .groupBy { it.category } @@ -142,7 +142,7 @@ class FinancesOverviewViewModel( totalExpenses } else { transactions - .filter { it.type == TransactionType.EXPENSE && it.category == budget.category && it.date in startOfMonth..endOfMonth } + .filter { it.type == TransactionType.EXPENSE && it.category == budget.category && it.date in startOfMonth until endOfMonth } .sumOf { it.amount } } BudgetComparison( diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesScreen.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesScreen.kt index 9b66bf9..3f08b0c 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesScreen.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -70,9 +71,11 @@ fun FinancesScreen( var showMonthPicker by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) } + val hasEffectiveBudget = hasBudget && totalBudget > 0 val balance = totalIncome - totalExpenses - // If budget exists, compare expenses to budget; otherwise compare income to expenses - val isOnTrack = if (hasBudget && totalBudget > 0) totalExpenses <= totalBudget else balance >= 0 + val displayedTotal = if (hasEffectiveBudget) totalBudget - totalExpenses else balance + val totalLabel = if (hasEffectiveBudget) "Remaining Budget" else "Total Balance" + val isOnTrack = if (hasEffectiveBudget) displayedTotal >= 0 else balance >= 0 val dateFormat = SimpleDateFormat("MMMM yyyy", Locale.getDefault()) val dayFormat = SimpleDateFormat("MMM d", Locale.getDefault()) @@ -80,6 +83,10 @@ fun FinancesScreen( fun formatCurrency(amount: Double): String { return CurrencyFormatter.formatWithSymbol(kotlin.math.abs(amount), currencySymbol) } + fun formatSignedCurrency(amount: Double): String { + val formatted = formatCurrency(amount) + return if (amount < 0) "-$formatted" else formatted + } Scaffold( topBar = { @@ -207,16 +214,20 @@ fun FinancesScreen( horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Total Balance", + text = totalLabel, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = formatCurrency(balance), + text = formatSignedCurrency(displayedTotal), style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.ExtraBold, - letterSpacing = (-1).sp + letterSpacing = (-1).sp, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Spacer(modifier = Modifier.height(8.dp)) @@ -497,7 +508,9 @@ private fun FinanceCard( style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = contentColor, - letterSpacing = (-0.5).sp + letterSpacing = (-0.5).sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesViewModel.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesViewModel.kt index a9a46dd..109c650 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesViewModel.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesViewModel.kt @@ -2,6 +2,7 @@ package com.allubie.nana.ui.screens.finances import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory @@ -14,7 +15,7 @@ import com.allubie.nana.data.model.Label import com.allubie.nana.data.model.LabelType import com.allubie.nana.data.model.Transaction import com.allubie.nana.data.model.TransactionType -import com.allubie.nana.widget.updateBudgetWidget +import com.allubie.nana.widget.requestBudgetWidgetRefresh import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -108,14 +109,14 @@ class FinancesViewModel( fun deleteTransaction(transaction: Transaction) { viewModelScope.launch { transactionDao.deleteTransaction(transaction) - updateBudgetWidget(application) + requestBudgetWidgetRefresh(application) } } companion object { val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { - val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as NanaApplication + val application = this[APPLICATION_KEY] as NanaApplication FinancesViewModel( application.database.transactionDao(), application.database.budgetDao(), diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/TransactionEditorScreen.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/TransactionEditorScreen.kt index eaae4be..782a3f3 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/TransactionEditorScreen.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/TransactionEditorScreen.kt @@ -48,7 +48,6 @@ fun TransactionEditorScreen( ) { val uiState by viewModel.uiState.collectAsState() val currencySymbol by viewModel.currencySymbol.collectAsState() - val currencyCode by viewModel.currencyCode.collectAsState() var showCategoryPicker by remember { mutableStateOf(false) } var showDatePicker by remember { mutableStateOf(false) } val datePickerState = rememberDatePickerState( @@ -294,16 +293,6 @@ fun TransactionEditorScreen( ) } - Spacer(modifier = Modifier.width(8.dp)) - - // Currency code on right - Text( - text = currencyCode, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - letterSpacing = 1.sp - ) } Spacer(modifier = Modifier.height(32.dp)) diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/TransactionEditorViewModel.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/TransactionEditorViewModel.kt index 00ddf8b..2dce374 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/TransactionEditorViewModel.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/TransactionEditorViewModel.kt @@ -2,6 +2,7 @@ package com.allubie.nana.ui.screens.finances import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory @@ -16,7 +17,7 @@ import com.allubie.nana.data.model.LabelType import com.allubie.nana.data.model.Transaction import com.allubie.nana.data.model.TransactionType import com.allubie.nana.data.repository.LabelRepository -import com.allubie.nana.widget.updateBudgetWidget +import com.allubie.nana.widget.requestBudgetWidgetRefresh import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -73,7 +74,7 @@ class TransactionEditorViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "USD") private val labelRepository = LabelRepository(labelDao) - + init { loadCustomBudgets() seedLabelsIfNeeded() @@ -177,8 +178,8 @@ class TransactionEditorViewModel( updatedAt = System.currentTimeMillis() ) transactionDao.insertTransaction(transaction) + requestBudgetWidgetRefresh(application) _saveComplete.emit(true) - viewModelScope.launch { updateBudgetWidget(application) } } } @@ -186,7 +187,7 @@ class TransactionEditorViewModel( viewModelScope.launch { _uiState.value.id?.let { id -> transactionDao.deleteTransactionById(id) - updateBudgetWidget(application) + requestBudgetWidgetRefresh(application) } } } @@ -194,7 +195,7 @@ class TransactionEditorViewModel( companion object { val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { - val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as NanaApplication + val application = this[APPLICATION_KEY] as NanaApplication TransactionEditorViewModel( application.database.transactionDao(), application.database.budgetDao(), diff --git a/app/src/main/java/com/allubie/nana/widget/BudgetStatusWidget.kt b/app/src/main/java/com/allubie/nana/widget/BudgetStatusWidget.kt index bbc8ef7..466d841 100644 --- a/app/src/main/java/com/allubie/nana/widget/BudgetStatusWidget.kt +++ b/app/src/main/java/com/allubie/nana/widget/BudgetStatusWidget.kt @@ -7,6 +7,7 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint import android.graphics.RectF +import android.util.Log import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.glance.GlanceId @@ -37,6 +38,10 @@ import kotlinx.coroutines.withContext import java.util.Calendar class BudgetStatusWidget : GlanceAppWidget() { + private companion object { + const val TAG = "BudgetStatusWidget" + } + override suspend fun provideGlance(context: Context, id: GlanceId) { val db = NanaDatabase.getDatabase(context) val prefs = PreferencesManager(context) @@ -55,42 +60,57 @@ class BudgetStatusWidget : GlanceAppWidget() { calendar.add(Calendar.MONTH, 1) val endOfMonth = calendar.timeInMillis - val monthSpending = runCatching { + val monthSpending = try { db.transactionDao().getTotalByTypeInRange( TransactionType.EXPENSE, startOfMonth, endOfMonth ) ?: 0.0 - }.getOrDefault(0.0) + } catch (e: Exception) { + Log.e(TAG, "Failed to read monthly spending for widget", e) + 0.0 + } val totalBudgetLimit = prefs.totalBudget.first() - val allBudgets = runCatching { db.budgetDao().getAllBudgets().first() } - .getOrDefault(emptyList()) + val allBudgets = try { + db.budgetDao().getAllBudgets().first() + } catch (e: Exception) { + Log.e(TAG, "Failed to read budgets for widget", e) + emptyList() + } val totalAllocated = allBudgets.sumOf { it.amount } val budgetAmount = if (totalBudgetLimit > 0) totalBudgetLimit else totalAllocated - val percentage = if (budgetAmount > 0) (monthSpending / budgetAmount * 100).coerceIn(0.0, 100.0) else 0.0 + val remainingAmountRaw = budgetAmount - monthSpending + val remainingAmount = remainingAmountRaw.coerceAtLeast(0.0) + val remainingPercentage = if (budgetAmount > 0) { + (remainingAmount / budgetAmount * 100).coerceIn(0.0, 100.0) + } else { + 0.0 + } BudgetSnapshot( currencySymbol = currencySymbol, monthSpending = monthSpending, budgetAmount = budgetAmount, + remainingAmount = remainingAmount, hasBudget = budgetAmount > 0, - percentage = percentage + remainingPercentage = remainingPercentage ) } val isDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES val progressColor = when { - snapshot.percentage >= 90 -> 0xFFBA1A1A.toInt() + snapshot.remainingPercentage <= 10 -> 0xFFBA1A1A.toInt() else -> if (isDark) 0xFFD0BCFF.toInt() else 0xFF6750A4.toInt() } val trackColor = if (isDark) 0xFF4A4A4D.toInt() else 0xFFE1DFE4.toInt() val progressBitmap = withContext(Dispatchers.Default) { - createCircularProgressBitmap(context, snapshot.percentage, progressColor, trackColor, 56) + createCircularProgressBitmap(context, snapshot.remainingPercentage, progressColor, trackColor, 56) } val spendingFormatted = formatAmount(snapshot.monthSpending, snapshot.currencySymbol) val budgetFormatted = formatAmount(snapshot.budgetAmount, snapshot.currencySymbol) - val percentageInt = snapshot.percentage.toInt() + val remainingFormatted = formatAmount(snapshot.remainingAmount, snapshot.currencySymbol) + val percentageInt = snapshot.remainingPercentage.toInt() provideContent { GlanceTheme(colors = NanaWidgetColorProviders) { @@ -112,7 +132,7 @@ class BudgetStatusWidget : GlanceAppWidget() { ) { Image( provider = ImageProvider(progressBitmap), - contentDescription = "$percentageInt% of budget spent", + contentDescription = "$percentageInt% of budget remaining", modifier = GlanceModifier.size(56.dp) ) @@ -136,10 +156,17 @@ class BudgetStatusWidget : GlanceAppWidget() { fontWeight = FontWeight.Bold ) ) + Text( + text = "Spent $spendingFormatted", + style = TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontSize = 10.sp + ) + ) } else { Row(verticalAlignment = Alignment.Bottom) { Text( - text = spendingFormatted, + text = remainingFormatted, style = TextStyle( color = GlanceTheme.colors.primary, fontWeight = FontWeight.Bold, @@ -156,7 +183,7 @@ class BudgetStatusWidget : GlanceAppWidget() { } Text( - text = "${percentageInt}% spent", + text = "${percentageInt}% left", style = TextStyle( color = GlanceTheme.colors.onSurfaceVariant, fontSize = 10.sp @@ -173,8 +200,9 @@ class BudgetStatusWidget : GlanceAppWidget() { val currencySymbol: String, val monthSpending: Double, val budgetAmount: Double, + val remainingAmount: Double, val hasBudget: Boolean, - val percentage: Double + val remainingPercentage: Double ) private fun createCircularProgressBitmap( diff --git a/app/src/main/java/com/allubie/nana/widget/BudgetWidgetRefreshCoordinator.kt b/app/src/main/java/com/allubie/nana/widget/BudgetWidgetRefreshCoordinator.kt new file mode 100644 index 0000000..d1e2498 --- /dev/null +++ b/app/src/main/java/com/allubie/nana/widget/BudgetWidgetRefreshCoordinator.kt @@ -0,0 +1,83 @@ +package com.allubie.nana.widget + +import android.content.Context +import android.util.Log +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.concurrent.TimeUnit + +private object BudgetWidgetRefreshCoordinator { + private const val TAG = "BudgetWidgetRefresh" + private const val RETRY_DELAY_MS = 300L + private const val DEBOUNCE_MS = 250L + private const val FALLBACK_WORK_NAME = "budget_widget_refresh_fallback" + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val refreshLock = Mutex() + private val refreshRequests = MutableSharedFlow(extraBufferCapacity = 1) + + init { + refreshRequests + .debounce(DEBOUNCE_MS) + .onEach { context -> + refreshLock.withLock { + val refreshed = runCatching { + updateBudgetWidget(context) + }.onFailure { error -> + Log.e(TAG, "Budget widget refresh failed, retrying", error) + }.isSuccess + + if (!refreshed) { + delay(RETRY_DELAY_MS) + val retried = runCatching { updateBudgetWidget(context) } + .onFailure { error -> + Log.e(TAG, "Budget widget refresh failed after retry", error) + } + .isSuccess + if (!retried) { + // A fallback one-time WorkManager refresh has already been enqueued on request. + } + } + } + } + .launchIn(scope) + } + + fun requestRefresh(context: Context) { + val appContext = context.applicationContext + scheduleFallbackWork(appContext) + if (!refreshRequests.tryEmit(appContext)) { + scope.launch { refreshRequests.emit(appContext) } + } + } + + private fun scheduleFallbackWork(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(5, TimeUnit.SECONDS) + .setConstraints(Constraints.NONE) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + FALLBACK_WORK_NAME, + ExistingWorkPolicy.REPLACE, + request + ) + } +} + +fun requestBudgetWidgetRefresh(context: Context) { + BudgetWidgetRefreshCoordinator.requestRefresh(context) +} diff --git a/app/src/main/java/com/allubie/nana/widget/BudgetWidgetRefreshWorker.kt b/app/src/main/java/com/allubie/nana/widget/BudgetWidgetRefreshWorker.kt new file mode 100644 index 0000000..b09cc08 --- /dev/null +++ b/app/src/main/java/com/allubie/nana/widget/BudgetWidgetRefreshWorker.kt @@ -0,0 +1,25 @@ +package com.allubie.nana.widget + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters + +class BudgetWidgetRefreshWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + private companion object { + const val TAG = "BudgetWidgetRefresh" + } + + override suspend fun doWork(): Result { + return try { + updateBudgetWidget(applicationContext) + Result.success() + } catch (e: Exception) { + Log.e(TAG, "Fallback budget widget refresh worker failed", e) + Result.retry() + } + } +} diff --git a/app/src/main/java/com/allubie/nana/widget/ChecklistWidget.kt b/app/src/main/java/com/allubie/nana/widget/ChecklistWidget.kt index d3c685e..88bcc67 100644 --- a/app/src/main/java/com/allubie/nana/widget/ChecklistWidget.kt +++ b/app/src/main/java/com/allubie/nana/widget/ChecklistWidget.kt @@ -113,7 +113,7 @@ private fun ChecklistWidgetContent( ) { val openAction = actionStartActivity( Intent(context, MainActivity::class.java).apply { - putExtra("navigate_to", if (noteId != null) "notes/checklist/$noteId" else "notes") + putExtra("navigate_to", if (noteId != null) "notes/checklist/$noteId" else "notes/checklist/-1") flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1df21e5..b30cb79 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.7.3" +agp = "8.13.2" kotlin = "2.0.21" coreKtx = "1.15.0" lifecycleRuntimeKtx = "2.8.7" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0..37f853b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 778d82c714b5a50811294fbe253a943404118b07 Mon Sep 17 00:00:00 2001 From: Ishtiaque Date: Tue, 14 Apr 2026 19:44:50 +0100 Subject: [PATCH 2/6] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index afe2f67..f733d8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ # Changelog +## v0.9.5 (Build 6) +- Various bug fixes and improvements ## v0.9.1 (Build 5) From ded2d14a2d8a5417c2c2977fb8f74042215f796c Mon Sep 17 00:00:00 2001 From: Ishtiaque Date: Mon, 27 Apr 2026 16:04:14 +0600 Subject: [PATCH 3/6] upgraded gradle to 9.4.1 --- gradle.properties | 10 ++++++++++ gradle/libs.versions.toml | 6 +++--- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/gradle.properties b/gradle.properties index 32afba7..46ddbf7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,3 +5,13 @@ android.useAndroidX=true kotlin.code.style=official android.nonTransitiveRClass=true android.builder.sdkDownload=false +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b30cb79..d29b483 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,13 @@ [versions] -agp = "8.13.2" -kotlin = "2.0.21" +agp = "9.2.0" +kotlin = "2.2.10" coreKtx = "1.15.0" lifecycleRuntimeKtx = "2.8.7" activityCompose = "1.9.3" composeBom = "2024.12.01" navigationCompose = "2.8.5" roomRuntime = "2.6.1" -ksp = "2.0.21-1.0.28" +ksp = "2.3.2" coroutines = "1.9.0" materialIconsExtended = "1.7.6" datastorePreferences = "1.1.1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b..c61a118 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 1d80fa11ed1607cdf9bc8a3ad403af24b5867903 Mon Sep 17 00:00:00 2001 From: Ishtiaque Date: Mon, 27 Jul 2026 16:55:05 +0600 Subject: [PATCH 4/6] build 7 --- CHANGELOG.md | 11 + app/build.gradle.kts | 9 +- app/proguard-rules.pro | 4 - app/src/main/AndroidManifest.xml | 11 + .../java/com/allubie/nana/NanaApplication.kt | 5 +- .../com/allubie/nana/data/BackupManager.kt | 51 +- .../com/allubie/nana/data/NanaDatabase.kt | 41 +- .../allubie/nana/data/PreferencesManager.kt | 64 +- .../java/com/allubie/nana/data/dao/NoteDao.kt | 2 +- .../allubie/nana/data/dao/TransactionDao.kt | 8 + .../java/com/allubie/nana/data/model/Note.kt | 17 +- .../com/allubie/nana/data/model/NoteFts.kt | 11 + .../com/allubie/nana/data/model/Routine.kt | 9 +- .../nana/data/repository/EventRepository.kt | 19 + .../nana/data/repository/NoteRepository.kt | 53 ++ .../nana/data/repository/RoutineRepository.kt | 30 + .../data/repository/TransactionRepository.kt | 39 ++ .../allubie/nana/notification/BootReceiver.kt | 3 +- .../nana/notification/NotificationHelper.kt | 5 +- .../nana/notification/ReminderReceiver.kt | 5 +- .../nana/notification/ReminderScheduler.kt | 2 - .../com/allubie/nana/ui/components/Dialogs.kt | 20 +- .../nana/ui/components/SettingsComponents.kt | 3 + .../allubie/nana/ui/navigation/NanaNavHost.kt | 550 +++++++----------- .../screens/finances/BudgetManagerScreen.kt | 86 +-- .../finances/BudgetManagerViewModel.kt | 124 ++-- .../finances/FinancesOverviewScreen.kt | 28 +- .../finances/FinancesOverviewViewModel.kt | 28 +- .../ui/screens/finances/FinancesScreen.kt | 82 +-- .../ui/screens/finances/FinancesViewModel.kt | 105 ++-- .../finances/TransactionEditorScreen.kt | 547 ++++++++--------- .../finances/TransactionEditorViewModel.kt | 32 +- .../ui/screens/notes/ChecklistEditorScreen.kt | 21 +- .../screens/notes/ChecklistEditorViewModel.kt | 48 +- .../nana/ui/screens/notes/NoteEditorScreen.kt | 63 +- .../ui/screens/notes/NoteEditorViewModel.kt | 53 +- .../nana/ui/screens/notes/NoteViewerScreen.kt | 67 +-- .../ui/screens/notes/NotesArchiveScreen.kt | 11 +- .../ui/screens/notes/NotesArchiveViewModel.kt | 18 +- .../nana/ui/screens/notes/NotesScreen.kt | 174 ++---- .../nana/ui/screens/notes/NotesTrashScreen.kt | 43 +- .../ui/screens/notes/NotesTrashViewModel.kt | 20 +- .../nana/ui/screens/notes/NotesViewModel.kt | 44 +- .../screens/routines/RoutineEditorScreen.kt | 162 +++--- .../routines/RoutineEditorViewModel.kt | 25 +- .../routines/RoutineStatisticsScreen.kt | 29 +- .../routines/RoutineStatisticsViewModel.kt | 20 +- .../ui/screens/routines/RoutinesScreen.kt | 75 +-- .../ui/screens/routines/RoutinesViewModel.kt | 94 +-- .../screens/schedule/ScheduleEditorScreen.kt | 69 ++- .../schedule/ScheduleEditorViewModel.kt | 17 +- .../ui/screens/schedule/ScheduleScreen.kt | 51 +- .../ui/screens/schedule/ScheduleViewModel.kt | 41 +- .../screens/schedule/ScheduleViewerScreen.kt | 45 +- .../settings/LabelsAndCategoriesScreen.kt | 39 +- .../ui/screens/settings/SettingsScreen.kt | 117 ++-- .../ui/screens/settings/SettingsViewModel.kt | 14 +- .../com/allubie/nana/util/BugReportUtils.kt | 78 +++ .../java/com/allubie/nana/util/DateUtils.kt | 97 +-- .../com/allubie/nana/util/FinanceUtils.kt | 8 +- .../java/com/allubie/nana/util/HtmlUtils.kt | 99 ++++ .../allubie/nana/widget/BudgetStatusWidget.kt | 2 +- .../widget/BudgetWidgetRefreshCoordinator.kt | 2 +- .../allubie/nana/widget/ChecklistWidget.kt | 6 - .../allubie/nana/widget/RecentNotesWidget.kt | 5 +- app/src/main/res/values/strings.xml | 123 ++++ app/src/main/res/xml/file_paths.xml | 5 + build.gradle.kts | 1 - gradle.properties | 6 +- gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 11 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 72 files changed, 2136 insertions(+), 1685 deletions(-) create mode 100644 app/src/main/java/com/allubie/nana/data/model/NoteFts.kt create mode 100644 app/src/main/java/com/allubie/nana/data/repository/EventRepository.kt create mode 100644 app/src/main/java/com/allubie/nana/data/repository/NoteRepository.kt create mode 100644 app/src/main/java/com/allubie/nana/data/repository/RoutineRepository.kt create mode 100644 app/src/main/java/com/allubie/nana/data/repository/TransactionRepository.kt create mode 100644 app/src/main/java/com/allubie/nana/util/BugReportUtils.kt create mode 100644 app/src/main/java/com/allubie/nana/util/HtmlUtils.kt create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/CHANGELOG.md b/CHANGELOG.md index f733d8c..8e778ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,15 @@ # Changelog + +## v1.0.0 (Build 7) + +### Highlights +- Upgraded WYSIWYG rich text editor to v1.0.0 stable +- Consolidated ViewModel screen states into unified UiState flows +- Added Room FTS4 full-text search engine for fast note search +- Added missing Foreign Key CASCADE constraints across database entities +- Refactored transition animations and deduplicated navigation boilerplate +- Converted date utilities to thread-safe `java.time` APIs and extracted all UI strings to `strings.xml` + ## v0.9.5 (Build 6) - Various bug fixes and improvements diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 62a8c55..c064f08 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,5 @@ plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) } @@ -13,8 +12,8 @@ android { applicationId = "com.allubie.nana" minSdk = 26 targetSdk = 35 - versionCode = 6 - versionName = "0.9.5" + versionCode = 7 + versionName = "0.9.8" vectorDrawables { useSupportLibrary = true @@ -35,9 +34,6 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } buildFeatures { compose = true buildConfig = true @@ -53,6 +49,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.activity.compose) implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ui) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 2fb1452..235759f 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,7 +1,3 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle.kts. - # Keep Room entities -keep class com.allubie.nana.data.model.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e66d967..563fd79 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,6 +30,17 @@ + + + + + = withContext(Dispatchers.IO) { try { - val notes = database.noteDao().getAllNotesSync() - val noteImages = database.noteImageDao().getAllImagesSync() - val checklistItems = database.checklistItemDao().getAllItemsSync() - val events = database.eventDao().getAllEventsSync() - val routines = database.routineDao().getAllRoutinesSync() - val routineCompletions = database.routineCompletionDao().getAllCompletionsSync() - val transactions = database.transactionDao().getAllTransactionsSync() - val budgets = database.budgetDao().getAllBudgetsSync() - val labels = database.labelDao().getAllLabelsSync() + // Read all data in a single transaction for a consistent snapshot + val backupData = database.withTransaction { + val notes = database.noteDao().getAllNotesSync() + val noteImages = database.noteImageDao().getAllImagesSync() + val checklistItems = database.checklistItemDao().getAllItemsSync() + val events = database.eventDao().getAllEventsSync() + val routines = database.routineDao().getAllRoutinesSync() + val routineCompletions = database.routineCompletionDao().getAllCompletionsSync() + val transactions = database.transactionDao().getAllTransactionsSync() + val budgets = database.budgetDao().getAllBudgetsSync() + val labels = database.labelDao().getAllLabelsSync() + + BackupData( + notes = notes, + noteImages = noteImages, + checklistItems = checklistItems, + events = events, + routines = routines, + routineCompletions = routineCompletions, + transactions = transactions, + budgets = budgets, + labels = labels + ) + } - // Export preferences + // Export preferences (outside transaction — DataStore is separate) val prefs = BackupPreferences( themeMode = preferencesManager.themeMode.first().name.lowercase(), currencyCode = preferencesManager.currencyCode.first(), @@ -67,21 +82,9 @@ class BackupManager( timezone = preferencesManager.timezone.first(), use24HourFormat = preferencesManager.use24HourFormat.first() ) + val backupDataWithPrefs = backupData.copy(preferences = prefs) - val backupData = BackupData( - notes = notes, - noteImages = noteImages, - checklistItems = checklistItems, - events = events, - routines = routines, - routineCompletions = routineCompletions, - transactions = transactions, - budgets = budgets, - labels = labels, - preferences = prefs - ) - - val json = gson.toJson(backupData) + val json = gson.toJson(backupDataWithPrefs) val dateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()) val fileName = "nana_backup_${dateFormat.format(Date())}.json" diff --git a/app/src/main/java/com/allubie/nana/data/NanaDatabase.kt b/app/src/main/java/com/allubie/nana/data/NanaDatabase.kt index 3998a7a..d334908 100644 --- a/app/src/main/java/com/allubie/nana/data/NanaDatabase.kt +++ b/app/src/main/java/com/allubie/nana/data/NanaDatabase.kt @@ -12,6 +12,7 @@ import com.allubie.nana.data.model.* @Database( entities = [ Note::class, + NoteFts::class, NoteImage::class, ChecklistItem::class, Event::class, @@ -21,7 +22,7 @@ import com.allubie.nana.data.model.* Budget::class, Label::class ], - version = 9, + version = 11, exportSchema = true ) abstract class NanaDatabase : RoomDatabase() { @@ -108,6 +109,42 @@ abstract class NanaDatabase : RoomDatabase() { database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_labels_name_type ON labels(name, type)") } } + + // Migration from version 9 to 10 - Add foreign key constraints + private val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(database: SupportSQLiteDatabase) { + // Recreate note_images with foreign key + database.execSQL("CREATE TABLE IF NOT EXISTS note_images_new (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, noteId INTEGER NOT NULL, imagePath TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0, createdAt INTEGER NOT NULL, FOREIGN KEY(noteId) REFERENCES notes(id) ON DELETE CASCADE)") + database.execSQL("INSERT INTO note_images_new (id, noteId, imagePath, position, createdAt) SELECT id, noteId, imagePath, position, createdAt FROM note_images") + database.execSQL("DROP TABLE note_images") + database.execSQL("ALTER TABLE note_images_new RENAME TO note_images") + database.execSQL("CREATE INDEX IF NOT EXISTS index_note_images_noteId ON note_images(noteId)") + + // Recreate checklist_items with foreign key + database.execSQL("CREATE TABLE IF NOT EXISTS checklist_items_new (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, noteId INTEGER NOT NULL, text TEXT NOT NULL, isChecked INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(noteId) REFERENCES notes(id) ON DELETE CASCADE)") + database.execSQL("INSERT INTO checklist_items_new (id, noteId, text, isChecked, position) SELECT id, noteId, text, isChecked, position FROM checklist_items") + database.execSQL("DROP TABLE checklist_items") + database.execSQL("ALTER TABLE checklist_items_new RENAME TO checklist_items") + database.execSQL("CREATE INDEX IF NOT EXISTS index_checklist_items_noteId ON checklist_items(noteId)") + + // Recreate routine_completions with foreign key + database.execSQL("CREATE TABLE IF NOT EXISTS routine_completions_new (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, routineId INTEGER NOT NULL, date TEXT NOT NULL, isCompleted INTEGER NOT NULL DEFAULT 0, currentCount INTEGER NOT NULL DEFAULT 0, elapsedSeconds INTEGER NOT NULL DEFAULT 0, completedAt INTEGER NOT NULL, FOREIGN KEY(routineId) REFERENCES routines(id) ON DELETE CASCADE)") + database.execSQL("INSERT INTO routine_completions_new (id, routineId, date, isCompleted, currentCount, elapsedSeconds, completedAt) SELECT id, routineId, date, isCompleted, currentCount, elapsedSeconds, completedAt FROM routine_completions") + database.execSQL("DROP TABLE routine_completions") + database.execSQL("ALTER TABLE routine_completions_new RENAME TO routine_completions") + database.execSQL("CREATE INDEX IF NOT EXISTS index_routine_completions_routineId ON routine_completions(routineId)") + database.execSQL("CREATE INDEX IF NOT EXISTS index_routine_completions_date ON routine_completions(date)") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_routine_completions_routineId_date ON routine_completions(routineId, date)") + } + } + + // Migration from version 10 to 11 - Add FTS for notes + private val MIGRATION_10_11 = object : Migration(10, 11) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE VIRTUAL TABLE IF NOT EXISTS `notes_fts` USING FTS4(`title`, `content`, content=`notes`)") + database.execSQL("INSERT INTO notes_fts(notes_fts) VALUES ('rebuild')") + } + } fun getDatabase(context: Context): NanaDatabase { return INSTANCE ?: synchronized(this) { @@ -116,7 +153,7 @@ abstract class NanaDatabase : RoomDatabase() { NanaDatabase::class.java, "nana_database" ) - .addMigrations(MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9) + .addMigrations(MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11) .build() INSTANCE = instance instance diff --git a/app/src/main/java/com/allubie/nana/data/PreferencesManager.kt b/app/src/main/java/com/allubie/nana/data/PreferencesManager.kt index 7b50a8d..aac7d77 100644 --- a/app/src/main/java/com/allubie/nana/data/PreferencesManager.kt +++ b/app/src/main/java/com/allubie/nana/data/PreferencesManager.kt @@ -7,7 +7,9 @@ import androidx.datastore.preferences.core.* import androidx.datastore.preferences.preferencesDataStore import com.allubie.nana.ui.theme.ThemeMode import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map +import java.io.IOException import java.util.Currency import java.util.Locale import java.util.TimeZone @@ -24,12 +26,44 @@ class PreferencesManager(private val context: Context) { val TOTAL_BUDGET = doublePreferencesKey("total_budget") val USE_24_HOUR_FORMAT = booleanPreferencesKey("use_24_hour_format") + private val knownCurrencySymbols = mapOf( + "USD" to "$", "EUR" to "€", "GBP" to "£", "JPY" to "¥", "CNY" to "¥", + "INR" to "₹", "CAD" to "C$", "AUD" to "A$", "CHF" to "Fr", "SEK" to "kr", + "NOK" to "kr", "DKK" to "kr", "PLN" to "zł", "CZK" to "Kč", "HUF" to "Ft", + "TRY" to "₺", "RUB" to "₽", "BRL" to "R$", "MXN" to "$", "ARS" to "$", + "COP" to "$", "CLP" to "$", "ZAR" to "R", "NGN" to "₦", "EGP" to "E£", + "KES" to "KSh", "GHS" to "₵", "AED" to "د.إ", "SAR" to "﷼", "QAR" to "﷼", + "KWD" to "د.ك", "THB" to "฿", "MYR" to "RM", "SGD" to "S$", "IDR" to "Rp", + "PHP" to "₱", "VND" to "₫", "PKR" to "₨", "LKR" to "₨", "TWD" to "NT$", + "HKD" to "HK$", "NZD" to "NZ$" + ) + + private fun resolveCurrencySymbol(code: String, rawSymbol: String?): String { + val cleanCode = code.trim().uppercase() + if (!rawSymbol.isNullOrBlank() && rawSymbol != cleanCode) { + return rawSymbol + } + val mappedSymbol = knownCurrencySymbols[cleanCode] + if (mappedSymbol != null) { + return mappedSymbol + } + return try { + val currency = Currency.getInstance(cleanCode) + val symbol = currency.getSymbol(Locale.US) + if (symbol.isNotBlank() && symbol != cleanCode) symbol else "$" + } catch (e: Exception) { + "$" + } + } + // Get default currency from device locale private fun getDefaultCurrency(): Pair { return try { val locale = Locale.getDefault() val currency = Currency.getInstance(locale) - Pair(currency.currencyCode, currency.symbol) + val code = currency.currencyCode + val symbol = resolveCurrencySymbol(code, currency.getSymbol(Locale.US)) + Pair(code, symbol) } catch (e: Exception) { Pair("USD", "$") } @@ -43,7 +77,9 @@ class PreferencesManager(private val context: Context) { private val defaultCurrency = getDefaultCurrency() private val defaultTimezone = getDefaultTimezone() - val themeMode: Flow = context.dataStore.data.map { preferences -> + val themeMode: Flow = context.dataStore.data + .catch { if (it is IOException) emit(emptyPreferences()) else throw it } + .map { preferences -> when (preferences[THEME_MODE]) { "light" -> ThemeMode.LIGHT "dark" -> ThemeMode.DARK @@ -52,23 +88,35 @@ class PreferencesManager(private val context: Context) { } } - val currencyCode: Flow = context.dataStore.data.map { preferences -> + val currencyCode: Flow = context.dataStore.data + .catch { if (it is IOException) emit(emptyPreferences()) else throw it } + .map { preferences -> preferences[CURRENCY_CODE] ?: defaultCurrency.first } - val currencySymbol: Flow = context.dataStore.data.map { preferences -> - preferences[CURRENCY_SYMBOL] ?: defaultCurrency.second + val currencySymbol: Flow = context.dataStore.data + .catch { if (it is IOException) emit(emptyPreferences()) else throw it } + .map { preferences -> + val code = preferences[CURRENCY_CODE] ?: defaultCurrency.first + val rawSymbol = preferences[CURRENCY_SYMBOL] ?: defaultCurrency.second + resolveCurrencySymbol(code, rawSymbol) } - val timezone: Flow = context.dataStore.data.map { preferences -> + val timezone: Flow = context.dataStore.data + .catch { if (it is IOException) emit(emptyPreferences()) else throw it } + .map { preferences -> preferences[TIMEZONE] ?: defaultTimezone } - val totalBudget: Flow = context.dataStore.data.map { preferences -> + val totalBudget: Flow = context.dataStore.data + .catch { if (it is IOException) emit(emptyPreferences()) else throw it } + .map { preferences -> preferences[TOTAL_BUDGET] ?: 0.0 } - val use24HourFormat: Flow = context.dataStore.data.map { preferences -> + val use24HourFormat: Flow = context.dataStore.data + .catch { if (it is IOException) emit(emptyPreferences()) else throw it } + .map { preferences -> preferences[USE_24_HOUR_FORMAT] ?: DateFormat.is24HourFormat(context) } diff --git a/app/src/main/java/com/allubie/nana/data/dao/NoteDao.kt b/app/src/main/java/com/allubie/nana/data/dao/NoteDao.kt index 20817f6..a6852e2 100644 --- a/app/src/main/java/com/allubie/nana/data/dao/NoteDao.kt +++ b/app/src/main/java/com/allubie/nana/data/dao/NoteDao.kt @@ -28,7 +28,7 @@ interface NoteDao { @Query("SELECT * FROM notes WHERE id = :id") suspend fun getNoteById(id: Long): Note? - @Query("SELECT * FROM notes WHERE (title LIKE '%' || :query || '%' OR content LIKE '%' || :query || '%') AND isDeleted = 0") + @Query("SELECT notes.* FROM notes JOIN notes_fts ON notes.rowid = notes_fts.docid WHERE notes_fts MATCH :query AND notes.isDeleted = 0 ORDER BY notes.isPinned DESC, notes.updatedAt DESC") fun searchNotes(query: String): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/app/src/main/java/com/allubie/nana/data/dao/TransactionDao.kt b/app/src/main/java/com/allubie/nana/data/dao/TransactionDao.kt index ed70385..05a3307 100644 --- a/app/src/main/java/com/allubie/nana/data/dao/TransactionDao.kt +++ b/app/src/main/java/com/allubie/nana/data/dao/TransactionDao.kt @@ -6,6 +6,11 @@ import com.allubie.nana.data.model.Transaction import com.allubie.nana.data.model.TransactionType import kotlinx.coroutines.flow.Flow +data class CategoryTotal( + val category: String, + val total: Double +) + @Dao interface TransactionDao { @Query("SELECT * FROM transactions ORDER BY date DESC") @@ -32,6 +37,9 @@ interface TransactionDao { @Query("SELECT SUM(amount) FROM transactions WHERE type = :type AND category = :category AND date >= :startDate AND date < :endDate") suspend fun getTotalByCategoryInRange(type: TransactionType, category: String, startDate: Long, endDate: Long): Double? + @Query("SELECT category, SUM(amount) as total FROM transactions WHERE type = :type AND date >= :startDate AND date < :endDate GROUP BY category") + fun getCategoryTotalsInRange(type: TransactionType, startDate: Long, endDate: Long): Flow> + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertTransaction(transaction: Transaction): Long diff --git a/app/src/main/java/com/allubie/nana/data/model/Note.kt b/app/src/main/java/com/allubie/nana/data/model/Note.kt index d5de3bf..87de547 100644 --- a/app/src/main/java/com/allubie/nana/data/model/Note.kt +++ b/app/src/main/java/com/allubie/nana/data/model/Note.kt @@ -1,6 +1,7 @@ package com.allubie.nana.data.model import androidx.room.Entity +import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey @@ -29,7 +30,13 @@ data class Note( @Entity( tableName = "note_images", - indices = [Index(value = ["noteId"])] + indices = [Index(value = ["noteId"])], + foreignKeys = [ForeignKey( + entity = Note::class, + parentColumns = ["id"], + childColumns = ["noteId"], + onDelete = ForeignKey.CASCADE + )] ) data class NoteImage( @PrimaryKey(autoGenerate = true) @@ -42,7 +49,13 @@ data class NoteImage( @Entity( tableName = "checklist_items", - indices = [Index(value = ["noteId"])] + indices = [Index(value = ["noteId"])], + foreignKeys = [ForeignKey( + entity = Note::class, + parentColumns = ["id"], + childColumns = ["noteId"], + onDelete = ForeignKey.CASCADE + )] ) data class ChecklistItem( @PrimaryKey(autoGenerate = true) diff --git a/app/src/main/java/com/allubie/nana/data/model/NoteFts.kt b/app/src/main/java/com/allubie/nana/data/model/NoteFts.kt new file mode 100644 index 0000000..54611c6 --- /dev/null +++ b/app/src/main/java/com/allubie/nana/data/model/NoteFts.kt @@ -0,0 +1,11 @@ +package com.allubie.nana.data.model + +import androidx.room.Entity +import androidx.room.Fts4 + +@Entity(tableName = "notes_fts") +@Fts4(contentEntity = Note::class) +data class NoteFts( + val title: String, + val content: String +) diff --git a/app/src/main/java/com/allubie/nana/data/model/Routine.kt b/app/src/main/java/com/allubie/nana/data/model/Routine.kt index 938fa04..4f2a701 100644 --- a/app/src/main/java/com/allubie/nana/data/model/Routine.kt +++ b/app/src/main/java/com/allubie/nana/data/model/Routine.kt @@ -1,6 +1,7 @@ package com.allubie.nana.data.model import androidx.room.Entity +import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey @@ -43,7 +44,13 @@ data class Routine( Index(value = ["routineId"]), Index(value = ["date"]), Index(value = ["routineId", "date"], unique = true) - ] + ], + foreignKeys = [ForeignKey( + entity = Routine::class, + parentColumns = ["id"], + childColumns = ["routineId"], + onDelete = ForeignKey.CASCADE + )] ) data class RoutineCompletion( @PrimaryKey(autoGenerate = true) diff --git a/app/src/main/java/com/allubie/nana/data/repository/EventRepository.kt b/app/src/main/java/com/allubie/nana/data/repository/EventRepository.kt new file mode 100644 index 0000000..443b1aa --- /dev/null +++ b/app/src/main/java/com/allubie/nana/data/repository/EventRepository.kt @@ -0,0 +1,19 @@ +package com.allubie.nana.data.repository + +import com.allubie.nana.data.dao.* +import com.allubie.nana.data.model.* +import kotlinx.coroutines.flow.Flow + +class EventRepository( + private val eventDao: EventDao +) { + fun getAllEvents(): Flow> = eventDao.getAllEvents() + fun getEventsForDay(startOfDay: Long, endOfDay: Long): Flow> = eventDao.getEventsForDay(startOfDay, endOfDay) + fun getEventsInRange(startTime: Long, endTime: Long): Flow> = eventDao.getEventsInRange(startTime, endTime) + suspend fun getEventById(id: Long): Event? = eventDao.getEventById(id) + fun getUpcomingEvents(now: Long, limit: Int = 10): Flow> = eventDao.getUpcomingEvents(now, limit) + suspend fun insertEvent(event: Event): Long = eventDao.insertEvent(event) + suspend fun updateEvent(event: Event) = eventDao.updateEvent(event) + suspend fun deleteEvent(event: Event) = eventDao.deleteEvent(event) + suspend fun deleteEventById(id: Long) = eventDao.deleteEventById(id) +} diff --git a/app/src/main/java/com/allubie/nana/data/repository/NoteRepository.kt b/app/src/main/java/com/allubie/nana/data/repository/NoteRepository.kt new file mode 100644 index 0000000..8fa8701 --- /dev/null +++ b/app/src/main/java/com/allubie/nana/data/repository/NoteRepository.kt @@ -0,0 +1,53 @@ +package com.allubie.nana.data.repository + +import com.allubie.nana.data.dao.* +import com.allubie.nana.data.model.* +import kotlinx.coroutines.flow.Flow + +class NoteRepository( + private val noteDao: NoteDao, + private val noteImageDao: NoteImageDao, + private val checklistItemDao: ChecklistItemDao +) { + // --- NoteDao --- + fun getAllNotes(): Flow> = noteDao.getAllNotes() + fun getRecentNonChecklistNotes(limit: Int = 3): Flow> = noteDao.getRecentNonChecklistNotes(limit) + suspend fun getRecentNonChecklistNotesOnce(limit: Int = 3): List = noteDao.getRecentNonChecklistNotesOnce(limit) + fun getPinnedNotes(): Flow> = noteDao.getPinnedNotes() + fun getArchivedNotes(): Flow> = noteDao.getArchivedNotes() + fun getDeletedNotes(): Flow> = noteDao.getDeletedNotes() + suspend fun getNoteById(id: Long): Note? = noteDao.getNoteById(id) + fun searchNotes(query: String): Flow> { + val sanitizedQuery = query.replace(Regex("[^a-zA-Z0-9 ]"), "").trim() + val ftsQuery = if (sanitizedQuery.isNotBlank()) { + sanitizedQuery.split("\\s+".toRegex()).joinToString(" ") { "$it*" } + } else { + "" + } + return noteDao.searchNotes(ftsQuery) + } + suspend fun insertNote(note: Note): Long = noteDao.insertNote(note) + suspend fun updateNote(note: Note) = noteDao.updateNote(note) + suspend fun updatePinStatus(id: Long, isPinned: Boolean) = noteDao.updatePinStatus(id, isPinned) + suspend fun updateArchiveStatus(id: Long, isArchived: Boolean) = noteDao.updateArchiveStatus(id, isArchived) + suspend fun updateDeleteStatus(id: Long, isDeleted: Boolean) = noteDao.updateDeleteStatus(id, isDeleted) + suspend fun emptyTrash() = noteDao.emptyTrash() + suspend fun deleteNote(note: Note) = noteDao.deleteNote(note) + + // --- NoteImageDao --- + fun getImagesForNote(noteId: Long): Flow> = noteImageDao.getImagesForNote(noteId) + suspend fun getImagesForNoteSync(noteId: Long): List = noteImageDao.getImagesForNoteSync(noteId) + suspend fun insertImage(image: NoteImage): Long = noteImageDao.insertImage(image) + suspend fun deleteImage(image: NoteImage) = noteImageDao.deleteImage(image) + suspend fun deleteImagesForNote(noteId: Long) = noteImageDao.deleteImagesForNote(noteId) + suspend fun deleteImageById(imageId: Long) = noteImageDao.deleteImageById(imageId) + + // --- ChecklistItemDao --- + fun getItemsForNote(noteId: Long): Flow> = checklistItemDao.getItemsForNote(noteId) + suspend fun getItemById(id: Long): ChecklistItem? = checklistItemDao.getItemById(id) + suspend fun insertItem(item: ChecklistItem): Long = checklistItemDao.insertItem(item) + suspend fun insertItems(items: List) = checklistItemDao.insertItems(items) + suspend fun updateItem(item: ChecklistItem) = checklistItemDao.updateItem(item) + suspend fun deleteItem(item: ChecklistItem) = checklistItemDao.deleteItem(item) + suspend fun deleteItemsForNote(noteId: Long) = checklistItemDao.deleteItemsForNote(noteId) +} diff --git a/app/src/main/java/com/allubie/nana/data/repository/RoutineRepository.kt b/app/src/main/java/com/allubie/nana/data/repository/RoutineRepository.kt new file mode 100644 index 0000000..0748c13 --- /dev/null +++ b/app/src/main/java/com/allubie/nana/data/repository/RoutineRepository.kt @@ -0,0 +1,30 @@ +package com.allubie.nana.data.repository + +import com.allubie.nana.data.dao.* +import com.allubie.nana.data.model.* +import kotlinx.coroutines.flow.Flow + +class RoutineRepository( + private val routineDao: RoutineDao, + private val routineCompletionDao: RoutineCompletionDao +) { + // --- RoutineDao --- + fun getActiveRoutines(): Flow> = routineDao.getActiveRoutines() + fun getAllRoutines(): Flow> = routineDao.getAllRoutines() + suspend fun getRoutineById(id: Long): Routine? = routineDao.getRoutineById(id) + suspend fun insertRoutine(routine: Routine): Long = routineDao.insertRoutine(routine) + suspend fun updateRoutine(routine: Routine) = routineDao.updateRoutine(routine) + suspend fun deleteRoutine(routine: Routine) = routineDao.deleteRoutine(routine) + suspend fun deleteRoutineById(id: Long) = routineDao.deleteRoutineById(id) + + // --- RoutineCompletionDao --- + fun getCompletionsForRoutine(routineId: Long): Flow> = routineCompletionDao.getCompletionsForRoutine(routineId) + suspend fun getCompletionForDate(routineId: Long, date: String): RoutineCompletion? = routineCompletionDao.getCompletionForDate(routineId, date) + fun getCompletionsForDate(date: String): Flow> = routineCompletionDao.getCompletionsForDate(date) + fun getCompletionsInRange(startDate: String, endDate: String): Flow> = routineCompletionDao.getCompletionsInRange(startDate, endDate) + suspend fun getTotalCompletions(routineId: Long): Int = routineCompletionDao.getTotalCompletions(routineId) + suspend fun insertCompletion(completion: RoutineCompletion): Long = routineCompletionDao.insertCompletion(completion) + suspend fun deleteCompletion(completion: RoutineCompletion) = routineCompletionDao.deleteCompletion(completion) + suspend fun deleteCompletionForDate(routineId: Long, date: String) = routineCompletionDao.deleteCompletionForDate(routineId, date) + suspend fun deleteCompletionsForRoutine(routineId: Long) = routineCompletionDao.deleteCompletionsForRoutine(routineId) +} diff --git a/app/src/main/java/com/allubie/nana/data/repository/TransactionRepository.kt b/app/src/main/java/com/allubie/nana/data/repository/TransactionRepository.kt new file mode 100644 index 0000000..20978a0 --- /dev/null +++ b/app/src/main/java/com/allubie/nana/data/repository/TransactionRepository.kt @@ -0,0 +1,39 @@ +package com.allubie.nana.data.repository + +import com.allubie.nana.data.dao.* +import com.allubie.nana.data.model.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class TransactionRepository( + private val transactionDao: TransactionDao, + private val budgetDao: BudgetDao +) { + // --- TransactionDao --- + fun getAllTransactions(): Flow> = transactionDao.getAllTransactions() + fun getTransactionsInRange(startDate: Long, endDate: Long): Flow> = transactionDao.getTransactionsInRange(startDate, endDate) + fun getTransactionsByType(type: TransactionType): Flow> = transactionDao.getTransactionsByType(type) + fun getTransactionsByCategory(category: String): Flow> = transactionDao.getTransactionsByCategory(category) + suspend fun getTransactionById(id: Long): Transaction? = transactionDao.getTransactionById(id) + fun getRecentTransactions(limit: Int = 10): Flow> = transactionDao.getRecentTransactions(limit) + suspend fun getTotalByTypeInRange(type: TransactionType, startDate: Long, endDate: Long): Double? = transactionDao.getTotalByTypeInRange(type, startDate, endDate) + suspend fun getTotalByCategoryInRange(type: TransactionType, category: String, startDate: Long, endDate: Long): Double? = transactionDao.getTotalByCategoryInRange(type, category, startDate, endDate) + suspend fun insertTransaction(transaction: Transaction): Long = transactionDao.insertTransaction(transaction) + suspend fun updateTransaction(transaction: Transaction) = transactionDao.updateTransaction(transaction) + suspend fun deleteTransaction(transaction: Transaction) = transactionDao.deleteTransaction(transaction) + suspend fun deleteTransactionById(id: Long) = transactionDao.deleteTransactionById(id) + + // --- BudgetDao --- + fun getAllBudgets(): Flow> = budgetDao.getAllBudgets() + suspend fun getOverallBudget(): Budget? = budgetDao.getOverallBudget() + suspend fun getBudgetForCategory(category: String): Budget? = budgetDao.getBudgetForCategory(category) + suspend fun getBudgetById(id: Long): Budget? = budgetDao.getBudgetById(id) + suspend fun insertBudget(budget: Budget): Long = budgetDao.insertBudget(budget) + suspend fun updateBudget(budget: Budget) = budgetDao.updateBudget(budget) + suspend fun deleteBudget(budget: Budget) = budgetDao.deleteBudget(budget) + + fun getCategorySpending(startDate: Long, endDate: Long): Flow> { + return transactionDao.getCategoryTotalsInRange(TransactionType.EXPENSE, startDate, endDate) + .map { totals -> totals.associate { it.category to it.total } } + } +} diff --git a/app/src/main/java/com/allubie/nana/notification/BootReceiver.kt b/app/src/main/java/com/allubie/nana/notification/BootReceiver.kt index ad00578..7a61ff6 100644 --- a/app/src/main/java/com/allubie/nana/notification/BootReceiver.kt +++ b/app/src/main/java/com/allubie/nana/notification/BootReceiver.kt @@ -3,6 +3,7 @@ package com.allubie.nana.notification import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -26,7 +27,7 @@ class BootReceiver : BroadcastReceiver() { updateAllWidgets(context) } } catch (e: Exception) { - e.printStackTrace() + Log.e("BootReceiver", "Failed to reschedule reminders", e) } finally { pendingResult.finish() } diff --git a/app/src/main/java/com/allubie/nana/notification/NotificationHelper.kt b/app/src/main/java/com/allubie/nana/notification/NotificationHelper.kt index 518c658..9f37467 100644 --- a/app/src/main/java/com/allubie/nana/notification/NotificationHelper.kt +++ b/app/src/main/java/com/allubie/nana/notification/NotificationHelper.kt @@ -6,6 +6,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build +import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.allubie.nana.MainActivity @@ -96,7 +97,7 @@ object NotificationHelper { NotificationManagerCompat.from(context).notify(notificationId, notification) } catch (e: SecurityException) { // Permission not granted - e.printStackTrace() + Log.e("NotificationHelper", "Failed to show event notification", e) } } @@ -153,7 +154,7 @@ object NotificationHelper { try { NotificationManagerCompat.from(context).notify(notificationId, notification) } catch (e: SecurityException) { - e.printStackTrace() + Log.e("NotificationHelper", "Failed to show routine notification", e) } } diff --git a/app/src/main/java/com/allubie/nana/notification/ReminderReceiver.kt b/app/src/main/java/com/allubie/nana/notification/ReminderReceiver.kt index 07fc998..bb3bdb1 100644 --- a/app/src/main/java/com/allubie/nana/notification/ReminderReceiver.kt +++ b/app/src/main/java/com/allubie/nana/notification/ReminderReceiver.kt @@ -3,6 +3,7 @@ package com.allubie.nana.notification import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.util.Log import androidx.core.app.NotificationManagerCompat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -122,7 +123,7 @@ class ReminderReceiver : BroadcastReceiver() { } } } catch (e: Exception) { - e.printStackTrace() + Log.e("ReminderReceiver", "Failed to mark routine done", e) } finally { pendingResult.finish() } @@ -140,7 +141,7 @@ class ReminderReceiver : BroadcastReceiver() { ReminderScheduler.rescheduleAllReminders(context, it.database) } } catch (e: Exception) { - e.printStackTrace() + Log.e("ReminderReceiver", "Failed to reschedule reminders on boot", e) } finally { pendingResult.finish() } diff --git a/app/src/main/java/com/allubie/nana/notification/ReminderScheduler.kt b/app/src/main/java/com/allubie/nana/notification/ReminderScheduler.kt index 12eafae..066f060 100644 --- a/app/src/main/java/com/allubie/nana/notification/ReminderScheduler.kt +++ b/app/src/main/java/com/allubie/nana/notification/ReminderScheduler.kt @@ -129,8 +129,6 @@ object ReminderScheduler { val hour = timeParts[0].toIntOrNull() ?: return val minute = timeParts[1].toIntOrNull() ?: return - - getAlarmManager(context) // Schedule for each day of the week val daysToSchedule = if (scheduledDays.isEmpty()) { diff --git a/app/src/main/java/com/allubie/nana/ui/components/Dialogs.kt b/app/src/main/java/com/allubie/nana/ui/components/Dialogs.kt index 5434012..e7af3c1 100644 --- a/app/src/main/java/com/allubie/nana/ui/components/Dialogs.kt +++ b/app/src/main/java/com/allubie/nana/ui/components/Dialogs.kt @@ -13,15 +13,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -/** - * Material 3 confirmation dialog for destructive and non-destructive actions. - * - * Follows M3 guidelines: - * - Optional hero icon above title - * - Dismiss button on the left, confirm on the right - * - Destructive confirm button uses error color - * - Shape: RoundedCornerShape(28.dp) (M3 default) - */ + @Composable fun NanaConfirmationDialog( onDismiss: () -> Unit, @@ -70,11 +62,6 @@ fun NanaConfirmationDialog( ) } -/** - * Material 3 single-selection dialog with radio buttons. - * - * Follows M3 guidelines for simple dialogs with list items. - */ @Composable fun NanaSelectionDialog( onDismiss: () -> Unit, @@ -120,11 +107,6 @@ fun NanaSelectionDialog( ) } -/** - * Material 3 searchable list dialog with radio-button items. - * - * Includes an OutlinedTextField for filtering and a scrollable list below. - */ @Composable fun NanaSearchableListDialog( onDismiss: () -> Unit, diff --git a/app/src/main/java/com/allubie/nana/ui/components/SettingsComponents.kt b/app/src/main/java/com/allubie/nana/ui/components/SettingsComponents.kt index 33c2dab..b77593d 100644 --- a/app/src/main/java/com/allubie/nana/ui/components/SettingsComponents.kt +++ b/app/src/main/java/com/allubie/nana/ui/components/SettingsComponents.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp /** @@ -92,6 +93,7 @@ fun SettingsItem( }, modifier = Modifier .fillMaxWidth() + .semantics(mergeDescendants = true) {} .clickable { onClick() } ) } @@ -135,6 +137,7 @@ fun SettingsItemWithSwitch( }, modifier = Modifier .fillMaxWidth() + .semantics(mergeDescendants = true) {} .clickable { onCheckedChange(!checked) } ) } diff --git a/app/src/main/java/com/allubie/nana/ui/navigation/NanaNavHost.kt b/app/src/main/java/com/allubie/nana/ui/navigation/NanaNavHost.kt index 526056e..101435e 100644 --- a/app/src/main/java/com/allubie/nana/ui/navigation/NanaNavHost.kt +++ b/app/src/main/java/com/allubie/nana/ui/navigation/NanaNavHost.kt @@ -1,11 +1,14 @@ package com.allubie.nana.ui.navigation import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -29,49 +32,106 @@ import com.allubie.nana.ui.screens.schedule.ScheduleScreen import com.allubie.nana.ui.screens.schedule.ScheduleViewerScreen import com.allubie.nana.ui.screens.settings.SettingsScreen +// ── Shared transition helpers ──────────────────────────────────────────────── +// All sub-screens share the same enter/popEnter/popExit animations. +// Only the exit animation varies: slideOutLeft for screens that push deeper, +// fadeOut for leaf/editor screens. + +private const val TRANSITION_DURATION_MS = 300 + +/** Sub-screen enter: slide in from the right edge (visually "pushed" onto the stack). */ +private val subScreenEnter: AnimatedContentTransitionScope.() -> EnterTransition = { + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(TRANSITION_DURATION_MS)) +} + +/** Sub-screen pop-enter: slide back in from the left edge (returning from a deeper screen). */ +private val subScreenPopEnter: AnimatedContentTransitionScope.() -> EnterTransition = { + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(TRANSITION_DURATION_MS)) +} + +/** Sub-screen pop-exit: slide out to the right edge (being popped off the stack). */ +private val subScreenPopExit: AnimatedContentTransitionScope.() -> ExitTransition = { + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(TRANSITION_DURATION_MS)) +} + +/** Exit by sliding out left – used by screens that can navigate deeper (viewers, settings). */ +private val exitSlideLeft: AnimatedContentTransitionScope.() -> ExitTransition = { + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(TRANSITION_DURATION_MS)) +} + +/** Exit with a fade – used by leaf/editor screens that don't push further. */ +private val exitFade: AnimatedContentTransitionScope.() -> ExitTransition = { + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)) +} + +// ── Main-tab transition helpers ────────────────────────────────────────────── +// Main tabs use conditional transitions: slide when navigating to/from child +// sub-screens, fade when switching between sibling tabs. + +private fun mainTabEnter( + childRoutes: Set +): AnimatedContentTransitionScope.() -> EnterTransition = { + if (initialState.destination.route in childRoutes) + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(TRANSITION_DURATION_MS)) + else + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)) +} + +private fun mainTabExit( + childRoutes: Set +): AnimatedContentTransitionScope.() -> ExitTransition = { + if (targetState.destination.route in childRoutes) + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(TRANSITION_DURATION_MS)) + else + fadeOut(animationSpec = tween(TRANSITION_DURATION_MS)) +} + +private fun mainTabPopEnter( + childRoutes: Set +): AnimatedContentTransitionScope.() -> EnterTransition = { + if (initialState.destination.route in childRoutes) + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(TRANSITION_DURATION_MS)) + else + fadeIn(animationSpec = tween(TRANSITION_DURATION_MS)) +} + +// ── NanaNavHost ────────────────────────────────────────────────────────────── + @Composable fun NanaNavHost( navController: NavHostController, modifier: Modifier = Modifier ) { + // Child route sets for each main tab's conditional transitions + val notesChildRoutes = setOf( + Screen.NoteViewer.route, Screen.NoteEditor.route, + Screen.ChecklistEditor.route, Screen.NotesArchive.route, + Screen.NotesTrash.route, Screen.Settings.route + ) + val scheduleChildRoutes = setOf( + Screen.ScheduleViewer.route, Screen.ScheduleEditor.route, Screen.Settings.route + ) + val routinesChildRoutes = setOf( + Screen.RoutineEditor.route, Screen.RoutineStatistics.route, Screen.Settings.route + ) + val financesChildRoutes = setOf( + Screen.TransactionEditor.route, Screen.FinancesOverview.route, + Screen.BudgetManager.route, Screen.Settings.route + ) + NavHost( navController = navController, startDestination = Screen.Notes.route, modifier = modifier ) { - // Main screens - with slide transitions for navigating to/from sub-screens + // ── Main tab screens ───────────────────────────────────────────── + composable( route = Screen.Notes.route, - enterTransition = { - when (initialState.destination.route) { - Screen.NoteViewer.route, Screen.NoteEditor.route, - Screen.ChecklistEditor.route, Screen.NotesArchive.route, - Screen.NotesTrash.route, Screen.Settings.route -> - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - else -> fadeIn(animationSpec = tween(300)) - } - }, - exitTransition = { - when (targetState.destination.route) { - Screen.NoteViewer.route, Screen.NoteEditor.route, - Screen.ChecklistEditor.route, Screen.NotesArchive.route, - Screen.NotesTrash.route, Screen.Settings.route -> - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - else -> fadeOut(animationSpec = tween(300)) - } - }, - popEnterTransition = { - when (initialState.destination.route) { - Screen.NoteViewer.route, Screen.NoteEditor.route, - Screen.ChecklistEditor.route, Screen.NotesArchive.route, - Screen.NotesTrash.route, Screen.Settings.route -> - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - else -> fadeIn(animationSpec = tween(300)) - } - }, - popExitTransition = { - fadeOut(animationSpec = tween(300)) - } + enterTransition = mainTabEnter(notesChildRoutes), + exitTransition = mainTabExit(notesChildRoutes), + popEnterTransition = mainTabPopEnter(notesChildRoutes), + popExitTransition = exitFade ) { NotesScreen( onNavigateToViewer = { noteId -> @@ -94,33 +154,13 @@ fun NanaNavHost( } ) } - + composable( route = Screen.Schedule.route, - enterTransition = { - when (initialState.destination.route) { - Screen.ScheduleViewer.route, Screen.ScheduleEditor.route, Screen.Settings.route -> - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - else -> fadeIn(animationSpec = tween(300)) - } - }, - exitTransition = { - when (targetState.destination.route) { - Screen.ScheduleViewer.route, Screen.ScheduleEditor.route, Screen.Settings.route -> - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - else -> fadeOut(animationSpec = tween(300)) - } - }, - popEnterTransition = { - when (initialState.destination.route) { - Screen.ScheduleViewer.route, Screen.ScheduleEditor.route, Screen.Settings.route -> - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - else -> fadeIn(animationSpec = tween(300)) - } - }, - popExitTransition = { - fadeOut(animationSpec = tween(300)) - } + enterTransition = mainTabEnter(scheduleChildRoutes), + exitTransition = mainTabExit(scheduleChildRoutes), + popEnterTransition = mainTabPopEnter(scheduleChildRoutes), + popExitTransition = exitFade ) { ScheduleScreen( onNavigateToViewer = { eventId -> @@ -134,33 +174,13 @@ fun NanaNavHost( } ) } - + composable( route = Screen.Routines.route, - enterTransition = { - when (initialState.destination.route) { - Screen.RoutineEditor.route, Screen.RoutineStatistics.route, Screen.Settings.route -> - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - else -> fadeIn(animationSpec = tween(300)) - } - }, - exitTransition = { - when (targetState.destination.route) { - Screen.RoutineEditor.route, Screen.RoutineStatistics.route, Screen.Settings.route -> - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - else -> fadeOut(animationSpec = tween(300)) - } - }, - popEnterTransition = { - when (initialState.destination.route) { - Screen.RoutineEditor.route, Screen.RoutineStatistics.route, Screen.Settings.route -> - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - else -> fadeIn(animationSpec = tween(300)) - } - }, - popExitTransition = { - fadeOut(animationSpec = tween(300)) - } + enterTransition = mainTabEnter(routinesChildRoutes), + exitTransition = mainTabExit(routinesChildRoutes), + popEnterTransition = mainTabPopEnter(routinesChildRoutes), + popExitTransition = exitFade ) { RoutinesScreen( onNavigateToEditor = { routineId -> @@ -174,36 +194,13 @@ fun NanaNavHost( } ) } - + composable( route = Screen.Finances.route, - enterTransition = { - when (initialState.destination.route) { - Screen.TransactionEditor.route, Screen.FinancesOverview.route, - Screen.BudgetManager.route, Screen.Settings.route -> - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - else -> fadeIn(animationSpec = tween(300)) - } - }, - exitTransition = { - when (targetState.destination.route) { - Screen.TransactionEditor.route, Screen.FinancesOverview.route, - Screen.BudgetManager.route, Screen.Settings.route -> - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - else -> fadeOut(animationSpec = tween(300)) - } - }, - popEnterTransition = { - when (initialState.destination.route) { - Screen.TransactionEditor.route, Screen.FinancesOverview.route, - Screen.BudgetManager.route, Screen.Settings.route -> - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - else -> fadeIn(animationSpec = tween(300)) - } - }, - popExitTransition = { - fadeOut(animationSpec = tween(300)) - } + enterTransition = mainTabEnter(financesChildRoutes), + exitTransition = mainTabExit(financesChildRoutes), + popEnterTransition = mainTabPopEnter(financesChildRoutes), + popExitTransition = exitFade ) { FinancesScreen( onNavigateToEditor = { transactionId -> @@ -220,23 +217,16 @@ fun NanaNavHost( } ) } - - // Sub-screens with slide animation + + // ── Viewer sub-screens (slide-out-left exit: can push deeper) ──── + composable( route = Screen.NoteViewer.route, arguments = listOf(navArgument("noteId") { type = NavType.LongType }), - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } + enterTransition = subScreenEnter, + exitTransition = exitSlideLeft, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit ) { backStackEntry -> val noteId = backStackEntry.arguments?.getLong("noteId") ?: -1 NoteViewerScreen( @@ -247,22 +237,75 @@ fun NanaNavHost( } ) } - + + composable( + route = Screen.NotesArchive.route, + enterTransition = subScreenEnter, + exitTransition = exitSlideLeft, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit + ) { + NotesArchiveScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToEditor = { noteId -> + navController.navigate(Screen.NoteEditor.createRoute(noteId)) + } + ) + } + + composable( + route = Screen.ScheduleViewer.route, + arguments = listOf(navArgument("eventId") { type = NavType.LongType }), + enterTransition = subScreenEnter, + exitTransition = exitSlideLeft, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit + ) { backStackEntry -> + val eventId = backStackEntry.arguments?.getLong("eventId") ?: -1 + ScheduleViewerScreen( + eventId = eventId, + onNavigateBack = { navController.popBackStack() }, + onNavigateToEditor = { + navController.navigate(Screen.ScheduleEditor.createRoute(eventId)) + } + ) + } + + composable( + route = Screen.BudgetManager.route, + enterTransition = subScreenEnter, + exitTransition = exitSlideLeft, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit + ) { + BudgetManagerScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToSettings = { navController.navigate(Screen.Settings.route) } + ) + } + + composable( + route = Screen.Settings.route, + enterTransition = subScreenEnter, + exitTransition = exitSlideLeft, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit + ) { + SettingsScreen( + onNavigateBack = { navController.popBackStack() }, + onNavigateToLabels = { navController.navigate(Screen.LabelsAndCategories.route) } + ) + } + + // ── Leaf/editor sub-screens (fade exit: don't push deeper) ─────── + composable( route = Screen.NoteEditor.route, arguments = listOf(navArgument("noteId") { type = NavType.LongType }), - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - fadeOut(animationSpec = tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } + enterTransition = subScreenEnter, + exitTransition = exitFade, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit ) { backStackEntry -> val noteId = backStackEntry.arguments?.getLong("noteId") ?: -1 NoteEditorScreen( @@ -270,22 +313,14 @@ fun NanaNavHost( onNavigateBack = { navController.popBackStack() } ) } - + composable( route = Screen.ChecklistEditor.route, arguments = listOf(navArgument("noteId") { type = NavType.LongType }), - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - fadeOut(animationSpec = tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } + enterTransition = subScreenEnter, + exitTransition = exitFade, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit ) { backStackEntry -> val noteId = backStackEntry.arguments?.getLong("noteId") ?: -1 ChecklistEditorScreen( @@ -293,91 +328,26 @@ fun NanaNavHost( onNavigateBack = { navController.popBackStack() } ) } - - composable( - route = Screen.NotesArchive.route, - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } - ) { - NotesArchiveScreen( - onNavigateBack = { navController.popBackStack() }, - onNavigateToEditor = { noteId -> - navController.navigate(Screen.NoteEditor.createRoute(noteId)) - } - ) - } - + composable( route = Screen.NotesTrash.route, - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - fadeOut(animationSpec = tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } + enterTransition = subScreenEnter, + exitTransition = exitFade, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit ) { NotesTrashScreen( onNavigateBack = { navController.popBackStack() } ) } - - composable( - route = Screen.ScheduleViewer.route, - arguments = listOf(navArgument("eventId") { type = NavType.LongType }), - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } - ) { backStackEntry -> - val eventId = backStackEntry.arguments?.getLong("eventId") ?: -1 - ScheduleViewerScreen( - eventId = eventId, - onNavigateBack = { navController.popBackStack() }, - onNavigateToEditor = { - navController.navigate(Screen.ScheduleEditor.createRoute(eventId)) - } - ) - } - + composable( route = Screen.ScheduleEditor.route, arguments = listOf(navArgument("eventId") { type = NavType.LongType }), - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - fadeOut(animationSpec = tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } + enterTransition = subScreenEnter, + exitTransition = exitFade, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit ) { backStackEntry -> val eventId = backStackEntry.arguments?.getLong("eventId") ?: -1 ScheduleEditorScreen( @@ -385,22 +355,14 @@ fun NanaNavHost( onNavigateBack = { navController.popBackStack() } ) } - + composable( route = Screen.RoutineEditor.route, arguments = listOf(navArgument("routineId") { type = NavType.LongType }), - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - fadeOut(animationSpec = tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } + enterTransition = subScreenEnter, + exitTransition = exitFade, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit ) { backStackEntry -> val routineId = backStackEntry.arguments?.getLong("routineId") ?: -1 RoutineEditorScreen( @@ -408,83 +370,38 @@ fun NanaNavHost( onNavigateBack = { navController.popBackStack() } ) } - + composable( route = Screen.RoutineStatistics.route, - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - fadeOut(animationSpec = tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } + enterTransition = subScreenEnter, + exitTransition = exitFade, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit ) { RoutineStatisticsScreen( onNavigateBack = { navController.popBackStack() } ) } - + composable( route = Screen.FinancesOverview.route, - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - fadeOut(animationSpec = tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } + enterTransition = subScreenEnter, + exitTransition = exitFade, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit ) { FinancesOverviewScreen( onNavigateBack = { navController.popBackStack() } ) } - - composable( - route = Screen.BudgetManager.route, - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } - ) { - BudgetManagerScreen( - onNavigateBack = { navController.popBackStack() }, - onNavigateToSettings = { navController.navigate(Screen.Settings.route) } - ) - } - + composable( route = Screen.TransactionEditor.route, arguments = listOf(navArgument("transactionId") { type = NavType.LongType }), - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - fadeOut(animationSpec = tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } + enterTransition = subScreenEnter, + exitTransition = exitFade, + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit ) { backStackEntry -> val transactionId = backStackEntry.arguments?.getLong("transactionId") ?: -1 TransactionEditorScreen( @@ -492,42 +409,13 @@ fun NanaNavHost( onNavigateBack = { navController.popBackStack() } ) } - - composable( - route = Screen.Settings.route, - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } - ) { - SettingsScreen( - onNavigateBack = { navController.popBackStack() }, - onNavigateToLabels = { navController.navigate(Screen.LabelsAndCategories.route) } - ) - } - + composable( route = Screen.LabelsAndCategories.route, - enterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, tween(300)) - }, - exitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popEnterTransition = { - slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - }, - popExitTransition = { - slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, tween(300)) - } + enterTransition = subScreenEnter, + exitTransition = exitFade, // Fixed: was incorrectly slideOutRight + popEnterTransition = subScreenPopEnter, + popExitTransition = subScreenPopExit ) { com.allubie.nana.ui.screens.settings.LabelsAndCategoriesScreen( database = com.allubie.nana.data.NanaDatabase.getDatabase( diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerScreen.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerScreen.kt index e3f5cbf..f170edf 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerScreen.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerScreen.kt @@ -34,12 +34,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.allubie.nana.data.model.Budget import com.allubie.nana.data.model.BudgetPeriod import com.allubie.nana.data.model.ExpenseCategories import com.allubie.nana.util.CurrencyFormatter import java.util.Calendar import com.allubie.nana.ui.theme.* +import androidx.compose.ui.res.stringResource +import com.allubie.nana.R // Category colors - using theme colors private val CategoryColors = mapOf( @@ -117,13 +120,14 @@ fun BudgetManagerScreen( onNavigateToSettings: () -> Unit = {}, viewModel: BudgetManagerViewModel = viewModel(factory = BudgetManagerViewModel.Factory) ) { - val budgets by viewModel.budgets.collectAsState() - val totalBudget by viewModel.totalBudget.collectAsState() - val totalBudgetLimit by viewModel.totalBudgetLimit.collectAsState() - val totalSpent by viewModel.totalSpent.collectAsState() - val selectedMonth by viewModel.selectedMonth.collectAsState() - val categorySpending by viewModel.categorySpending.collectAsState() - val currencySymbol by viewModel.currencySymbol.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val budgets = uiState.budgets + val totalBudget = uiState.totalBudget + val totalBudgetLimit = uiState.totalBudgetLimit + val totalSpent = uiState.totalSpent + val selectedMonth = uiState.selectedMonth + val categorySpending = uiState.categorySpending + val currencySymbol = uiState.currencySymbol var showAddBudgetDialog by remember { mutableStateOf(false) } var editingBudget by remember { mutableStateOf(null) } @@ -192,7 +196,7 @@ fun BudgetManagerScreen( TopAppBar( title = { Text( - text = "Monthly Budget", + text = stringResource(R.string.title_monthly_budget), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) @@ -201,7 +205,7 @@ fun BudgetManagerScreen( IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + contentDescription = stringResource(R.string.cd_back) ) } }, @@ -209,7 +213,7 @@ fun BudgetManagerScreen( IconButton(onClick = onNavigateToSettings) { Icon( imageVector = Icons.Outlined.Settings, - contentDescription = "Settings" + contentDescription = stringResource(R.string.cd_settings) ) } }, @@ -314,7 +318,7 @@ fun BudgetManagerScreen( Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Remaining Budget", + text = stringResource(R.string.status_remaining_budget), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant @@ -332,7 +336,7 @@ fun BudgetManagerScreen( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "of ${formatCurrency(totalBudget)} Total Limit", + text = stringResource(R.string.template_of_total_limit, formatCurrency(totalBudget)), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) @@ -353,7 +357,7 @@ fun BudgetManagerScreen( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = if (totalBudgetLimit > 0) "Edit Budget Limit" else "Set Budget Limit", + text = if (totalBudgetLimit > 0) stringResource(R.string.action_edit_budget_limit) else stringResource(R.string.action_set_budget_limit), fontWeight = FontWeight.Medium ) } @@ -391,13 +395,13 @@ fun BudgetManagerScreen( ) Column(modifier = Modifier.weight(1f)) { Text( - text = "Budget Alert", + text = stringResource(R.string.status_budget_alert), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = if (isDark) AlertWarningTitle else Color(0xFFE65100) ) Text( - text = "You've spent $usagePercentage% of your budget this month.", + text = stringResource(R.string.template_budget_spent, usagePercentage), style = MaterialTheme.typography.bodySmall, color = if (isDark) AlertWarningText.copy(alpha = 0.8f) else Color(0xFFBF360C) ) @@ -417,7 +421,7 @@ fun BudgetManagerScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Allocations", + text = stringResource(R.string.section_allocations), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) @@ -471,7 +475,7 @@ fun BudgetManagerScreen( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Add Category", + text = stringResource(R.string.status_add_category), fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -551,7 +555,7 @@ private fun BudgetCategoryItem( fontWeight = FontWeight.Bold ) Text( - text = "$usagePercent% used", + text = stringResource(R.string.template_percent_used, usagePercent), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -565,7 +569,7 @@ private fun BudgetCategoryItem( fontWeight = FontWeight.Bold ) Text( - text = "of ${formatCurrency(budgeted)}", + text = stringResource(R.string.template_of_amount, formatCurrency(budgeted)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -618,8 +622,8 @@ private fun BudgetDialog( if (showDeleteConfirmation && budget != null) { AlertDialog( onDismissRequest = { showDeleteConfirmation = false }, - title = { Text("Delete Budget") }, - text = { Text("Are you sure you want to delete the budget for \"${budget.category}\"? This action cannot be undone.") }, + title = { Text(stringResource(R.string.dialog_delete_budget)) }, + text = { Text(stringResource(R.string.dialog_msg_delete_budget_named, budget.category)) }, confirmButton = { TextButton( onClick = { @@ -627,12 +631,12 @@ private fun BudgetDialog( showDeleteConfirmation = false } ) { - Text("Delete", color = MaterialTheme.colorScheme.error) + Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error) } }, dismissButton = { TextButton(onClick = { showDeleteConfirmation = false }) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) } } ) @@ -641,7 +645,7 @@ private fun BudgetDialog( AlertDialog( onDismissRequest = onDismiss, title = { - Text(if (budget != null) "Edit Budget" else "Add Budget") + Text(if (budget != null) stringResource(R.string.dialog_edit_budget) else stringResource(R.string.dialog_add_budget)) }, text = { Column( @@ -658,12 +662,12 @@ private fun BudgetDialog( FilterChip( selected = !useCustomCategory, onClick = { useCustomCategory = false }, - label = { Text("Predefined") } + label = { Text(stringResource(R.string.label_predefined)) } ) FilterChip( selected = useCustomCategory, onClick = { useCustomCategory = true }, - label = { Text("Custom") } + label = { Text(stringResource(R.string.label_custom)) } ) } @@ -671,14 +675,14 @@ private fun BudgetDialog( OutlinedTextField( value = customCategory, onValueChange = { customCategory = it }, - label = { Text("Custom Category Name") }, + label = { Text(stringResource(R.string.label_custom_category_name)) }, modifier = Modifier.fillMaxWidth(), singleLine = true ) // Icon selector for custom category Text( - text = "Choose Icon", + text = stringResource(R.string.dialog_choose_icon), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant @@ -724,7 +728,7 @@ private fun BudgetDialog( value = selectedCategory, onValueChange = {}, readOnly = true, - label = { Text("Category") }, + label = { Text(stringResource(R.string.label_category)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = showCategoryDropdown) }, modifier = Modifier .fillMaxWidth() @@ -766,7 +770,7 @@ private fun BudgetDialog( if (useCustomCategory) customCategory = it else selectedCategory = it }, - label = { Text("Category Name") }, + label = { Text(stringResource(R.string.label_category_name)) }, modifier = Modifier.fillMaxWidth(), singleLine = true ) @@ -774,7 +778,7 @@ private fun BudgetDialog( // Icon selector for existing custom category if (budget.category !in ExpenseCategories.list || budget.iconName.isNotEmpty()) { Text( - text = "Choose Icon", + text = stringResource(R.string.dialog_choose_icon), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant @@ -818,12 +822,12 @@ private fun BudgetDialog( OutlinedTextField( value = amount, onValueChange = { amount = it.filter { c -> c.isDigit() || c == '.' } }, - label = { Text("Budget Amount") }, + label = { Text(stringResource(R.string.label_budget_amount)) }, prefix = { Text(currencySymbol) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), modifier = Modifier.fillMaxWidth(), singleLine = true, - placeholder = { Text("0.00") } + placeholder = { Text(stringResource(R.string.hint_amount)) } ) } }, @@ -852,18 +856,18 @@ private fun BudgetDialog( finalCategory.isNotBlank() } ) { - Text("Save") + Text(stringResource(R.string.action_save)) } }, dismissButton = { Row { if (budget != null) { TextButton(onClick = { showDeleteConfirmation = true }) { - Text("Delete", color = MaterialTheme.colorScheme.error) + Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error) } } TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) } } } @@ -882,13 +886,13 @@ private fun TotalBudgetDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Set Total Budget Limit") }, + title = { Text(stringResource(R.string.dialog_set_budget_limit)) }, text = { Column( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = "Set your overall monthly budget limit. This overrides the sum of category allocations.", + text = stringResource(R.string.dialog_msg_budget_limit), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -904,7 +908,7 @@ private fun TotalBudgetDialog( ) Text( - text = "Set to 0 to use sum of category allocations instead.", + text = stringResource(R.string.dialog_msg_budget_zero), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) @@ -917,12 +921,12 @@ private fun TotalBudgetDialog( onSave(amountValue) } ) { - Text("Save") + Text(stringResource(R.string.action_save)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) } } ) diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerViewModel.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerViewModel.kt index f319d90..c8d5f32 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerViewModel.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/BudgetManagerViewModel.kt @@ -8,10 +8,9 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.allubie.nana.NanaApplication import com.allubie.nana.data.PreferencesManager -import com.allubie.nana.data.dao.BudgetDao -import com.allubie.nana.data.dao.TransactionDao import com.allubie.nana.data.model.Budget import com.allubie.nana.data.model.BudgetPeriod +import com.allubie.nana.data.repository.TransactionRepository import com.allubie.nana.data.model.TransactionType import com.allubie.nana.widget.requestBudgetWidgetRefresh import kotlinx.coroutines.flow.* @@ -19,79 +18,81 @@ import kotlinx.coroutines.launch import java.util.* class BudgetManagerViewModel( - private val budgetDao: BudgetDao, - private val transactionDao: TransactionDao, + private val transactionRepository: TransactionRepository, private val preferencesManager: PreferencesManager, private val application: NanaApplication ) : ViewModel() { + data class BudgetManagerUiState( + val selectedMonth: Int = Calendar.getInstance().get(Calendar.MONTH), + val currencySymbol: String = "$", + val totalBudgetLimit: Double = 0.0, + val budgets: List = emptyList(), + val totalAllocated: Double = 0.0, + val totalBudget: Double = 0.0, + val categorySpending: Map = emptyMap(), + val totalSpent: Double = 0.0 + ) + private val _selectedMonth = MutableStateFlow(Calendar.getInstance().get(Calendar.MONTH)) - val selectedMonth: StateFlow = _selectedMonth.asStateFlow() - val currencySymbol: StateFlow = preferencesManager.currencySymbol - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "$") + private val _currencySymbol = preferencesManager.currencySymbol - // Total budget from preferences (user-set overall limit) - val totalBudgetLimit: StateFlow = preferencesManager.totalBudget - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0.0) + private val _totalBudgetLimit = preferencesManager.totalBudget - val budgets: StateFlow> = budgetDao.getAllBudgets() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = emptyList() - ) + private val _budgets = transactionRepository.getAllBudgets() - // Sum of all category allocations - val totalAllocated: StateFlow = budgets.map { budgetList -> + private val _totalAllocated = _budgets.map { budgetList -> budgetList.sumOf { it.amount } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = 0.0 - ) + } - // Effective total budget: use user-set limit if > 0, otherwise sum of allocations - val totalBudget: StateFlow = combine(totalBudgetLimit, totalAllocated) { limit, allocated -> + private val _totalBudget = combine(_totalBudgetLimit, _totalAllocated) { limit, allocated -> if (limit > 0) limit else allocated - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = 0.0 - ) + } - // Calculate spending per category for the selected month - val categorySpending: StateFlow> = combine( - transactionDao.getAllTransactions(), - _selectedMonth - ) { transactions, month -> + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + private val _categorySpending = _selectedMonth.flatMapLatest { month -> val calendar = Calendar.getInstance() - val year = calendar.get(Calendar.YEAR) + calendar.set(Calendar.MONTH, month) + calendar.set(Calendar.DAY_OF_MONTH, 1) + calendar.set(Calendar.HOUR_OF_DAY, 0) + calendar.set(Calendar.MINUTE, 0) + calendar.set(Calendar.SECOND, 0) + calendar.set(Calendar.MILLISECOND, 0) + val startOfMonth = calendar.timeInMillis - // Filter transactions for selected month and calculate per category - transactions - .filter { transaction -> - transaction.type == TransactionType.EXPENSE && - Calendar.getInstance().apply { timeInMillis = transaction.date }.let { - it.get(Calendar.MONTH) == month && it.get(Calendar.YEAR) == year - } - } - .groupBy { it.category } - .mapValues { (_, categoryTransactions) -> - categoryTransactions.sumOf { it.amount } - } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = emptyMap() - ) + calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH)) + calendar.set(Calendar.HOUR_OF_DAY, 23) + calendar.set(Calendar.MINUTE, 59) + calendar.set(Calendar.SECOND, 59) + calendar.set(Calendar.MILLISECOND, 999) + val endOfMonth = calendar.timeInMillis + + transactionRepository.getCategorySpending(startOfMonth, endOfMonth) + } - val totalSpent: StateFlow = categorySpending.map { spending -> + private val _totalSpent = _categorySpending.map { spending -> spending.values.sum() + } + + val uiState: StateFlow = combine( + _selectedMonth, _currencySymbol, _totalBudgetLimit, _budgets, + _totalAllocated, _totalBudget, _categorySpending, _totalSpent + ) { args: Array -> + BudgetManagerUiState( + selectedMonth = args[0] as Int, + currencySymbol = args[1] as String, + totalBudgetLimit = args[2] as Double, + budgets = (args[3] as List<*>).filterIsInstance(), + totalAllocated = args[4] as Double, + totalBudget = args[5] as Double, + categorySpending = args[6] as Map, + totalSpent = args[7] as Double + ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), - initialValue = 0.0 + initialValue = BudgetManagerUiState() ) fun selectMonth(month: Int) { @@ -107,19 +108,19 @@ class BudgetManagerViewModel( startDate = System.currentTimeMillis(), iconName = iconName ) - budgetDao.insertBudget(budget) + transactionRepository.insertBudget(budget) } } fun updateBudget(budget: Budget) { viewModelScope.launch { - budgetDao.updateBudget(budget) + transactionRepository.updateBudget(budget) } } fun deleteBudget(budget: Budget) { viewModelScope.launch { - budgetDao.deleteBudget(budget) + transactionRepository.deleteBudget(budget) } } @@ -135,9 +136,12 @@ class BudgetManagerViewModel( initializer { val application = (this[APPLICATION_KEY] as NanaApplication) val database = application.database + val transactionRepository = TransactionRepository( + database.transactionDao(), + database.budgetDao() + ) BudgetManagerViewModel( - budgetDao = database.budgetDao(), - transactionDao = database.transactionDao(), + transactionRepository = transactionRepository, preferencesManager = application.preferencesManager, application = application ) diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewScreen.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewScreen.kt index 6b98531..9ca767b 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewScreen.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewScreen.kt @@ -23,10 +23,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.allubie.nana.util.CurrencyFormatter import kotlin.math.roundToInt import java.text.NumberFormat import java.util.* +import androidx.compose.ui.res.stringResource +import com.allubie.nana.R @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -34,8 +37,8 @@ fun FinancesOverviewScreen( onNavigateBack: () -> Unit, viewModel: FinancesOverviewViewModel = viewModel(factory = FinancesOverviewViewModel.Factory) ) { - val overview by viewModel.overview.collectAsState() - val currencySymbol by viewModel.currencySymbol.collectAsState() + val overview by viewModel.overview.collectAsStateWithLifecycle() + val currencySymbol by viewModel.currencySymbol.collectAsStateWithLifecycle() // Format currency with symbol from settings fun formatCurrency(amount: Double): String { @@ -45,12 +48,12 @@ fun FinancesOverviewScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Overview") }, + title = { Text(stringResource(R.string.title_overview)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + contentDescription = stringResource(R.string.cd_back) ) } }, @@ -82,7 +85,7 @@ fun FinancesOverviewScreen( .padding(20.dp) ) { Text( - text = "Cash Flow Summary", + text = stringResource(R.string.section_cash_flow), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) @@ -94,7 +97,7 @@ fun FinancesOverviewScreen( ) { Column { Text( - text = "Income", + text = stringResource(R.string.label_income), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) ) @@ -107,7 +110,7 @@ fun FinancesOverviewScreen( } Column(horizontalAlignment = Alignment.End) { Text( - text = "Expenses", + text = stringResource(R.string.status_expenses), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) ) @@ -131,7 +134,7 @@ fun FinancesOverviewScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Net Savings", + text = stringResource(R.string.status_net_savings), style = MaterialTheme.typography.titleSmall ) Text( @@ -150,7 +153,7 @@ fun FinancesOverviewScreen( // Spending by Category item { Text( - text = "Spending by Category", + text = stringResource(R.string.section_spending_by_category), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) @@ -176,12 +179,11 @@ fun FinancesOverviewScreen( ) } - // Budget vs Actual (if budgets exist) if (overview.budgetComparisons.isNotEmpty()) { item { Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Budget vs Actual", + text = stringResource(R.string.section_budget_vs_actual), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) @@ -272,7 +274,7 @@ private fun BudgetComparisonItem( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = category.ifEmpty { "Overall" }, + text = category.ifEmpty { stringResource(R.string.label_overall) }, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold ) @@ -358,7 +360,7 @@ private fun SpendingDonutChart( // Center text Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = "Total", + text = stringResource(R.string.status_total), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewViewModel.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewViewModel.kt index 2fec159..a7d4f92 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewViewModel.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesOverviewViewModel.kt @@ -7,11 +7,10 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.allubie.nana.NanaApplication import com.allubie.nana.data.PreferencesManager -import com.allubie.nana.data.dao.BudgetDao -import com.allubie.nana.data.dao.LabelDao -import com.allubie.nana.data.dao.TransactionDao import com.allubie.nana.data.model.Label import com.allubie.nana.data.model.LabelType +import com.allubie.nana.data.repository.LabelRepository +import com.allubie.nana.data.repository.TransactionRepository import com.allubie.nana.data.model.TransactionType import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -39,9 +38,8 @@ data class BudgetComparison( ) class FinancesOverviewViewModel( - private val transactionDao: TransactionDao, - private val budgetDao: BudgetDao, - private val labelDao: LabelDao, + private val transactionRepository: TransactionRepository, + private val labelRepository: LabelRepository, private val preferencesManager: PreferencesManager ) : ViewModel() { @@ -52,7 +50,7 @@ class FinancesOverviewViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "$") // Labels for overview - val expenseLabels: StateFlow> = labelDao.getLabelsByType(LabelType.EXPENSE) + val expenseLabels: StateFlow> = labelRepository.getLabelsByType(LabelType.EXPENSE) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) // Standard category colors as fallbacks @@ -85,9 +83,9 @@ class FinancesOverviewViewModel( private fun observeOverview() { viewModelScope.launch { combine( - transactionDao.getAllTransactions(), - budgetDao.getAllBudgets(), - labelDao.getLabelsByType(LabelType.EXPENSE) + transactionRepository.getAllTransactions(), + transactionRepository.getAllBudgets(), + labelRepository.getLabelsByType(LabelType.EXPENSE) ) { transactions, budgets, labels -> Triple(transactions, budgets, labels) }.collect { (transactions, budgets, labels) -> @@ -170,10 +168,14 @@ class FinancesOverviewViewModel( val Factory: ViewModelProvider.Factory = viewModelFactory { initializer { val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as NanaApplication - FinancesOverviewViewModel( + val transactionRepository = TransactionRepository( application.database.transactionDao(), - application.database.budgetDao(), - application.database.labelDao(), + application.database.budgetDao() + ) + val labelRepository = LabelRepository(application.database.labelDao()) + FinancesOverviewViewModel( + transactionRepository, + labelRepository, application.preferencesManager ) } diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesScreen.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesScreen.kt index 3f08b0c..65b8a86 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesScreen.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.semantics.semantics import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.TrendingUp import androidx.compose.material.icons.filled.Add @@ -30,6 +31,7 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.allubie.nana.data.model.Label import com.allubie.nana.data.model.Transaction import com.allubie.nana.data.model.TransactionType @@ -41,6 +43,8 @@ import com.allubie.nana.util.CurrencyFormatter import java.text.NumberFormat import java.text.SimpleDateFormat import java.util.* +import androidx.compose.ui.res.stringResource +import com.allubie.nana.R import com.allubie.nana.ui.theme.* @OptIn(ExperimentalMaterial3Api::class) @@ -52,16 +56,17 @@ fun FinancesScreen( onNavigateToSettings: () -> Unit, viewModel: FinancesViewModel = viewModel(factory = FinancesViewModel.Factory) ) { - val transactions by viewModel.filteredTransactions.collectAsState(initial = emptyList()) - val isLoading by viewModel.isLoading.collectAsState() - val totalIncome by viewModel.totalIncome.collectAsState() - val totalExpenses by viewModel.totalExpenses.collectAsState() - val selectedMonth by viewModel.selectedMonth.collectAsState() - val currencySymbol by viewModel.currencySymbol.collectAsState() - val hasBudget by viewModel.hasBudget.collectAsState() - val totalBudget by viewModel.totalBudget.collectAsState() - val expenseLabels by viewModel.expenseLabels.collectAsState() - val incomeLabels by viewModel.incomeLabels.collectAsState() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val transactions = uiState.filteredTransactions + val isLoading = uiState.isLoading + val totalIncome = uiState.totalIncome + val totalExpenses = uiState.totalExpenses + val selectedMonth = uiState.selectedMonth + val currencySymbol = uiState.currencySymbol + val hasBudget = uiState.hasBudget + val totalBudget = uiState.totalBudget + val expenseLabels = uiState.expenseLabels + val incomeLabels = uiState.incomeLabels // Create label lookup map for quick access val labelMap = remember(expenseLabels, incomeLabels) { @@ -74,7 +79,7 @@ fun FinancesScreen( val hasEffectiveBudget = hasBudget && totalBudget > 0 val balance = totalIncome - totalExpenses val displayedTotal = if (hasEffectiveBudget) totalBudget - totalExpenses else balance - val totalLabel = if (hasEffectiveBudget) "Remaining Budget" else "Total Balance" + val totalLabel = if (hasEffectiveBudget) stringResource(R.string.status_remaining_budget) else stringResource(R.string.section_total_balance) val isOnTrack = if (hasEffectiveBudget) displayedTotal >= 0 else balance >= 0 val dateFormat = SimpleDateFormat("MMMM yyyy", Locale.getDefault()) val dayFormat = SimpleDateFormat("MMM d", Locale.getDefault()) @@ -91,13 +96,13 @@ fun FinancesScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Finances") }, + title = { Text(stringResource(R.string.nav_finances)) }, actions = { Box { IconButton(onClick = { showMenu = true }) { Icon( imageVector = Icons.Outlined.MoreVert, - contentDescription = "More options" + contentDescription = stringResource(R.string.cd_more_options) ) } DropdownMenu( @@ -105,7 +110,7 @@ fun FinancesScreen( onDismissRequest = { showMenu = false } ) { DropdownMenuItem( - text = { Text("Budget Manager") }, + text = { Text(stringResource(R.string.title_budget_manager)) }, onClick = { showMenu = false onNavigateToBudgetManager() @@ -119,7 +124,7 @@ fun FinancesScreen( ) HorizontalDivider() DropdownMenuItem( - text = { Text("Settings") }, + text = { Text(stringResource(R.string.menu_settings)) }, onClick = { showMenu = false onNavigateToSettings() @@ -149,7 +154,7 @@ fun FinancesScreen( ) { Icon( Icons.Default.Add, - contentDescription = "Add Transaction", + contentDescription = stringResource(R.string.cd_add_transaction), modifier = Modifier.size(28.dp) ) } @@ -251,7 +256,7 @@ fun FinancesScreen( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = if (isOnTrack) "On track" else "Over budget", + text = if (isOnTrack) stringResource(R.string.status_on_track) else stringResource(R.string.status_over_budget), style = MaterialTheme.typography.labelMedium, color = if (isOnTrack) MaterialTheme.colorScheme.primary else Expense, fontWeight = FontWeight.SemiBold @@ -270,14 +275,14 @@ fun FinancesScreen( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { FinanceCard( - title = "Income", + title = stringResource(R.string.label_income), amount = formatCurrency(totalIncome), icon = Icons.Outlined.ArrowDownward, isIncome = true, modifier = Modifier.weight(1f) ) FinanceCard( - title = "Expenses", + title = stringResource(R.string.status_expenses), amount = formatCurrency(totalExpenses), icon = Icons.Outlined.ArrowUpward, isIncome = false, @@ -325,13 +330,13 @@ fun FinancesScreen( Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = "Set up your budget", + text = stringResource(R.string.status_setup_budget), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface ) Text( - text = "Track spending and reach your goals", + text = stringResource(R.string.status_track_spending), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -358,7 +363,7 @@ fun FinancesScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "RECENT TRANSACTIONS", + text = stringResource(R.string.section_recent_transactions), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -366,7 +371,7 @@ fun FinancesScreen( ) TextButton(onClick = onNavigateToOverview) { Text( - text = "Budget Overview", + text = stringResource(R.string.action_budget_overview), color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold ) @@ -400,13 +405,13 @@ fun FinancesScreen( ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = "No transactions yet", + text = stringResource(R.string.empty_transactions), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Tap + to add your first transaction", + text = stringResource(R.string.empty_transactions_hint), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) @@ -452,7 +457,7 @@ private fun FinanceCard( modifier: Modifier = Modifier ) { Surface( - modifier = modifier, + modifier = modifier.semantics(mergeDescendants = true) {}, shape = RoundedCornerShape(24.dp), color = if (isIncome) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, border = if (!isIncome) androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f)) else null @@ -556,12 +561,12 @@ private fun TransactionItem( onDelete() showDeleteConfirmation = false }, - title = "Delete this transaction?", + title = stringResource(R.string.dialog_delete_transaction), message = if (transaction.title.isNotEmpty()) - "\"${transaction.title}\" will be permanently deleted." + stringResource(R.string.dialog_msg_delete_transaction_named, transaction.title) else - "This transaction will be permanently deleted.", - confirmText = "Delete", + stringResource(R.string.dialog_msg_delete_transaction_unnamed), + confirmText = stringResource(R.string.action_delete), isDestructive = true, icon = Icons.Outlined.Delete ) @@ -571,6 +576,7 @@ private fun TransactionItem( Surface( modifier = Modifier .fillMaxWidth() + .semantics(mergeDescendants = true) {} .padding(horizontal = 16.dp, vertical = 6.dp) .clip(RoundedCornerShape(24.dp)) .combinedClickable( @@ -581,7 +587,7 @@ private fun TransactionItem( } ), shape = RoundedCornerShape(24.dp), - color = CardSurfaceDark, + color = MaterialTheme.colorScheme.surfaceVariant, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f)) ) { Row( @@ -639,7 +645,7 @@ private fun TransactionItem( offset = DpOffset(x = 8.dp, y = 0.dp) ) { DropdownMenuItem( - text = { Text("Edit") }, + text = { Text(stringResource(R.string.action_edit)) }, onClick = { showMenu = false onClick() @@ -653,7 +659,7 @@ private fun TransactionItem( ) HorizontalDivider() DropdownMenuItem( - text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, + text = { Text(stringResource(R.string.action_delete), color = MaterialTheme.colorScheme.error) }, onClick = { showMenu = false showDeleteConfirmation = true @@ -686,7 +692,7 @@ private fun MonthYearPickerDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Select Month") }, + title = { Text(stringResource(R.string.dialog_select_month)) }, text = { Column { // Year selector @@ -696,7 +702,7 @@ private fun MonthYearPickerDialog( verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = { selectedYear-- }) { - Icon(Icons.Outlined.ChevronLeft, contentDescription = "Previous Year") + Icon(Icons.Outlined.ChevronLeft, contentDescription = stringResource(R.string.cd_previous_year)) } Text( text = selectedYear.toString(), @@ -704,7 +710,7 @@ private fun MonthYearPickerDialog( fontWeight = FontWeight.Bold ) IconButton(onClick = { selectedYear++ }) { - Icon(Icons.Outlined.ChevronRight, contentDescription = "Next Year") + Icon(Icons.Outlined.ChevronRight, contentDescription = stringResource(R.string.cd_next_year)) } } @@ -751,12 +757,12 @@ private fun MonthYearPickerDialog( }, confirmButton = { TextButton(onClick = { onConfirm(selectedYear, selectedMonth) }) { - Text("OK") + Text(stringResource(R.string.action_ok)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) } } ) diff --git a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesViewModel.kt b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesViewModel.kt index 109c650..6abbfb9 100644 --- a/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesViewModel.kt +++ b/app/src/main/java/com/allubie/nana/ui/screens/finances/FinancesViewModel.kt @@ -8,12 +8,11 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.allubie.nana.NanaApplication import com.allubie.nana.data.PreferencesManager -import com.allubie.nana.data.dao.BudgetDao -import com.allubie.nana.data.dao.LabelDao -import com.allubie.nana.data.dao.TransactionDao import com.allubie.nana.data.model.Label import com.allubie.nana.data.model.LabelType import com.allubie.nana.data.model.Transaction +import com.allubie.nana.data.repository.LabelRepository +import com.allubie.nana.data.repository.TransactionRepository import com.allubie.nana.data.model.TransactionType import com.allubie.nana.widget.requestBudgetWidgetRefresh import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -23,64 +22,74 @@ import java.util.* @OptIn(ExperimentalCoroutinesApi::class) class FinancesViewModel( - private val transactionDao: TransactionDao, - private val budgetDao: BudgetDao, - private val labelDao: LabelDao, + private val transactionRepository: TransactionRepository, + private val labelRepository: LabelRepository, private val preferencesManager: PreferencesManager, private val application: NanaApplication ) : ViewModel() { - private val _selectedMonth = MutableStateFlow(Calendar.getInstance()) - val selectedMonth: StateFlow = _selectedMonth.asStateFlow() + data class FinancesUiState( + val selectedMonth: Calendar = Calendar.getInstance(), + val isLoading: Boolean = true, + val totalBudget: Double = 0.0, + val hasBudget: Boolean = false, + val currencySymbol: String = "$", + val expenseLabels: List