diff --git a/PLAN.md b/PLAN.md index 56332cb..f4b817e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/android/app/src/main/java/com/yarisobd/app/DashboardScreen.kt b/android/app/src/main/java/com/yarisobd/app/DashboardScreen.kt index 74366ae..d8f003e 100644 --- a/android/app/src/main/java/com/yarisobd/app/DashboardScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/DashboardScreen.kt @@ -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), diff --git a/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt b/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt index 7f5ebc2..51d6353 100644 --- a/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt @@ -69,7 +69,6 @@ fun DashcamScreen( // Auto-refresh on first load LaunchedEffect(Unit) { onRefresh() } - com.yarisobd.app.ui.components.YarisHeader() // USB Dashcam section Card( diff --git a/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt b/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt index 21163f9..a0d6f8b 100644 --- a/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt @@ -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), diff --git a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt index cf92238..afd04b7 100644 --- a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt +++ b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt @@ -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(null) } var selectedPortalTrip by remember { mutableStateOf(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, ) } } diff --git a/android/app/src/main/java/com/yarisobd/app/PortalScreen.kt b/android/app/src/main/java/com/yarisobd/app/PortalScreen.kt index 2d80ae5..38fcf7a 100644 --- a/android/app/src/main/java/com/yarisobd/app/PortalScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/PortalScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/yarisobd/app/SettingsScreen.kt b/android/app/src/main/java/com/yarisobd/app/SettingsScreen.kt index 5fbf813..24683fb 100644 --- a/android/app/src/main/java/com/yarisobd/app/SettingsScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/SettingsScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/yarisobd/app/TripListScreen.kt b/android/app/src/main/java/com/yarisobd/app/TripListScreen.kt index dc58bc9..471c9d3 100644 --- a/android/app/src/main/java/com/yarisobd/app/TripListScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/TripListScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt b/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt index e2b5044..5906ab0 100644 --- a/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt @@ -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,26 +93,29 @@ fun TripReplayScreen( if (currentIndex >= points.size - 1) playing = false } - Column(modifier = Modifier.fillMaxSize().safeDrawingPadding()) { - // Top bar: back + title - Row( - Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - Column { - Text( - "Trip #${trip.id}", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - dateText(trip.start_datetime), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Column(modifier = Modifier.fillMaxSize()) { + // Top bar: back + title (dark, matching the global header) + Surface(color = YarisDark) { + Row( + Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Color.White) + } + Column { + Text( + "Trip #${trip.id}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White + ) + Text( + dateText(trip.start_datetime), + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.7f) + ) + } } } @@ -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()) diff --git a/android/app/src/main/java/com/yarisobd/app/ui/components/YarisHeader.kt b/android/app/src/main/java/com/yarisobd/app/ui/components/YarisHeader.kt index 281a317..7f1affd 100644 --- a/android/app/src/main/java/com/yarisobd/app/ui/components/YarisHeader.kt +++ b/android/app/src/main/java/com/yarisobd/app/ui/components/YarisHeader.kt @@ -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() { - Text( - "YARIS-OBD", - style = MaterialTheme.typography.headlineMedium.copy( - fontWeight = FontWeight.Normal, - fontStyle = FontStyle.Italic, - letterSpacing = 1.5.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) + 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.titleLarge.copy( + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Italic, + letterSpacing = 1.2.sp, + ), + color = Color.White, + maxLines = 1, + ) + } + } } - Spacer(Modifier.height(6.dp)) - Text( - "phase 28 — android companion", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 16.dp) - ) } diff --git a/android/app/src/main/java/com/yarisobd/app/ui/theme/Theme.kt b/android/app/src/main/java/com/yarisobd/app/ui/theme/Theme.kt index 2bb0eaf..01871a4 100644 --- a/android/app/src/main/java/com/yarisobd/app/ui/theme/Theme.kt +++ b/android/app/src/main/java/com/yarisobd/app/ui/theme/Theme.kt @@ -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 } } diff --git a/android/plan.md b/android/plan.md index 8caf2c5..9d4e81e 100644 --- a/android/plan.md +++ b/android/plan.md @@ -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`