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()