From 42236c88e407e95626999eda83a8c37e9bc6e507 Mon Sep 17 00:00:00 2001 From: ngenohkevin Date: Sun, 28 Jun 2026 16:49:58 +0300 Subject: [PATCH] v1.6.0: car expense tracking (Fuel/Service) + tappable notifications Mark any transaction as Fuel or Car Service from the detail sheet (a tag separate from its category). New 'Car expenses' screen (You tab) shows the car's lifetime cost plus per-tag This Week / This Month / All Time, summed across every SIM. DB v4->v5 adds a nullable carTag column (additive, no rebuild). Also: notifications now carry a tap intent, so tapping opens the app and clears them instead of lingering until manually swiped. --- app/build.gradle.kts | 4 +- .../java/com/ledga/app/data/db/AppDatabase.kt | 2 +- .../java/com/ledga/app/data/db/Converters.kt | 8 + .../java/com/ledga/app/data/db/Migrations.kt | 13 + .../ledga/app/data/db/dao/TransactionDao.kt | 32 +++ .../com/ledga/app/data/db/entity/CarTag.kt | 14 + .../app/data/db/entity/TransactionEntity.kt | 6 + .../data/repository/TransactionRepository.kt | 19 ++ .../java/com/ledga/app/di/DatabaseModule.kt | 1 + .../com/ledga/app/ui/car/CarExpensesScreen.kt | 267 ++++++++++++++++++ .../ledga/app/ui/car/CarExpensesViewModel.kt | 110 ++++++++ .../ledga/app/ui/navigation/AppNavigation.kt | 5 + .../com/ledga/app/ui/navigation/Routes.kt | 1 + .../ui/transactions/TransactionDetailSheet.kt | 33 +++ .../app/ui/transactions/TransactionsScreen.kt | 6 + .../ui/transactions/TransactionsViewModel.kt | 17 ++ .../java/com/ledga/app/ui/you/YouScreen.kt | 9 + .../ledga/app/worker/NotificationHelper.kt | 17 +- 18 files changed, 560 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/ledga/app/data/db/entity/CarTag.kt create mode 100644 app/src/main/java/com/ledga/app/ui/car/CarExpensesScreen.kt create mode 100644 app/src/main/java/com/ledga/app/ui/car/CarExpensesViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 32a23c0..eceb626 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,8 +27,8 @@ android { applicationId = "com.ledga.app" minSdk = 26 targetSdk = 34 - versionCode = 20 - versionName = "1.5.2" + versionCode = 21 + versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt b/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt index d612070..ddacb0f 100644 --- a/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt +++ b/app/src/main/java/com/ledga/app/data/db/AppDatabase.kt @@ -30,7 +30,7 @@ import com.ledga.app.data.db.entity.TransactionEntity GoalContribution::class, Insight::class, ], - version = 4, + version = 5, exportSchema = true ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/ledga/app/data/db/Converters.kt b/app/src/main/java/com/ledga/app/data/db/Converters.kt index 5f50d42..877e879 100644 --- a/app/src/main/java/com/ledga/app/data/db/Converters.kt +++ b/app/src/main/java/com/ledga/app/data/db/Converters.kt @@ -1,6 +1,7 @@ package com.ledga.app.data.db import androidx.room.TypeConverter +import com.ledga.app.data.db.entity.CarTag import com.ledga.app.data.db.entity.InsightSeverity import com.ledga.app.data.db.entity.InsightType import com.ledga.app.data.db.entity.MatchType @@ -38,4 +39,11 @@ class Converters { @TypeConverter fun toInsightSeverity(value: String): InsightSeverity = InsightSeverity.valueOf(value) + + // Nullable: a transaction with no car tag stores SQL NULL. + @TypeConverter + fun fromCarTag(value: CarTag?): String? = value?.name + + @TypeConverter + fun toCarTag(value: String?): CarTag? = value?.let { CarTag.valueOf(it) } } diff --git a/app/src/main/java/com/ledga/app/data/db/Migrations.kt b/app/src/main/java/com/ledga/app/data/db/Migrations.kt index d01b808..96af7a6 100644 --- a/app/src/main/java/com/ledga/app/data/db/Migrations.kt +++ b/app/src/main/java/com/ledga/app/data/db/Migrations.kt @@ -18,6 +18,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase * 4 - categories gains isTransfer (own-account transfer categories, * excluded from spending) + seed the "My Accounts" category; * transactions gains fulizaLimit (available Fuliza limit from SMS). + * 5 - transactions gains carTag (user-applied Fuel/Service car-expense tag, + * stored as TEXT enum name; null = not a car expense). */ object Migrations { @@ -204,4 +206,15 @@ object Migrations { ) } } + + val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + // Additive nullable column — no table rebuild, no FK risk. Stores + // the CarTag enum name ("FUEL" / "SERVICE"); NULL for the vast + // majority of rows that aren't car expenses. + db.execSQL( + "ALTER TABLE `transactions` ADD COLUMN `carTag` TEXT" + ) + } + } } diff --git a/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt index 0857732..c5497cf 100644 --- a/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt +++ b/app/src/main/java/com/ledga/app/data/db/dao/TransactionDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import com.ledga.app.data.db.entity.CarTag import com.ledga.app.data.db.entity.TransactionEntity import com.ledga.app.data.db.entity.TransactionWithCategory import com.ledga.app.data.parser.TransactionType @@ -412,6 +413,37 @@ interface TransactionDao { @Query("DELETE FROM transactions WHERE id = :id") suspend fun deleteById(id: Long) + // ---- Car expenses (Fuel / Service tag) ---- + // carTag is an orthogonal user tag; these power the Car expenses screen. + // Reversed originals are excluded so a refunded fuel payment doesn't count. + // The Car screen is account-agnostic (the repo always passes accountId=null, + // like the goal helpers) — the :accountId param is kept for symmetry. + + @Query("UPDATE transactions SET carTag = :tag WHERE id = :id") + suspend fun updateCarTag(id: Long, tag: CarTag?) + + @Query(""" + SELECT COALESCE(SUM(amount), 0.0) FROM transactions + WHERE carTag = :tag + AND direction = 'OUTFLOW' + AND timestamp BETWEEN :startTime AND :endTime + AND (:accountId IS NULL OR accountId = :accountId) + AND transactionCode NOT IN ( + SELECT reversedTransactionCode FROM transactions + WHERE reversedTransactionCode IS NOT NULL + ) + """) + fun getCarSpending(tag: CarTag, startTime: Long, endTime: Long, accountId: Long? = null): Flow + + @Transaction + @Query(""" + SELECT * FROM transactions + WHERE carTag = :tag + AND (:accountId IS NULL OR accountId = :accountId) + ORDER BY timestamp DESC + """) + fun getCarTransactions(tag: CarTag, accountId: Long? = null): Flow> + // ---- Fuliza status (latest known values from SMS) ---- // Fuliza is a per-LINE facility: each SIM has its own limit and // outstanding. These queries take the account filter so dual-SIM users diff --git a/app/src/main/java/com/ledga/app/data/db/entity/CarTag.kt b/app/src/main/java/com/ledga/app/data/db/entity/CarTag.kt new file mode 100644 index 0000000..3c73bc1 --- /dev/null +++ b/app/src/main/java/com/ledga/app/data/db/entity/CarTag.kt @@ -0,0 +1,14 @@ +package com.ledga.app.data.db.entity + +/** + * An optional, user-applied tag marking a transaction as a car expense. + * + * This is a SEPARATE dimension from [Category]: a fuel purchase keeps its + * normal category (e.g. Transport) AND carries a [CarTag], so car spending can + * be totalled on its own without disturbing the regular category breakdown. + * `null` on a transaction means "not a car expense" — the common case. + */ +enum class CarTag(val label: String) { + FUEL("Fuel"), + SERVICE("Service"), +} diff --git a/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt b/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt index bc6abc8..5028885 100644 --- a/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt +++ b/app/src/main/java/com/ledga/app/data/db/entity/TransactionEntity.kt @@ -57,4 +57,10 @@ data class TransactionEntity( // v2 additions (DB v2) val accountId: Long? = null, val note: String? = null, + /** + * Optional car-expense tag (Fuel / Service), set by the user from the + * transaction detail sheet. Null = not a car expense. Added DB v5. + * Orthogonal to [categoryId] — see [CarTag]. + */ + val carTag: CarTag? = null, ) diff --git a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt index 109a106..21bac91 100644 --- a/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt +++ b/app/src/main/java/com/ledga/app/data/repository/TransactionRepository.kt @@ -7,6 +7,7 @@ import com.ledga.app.data.db.dao.DailySpending import com.ledga.app.data.db.dao.MonthlySpending import com.ledga.app.data.db.dao.TopMerchant import com.ledga.app.data.db.dao.TransactionDao +import com.ledga.app.data.db.entity.CarTag import com.ledga.app.data.db.entity.CategoryRule import com.ledga.app.data.db.entity.MatchType import com.ledga.app.data.db.entity.TransactionEntity @@ -181,6 +182,24 @@ class TransactionRepository @Inject constructor( transactionDao.updateAccount(transactionId, accountId) } + // ---- Car expenses (Fuel / Service) ---- + // Account-agnostic by design: a car costs the same regardless of which + // SIM paid for the fuel or service, so these never apply the account + // filter (mirrors the goal helpers above). + + /** Set/clear the Fuel/Service tag on a single transaction. */ + suspend fun updateCarTag(transactionId: Long, tag: CarTag?) { + transactionDao.updateCarTag(transactionId, tag) + } + + /** Total spent on [tag] in [startTime]..[endTime] across all lines. */ + fun getCarSpending(tag: CarTag, startTime: Long, endTime: Long): Flow = + transactionDao.getCarSpending(tag, startTime, endTime, null) + + /** Every transaction tagged [tag], newest first, across all lines. */ + fun getCarTransactions(tag: CarTag): Flow> = + transactionDao.getCarTransactions(tag, null) + /** Bulk date-range backfill. Returns the number of rows updated. */ suspend fun bulkAttribute(accountId: Long?, startTime: Long, endTime: Long): Int = transactionDao.updateAccountForRange(accountId, startTime, endTime) diff --git a/app/src/main/java/com/ledga/app/di/DatabaseModule.kt b/app/src/main/java/com/ledga/app/di/DatabaseModule.kt index 7c53ff3..c58f0cb 100644 --- a/app/src/main/java/com/ledga/app/di/DatabaseModule.kt +++ b/app/src/main/java/com/ledga/app/di/DatabaseModule.kt @@ -40,6 +40,7 @@ object DatabaseModule { Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4, + Migrations.MIGRATION_4_5, ) .addCallback(object : RoomDatabase.Callback() { override fun onCreate(db: SupportSQLiteDatabase) { diff --git a/app/src/main/java/com/ledga/app/ui/car/CarExpensesScreen.kt b/app/src/main/java/com/ledga/app/ui/car/CarExpensesScreen.kt new file mode 100644 index 0000000..6f18f15 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/car/CarExpensesScreen.kt @@ -0,0 +1,267 @@ +package com.ledga.app.ui.car + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ledga.app.data.db.entity.CarTag +import com.ledga.app.data.db.entity.TransactionEntity +import com.ledga.app.data.parser.TransactionDirection +import com.ledga.app.ui.components.parseColor +import com.ledga.app.ui.components.v2.BackLeading +import com.ledga.app.ui.components.v2.BentoCard +import com.ledga.app.ui.components.v2.LedgaChip +import com.ledga.app.ui.components.v2.LedgaTopBar +import com.ledga.app.ui.components.v2.TransactionRowV2 +import com.ledga.app.ui.components.v2.onTonal +import com.ledga.app.ui.components.v2.onTonalMuted +import com.ledga.app.ui.theme.LedgaAccentSoft +import com.ledga.app.ui.theme.LedgaText +import com.ledga.app.ui.theme.Space +import com.ledga.app.util.CurrencyFormatter +import com.ledga.app.util.DateUtils + +/** + * Car expenses — fuel & service spend, summed across every line. + * + * A car's running cost doesn't care which SIM paid, so every figure here is + * account-agnostic (see [CarExpensesViewModel]). The hero shows the lifetime + * total; the toggle picks Fuel or Service, and the three stat cards break that + * down into this week / this month / all time. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CarExpensesScreen( + onBack: () -> Unit, + viewModel: CarExpensesViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsState() + + state.selected?.let { txn -> + RetagSheet( + transaction = txn, + onDismiss = { viewModel.clearSelection() }, + onSetTag = { tag -> viewModel.setTag(txn.id, tag) }, + ) + } + + Column(modifier = Modifier.fillMaxSize()) { + LedgaTopBar(title = "Car", leading = { BackLeading(onBack) }) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Space.Screen) + .padding(bottom = 120.dp), + verticalArrangement = Arrangement.spacedBy(Space.Section), + ) { + // ---- Hero: lifetime cost of the car ---- + BentoCard(tonal = true, tonalColor = LedgaAccentSoft) { + Text( + text = "What your car has cost you".uppercase(), + style = LedgaText.Overline, + color = onTonalMuted(LedgaAccentSoft), + ) + Text( + text = CurrencyFormatter.formatKsh(state.totalAllTime), + style = LedgaText.DisplayL, + color = onTonal(LedgaAccentSoft), + ) + Text( + text = "Fuel ${CurrencyFormatter.formatKshCompact(state.fuelAllTime)}" + + " · Service ${CurrencyFormatter.formatKshCompact(state.serviceAllTime)}", + style = LedgaText.BodyM, + color = onTonalMuted(LedgaAccentSoft), + ) + } + + // ---- Fuel / Service toggle ---- + Row(horizontalArrangement = Arrangement.spacedBy(Space.s3)) { + CarTag.entries.forEach { tag -> + LedgaChip( + label = tag.label, + selected = state.selectedTag == tag, + onClick = { viewModel.selectTag(tag) }, + ) + } + } + + // ---- This week / This month / All time (for the selected tag) ---- + Row(horizontalArrangement = Arrangement.spacedBy(Space.s4)) { + StatCard(modifier = Modifier.weight(1f), label = "This week", value = state.week) + StatCard(modifier = Modifier.weight(1f), label = "This month", value = state.month) + StatCard(modifier = Modifier.weight(1f), label = "All time", value = state.allTime) + } + + // ---- Tagged transactions ---- + val label = state.selectedTag.label + if (state.transactions.isEmpty()) { + BentoCard { + Text( + text = "No ${label.lowercase()} payments tagged yet.", + style = LedgaText.BodyL, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Open any transaction, scroll to “Car expense” and mark " + + "it as $label. It’ll show up here.", + style = LedgaText.BodyM, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + BentoCard(contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Space.Card, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "$label payments".uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + text = "${state.transactions.size}", + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), + ) + state.transactions.forEachIndexed { index, twc -> + TransactionRowV2( + recipient = twc.transaction.recipientName + ?: twc.transaction.type.name.replace("_", " "), + amount = twc.transaction.amount, + isInflow = twc.transaction.direction == TransactionDirection.INFLOW, + category = DateUtils.formatDate(twc.transaction.timestamp), + color = twc.category?.color?.let(::parseColor) + ?: androidx.compose.ui.graphics.Color.Gray, + icon = twc.category?.icon ?: "category", + balance = twc.transaction.balance, + timestamp = twc.transaction.timestamp, + onClick = { viewModel.selectTransaction(twc.transaction) }, + ) + if (index < state.transactions.lastIndex) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = Space.Card), + ) + } + } + } + } + + Box(modifier = Modifier.padding(bottom = Space.s7)) + } + } +} + +@Composable +private fun StatCard(modifier: Modifier = Modifier, label: String, value: Double) { + BentoCard(modifier = modifier) { + Text( + text = label.uppercase(), + style = LedgaText.Overline, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = CurrencyFormatter.formatKshCompact(value), + style = LedgaText.TitleM, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +/** + * Lightweight sheet for re-tagging or removing a car tag straight from this + * screen — so a mis-tagged row can be fixed without hunting for it in Activity. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RetagSheet( + transaction: TransactionEntity, + onDismiss: () -> Unit, + onSetTag: (CarTag?) -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + ) { + Text( + text = CurrencyFormatter.formatKsh(transaction.amount), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = transaction.recipientName ?: transaction.type.name.replace("_", " "), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = DateUtils.formatDate(transaction.timestamp), + style = LedgaText.Caption, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Space.s5) + + Text( + text = "Car expense", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Space.s3) + Row(horizontalArrangement = Arrangement.spacedBy(Space.s3)) { + LedgaChip( + label = "Remove", + selected = transaction.carTag == null, + onClick = { onSetTag(null) }, + ) + CarTag.entries.forEach { tag -> + LedgaChip( + label = tag.label, + selected = transaction.carTag == tag, + onClick = { onSetTag(tag) }, + ) + } + } + + Spacer(Space.s8) + } + } +} + +@Composable +private fun Spacer(height: androidx.compose.ui.unit.Dp) { + Box(modifier = Modifier.height(height)) +} diff --git a/app/src/main/java/com/ledga/app/ui/car/CarExpensesViewModel.kt b/app/src/main/java/com/ledga/app/ui/car/CarExpensesViewModel.kt new file mode 100644 index 0000000..abdb261 --- /dev/null +++ b/app/src/main/java/com/ledga/app/ui/car/CarExpensesViewModel.kt @@ -0,0 +1,110 @@ +package com.ledga.app.ui.car + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ledga.app.data.db.entity.CarTag +import com.ledga.app.data.db.entity.TransactionEntity +import com.ledga.app.data.db.entity.TransactionWithCategory +import com.ledga.app.data.repository.TransactionRepository +import com.ledga.app.util.DateUtils +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Backs the Car expenses screen. All figures are account-agnostic (summed + * across every M-PESA line) — a car costs the same no matter which SIM paid, + * so the repository's car queries deliberately ignore the account switcher. + */ +data class CarUiState( + val selectedTag: CarTag = CarTag.FUEL, + /** All-time totals per tag — drive the hero headline + the toggle subtitles. */ + val fuelAllTime: Double = 0.0, + val serviceAllTime: Double = 0.0, + val totalAllTime: Double = 0.0, + /** For the currently-selected tag. */ + val week: Double = 0.0, + val month: Double = 0.0, + val allTime: Double = 0.0, + val transactions: List = emptyList(), + /** The row the user tapped, shown in the re-tag sheet (null = closed). */ + val selected: TransactionEntity? = null, +) + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class CarExpensesViewModel @Inject constructor( + private val repository: TransactionRepository, +) : ViewModel() { + + private val _selectedTag = MutableStateFlow(CarTag.FUEL) + private val _selected = MutableStateFlow(null) + + // Captured once when the screen opens — "this week"/"this month" boundaries. + // Open intervals (start .. MAX) so a payment made seconds ago still counts. + private val weekStart = DateUtils.getStartOfWeek() + private val monthStart = DateUtils.getStartOfMonth() + + private data class PerTag( + val tag: CarTag, + val week: Double, + val month: Double, + val allTime: Double, + val transactions: List, + ) + + private val perTag = _selectedTag.flatMapLatest { tag -> + combine( + repository.getCarSpending(tag, weekStart, Long.MAX_VALUE), + repository.getCarSpending(tag, monthStart, Long.MAX_VALUE), + repository.getCarSpending(tag, 0L, Long.MAX_VALUE), + repository.getCarTransactions(tag), + ) { week, month, all, txns -> PerTag(tag, week, month, all, txns) } + } + + val uiState: StateFlow = combine( + repository.getCarSpending(CarTag.FUEL, 0L, Long.MAX_VALUE), + repository.getCarSpending(CarTag.SERVICE, 0L, Long.MAX_VALUE), + perTag, + _selected, + ) { fuelAll, serviceAll, pt, selected -> + CarUiState( + selectedTag = pt.tag, + fuelAllTime = fuelAll, + serviceAllTime = serviceAll, + totalAllTime = fuelAll + serviceAll, + week = pt.week, + month = pt.month, + allTime = pt.allTime, + transactions = pt.transactions, + selected = selected, + ) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CarUiState()) + + fun selectTag(tag: CarTag) { + _selectedTag.value = tag + } + + fun selectTransaction(transaction: TransactionEntity) { + _selected.value = transaction + } + + fun clearSelection() { + _selected.value = null + } + + /** Re-tag or untag (tag = null) the tapped transaction; closes the sheet. */ + fun setTag(transactionId: Long, tag: CarTag?) { + viewModelScope.launch { + repository.updateCarTag(transactionId, tag) + _selected.value = null + } + } +} diff --git a/app/src/main/java/com/ledga/app/ui/navigation/AppNavigation.kt b/app/src/main/java/com/ledga/app/ui/navigation/AppNavigation.kt index 1f5bc06..988d139 100644 --- a/app/src/main/java/com/ledga/app/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/ledga/app/ui/navigation/AppNavigation.kt @@ -7,6 +7,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.ledga.app.ui.accounts.AccountsScreen import com.ledga.app.ui.accounts.BackfillScreen +import com.ledga.app.ui.car.CarExpensesScreen import com.ledga.app.ui.update.UpdateScreen import com.ledga.app.ui.activity.ActivityScreen import com.ledga.app.ui.goals.GoalDetailScreen @@ -61,6 +62,7 @@ fun AppNavigation( onNavigateToBudgets = { navController.navigate(BudgetRoute) }, onNavigateToGoals = { navController.navigate(GoalsRoute) }, onNavigateToAccounts = { navController.navigate(AccountsRoute) }, + onNavigateToCar = { navController.navigate(CarExpensesRoute) }, onNavigateToUpdate = { navController.navigate(UpdateRoute) }, ) } @@ -90,6 +92,9 @@ fun AppNavigation( composable { BackfillScreen(onBack = { navController.popBackStack() }) } + composable { + CarExpensesScreen(onBack = { navController.popBackStack() }) + } composable { UpdateScreen(onBack = { navController.popBackStack() }) } diff --git a/app/src/main/java/com/ledga/app/ui/navigation/Routes.kt b/app/src/main/java/com/ledga/app/ui/navigation/Routes.kt index e0ec5f2..903c776 100644 --- a/app/src/main/java/com/ledga/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/ledga/app/ui/navigation/Routes.kt @@ -18,6 +18,7 @@ import kotlinx.serialization.Serializable @Serializable object AccountsRoute @Serializable object AccountBackfillRoute @Serializable object UpdateRoute +@Serializable object CarExpensesRoute // ---- Legacy aliases kept until Phase B sweep is complete ---- // Activity bar replaces these; AppNavigation will route them inside Activity. diff --git a/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt b/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt index 5c5f947..6531950 100644 --- a/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionDetailSheet.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.ledga.app.data.db.entity.CarTag import com.ledga.app.data.db.entity.Category import com.ledga.app.data.db.entity.Goal import com.ledga.app.data.db.entity.MpesaAccount @@ -51,6 +52,7 @@ fun TransactionDetailSheet( onToggleGoal: ((goalId: Long, currentlyIn: Boolean) -> Unit)? = null, isOwnAccount: Boolean = false, onToggleOwnAccount: (() -> Unit)? = null, + onCarTagChange: ((CarTag?) -> Unit)? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val isInflow = transaction.direction == TransactionDirection.INFLOW @@ -150,6 +152,37 @@ fun TransactionDetailSheet( } } + // ---- Car expense tag (Fuel / Service) ---- + // A separate dimension from category: tag a fuel or service payment + // so it's totalled on the Car expenses screen. Outflows only. + if (onCarTagChange != null && + transaction.direction == TransactionDirection.OUTFLOW + ) { + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Car expense", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LedgaChip( + label = "None", + selected = transaction.carTag == null, + onClick = { onCarTagChange(null) }, + ) + CarTag.entries.forEach { tag -> + LedgaChip( + label = tag.label, + selected = transaction.carTag == tag, + onClick = { onCarTagChange(tag) }, + ) + } + } + } + // ---- Add to goal (only when user has manual-rule goals) ---- if (manualGoals.isNotEmpty() && onToggleGoal != null) { Spacer(modifier = Modifier.height(16.dp)) diff --git a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt index 64b09e2..8e51710 100644 --- a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsScreen.kt @@ -83,6 +83,12 @@ fun TransactionsScreen( ) } }, + onCarTagChange = { tag -> + viewModel.changeCarTag( + state.selectedTransaction!!.transaction.id, + tag, + ) + }, ) } diff --git a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt index 8cb68b2..e750658 100644 --- a/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt +++ b/app/src/main/java/com/ledga/app/ui/transactions/TransactionsViewModel.kt @@ -2,6 +2,7 @@ package com.ledga.app.ui.transactions import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.ledga.app.data.db.entity.CarTag import com.ledga.app.data.db.entity.Category import com.ledga.app.data.db.entity.Goal import com.ledga.app.data.db.entity.MpesaAccount @@ -182,6 +183,22 @@ class TransactionsViewModel @Inject constructor( } } + /** + * Set/clear the Fuel/Service car tag on the selected transaction. Keeps the + * sheet open and reflects the change in the snapshot so the chip highlights + * immediately (the selected transaction isn't a live DB flow). + */ + fun changeCarTag(transactionId: Long, tag: CarTag?) { + viewModelScope.launch { + transactionRepository.updateCarTag(transactionId, tag) + _selectedTransaction.value = _selectedTransaction.value?.let { cur -> + if (cur.transaction.id == transactionId) + cur.copy(transaction = cur.transaction.copy(carTag = tag)) + else cur + } + } + } + /** Toggle membership of the selected transaction in a Manual-rule goal. */ fun toggleGoalContribution(transactionId: Long, goalId: Long, currentlyIn: Boolean) { viewModelScope.launch { diff --git a/app/src/main/java/com/ledga/app/ui/you/YouScreen.kt b/app/src/main/java/com/ledga/app/ui/you/YouScreen.kt index 13909d3..8fa8db2 100644 --- a/app/src/main/java/com/ledga/app/ui/you/YouScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/you/YouScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Backup import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.DirectionsCar import androidx.compose.material.icons.filled.Flag import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Notifications @@ -63,6 +64,7 @@ fun YouScreen( onNavigateToBudgets: () -> Unit, onNavigateToGoals: () -> Unit, onNavigateToAccounts: () -> Unit, + onNavigateToCar: () -> Unit, onNavigateToUpdate: () -> Unit, viewModel: SettingsViewModel = hiltViewModel(), ) { @@ -144,6 +146,13 @@ fun YouScreen( onClick = onNavigateToBudgets, ) CardDivider() + NavRow( + icon = Icons.Filled.DirectionsCar, + iconTint = LedgaAccentDeep, + label = "Car expenses", + onClick = onNavigateToCar, + ) + CardDivider() NavRow( icon = Icons.Filled.SimCard, iconTint = LedgaAccentDeep, diff --git a/app/src/main/java/com/ledga/app/worker/NotificationHelper.kt b/app/src/main/java/com/ledga/app/worker/NotificationHelper.kt index 6b77d53..b69b70e 100644 --- a/app/src/main/java/com/ledga/app/worker/NotificationHelper.kt +++ b/app/src/main/java/com/ledga/app/worker/NotificationHelper.kt @@ -2,8 +2,9 @@ package com.ledga.app.worker import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context -import android.os.Build +import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -50,11 +51,25 @@ object NotificationHelper { title: String, body: String ) { + // Tapping opens the app. Without a content intent the tap does nothing + // and setAutoCancel never fires, so the notification just sits in the + // shade until manually swiped — the "can't mark it read" complaint. + val launchIntent = context.packageManager + .getLaunchIntentForPackage(context.packageName) + ?.apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } + val contentIntent = PendingIntent.getActivity( + context, + notificationId, + launchIntent ?: Intent(), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val notification = NotificationCompat.Builder(context, channelId) .setSmallIcon(android.R.drawable.ic_dialog_info) .setContentTitle(title) .setContentText(body) .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setContentIntent(contentIntent) .setAutoCancel(true) .build()