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.
This commit is contained in:
ngenohkevin
2026-06-28 16:49:58 +03:00
parent 9d7fc230f4
commit 42236c88e4
18 changed files with 560 additions and 4 deletions
+2 -2
View File
@@ -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"
}
@@ -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)
@@ -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) }
}
@@ -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"
)
}
}
}
@@ -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<Double>
@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<List<TransactionWithCategory>>
// ---- 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
@@ -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"),
}
@@ -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,
)
@@ -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<Double> =
transactionDao.getCarSpending(tag, startTime, endTime, null)
/** Every transaction tagged [tag], newest first, across all lines. */
fun getCarTransactions(tag: CarTag): Flow<List<TransactionWithCategory>> =
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)
@@ -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) {
@@ -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))
}
@@ -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<TransactionWithCategory> = 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<TransactionEntity?>(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<TransactionWithCategory>,
)
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<CarUiState> = 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
}
}
}
@@ -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<AccountBackfillRoute> {
BackfillScreen(onBack = { navController.popBackStack() })
}
composable<CarExpensesRoute> {
CarExpensesScreen(onBack = { navController.popBackStack() })
}
composable<UpdateRoute> {
UpdateScreen(onBack = { navController.popBackStack() })
}
@@ -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.
@@ -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))
@@ -83,6 +83,12 @@ fun TransactionsScreen(
)
}
},
onCarTagChange = { tag ->
viewModel.changeCarTag(
state.selectedTransaction!!.transaction.id,
tag,
)
},
)
}
@@ -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 {
@@ -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,
@@ -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()