diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 46b95dd..d319647 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 = 4 - versionName = "1.3.1" + versionCode = 5 + versionName = "1.3.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } 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 9a12487..4382a8a 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 @@ -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`)") } } diff --git a/app/src/main/java/com/ledga/app/ui/components/v2/BentoCard.kt b/app/src/main/java/com/ledga/app/ui/components/v2/BentoCard.kt index a3811b5..04a7717 100644 --- a/app/src/main/java/com/ledga/app/ui/components/v2/BentoCard.kt +++ b/app/src/main/java/com/ledga/app/ui/components/v2/BentoCard.kt @@ -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. diff --git a/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt b/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt index 5946a46..9524f92 100644 --- a/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/ledga/app/ui/home/HomeScreen.kt @@ -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, ) } }