feat(android): UI redesign — light web-matching theme, dark top bar + bottom nav (Phase 28.7)

This commit is contained in:
Mathieu Moran
2026-08-15 20:18:53 -04:00
parent 0cb89596b0
commit 699ce16dab
12 changed files with 170 additions and 113 deletions
+11 -2
View File
@@ -2021,7 +2021,7 @@ tracked in FEATURES.md.
2. **FEAT-02** — QR / claim-code device pairing flow (medium) 2. **FEAT-02** — QR / claim-code device pairing flow (medium)
3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large) 3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large)
### Phase 28: Native Android companion app — 🚧 IN PROGRESS (28.1 ✅, 28.2 ✅, 28.3 ✅, 28.4 ✅, 28.5 ✅, 28.6 ✅, 28.7 pending) ### Phase 28: Native Android companion app — 🚧 IN PROGRESS (28.1 ✅, 28.2 ✅, 28.3 ✅, 28.4 ✅, 28.5 ✅, 28.6 ✅, 28.7 ✅, 28.8 pending)
**Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase. **Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase.
Renumbered to Phase 28 on 2026-08-11 to make room for Phase 27 feature additions. Planning Renumbered to Phase 28 on 2026-08-11 to make room for Phase 27 feature additions. Planning
completed 2026-08-11. Detailed implementation plan in [android/plan.md](android/plan.md).** completed 2026-08-11. Detailed implementation plan in [android/plan.md](android/plan.md).**
@@ -2220,7 +2220,16 @@ The app reuses the existing Pi REST API surface with no changes:
- Deferred: dashcam video streaming/player, session-cookie persistence across launches (currently - Deferred: dashcam video streaming/player, session-cookie persistence across launches (currently
re-logs-in each launch), and a server-side tile source for the replay map when off the hotspot. re-logs-in each launch), and a server-side tile source for the replay map when off the hotspot.
**28.7 — Polish + distribution** **28.7 — UI redesign: match the mobile web UI ✅ DONE (2026-08-15)**
- Light theme matching `static/yaris.css` (content `#f4f6fb`, white cards, primary
`#4e73df`, accent red `#e63946`) — the app now defaults to light like the web.
- Global dark top bar (`YarisHeader`) with the white italic "YARIS-OBD" wordmark +
red underline, and a dark bottom nav (`#1c1c1e`) mirroring the web's
`.mobile-bottom-nav`. Per-screen headers removed; the replay screen got the same
dark top bar.
- Follow-up polish deferred: card shadows, blue `btn-primary` buttons, page titles.
**28.8 — Polish + distribution**
- App icon, splash screen, dark theme polish - App icon, splash screen, dark theme polish
- Error handling: Pi unreachable, server unreachable, no cellular data, storage full - Error handling: Pi unreachable, server unreachable, no cellular data, storage full
- APK signing + release build - APK signing + release build
@@ -62,8 +62,6 @@ fun DashboardScreen(
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
com.yarisobd.app.ui.components.YarisHeader()
// Connection status bar // Connection status bar
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
@@ -69,7 +69,6 @@ fun DashcamScreen(
// Auto-refresh on first load // Auto-refresh on first load
LaunchedEffect(Unit) { onRefresh() } LaunchedEffect(Unit) { onRefresh() }
com.yarisobd.app.ui.components.YarisHeader()
// USB Dashcam section // USB Dashcam section
Card( Card(
@@ -75,8 +75,6 @@ fun DownloadsScreen(
val context = LocalContext.current val context = LocalContext.current
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
com.yarisobd.app.ui.components.YarisHeader()
// Header card // Header card
Card( Card(
modifier = Modifier.fillMaxWidth().padding(16.dp), modifier = Modifier.fillMaxWidth().padding(16.dp),
@@ -12,6 +12,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -32,9 +33,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.yarisobd.app.data.remote.dto.TripSummary import com.yarisobd.app.data.remote.dto.TripSummary
import com.yarisobd.app.ui.components.YarisHeader
import com.yarisobd.app.ui.theme.YarisOBDTheme import com.yarisobd.app.ui.theme.YarisOBDTheme
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -98,6 +101,14 @@ fun YarisMainScreen(client: OkHttpClient) {
var selectedTrip by remember { mutableStateOf<TripSummary?>(null) } var selectedTrip by remember { mutableStateOf<TripSummary?>(null) }
var selectedPortalTrip by remember { mutableStateOf<TripSummary?>(null) } var selectedPortalTrip by remember { mutableStateOf<TripSummary?>(null) }
val navItemColors = NavigationBarItemDefaults.colors(
selectedIconColor = Color.White,
selectedTextColor = Color.White,
indicatorColor = Color(0x26FFFFFF),
unselectedIconColor = Color(0x8CFFFFFF),
unselectedTextColor = Color(0x8CFFFFFF),
)
// Show download/delete events as snackbars // Show download/delete events as snackbars
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
dashcamVm.downloadEvents.collect { event -> dashcamVm.downloadEvents.collect { event ->
@@ -130,44 +141,51 @@ fun YarisMainScreen(client: OkHttpClient) {
) )
} else { } else {
Scaffold( Scaffold(
topBar = { YarisHeader() },
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = { bottomBar = {
NavigationBar { NavigationBar(containerColor = Color(0xFF1C1C1E)) {
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 0, selected = selectedTab == 0,
onClick = { selectedTab = 0 }, onClick = { selectedTab = 0 },
icon = { Icon(Icons.Filled.Dashboard, contentDescription = "Dashboard") }, icon = { Icon(Icons.Filled.Dashboard, contentDescription = "Dashboard") },
label = { Text("Dashboard") } label = { Text("Dashboard") },
colors = navItemColors,
) )
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 1, selected = selectedTab == 1,
onClick = { selectedTab = 1 }, onClick = { selectedTab = 1 },
icon = { Icon(Icons.Filled.Videocam, contentDescription = "Dashcam") }, icon = { Icon(Icons.Filled.Videocam, contentDescription = "Dashcam") },
label = { Text("Dashcam") } label = { Text("Dashcam") },
colors = navItemColors,
) )
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 2, selected = selectedTab == 2,
onClick = { selectedTab = 2 }, onClick = { selectedTab = 2 },
icon = { Icon(Icons.Filled.FileDownload, contentDescription = "Downloads") }, icon = { Icon(Icons.Filled.FileDownload, contentDescription = "Downloads") },
label = { Text("Downloads") } label = { Text("Downloads") },
colors = navItemColors,
) )
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 3, selected = selectedTab == 3,
onClick = { selectedTab = 3 }, onClick = { selectedTab = 3 },
icon = { Icon(Icons.Filled.DirectionsCar, contentDescription = "Trips") }, icon = { Icon(Icons.Filled.DirectionsCar, contentDescription = "Trips") },
label = { Text("Trips") } label = { Text("Trips") },
colors = navItemColors,
) )
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 4, selected = selectedTab == 4,
onClick = { selectedTab = 4 }, onClick = { selectedTab = 4 },
icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") }, icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
label = { Text("Settings") } label = { Text("Settings") },
colors = navItemColors,
) )
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 5, selected = selectedTab == 5,
onClick = { selectedTab = 5 }, onClick = { selectedTab = 5 },
icon = { Icon(Icons.Filled.Cloud, contentDescription = "Server") }, icon = { Icon(Icons.Filled.Cloud, contentDescription = "Server") },
label = { Text("Server") } label = { Text("Server") },
colors = navItemColors,
) )
} }
} }
@@ -55,8 +55,6 @@ fun PortalScreen(
var password by remember { mutableStateOf(CredentialStore.getPortalPassword(context)) } var password by remember { mutableStateOf(CredentialStore.getPortalPassword(context)) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
com.yarisobd.app.ui.components.YarisHeader()
Row( Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -49,7 +49,6 @@ fun SettingsScreen(client: OkHttpClient) {
Column( Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
) { ) {
com.yarisobd.app.ui.components.YarisHeader()
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Server credentials // Server credentials
@@ -47,8 +47,6 @@ fun TripListScreen(
} }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
com.yarisobd.app.ui.components.YarisHeader()
Row( Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -11,9 +11,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -32,6 +33,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -50,6 +52,7 @@ import com.yarisobd.app.data.remote.dto.RoutePoint
import com.yarisobd.app.data.remote.dto.TripMeta import com.yarisobd.app.data.remote.dto.TripMeta
import com.yarisobd.app.data.remote.dto.TripRoute import com.yarisobd.app.data.remote.dto.TripRoute
import com.yarisobd.app.data.remote.dto.TripSummary import com.yarisobd.app.data.remote.dto.TripSummary
import com.yarisobd.app.ui.theme.YarisDark
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.util.Locale import java.util.Locale
@@ -90,28 +93,31 @@ fun TripReplayScreen(
if (currentIndex >= points.size - 1) playing = false if (currentIndex >= points.size - 1) playing = false
} }
Column(modifier = Modifier.fillMaxSize().safeDrawingPadding()) { Column(modifier = Modifier.fillMaxSize()) {
// Top bar: back + title // Top bar: back + title (dark, matching the global header)
Surface(color = YarisDark) {
Row( Row(
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Color.White)
} }
Column { Column {
Text( Text(
"Trip #${trip.id}", "Trip #${trip.id}",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
color = Color.White
) )
Text( Text(
dateText(trip.start_datetime), dateText(trip.start_datetime),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = Color.White.copy(alpha = 0.7f)
) )
} }
} }
}
// Map // Map
Box(Modifier.fillMaxWidth().height(300.dp)) { Box(Modifier.fillMaxWidth().height(300.dp)) {
@@ -144,7 +150,7 @@ fun TripReplayScreen(
// Stats + scrubber // Stats + scrubber
Column( Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), Modifier.fillMaxSize().verticalScroll(rememberScrollState()).navigationBarsPadding().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
TripStatsHeader(route?.trip ?: TripMeta()) TripStatsHeader(route?.trip ?: TripMeta())
@@ -1,52 +1,53 @@
package com.yarisobd.app.ui.components package com.yarisobd.app.ui.components
import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.yarisobd.app.ui.theme.YarisDark
import com.yarisobd.app.ui.theme.YarisRed import com.yarisobd.app.ui.theme.YarisRed
/** Shared branding header matching web UI style */ /** Global dark top bar matching the web UI's mobile topbar + YARIS wordmark. */
@Composable @Composable
fun YarisHeader() { fun YarisHeader() {
Surface(color = YarisDark) {
Row(Modifier.fillMaxWidth().statusBarsPadding()) {
Column(
Modifier
.padding(horizontal = 16.dp, vertical = 12.dp)
.drawBehind {
drawRect(
color = YarisRed,
topLeft = Offset(0f, size.height - 4.dp.toPx()),
size = Size(size.width, 3.dp.toPx()),
)
}
) {
Text( Text(
"YARIS-OBD", "YARIS-OBD",
style = MaterialTheme.typography.headlineMedium.copy( style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
letterSpacing = 1.5.sp, letterSpacing = 1.2.sp,
), ),
color = Color.White, color = Color.White,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp)
) )
Spacer(Modifier.height(1.dp))
// Red accent underline — 48dp wide
Canvas(
Modifier
.padding(start = 16.dp)
.width(48.dp)
.height(3.dp)
) {
drawRect(color = YarisRed)
} }
Spacer(Modifier.height(6.dp)) }
Text( }
"phase 28 — android companion",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp)
)
} }
@@ -16,46 +16,56 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
// Brand colors — matching web UI: #1c1c1e sidebar, #e63946 red, green primary // Brand colors — matching static/yaris.css (mobile web UI)
private val YarisGreen = Color(0xFF4CAF50) val YarisRed = Color(0xFFE63946) // --accent
private val YarisGreenDark = Color(0xFF388E3C) val YarisDark = Color(0xFF1C1C1E) // --sidebar-bg (top bar + bottom nav)
val YarisRed = Color(0xFFE63946) val YarisBlue = Color(0xFF4E73DF) // --primary
private val YarisBackground = Color(0xFF1C1C1E) val YarisBody = Color(0xFFF4F6FB) // body background
private val YarisSurface = Color(0xFF2A2A2C) val YarisMuted = Color(0xFF6C757D) // text-muted
private val YarisSurfaceVariant = Color(0xFF3A3A3C)
private val DarkColorScheme = darkColorScheme( private val LightColorScheme = lightColorScheme(
primary = YarisGreen, primary = YarisBlue,
onPrimary = Color.Black, onPrimary = Color.White,
primaryContainer = YarisGreenDark, primaryContainer = Color(0xFFD6E0FB),
secondary = Color(0xFF81C784), onPrimaryContainer = Color(0xFF1B2A5A),
background = YarisBackground, secondary = YarisBlue,
surface = YarisSurface, onSecondary = Color.White,
surfaceContainerHigh = YarisSurfaceVariant, secondaryContainer = Color(0xFFE8EDFB),
onBackground = Color(0xFFE0E0E0), onSecondaryContainer = Color(0xFF1B2A5A),
onSurface = Color(0xFFE0E0E0), tertiary = Color(0xFF36B9CC),
onSurfaceVariant = Color(0xFFAAAAAA), onTertiary = Color.White,
error = Color(0xFFEF5350), tertiaryContainer = Color(0xFFD7F2F8),
onTertiaryContainer = Color(0xFF0A3A42),
background = YarisBody,
onBackground = Color(0xFF212529),
surface = Color.White,
onSurface = Color(0xFF212529),
surfaceVariant = Color(0xFFEDF0F5),
onSurfaceVariant = YarisMuted,
surfaceContainerHigh = Color.White,
surfaceContainer = Color.White,
error = YarisRed,
onError = Color.White,
errorContainer = Color(0xFFFBE3E5),
onErrorContainer = Color(0xFF5A1016),
outline = Color(0xFFCED4DA),
) )
// Light scheme exists for completeness but the app defaults to dark private val DarkColorScheme = darkColorScheme(
private val LightColorScheme = lightColorScheme( primary = YarisBlue,
primary = YarisGreenDark,
onPrimary = Color.White, onPrimary = Color.White,
primaryContainer = Color(0xFFC8E6C9), background = YarisDark,
secondary = Color(0xFF43A047), onBackground = Color(0xFFE0E0E0),
background = Color(0xFFFAFAFA), surface = YarisDark,
surface = Color.White, onSurface = Color(0xFFE0E0E0),
surfaceContainerHigh = Color(0xFFF5F5F5), surfaceContainerHigh = Color(0xFF2A2A2C),
onBackground = Color(0xFF212121), onSurfaceVariant = Color(0xFFAAAAAA),
onSurface = Color(0xFF212121), error = YarisRed,
onSurfaceVariant = Color(0xFF757575),
error = Color(0xFFD32F2F),
) )
@Composable @Composable
fun YarisOBDTheme( fun YarisOBDTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = false, // the web UI is light — match it by default
dynamicColor: Boolean = false, // Off by default — use our brand colors dynamicColor: Boolean = false, // Off by default — use our brand colors
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
@@ -72,8 +82,9 @@ fun YarisOBDTheme(
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
val window = (view.context as Activity).window val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb() window.statusBarColor = YarisDark.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme // Dark top bar sits under the status bar, so status icons stay light.
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false
} }
} }
+26 -4
View File
@@ -227,10 +227,32 @@ android/
(`PORTAL_TILE_URL`, zoom 0–19) so it works over cellular away from the hotspot (`PORTAL_TILE_URL`, zoom 0–19) so it works over cellular away from the hotspot
- [ ] Video playback (stream endpoint) — deferred - [ ] Video playback (stream endpoint) — deferred
### 28.7 — Polish + distribution ⬜ NOT STARTED ### 28.7 — UI redesign: match the mobile web UI ✅ DONE (2026-08-15)
**Depends on: 28.1–28.6 (whichever subset ships).** **Depends on: 28.1–28.6. Renumbers the old 28.7 (polish) to 28.8.**
#### 28.7.1 — App polish #### 28.7.1 — Theme
- [x] Light theme matching `static/yaris.css`: content bg `#f4f6fb`, white cards,
primary `#4e73df`, accent/error red `#e63946`, text-muted `#6c757d`
- [x] App defaults to light (matches the web; the web has no dark mode)
#### 28.7.2 — Chrome (top bar + bottom nav)
- [x] Global dark top bar (`YarisHeader`): `#1c1c1e`, white italic "YARIS-OBD"
wordmark + red underline, status-bar aware
- [x] Dark bottom `NavigationBar` (`#1c1c1e`, white active / 55% white inactive)
mirroring the web's `.mobile-bottom-nav`
- [x] Removed the per-screen `YarisHeader` in favour of the one global top bar
- [x] Trip replay screen gets the same dark top bar
#### 28.7.3 — Component polish (follow-up)
- [ ] Card shadows (`shadow-sm`) + white card surfaces
- [ ] Blue primary buttons (`btn-primary` #4e73df)
- [ ] FontAwesome-style icons where they map to the web nav
- [ ] Page titles (`h3 fw-bold`) at the top of each screen
### 28.8 — Polish + distribution ⬜ NOT STARTED
**Depends on: 28.1–28.7 (whichever subset ships).**
#### 28.8.1 — App polish
- [ ] App icon (adaptive icon: foreground + background layers) - [ ] App icon (adaptive icon: foreground + background layers)
- [ ] Splash screen (Android 12+ SplashScreen API) - [ ] Splash screen (Android 12+ SplashScreen API)
- [ ] Dark theme verified on all screens (default, car-friendly) - [ ] Dark theme verified on all screens (default, car-friendly)
@@ -238,7 +260,7 @@ android/
- [ ] Accessibility: content descriptions on icons, reasonable touch targets (≥48dp) - [ ] Accessibility: content descriptions on icons, reasonable touch targets (≥48dp)
- [ ] Commit: `feat: app polish` - [ ] Commit: `feat: app polish`
#### 28.7.2 — Build + distribution #### 28.8.2 — Build + distribution
- [ ] Release build signing key (keystore generated, not committed) - [ ] Release build signing key (keystore generated, not committed)
- [ ] ProGuard/R8 rules verified (Retrofit, OkHttp, Coil, MapLibre keep rules) - [ ] ProGuard/R8 rules verified (Retrofit, OkHttp, Coil, MapLibre keep rules)
- [ ] APK output: `app/build/outputs/apk/release/app-release.apk` - [ ] APK output: `app/build/outputs/apk/release/app-release.apk`