diff --git a/CHANGELOG.md b/CHANGELOG.md index afe2f67..e8bed75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## v1.0.1 (Build 8) + +### Improvements +- **Finance — Currency Fix**: Currency symbol no longer defaults to `$`; now loads the user's locale-aware symbol from DataStore without a hardcoded flash +- **Finance — Spending Breakdown Redesign**: Replaced the legacy donut chart with a modern Material 3 gauge card (`CircularProgressIndicator`) matching the Budget Manager's premium visual style +- **Finance — Per-Month Budgets**: Converted budgets from global to per-month scoping (Database schema v12). Each month and year now has its own independent budget allocation, and the month selector updates both budget targets and spending data. +- **UI — Delete Note Dialog**: Improved dialog sizing and pill-shaped buttons for a more polished look +- **Settings**: Removed timezone picker and delete-all option; improved currency selector typography +- **Bug Report**: Added direct mail with attachment for bug reports +- **Strings**: Externalized remaining hardcoded UI strings to `strings.xml` + +## 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 + ## v0.9.1 (Build 5) ### Highlights diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d8210c..c936240 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 = 5 - versionName = "0.9.1" + versionCode = 8 + versionName = "1.0.1" 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 @@ + + + + + ) { + 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() @@ -35,12 +53,14 @@ class NanaApplication : Application() { // Create notification channels NotificationHelper.createNotificationChannels(this) - // Restore saved timezone - applicationScope.launch { + // Restore saved timezone synchronously to ensure correct time display from first frame + runBlocking(Dispatchers.IO) { val savedTimezone = preferencesManager.timezone.first() 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/BackupManager.kt b/app/src/main/java/com/allubie/nana/data/BackupManager.kt index 55751df..6f8dce8 100644 --- a/app/src/main/java/com/allubie/nana/data/BackupManager.kt +++ b/app/src/main/java/com/allubie/nana/data/BackupManager.kt @@ -49,17 +49,32 @@ class BackupManager( suspend fun exportBackup(): Result = 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..1208309 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 = 12, exportSchema = true ) abstract class NanaDatabase : RoomDatabase() { @@ -108,6 +109,61 @@ 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')") + } + } + + // Migration from version 11 to 12 - Per-month budgets + private val MIGRATION_11_12 = object : Migration(11, 12) { + override fun migrate(database: SupportSQLiteDatabase) { + val cal = java.util.Calendar.getInstance() + val currentMonth = cal.get(java.util.Calendar.MONTH) + val currentYear = cal.get(java.util.Calendar.YEAR) + + // Add budgetMonth and budgetYear columns with current month/year as defaults + database.execSQL("ALTER TABLE budgets ADD COLUMN budgetMonth INTEGER NOT NULL DEFAULT $currentMonth") + database.execSQL("ALTER TABLE budgets ADD COLUMN budgetYear INTEGER NOT NULL DEFAULT $currentYear") + + // Drop the old category-only unique index + database.execSQL("DROP INDEX IF EXISTS index_budgets_category") + + // Create new composite unique index + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_budgets_category_budgetMonth_budgetYear ON budgets(category, budgetMonth, budgetYear)") + } + } fun getDatabase(context: Context): NanaDatabase { return INSTANCE ?: synchronized(this) { @@ -116,7 +172,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, MIGRATION_11_12) .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 c9c33ae..066390a 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,12 +6,17 @@ 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") 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,12 +31,15 @@ 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? + @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 @@ -56,12 +64,18 @@ interface BudgetDao { @Query("SELECT * FROM budgets ORDER BY createdAt DESC") fun getAllBudgets(): Flow> + @Query("SELECT * FROM budgets WHERE budgetMonth = :month AND budgetYear = :year ORDER BY createdAt DESC") + fun getBudgetsForMonth(month: Int, year: Int): Flow> + @Query("SELECT * FROM budgets WHERE category = '' LIMIT 1") suspend fun getOverallBudget(): Budget? @Query("SELECT * FROM budgets WHERE category = :category LIMIT 1") suspend fun getBudgetForCategory(category: String): Budget? + @Query("SELECT * FROM budgets WHERE category = :category AND budgetMonth = :month AND budgetYear = :year LIMIT 1") + suspend fun getBudgetForCategoryInMonth(category: String, month: Int, year: Int): Budget? + @Query("SELECT * FROM budgets WHERE id = :id") suspend fun getBudgetById(id: Long): Budget? 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/model/Transaction.kt b/app/src/main/java/com/allubie/nana/data/model/Transaction.kt index 4836190..5be8174 100644 --- a/app/src/main/java/com/allubie/nana/data/model/Transaction.kt +++ b/app/src/main/java/com/allubie/nana/data/model/Transaction.kt @@ -3,6 +3,7 @@ package com.allubie.nana.data.model import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey +import java.util.Calendar @Entity( tableName = "transactions", @@ -32,7 +33,7 @@ enum class TransactionType { @Entity( tableName = "budgets", indices = [ - Index(value = ["category"], unique = true) + Index(value = ["category", "budgetMonth", "budgetYear"], unique = true) ] ) data class Budget( @@ -43,6 +44,8 @@ data class Budget( val period: BudgetPeriod, val startDate: Long, val iconName: String = "", // Icon name for custom categories + val budgetMonth: Int = Calendar.getInstance().get(Calendar.MONTH), // 0-11 + val budgetYear: Int = Calendar.getInstance().get(Calendar.YEAR), val createdAt: Long = System.currentTimeMillis() ) 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..e7e0526 --- /dev/null +++ b/app/src/main/java/com/allubie/nana/data/repository/TransactionRepository.kt @@ -0,0 +1,41 @@ +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() + fun getBudgetsForMonth(month: Int, year: Int): Flow> = budgetDao.getBudgetsForMonth(month, year) + suspend fun getOverallBudget(): Budget? = budgetDao.getOverallBudget() + suspend fun getBudgetForCategory(category: String): Budget? = budgetDao.getBudgetForCategory(category) + suspend fun getBudgetForCategoryInMonth(category: String, month: Int, year: Int): Budget? = budgetDao.getBudgetForCategoryInMonth(category, month, year) + 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..1879d67 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 @@ -10,71 +10,102 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.allubie.nana.R + + +import androidx.compose.ui.text.style.TextAlign -/** - * 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, onConfirm: () -> Unit, title: String, message: String, - confirmText: String = "Confirm", - dismissText: String = "Cancel", + confirmText: String = "", + dismissText: String = "", isDestructive: Boolean = false, icon: ImageVector? = null ) { + val resolvedConfirmText = confirmText.ifEmpty { stringResource(R.string.action_confirm) } + val resolvedDismissText = dismissText.ifEmpty { stringResource(R.string.action_cancel) } + AlertDialog( onDismissRequest = onDismiss, + modifier = Modifier + .fillMaxWidth(0.92f) + .widthIn(min = 320.dp, max = 560.dp), + shape = RoundedCornerShape(28.dp), icon = icon?.let { { Icon( imageVector = it, contentDescription = null, tint = if (isDestructive) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) ) } }, title = { Text( text = title, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + text = { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() ) }, - text = { Text(message) }, confirmButton = { - TextButton( + Button( onClick = onConfirm, - colors = if (isDestructive) ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) else ButtonDefaults.textButtonColors() + shape = RoundedCornerShape(50), + colors = if (isDestructive) ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) else ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp) ) { - Text(confirmText) + Text( + text = resolvedConfirmText, + fontWeight = FontWeight.SemiBold + ) } }, dismissButton = { - TextButton(onClick = onDismiss) { - Text(dismissText) + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(50), + border = androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) + ), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp) + ) { + Text( + text = resolvedDismissText, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) } } ) } -/** - * Material 3 single-selection dialog with radio buttons. - * - * Follows M3 guidelines for simple dialogs with list items. - */ @Composable fun NanaSelectionDialog( onDismiss: () -> Unit, @@ -83,8 +114,10 @@ fun NanaSelectionDialog( selectedOption: T, optionLabel: (T) -> String, onSelect: (T) -> Unit, - dismissText: String = "Cancel" + dismissText: String = "" ) { + val resolvedDismissText = dismissText.ifEmpty { stringResource(R.string.action_cancel) } + AlertDialog( onDismissRequest = onDismiss, title = { @@ -114,30 +147,28 @@ fun NanaSelectionDialog( }, confirmButton = { TextButton(onClick = onDismiss) { - Text(dismissText) + Text(resolvedDismissText) } } ) } -/** - * Material 3 searchable list dialog with radio-button items. - * - * Includes an OutlinedTextField for filtering and a scrollable list below. - */ @Composable fun NanaSearchableListDialog( onDismiss: () -> Unit, title: String, searchQuery: String, onSearchQueryChange: (String) -> Unit, - searchPlaceholder: String = "Search...", + searchPlaceholder: String = "", items: List, isSelected: (T) -> Boolean, itemLabel: (T) -> String, onSelect: (T) -> Unit, - dismissText: String = "Cancel" + dismissText: String = "" ) { + val resolvedSearchPlaceholder = searchPlaceholder.ifEmpty { stringResource(R.string.hint_search) } + val resolvedDismissText = dismissText.ifEmpty { stringResource(R.string.action_cancel) } + AlertDialog( onDismissRequest = onDismiss, title = { @@ -151,7 +182,7 @@ fun NanaSearchableListDialog( OutlinedTextField( value = searchQuery, onValueChange = onSearchQueryChange, - placeholder = { Text(searchPlaceholder) }, + placeholder = { Text(resolvedSearchPlaceholder) }, singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp) @@ -184,7 +215,7 @@ fun NanaSearchableListDialog( }, confirmButton = { TextButton(onClick = onDismiss) { - Text(dismissText) + Text(resolvedDismissText) } } ) 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 1881983..aedad06 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,16 +30,19 @@ 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 +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.text.NumberFormat -import java.util.* +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,34 +120,41 @@ 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) } 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 { return CurrencyFormatter.formatWithSymbol(kotlin.math.abs(amount), currencySymbol) } - // Generate months starting from current month + val selectedYear = uiState.selectedYear + + // Generate months starting from current month, tracking year val currentMonthIndex = Calendar.getInstance().get(Calendar.MONTH) + val currentYear = Calendar.getInstance().get(Calendar.YEAR) val monthNames = listOf("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December") - // Reorder months to start from current month + // Reorder months to start from current month, track year for each val months = (0..11).map { offset -> val monthIndex = (currentMonthIndex + offset) % 12 - Pair(monthIndex, monthNames[monthIndex]) + val yearForMonth = currentYear + (currentMonthIndex + offset) / 12 + Triple(monthIndex, monthNames[monthIndex], yearForMonth) } if (showAddBudgetDialog || editingBudget != null) { @@ -190,7 +200,7 @@ fun BudgetManagerScreen( TopAppBar( title = { Text( - text = "Monthly Budget", + text = stringResource(R.string.title_monthly_budget), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) @@ -199,7 +209,7 @@ fun BudgetManagerScreen( IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + contentDescription = stringResource(R.string.cd_back) ) } }, @@ -207,7 +217,7 @@ fun BudgetManagerScreen( IconButton(onClick = onNavigateToSettings) { Icon( imageVector = Icons.Outlined.Settings, - contentDescription = "Settings" + contentDescription = stringResource(R.string.cd_settings) ) } }, @@ -218,8 +228,8 @@ fun BudgetManagerScreen( } ) { paddingValues -> // Find index of selected month in the reordered list - val selectedMonthListIndex = remember(selectedMonth) { - months.indexOfFirst { it.first == selectedMonth }.coerceAtLeast(0) + val selectedMonthListIndex = remember(selectedMonth, selectedYear) { + months.indexOfFirst { it.first == selectedMonth && it.third == selectedYear }.coerceAtLeast(0) } val monthListState = rememberLazyListState() @@ -244,18 +254,21 @@ fun BudgetManagerScreen( .padding(horizontal = 16.dp, vertical = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - items(months.size, key = { index -> "month_${months[index].first}" }) { index -> - val (monthValue, monthName) = months[index] - val isSelected = monthValue == selectedMonth + items(months.size, key = { index -> "month_${months[index].first}_${months[index].third}" }) { index -> + val (monthValue, monthName, yearValue) = months[index] + val isSelected = monthValue == selectedMonth && yearValue == selectedYear Surface( shape = RoundedCornerShape(12.dp), color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, shadowElevation = if (isSelected) 8.dp else 0.dp, - modifier = Modifier.clickable { viewModel.selectMonth(monthValue) } + modifier = Modifier.clickable { + viewModel.selectMonth(monthValue) + viewModel.selectYear(yearValue) + } ) { Text( - text = monthName, + text = if (yearValue != currentYear) "$monthName '${yearValue % 100}" else monthName, style = MaterialTheme.typography.bodyMedium, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, color = if (isSelected) MaterialTheme.colorScheme.onPrimary @@ -293,7 +306,7 @@ fun BudgetManagerScreen( CircularProgressIndicator( progress = { (remainingPercentage / 100f).coerceIn(0f, 1f) }, modifier = Modifier.size(180.dp), - strokeWidth = 16.dp, + strokeWidth = 10.dp, trackColor = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.primary, strokeCap = StrokeCap.Round @@ -312,7 +325,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 @@ -322,15 +335,28 @@ 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( - 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) ) + if (budgets.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.template_allocated_categories, formatCurrency(budgets.sumOf { it.amount }), budgets.size), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + ) + } Spacer(modifier = Modifier.height(16.dp)) @@ -347,7 +373,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 ) } @@ -356,7 +382,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() @@ -383,13 +411,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) ) @@ -409,7 +437,7 @@ fun BudgetManagerScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Allocations", + text = stringResource(R.string.section_allocations), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) @@ -463,7 +491,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 ) @@ -543,7 +571,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 ) @@ -557,7 +585,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 ) @@ -610,8 +638,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 = { @@ -619,12 +647,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)) } } ) @@ -633,7 +661,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( @@ -650,12 +678,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)) } ) } @@ -663,14 +691,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 @@ -716,7 +744,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() @@ -758,7 +786,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 ) @@ -766,7 +794,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 @@ -810,12 +838,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)) } ) } }, @@ -844,18 +872,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)) } } } @@ -874,13 +902,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 ) @@ -896,7 +924,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) ) @@ -909,13 +937,13 @@ 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)) } } ) -} \ 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..5675353 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,96 +8,113 @@ 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.updateBudgetWidget +import com.allubie.nana.widget.requestBudgetWidgetRefresh import kotlinx.coroutines.flow.* 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 selectedYear: Int = Calendar.getInstance().get(Calendar.YEAR), + 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() + private val _selectedYear = MutableStateFlow(Calendar.getInstance().get(Calendar.YEAR)) - 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() - ) + // Budgets scoped to selected month/year + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + private val _budgets = combine(_selectedMonth, _selectedYear) { month, year -> + Pair(month, year) + }.flatMapLatest { (month, year) -> + transactionRepository.getBudgetsForMonth(month, year) + } - // 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 = combine(_selectedMonth, _selectedYear) { month, year -> + Pair(month, year) + }.flatMapLatest { (month, year) -> val calendar = Calendar.getInstance() - val year = calendar.get(Calendar.YEAR) + calendar.set(Calendar.YEAR, 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, _selectedYear, _currencySymbol, _totalBudgetLimit, _budgets, + _totalAllocated, _totalBudget, _categorySpending, _totalSpent + ) { args: Array -> + BudgetManagerUiState( + selectedMonth = args[0] as Int, + selectedYear = args[1] as Int, + currencySymbol = args[2] as String, + totalBudgetLimit = args[3] as Double, + budgets = (args[4] as List<*>).filterIsInstance(), + totalAllocated = args[5] as Double, + totalBudget = args[6] as Double, + categorySpending = args[7] as Map, + totalSpent = args[8] as Double + ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), - initialValue = 0.0 + initialValue = BudgetManagerUiState() ) fun selectMonth(month: Int) { _selectedMonth.value = month } + fun selectYear(year: Int) { + _selectedYear.value = year + } + fun addBudget(category: String, amount: Double, iconName: String = "") { viewModelScope.launch { val budget = Budget( @@ -105,31 +122,30 @@ class BudgetManagerViewModel( amount = amount, period = BudgetPeriod.MONTHLY, startDate = System.currentTimeMillis(), - iconName = iconName + iconName = iconName, + budgetMonth = _selectedMonth.value, + budgetYear = _selectedYear.value ) - budgetDao.insertBudget(budget) - updateBudgetWidget(application) + transactionRepository.insertBudget(budget) } } fun updateBudget(budget: Budget) { viewModelScope.launch { - budgetDao.updateBudget(budget) - updateBudgetWidget(application) + transactionRepository.updateBudget(budget) } } fun deleteBudget(budget: Budget) { viewModelScope.launch { - budgetDao.deleteBudget(budget) - updateBudgetWidget(application) + transactionRepository.deleteBudget(budget) } } fun setTotalBudgetLimit(amount: Double) { viewModelScope.launch { preferencesManager.setTotalBudget(amount) - updateBudgetWidget(application) + requestBudgetWidgetRefresh(application) } } @@ -138,9 +154,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 861e362..5be1736 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 @@ -1,6 +1,5 @@ package com.allubie.nana.ui.screens.finances -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -14,18 +13,17 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke 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 java.text.NumberFormat -import java.util.* +import kotlin.math.roundToInt +import androidx.compose.ui.res.stringResource +import com.allubie.nana.R @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -33,8 +31,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 { @@ -44,12 +42,12 @@ fun FinancesOverviewScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Overview") }, + title = { Text(stringResource(R.string.title_spending_analytics)) }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + contentDescription = stringResource(R.string.cd_back) ) } }, @@ -81,7 +79,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 ) @@ -93,7 +91,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) ) @@ -106,7 +104,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) ) @@ -130,7 +128,7 @@ fun FinancesOverviewScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Net Savings", + text = stringResource(R.string.status_net_savings), style = MaterialTheme.typography.titleSmall ) Text( @@ -149,7 +147,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 ) @@ -158,9 +156,10 @@ fun FinancesOverviewScreen( // Donut Chart if (overview.categoryBreakdown.isNotEmpty()) { item { - SpendingDonutChart( + SpendingGaugeCard( categories = overview.categoryBreakdown, totalAmount = overview.totalExpenses, + totalIncome = overview.totalIncome, currencySymbol = currencySymbol ) } @@ -175,12 +174,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 ) @@ -191,7 +189,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 +238,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 ) @@ -269,7 +269,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 ) @@ -297,102 +297,119 @@ private fun BudgetComparisonItem( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun SpendingDonutChart( +private fun SpendingGaugeCard( categories: List, totalAmount: Double, + totalIncome: Double, currencySymbol: String ) { val chartColors = categories.map { Color(it.color) } - fun formatAmount(amount: Double): String { - val formatted = NumberFormat.getNumberInstance(Locale.getDefault()).apply { - minimumFractionDigits = 2 - maximumFractionDigits = 2 - }.format(amount) - return "$currencySymbol$formatted" - } + // Spending ratio as percentage of income + val spentPercentage = if (totalIncome > 0) { + ((totalAmount / totalIncome) * 100).roundToInt().coerceIn(0, 100) + } else if (totalAmount > 0) 100 else 0 + + val remainingPercentage = (100 - spentPercentage).coerceAtLeast(0) Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(32.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { + // Circular gauge matching Budget Manager style Box( - modifier = Modifier.size(200.dp), + modifier = Modifier.size(180.dp), contentAlignment = Alignment.Center ) { - // Donut chart - Canvas(modifier = Modifier.size(180.dp)) { - val strokeWidth = 32.dp.toPx() - val radius = (size.minDimension - strokeWidth) / 2 - val topLeft = Offset( - (size.width - radius * 2) / 2, - (size.height - radius * 2) / 2 - ) - val arcSize = Size(radius * 2, radius * 2) - - var startAngle = -90f - categories.forEachIndexed { index, category -> - val sweepAngle = category.percentage * 360f - drawArc( - color = chartColors[index], - startAngle = startAngle, - sweepAngle = sweepAngle, - useCenter = false, - topLeft = topLeft, - size = arcSize, - style = Stroke(width = strokeWidth, cap = StrokeCap.Butt) - ) - startAngle += sweepAngle - } - } - - // Center text + CircularProgressIndicator( + progress = { (remainingPercentage / 100f).coerceIn(0f, 1f) }, + modifier = Modifier.size(180.dp), + strokeWidth = 10.dp, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + color = if (spentPercentage >= 80) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, + strokeCap = StrokeCap.Round + ) Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = "Total", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "$spentPercentage%", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold ) Text( - text = formatAmount(totalAmount), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center + text = stringResource(R.string.label_spent), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } Spacer(modifier = Modifier.height(16.dp)) - // Legend - FlowRow( + Text( + text = stringResource(R.string.status_total_spent), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = CurrencyFormatter.formatWithSymbol(totalAmount, currencySymbol), + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Bold, + color = if (spentPercentage >= 80) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - maxItemsInEachRow = 3 - ) { - categories.forEachIndexed { index, category -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(chartColors[index]) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = category.name, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + textAlign = TextAlign.Center, + maxLines = 1 + ) + if (totalIncome > 0) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.template_of_total_income, + CurrencyFormatter.formatWithSymbol(totalIncome, currencySymbol)), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + + if (categories.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + + // Category legend + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + maxItemsInEachRow = 3 + ) { + categories.forEachIndexed { index, category -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(chartColors[index]) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = category.name, + style = MaterialTheme.typography.labelSmall, + 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..ff16649 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() { @@ -49,10 +47,10 @@ class FinancesOverviewViewModel( val overview: StateFlow = _overview.asStateFlow() val currencySymbol: StateFlow = preferencesManager.currencySymbol - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), "$") + .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 @@ -84,10 +82,14 @@ class FinancesOverviewViewModel( private fun observeOverview() { viewModelScope.launch { + val currentCal = Calendar.getInstance() + val currentMonth = currentCal.get(Calendar.MONTH) + val currentYear = currentCal.get(Calendar.YEAR) + combine( - transactionDao.getAllTransactions(), - budgetDao.getAllBudgets(), - labelDao.getLabelsByType(LabelType.EXPENSE) + transactionRepository.getAllTransactions(), + transactionRepository.getBudgetsForMonth(currentMonth, currentYear), + labelRepository.getLabelsByType(LabelType.EXPENSE) ) { transactions, budgets, labels -> Triple(transactions, budgets, labels) }.collect { (transactions, budgets, labels) -> @@ -104,11 +106,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 +118,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 +144,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( @@ -170,10 +172,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 9b66bf9..360b691 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 @@ -25,10 +26,12 @@ 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 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 @@ -40,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) @@ -51,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) { @@ -69,10 +75,13 @@ fun FinancesScreen( var showMonthPicker by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) } + var showAllTransactions 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) 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()) @@ -80,17 +89,21 @@ 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 = { 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( @@ -98,7 +111,7 @@ fun FinancesScreen( onDismissRequest = { showMenu = false } ) { DropdownMenuItem( - text = { Text("Budget Manager") }, + text = { Text(stringResource(R.string.title_budget_manager)) }, onClick = { showMenu = false onNavigateToBudgetManager() @@ -112,7 +125,7 @@ fun FinancesScreen( ) HorizontalDivider() DropdownMenuItem( - text = { Text("Settings") }, + text = { Text(stringResource(R.string.menu_settings)) }, onClick = { showMenu = false onNavigateToSettings() @@ -142,7 +155,7 @@ fun FinancesScreen( ) { Icon( Icons.Default.Add, - contentDescription = "Add Transaction", + contentDescription = stringResource(R.string.cd_add_transaction), modifier = Modifier.size(28.dp) ) } @@ -198,25 +211,33 @@ fun FinancesScreen( } } - // Balance card + // Balance card - clickable to Budget Manager if budget is active item { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp), + .padding(horizontal = 16.dp, vertical = 16.dp) + .then( + if (hasEffectiveBudget) Modifier.clip(RoundedCornerShape(24.dp)).clickable { onNavigateToBudgetManager() } + else Modifier + ), 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)) @@ -240,7 +261,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 @@ -259,14 +280,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, @@ -314,13 +335,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 ) @@ -347,7 +368,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, @@ -355,7 +376,7 @@ fun FinancesScreen( ) TextButton(onClick = onNavigateToOverview) { Text( - text = "Budget Overview", + text = stringResource(R.string.action_spending_breakdown), color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold ) @@ -370,7 +391,7 @@ fun FinancesScreen( } } - // Transaction list + // Transaction list (supports full monthly list toggle) if (transactions.isEmpty()) { item { Box( @@ -389,13 +410,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) ) @@ -403,7 +424,8 @@ fun FinancesScreen( } } } else { - items(transactions.take(10), key = { it.id }) { transaction -> + val visibleTransactions = if (showAllTransactions) transactions else transactions.take(10) + items(visibleTransactions, key = { it.id }) { transaction -> TransactionItem( transaction = transaction, dayFormat = dayFormat, @@ -413,6 +435,34 @@ fun FinancesScreen( onDelete = { viewModel.deleteTransaction(transaction) } ) } + + if (transactions.size > 10) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + OutlinedButton( + onClick = { showAllTransactions = !showAllTransactions }, + shape = RoundedCornerShape(50), + border = androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + ) { + Text( + text = if (showAllTransactions) + stringResource(R.string.action_show_less) + else + stringResource(R.string.action_see_all, transactions.size), + fontWeight = FontWeight.SemiBold + ) + } + } + } + } } } } @@ -441,7 +491,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 @@ -497,7 +547,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 ) } } @@ -543,12 +595,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 ) @@ -558,6 +610,7 @@ private fun TransactionItem( Surface( modifier = Modifier .fillMaxWidth() + .semantics(mergeDescendants = true) {} .padding(horizontal = 16.dp, vertical = 6.dp) .clip(RoundedCornerShape(24.dp)) .combinedClickable( @@ -568,7 +621,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( @@ -626,7 +679,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() @@ -640,7 +693,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 @@ -673,7 +726,7 @@ private fun MonthYearPickerDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Select Month") }, + title = { Text(stringResource(R.string.dialog_select_month)) }, text = { Column { // Year selector @@ -683,7 +736,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(), @@ -691,7 +744,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)) } } @@ -738,12 +791,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 a9a46dd..14c16c6 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,19 +2,19 @@ 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 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.updateBudgetWidget +import com.allubie.nana.widget.requestBudgetWidgetRefresh import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -22,64 +22,79 @@ 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