v1.3.2 hotfix: migration FK crash + dark-theme insight teaser

MIGRATION_1_2 was using ALTER TABLE to add transactions.accountId, but
SQLite can't add a FOREIGN KEY via ALTER. Room validates schema after
migration and threw IllegalStateException ("Migration didn't properly
handle: transactions") because the FK to mpesa_accounts was missing.
The whole DB stayed at v1, blocking every read/write — which is why
SMS import + SMS receive looked dead too.

Fix: standard SQLite table-swap dance. Create transactions_new with
both FKs declared, INSERT … SELECT old rows in, drop the old table,
rename. All indices recreated. Defensive DROP IF EXISTS in case a
prior failed run left junk.

BentoCard now picks title + overline colors from the tonal background
luminance instead of unconditionally using onSurface. On dark theme
the Insights teaser had near-white text on the (light-theme) pale
green bg — invisible. Now it adapts:
  - light tonal bg → dark ink
  - dark tonal bg → light ink
HomeScreen Insights teaser switched from hardcoded LedgaAccentSoft to
MaterialTheme.colorScheme.primaryContainer so the bg itself also
adapts to theme.
This commit is contained in:
ngenohkevin
2026-05-16 13:55:31 +03:00
parent 3dc9e0305e
commit 5ca2f89303
4 changed files with 102 additions and 19 deletions
+2 -2
View File
@@ -27,8 +27,8 @@ android {
applicationId = "com.ledga.app"
minSdk = 26
targetSdk = 34
versionCode = 4
versionName = "1.3.1"
versionCode = 5
versionName = "1.3.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -78,17 +78,68 @@ object Migrations {
"ON `goal_contributions` (`transactionId`)"
)
// ---- transactions.accountId + note ----
// SQLite ALTER TABLE … ADD COLUMN can't carry a FOREIGN KEY clause —
// Room re-asserts the FK constraint via its own validation, but the
// raw column add is enough here. The index makes account-scoped
// queries cheap (we'll add a lot of them in Phase E).
db.execSQL("ALTER TABLE `transactions` ADD COLUMN `accountId` INTEGER")
db.execSQL("ALTER TABLE `transactions` ADD COLUMN `note` TEXT")
// ---- transactions: add accountId (FK → mpesa_accounts) + note ----
// SQLite cannot add a FOREIGN KEY via ALTER TABLE — the constraint
// can only be declared at CREATE TABLE time. So we recreate the
// table: build the new shape, copy rows, drop the old, rename.
// Room runs this migration inside its own transaction, which defers
// FK enforcement until commit, so the swap is safe.
// Defensive: if a previous failed run left a half-built table, drop it.
db.execSQL("DROP TABLE IF EXISTS `transactions_new`")
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_transactions_accountId` " +
"ON `transactions` (`accountId`)"
"""
CREATE TABLE `transactions_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`transactionCode` TEXT NOT NULL,
`type` TEXT NOT NULL,
`amount` REAL NOT NULL,
`transactionCost` REAL NOT NULL,
`recipientName` TEXT,
`recipientPhone` TEXT,
`accountNumber` TEXT,
`destinationCountry` TEXT,
`balance` REAL NOT NULL,
`direction` TEXT NOT NULL,
`categoryId` INTEGER,
`fulizaAmount` REAL,
`fulizaOutstanding` REAL,
`reversedTransactionCode` TEXT,
`rawSms` TEXT NOT NULL,
`timestamp` INTEGER NOT NULL,
`createdAt` INTEGER NOT NULL,
`accountId` INTEGER,
`note` TEXT,
FOREIGN KEY (`categoryId`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL,
FOREIGN KEY (`accountId`) REFERENCES `mpesa_accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL
)
""".trimIndent()
)
db.execSQL(
"""
INSERT INTO `transactions_new` (
`id`, `transactionCode`, `type`, `amount`, `transactionCost`,
`recipientName`, `recipientPhone`, `accountNumber`, `destinationCountry`,
`balance`, `direction`, `categoryId`, `fulizaAmount`, `fulizaOutstanding`,
`reversedTransactionCode`, `rawSms`, `timestamp`, `createdAt`,
`accountId`, `note`
)
SELECT
`id`, `transactionCode`, `type`, `amount`, `transactionCost`,
`recipientName`, `recipientPhone`, `accountNumber`, `destinationCountry`,
`balance`, `direction`, `categoryId`, `fulizaAmount`, `fulizaOutstanding`,
`reversedTransactionCode`, `rawSms`, `timestamp`, `createdAt`,
NULL, NULL
FROM `transactions`
""".trimIndent()
)
db.execSQL("DROP TABLE `transactions`")
db.execSQL("ALTER TABLE `transactions_new` RENAME TO `transactions`")
// Indices live with the table — recreate every one the entity declares.
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_transactions_transactionCode` ON `transactions` (`transactionCode`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_transactions_categoryId` ON `transactions` (`categoryId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_transactions_accountId` ON `transactions` (`accountId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_transactions_timestamp` ON `transactions` (`timestamp`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_transactions_type` ON `transactions` (`type`)")
}
}
@@ -20,8 +20,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.ledga.app.ui.theme.LedgaInk
import com.ledga.app.ui.theme.LedgaInkDark
import com.ledga.app.ui.theme.LedgaMuted
import com.ledga.app.ui.theme.LedgaMutedDark
import com.ledga.app.ui.theme.LedgaText
import com.ledga.app.ui.theme.Radius
import com.ledga.app.ui.theme.Space
@@ -53,6 +58,13 @@ fun BentoCard(
val container = if (tonal) tonalColor else MaterialTheme.colorScheme.surface
val border = if (tonal) null else BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
// For tonal cards we pick text colors that contrast with the tonal bg
// rather than the theme's default onSurface — otherwise a light tonal
// (e.g. accent-soft) in dark mode renders near-white text on a near-white
// bg and disappears.
val titleColor = if (tonal) onTonal(tonalColor) else MaterialTheme.colorScheme.onSurface
val overlineColor = if (tonal) onTonalMuted(tonalColor) else MaterialTheme.colorScheme.onSurfaceVariant
Surface(
modifier = modifier
.fillMaxWidth()
@@ -88,14 +100,14 @@ fun BentoCard(
Text(
text = overline.uppercase(),
style = LedgaText.Overline,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = overlineColor,
)
}
if (title != null) {
Text(
text = title,
style = LedgaText.TitleS,
color = MaterialTheme.colorScheme.onSurface,
color = titleColor,
)
}
}
@@ -107,6 +119,21 @@ fun BentoCard(
}
}
/**
* Pick a primary text color that has decent contrast against [background].
* Hand-rolled rather than relying on theme onSurface because tonal bgs
* are explicit colors set by the caller, not picked from MaterialTheme.
*
* Public so screens can render their own content slot in the matching ink
* (e.g. body text inside a tonal card).
*/
fun onTonal(background: Color): Color =
if (background.luminance() > 0.5f) LedgaInk else LedgaInkDark
/** Muted variant of [onTonal] for captions / overlines. */
fun onTonalMuted(background: Color): Color =
if (background.luminance() > 0.5f) LedgaMuted else LedgaMutedDark
/**
* Small 40×40 icon tile with rounded corners, category-color tint.
* Used in transaction rows, category chips, account avatars.
@@ -43,6 +43,7 @@ import com.ledga.app.ui.components.v2.LedgaTopBar
import com.ledga.app.ui.components.v2.StatCard
import com.ledga.app.ui.components.v2.TopBarIconButton
import com.ledga.app.ui.components.v2.TransactionRowV2
import com.ledga.app.ui.components.v2.onTonal
import com.ledga.app.ui.theme.LedgaAccent
import com.ledga.app.ui.theme.LedgaAccentDeep
import com.ledga.app.ui.theme.LedgaAccentSoft
@@ -232,22 +233,26 @@ fun HomeScreen(
}
// ---- Insights teaser ----
// Use the theme-adaptive accent-container so the card stays
// readable in both light + dark.
val insightBg = MaterialTheme.colorScheme.primaryContainer
val insightInk = onTonal(insightBg)
val topInsight = state.topInsight
if (topInsight != null) {
BentoCard(
overline = topInsight.typeLabel,
title = topInsight.headline,
icon = Icons.Filled.AutoAwesome,
iconTint = LedgaAccentDeep,
iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
tonal = true,
tonalColor = LedgaAccentSoft,
tonalColor = insightBg,
onClick = onNavigateToInsights,
) {
if (!topInsight.body.isNullOrBlank()) {
Text(
text = topInsight.body,
style = LedgaText.BodyM,
color = LedgaInk,
color = insightInk,
)
}
}
@@ -256,16 +261,16 @@ fun HomeScreen(
overline = "Insights",
title = "All quiet — nothing unusual",
icon = Icons.Filled.AutoAwesome,
iconTint = LedgaAccentDeep,
iconTint = MaterialTheme.colorScheme.onPrimaryContainer,
tonal = true,
tonalColor = LedgaAccentSoft,
tonalColor = insightBg,
onClick = onNavigateToInsights,
) {
Text(
text = "Ledga watches your activity daily and surfaces anything " +
"worth attention. Tap to see how it works.",
style = LedgaText.BodyM,
color = LedgaInk,
color = insightInk,
)
}
}