mirror of
https://github.com/andwati/ledga.git
synced 2026-10-18 03:12:55 +00:00
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:
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user