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
@@ -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,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())
@@ -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)
)
}
@@ -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
}
}