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)
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 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).**
@@ -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
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
- Error handling: Pi unreachable, server unreachable, no cellular data, storage full
- APK signing + release build
@@ -62,8 +62,6 @@ fun DashboardScreen(
}
Column(modifier = Modifier.fillMaxSize()) {
com.yarisobd.app.ui.components.YarisHeader()
// Connection status bar
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
@@ -69,7 +69,6 @@ fun DashcamScreen(
// Auto-refresh on first load
LaunchedEffect(Unit) { onRefresh() }
com.yarisobd.app.ui.components.YarisHeader()
// USB Dashcam section
Card(
@@ -75,8 +75,6 @@ fun DownloadsScreen(
val context = LocalContext.current
Column(modifier = Modifier.fillMaxSize()) {
com.yarisobd.app.ui.components.YarisHeader()
// Header card
Card(
modifier = Modifier.fillMaxWidth().padding(16.dp),
@@ -12,6 +12,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@@ -32,9 +33,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yarisobd.app.data.remote.dto.TripSummary
import com.yarisobd.app.ui.components.YarisHeader
import com.yarisobd.app.ui.theme.YarisOBDTheme
import okhttp3.OkHttpClient
@@ -98,6 +101,14 @@ fun YarisMainScreen(client: OkHttpClient) {
var selectedTrip 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
LaunchedEffect(Unit) {
dashcamVm.downloadEvents.collect { event ->
@@ -130,44 +141,51 @@ fun YarisMainScreen(client: OkHttpClient) {
)
} else {
Scaffold(
topBar = { YarisHeader() },
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
NavigationBar {
NavigationBar(containerColor = Color(0xFF1C1C1E)) {
NavigationBarItem(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
icon = { Icon(Icons.Filled.Dashboard, contentDescription = "Dashboard") },
label = { Text("Dashboard") }
label = { Text("Dashboard") },
colors = navItemColors,
)
NavigationBarItem(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
icon = { Icon(Icons.Filled.Videocam, contentDescription = "Dashcam") },
label = { Text("Dashcam") }
label = { Text("Dashcam") },
colors = navItemColors,
)
NavigationBarItem(
selected = selectedTab == 2,
onClick = { selectedTab = 2 },
icon = { Icon(Icons.Filled.FileDownload, contentDescription = "Downloads") },
label = { Text("Downloads") }
label = { Text("Downloads") },
colors = navItemColors,
)
NavigationBarItem(
selected = selectedTab == 3,
onClick = { selectedTab = 3 },
icon = { Icon(Icons.Filled.DirectionsCar, contentDescription = "Trips") },
label = { Text("Trips") }
label = { Text("Trips") },
colors = navItemColors,
)
NavigationBarItem(
selected = selectedTab == 4,
onClick = { selectedTab = 4 },
icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
label = { Text("Settings") }
label = { Text("Settings") },
colors = navItemColors,
)
NavigationBarItem(
selected = selectedTab == 5,
onClick = { selectedTab = 5 },
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)) }
Column(modifier = Modifier.fillMaxSize()) {
com.yarisobd.app.ui.components.YarisHeader()
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
@@ -49,7 +49,6 @@ fun SettingsScreen(client: OkHttpClient) {
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
) {
com.yarisobd.app.ui.components.YarisHeader()
Spacer(Modifier.height(16.dp))
// Server credentials
@@ -47,8 +47,6 @@ fun TripListScreen(
}
Column(modifier = Modifier.fillMaxSize()) {
com.yarisobd.app.ui.components.YarisHeader()
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
@@ -11,9 +11,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
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.width
import androidx.compose.foundation.rememberScrollState
@@ -32,6 +33,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.TripRoute
import com.yarisobd.app.data.remote.dto.TripSummary
import com.yarisobd.app.ui.theme.YarisDark
import kotlinx.coroutines.delay
import java.util.Locale
@@ -90,28 +93,31 @@ fun TripReplayScreen(
if (currentIndex >= points.size - 1) playing = false
}
Column(modifier = Modifier.fillMaxSize().safeDrawingPadding()) {
// Top bar: back + title
Column(modifier = Modifier.fillMaxSize()) {
// Top bar: back + title (dark, matching the global header)
Surface(color = YarisDark) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Color.White)
}
Column {
Text(
"Trip #${trip.id}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
color = Color.White
)
Text(
dateText(trip.start_datetime),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = Color.White.copy(alpha = 0.7f)
)
}
}
}
// Map
Box(Modifier.fillMaxWidth().height(300.dp)) {
@@ -144,7 +150,7 @@ fun TripReplayScreen(
// Stats + scrubber
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).navigationBarsPadding().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
TripStatsHeader(route?.trip ?: TripMeta())
@@ -1,52 +1,53 @@
package com.yarisobd.app.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.text.font.FontStyle
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.sp
import com.yarisobd.app.ui.theme.YarisDark
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
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(
"YARIS-OBD",
style = MaterialTheme.typography.headlineMedium.copy(
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Italic,
letterSpacing = 1.5.sp,
letterSpacing = 1.2.sp,
),
color = Color.White,
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.core.view.WindowCompat
// Brand colors — matching web UI: #1c1c1e sidebar, #e63946 red, green primary
private val YarisGreen = Color(0xFF4CAF50)
private val YarisGreenDark = Color(0xFF388E3C)
val YarisRed = Color(0xFFE63946)
private val YarisBackground = Color(0xFF1C1C1E)
private val YarisSurface = Color(0xFF2A2A2C)
private val YarisSurfaceVariant = Color(0xFF3A3A3C)
// Brand colors — matching static/yaris.css (mobile web UI)
val YarisRed = Color(0xFFE63946) // --accent
val YarisDark = Color(0xFF1C1C1E) // --sidebar-bg (top bar + bottom nav)
val YarisBlue = Color(0xFF4E73DF) // --primary
val YarisBody = Color(0xFFF4F6FB) // body background
val YarisMuted = Color(0xFF6C757D) // text-muted
private val DarkColorScheme = darkColorScheme(
primary = YarisGreen,
onPrimary = Color.Black,
primaryContainer = YarisGreenDark,
secondary = Color(0xFF81C784),
background = YarisBackground,
surface = YarisSurface,
surfaceContainerHigh = YarisSurfaceVariant,
onBackground = Color(0xFFE0E0E0),
onSurface = Color(0xFFE0E0E0),
onSurfaceVariant = Color(0xFFAAAAAA),
error = Color(0xFFEF5350),
private val LightColorScheme = lightColorScheme(
primary = YarisBlue,
onPrimary = Color.White,
primaryContainer = Color(0xFFD6E0FB),
onPrimaryContainer = Color(0xFF1B2A5A),
secondary = YarisBlue,
onSecondary = Color.White,
secondaryContainer = Color(0xFFE8EDFB),
onSecondaryContainer = Color(0xFF1B2A5A),
tertiary = Color(0xFF36B9CC),
onTertiary = Color.White,
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 LightColorScheme = lightColorScheme(
primary = YarisGreenDark,
private val DarkColorScheme = darkColorScheme(
primary = YarisBlue,
onPrimary = Color.White,
primaryContainer = Color(0xFFC8E6C9),
secondary = Color(0xFF43A047),
background = Color(0xFFFAFAFA),
surface = Color.White,
surfaceContainerHigh = Color(0xFFF5F5F5),
onBackground = Color(0xFF212121),
onSurface = Color(0xFF212121),
onSurfaceVariant = Color(0xFF757575),
error = Color(0xFFD32F2F),
background = YarisDark,
onBackground = Color(0xFFE0E0E0),
surface = YarisDark,
onSurface = Color(0xFFE0E0E0),
surfaceContainerHigh = Color(0xFF2A2A2C),
onSurfaceVariant = Color(0xFFAAAAAA),
error = YarisRed,
)
@Composable
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
content: @Composable () -> Unit
) {
@@ -72,8 +82,9 @@ fun YarisOBDTheme(
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
window.statusBarColor = YarisDark.toArgb()
// 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
- [ ] Video playback (stream endpoint) — deferred
### 28.7 — Polish + distribution ⬜ NOT STARTED
**Depends on: 28.1–28.6 (whichever subset ships).**
### 28.7 — UI redesign: match the mobile web UI ✅ DONE (2026-08-15)
**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)
- [ ] Splash screen (Android 12+ SplashScreen API)
- [ ] Dark theme verified on all screens (default, car-friendly)
@@ -238,7 +260,7 @@ android/
- [ ] Accessibility: content descriptions on icons, reasonable touch targets (≥48dp)
- [ ] Commit: `feat: app polish`
#### 28.7.2 — Build + distribution
#### 28.8.2 — Build + distribution
- [ ] Release build signing key (keystore generated, not committed)
- [ ] ProGuard/R8 rules verified (Retrofit, OkHttp, Coil, MapLibre keep rules)
- [ ] APK output: `app/build/outputs/apk/release/app-release.apk`