diff --git a/UX_VALIDATION_CHECKLIST.md b/UX_VALIDATION_CHECKLIST.md deleted file mode 100644 index 0067f17..0000000 --- a/UX_VALIDATION_CHECKLIST.md +++ /dev/null @@ -1,21 +0,0 @@ -# DriveSwipe UX Validation Checklist - -## Acceptance Criteria Coverage -- [ ] Home starts/stops gesture control within 2 taps from launch. -- [ ] Setup wizard reaches "Drive Ready" only after all required permissions are granted. -- [ ] Users can remap core actions (next, previous, play/pause, volume up/down) without code changes. -- [ ] Advanced tuning is optional; defaults work for first-use driving scenarios. -- [ ] Home always shows active status and current mode. - -## Reliability and Safety Checks -- [ ] Emergency Disable immediately stops foreground service. -- [ ] Cooldown value shown in UI matches runtime behavior in the service. -- [ ] Last recognized gesture and last action are visible on Home. -- [ ] History screen records recent recognized gestures and actions. -- [ ] Night mode behavior is clearly explained and switches correctly. - -## In-Car Validation Pass -- [ ] First-run setup can be completed in under 90 seconds. -- [ ] No accidental trigger while hands remain on steering wheel for 5 minutes. -- [ ] False positive rate remains low in daytime and low-light conditions. -- [ ] One-handed operation is possible for emergency stop and quick start. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 99eeb05..1907051 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,7 +44,9 @@ android:excludeFromRecents="true" android:exported="true" android:noHistory="true" - android:theme="@style/Theme.DriveSwipe"> + android:taskAffinity="" + android:launchMode="singleInstance" + android:theme="@android:style/Theme.Translucent.NoTitleBar"> diff --git a/app/src/main/java/com/example/driveswipe/DriveSwipeApp.kt b/app/src/main/java/com/example/driveswipe/DriveSwipeApp.kt index 2ed0723..2acc5ef 100644 --- a/app/src/main/java/com/example/driveswipe/DriveSwipeApp.kt +++ b/app/src/main/java/com/example/driveswipe/DriveSwipeApp.kt @@ -66,6 +66,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush @@ -75,9 +76,18 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.geometry.Offset +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.Canvas import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import androidx.compose.foundation.layout.ColumnScope +import androidx.navigation.compose.currentBackStackEntryAsState + private object Route { const val Home = "home" @@ -87,6 +97,7 @@ private object Route { const val History = "history" } + fun Modifier.bounceClick(onClick: () -> Unit) = composed { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -106,6 +117,124 @@ fun Modifier.bounceClick(onClick: () -> Unit) = composed { ) } +@Composable +fun GlassCard( + modifier: Modifier = Modifier, + shape: androidx.compose.ui.graphics.Shape = RoundedCornerShape(16.dp), + border: BorderStroke? = null, + onClick: (() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit +) { + val cardModifier = if (onClick != null) modifier.bounceClick(onClick) else modifier + Card( + modifier = cardModifier, + shape = shape, + colors = CardDefaults.cardColors( + containerColor = Color(0x991E293B) + ), + border = border ?: BorderStroke( + width = 1.dp, + color = Color(0x1AFFFFFF) + ), + content = content + ) +} + + + +@Composable +fun PowerIcon( + color: Color, + modifier: Modifier = Modifier +) { + Canvas(modifier = modifier) { + val strokeWidthPx = 6.dp.toPx() + drawArc( + color = color, + startAngle = -60f, + sweepAngle = 300f, + useCenter = false, + style = Stroke( + width = strokeWidthPx, + cap = StrokeCap.Round + ) + ) + drawLine( + color = color, + start = Offset(size.width / 2, size.height * 0.1f), + end = Offset(size.width / 2, size.height * 0.55f), + strokeWidth = strokeWidthPx, + cap = StrokeCap.Round + ) + } +} + +@Composable +fun BottomNavBar( + currentRoute: String?, + onNavigate: (String) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xCC152031)) + .border(BorderStroke(1.dp, Color(0x1AFFFFFF)), RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + .padding(bottom = 12.dp, top = 8.dp), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically + ) { + BottomNavItem( + label = "Drive", + icon = Icons.Default.PlayArrow, + isActive = currentRoute == Route.Home, + onClick = { onNavigate(Route.Home) } + ) + BottomNavItem( + label = "History", + icon = Icons.Default.List, + isActive = currentRoute == Route.History, + onClick = { onNavigate(Route.History) } + ) + BottomNavItem( + label = "Settings", + icon = Icons.Default.Settings, + isActive = currentRoute == Route.Settings, + onClick = { onNavigate(Route.Settings) } + ) + } +} + +@Composable +private fun BottomNavItem( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + isActive: Boolean, + onClick: () -> Unit +) { + Column( + modifier = Modifier + .bounceClick(onClick) + .padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = icon, + contentDescription = label, + tint = if (isActive) AccentCyan else TextSecondary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = if (isActive) AccentCyan else TextSecondary, + fontSize = 10.sp + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun DriveSwipeApp( @@ -120,6 +249,10 @@ fun DriveSwipeApp( onResetTuning: () -> Unit ) { val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + val showBottomBar = currentRoute in listOf(Route.Home, Route.History, Route.Settings) + Scaffold( topBar = { TopAppBar( @@ -137,6 +270,20 @@ fun DriveSwipeApp( ) ) }, + bottomBar = { + if (showBottomBar) { + BottomNavBar( + currentRoute = currentRoute, + onNavigate = { route -> + navController.navigate(route) { + popUpTo(Route.Home) { saveState = true } + launchSingleTop = true + restoreState = true + } + } + ) + } + }, containerColor = MaterialTheme.colorScheme.background ) { padding -> NavHost( @@ -205,14 +352,21 @@ private fun HomeScreen( ) { // Status indicator banner if (!uiState.isDriveReady) { - Card( + GlassCard( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp) - .bounceClick(onGoSetup), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error) + .padding(bottom = 16.dp), + onClick = onGoSetup, + shape = RoundedCornerShape(20.dp), + border = BorderStroke( + 1.dp, + Brush.verticalGradient( + listOf( + Color(0xFFFFB4AB).copy(alpha = 0.4f), + Color(0xFFFFB4AB).copy(alpha = 0.1f) + ) + ) + ) ) { Row( modifier = Modifier.padding(16.dp), @@ -221,7 +375,7 @@ private fun HomeScreen( Icon( imageVector = Icons.Default.Info, contentDescription = "Permission Alert", - tint = MaterialTheme.colorScheme.onErrorContainer + tint = StateError ) Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { @@ -229,83 +383,121 @@ private fun HomeScreen( text = "Setup Incomplete", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onErrorContainer + color = TextPrimary ) Text( text = "Camera & notifications are required.", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f) + color = TextSecondary ) } Text( text = "FIX", fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onErrorContainer, + color = StateError, modifier = Modifier.padding(horizontal = 8.dp) ) } } } - // Telemetry status panel - Card( + // Telemetry status panel (Bento-style Last Triggered Action Card from Stitch) + val lastEvent = uiState.gestureHistory.firstOrNull() + var triggerToggle by remember { mutableStateOf(false) } + LaunchedEffect(lastEvent) { + if (lastEvent != null) { + triggerToggle = true + kotlinx.coroutines.delay(2000) + triggerToggle = false + } + } + val barWidthProgress by animateFloatAsState( + targetValue = if (triggerToggle) 1f else 0f, + animationSpec = tween(durationMillis = if (triggerToggle) 300 else 1000), + label = "barWidth" + ) + + GlassCard( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f)), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.15f)) + .padding(vertical = 8.dp) ) { Column( - modifier = Modifier.padding(16.dp), + modifier = Modifier.padding(20.dp), horizontalAlignment = Alignment.Start ) { - Text( - text = "ACTIVE TELEMETRY STATUS", - style = MaterialTheme.typography.labelSmall, - color = TextSecondary, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(12.dp)) - Column( + Row( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp) + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.Start - ) { - Text("LAST TRIGGERED", style = MaterialTheme.typography.labelSmall, color = TextSecondary.copy(alpha = 0.6f)) - Spacer(modifier = Modifier.height(4.dp)) - val lastEvent = uiState.gestureHistory.firstOrNull() + Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = lastEvent?.let { "${it.gestureName.replace('_', ' ')} -> ${it.action.name.replace('_', ' ')}" } ?: "None", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = if (lastEvent != null) MaterialTheme.colorScheme.primary else TextSecondary + text = "LAST TRIGGERED ACTION", + style = MaterialTheme.typography.labelSmall, + color = TextSecondary.copy(alpha = 0.6f), + fontWeight = FontWeight.Bold ) + if (triggerToggle) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "JUST NOW", + style = MaterialTheme.typography.labelSmall, + color = AccentCyan.copy(alpha = 0.8f), + fontWeight = FontWeight.Bold + ) + } } - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(MaterialTheme.colorScheme.outline.copy(alpha = 0.08f))) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("NIGHT MODE", style = MaterialTheme.typography.labelSmall, color = TextSecondary.copy(alpha = 0.6f)) - Text( - text = if (uiState.settings.isNightMode) "ACTIVE" else "INACTIVE", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = if (uiState.settings.isNightMode) AccentCyan else TextSecondary + Icon( + imageVector = Icons.Default.Info, + contentDescription = "Sensors active", + tint = AccentCyan, + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = lastEvent?.let { "${it.gestureName.replace('_', ' ')} -> ${it.action.name.replace('_', ' ')}" } ?: "Waiting for gesture...", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = TextPrimary, + modifier = Modifier.weight(1f) + ) + if (uiState.isServiceRunning) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(AccentCyan) ) } } + Spacer(modifier = Modifier.height(16.dp)) + // Activity Progress Bar + Box( + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .background(Color(0xFF2A3548)) + .clip(CircleShape) + ) { + Box( + modifier = Modifier + .fillMaxWidth(barWidthProgress) + .fillMaxHeight() + .background(AccentCyan) + .clip(CircleShape) + ) + } } } Spacer(modifier = Modifier.weight(1f)) - // Hero Pulsing Button + // Hero Pulsing Button (Automotive style from Stitch) val infiniteTransition = rememberInfiniteTransition(label = "pulseRing") val pulseScale by infiniteTransition.animateFloat( initialValue = 1f, @@ -327,7 +519,7 @@ private fun HomeScreen( ) val stateColor = when { - !uiState.isServiceRunning -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + !uiState.isServiceRunning -> DarkBorder uiState.engineState == EngineState.IDLE -> AccentSteel uiState.engineState == EngineState.ALERTING -> StateAlerting uiState.engineState == EngineState.ACTIVE -> StateActive @@ -339,17 +531,17 @@ private fun HomeScreen( uiState.engineState == EngineState.IDLE -> "ENGINE SLEEPING" uiState.engineState == EngineState.ALERTING -> "HAND DETECTED" uiState.engineState == EngineState.ACTIVE -> "ENGINE LISTENING" - else -> "ACTIVE" + else -> "SYSTEM ACTIVE" } Box( contentAlignment = Alignment.Center, - modifier = Modifier.size(240.dp) + modifier = Modifier.size(260.dp) ) { - // Outer Pulsing Glow Ring + // Breathing Outer Glow Box( modifier = Modifier - .size(230.dp) + .size(250.dp) .scale(pulseScale) .clip(CircleShape) .background( @@ -362,65 +554,88 @@ private fun HomeScreen( ) ) - // Tactile engine switch button + // Start Engine Button Bezel Box( modifier = Modifier - .size(190.dp) + .size(210.dp) .clip(CircleShape) - .border(BorderStroke(3.dp, stateColor), CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) + .border(BorderStroke(1.dp, Color(0x1AFFFFFF)), CircleShape) + .background( + Brush.linearGradient( + colors = listOf(Color(0xFF1F2A3C), Color(0xFF111C2D)) + ) + ) .bounceClick { onToggleService(!uiState.isServiceRunning) }, contentAlignment = Alignment.Center ) { - Icon( - imageVector = if (uiState.isServiceRunning) Icons.Default.Refresh else Icons.Default.PlayArrow, - contentDescription = if (uiState.isServiceRunning) "Stop Engine" else "Start Engine", - tint = if (uiState.isServiceRunning) TextPrimary else stateColor, + // Inner Shadow Circle + Box( modifier = Modifier - .size(36.dp) - .align(Alignment.TopCenter) - .padding(top = 20.dp) - ) - - Column( - modifier = Modifier.align(Alignment.Center), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + .size(194.dp) + .clip(CircleShape) + .border(BorderStroke(1.dp, Color(0x0DFFFFFF)), CircleShape) + .background( + Brush.linearGradient( + colors = listOf(Color(0x0DFFFFFF), Color(0x33000000)) + ) + ), + contentAlignment = Alignment.Center ) { - Text( - text = if (uiState.isServiceRunning) "STOP" else "START", - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Black, - color = TextPrimary - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "ENGINE", - style = MaterialTheme.typography.labelSmall, - color = TextSecondary - ) + Box( + contentAlignment = Alignment.Center + ) { + // Icon layer + PowerIcon( + color = if (uiState.isServiceRunning) AccentCyan else stateColor, + modifier = Modifier.size(100.dp) + ) + } } } } - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(24.dp)) - // State label pill + // Status Badge (Pill-shaped active/inactive badge from Stitch) Box( modifier = Modifier - .border(BorderStroke(1.dp, stateColor.copy(alpha = 0.5f)), RoundedCornerShape(20.dp)) - .background(stateColor.copy(alpha = 0.08f)) - .padding(horizontal = 14.dp, vertical = 6.dp) + .border( + BorderStroke( + 1.dp, + if (uiState.isServiceRunning) AccentCyan.copy(alpha = 0.5f) else DarkBorder + ), + RoundedCornerShape(50.dp) + ) + .background( + if (uiState.isServiceRunning) AccentCyan.copy(alpha = 0.1f) else DarkSurface + ) + .padding(horizontal = 20.dp, vertical = 8.dp) ) { - Text( - text = stateLabel, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = if (!uiState.isServiceRunning) TextSecondary else stateColor - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (uiState.isServiceRunning) AccentCyan else TextSecondary) + .then( + if (uiState.isServiceRunning) Modifier.graphicsLayer { alpha = 0.8f } else Modifier + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stateLabel, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = if (uiState.isServiceRunning) TextPrimary else TextSecondary, + letterSpacing = 1.sp + ) + } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(20.dp)) Text( text = if (!uiState.isServiceRunning) "Tap the engine to activate hand gesture mapping." @@ -437,66 +652,22 @@ private fun HomeScreen( Spacer(modifier = Modifier.weight(1f)) - // Navigation dock at the bottom left (stacked vertically) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp) + // Quick permission prompt if needed + if (!uiState.hasOverlayPermission) { + GlassCard( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + onClick = onOpenOverlaySettings, + shape = RoundedCornerShape(16.dp) ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.15f)), CircleShape) - .bounceClick(onGoHistory), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.List, - contentDescription = "History Log", - tint = TextSecondary, - modifier = Modifier.size(22.dp) - ) - } - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.15f)), CircleShape) - .bounceClick(onGoSettings), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = "Configuration", - tint = TextSecondary, - modifier = Modifier.size(22.dp) - ) - } - } - - // Quick permission prompt if needed - if (!uiState.hasOverlayPermission) { - Box( - modifier = Modifier - .height(84.dp) - .width(200.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.15f)), RoundedCornerShape(16.dp)) - .bounceClick(onOpenOverlaySettings) - .padding(12.dp) + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.Center ) { - Column(verticalArrangement = Arrangement.Center, modifier = Modifier.fillMaxSize()) { - Text("Status Dot Overlay", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = TextPrimary) - Spacer(modifier = Modifier.height(2.dp)) - Text("Tap to configure dot overlay", style = MaterialTheme.typography.bodyMedium, color = TextSecondary, maxLines = 2, overflow = TextOverflow.Ellipsis) - } + Text("Status Dot Overlay", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = TextPrimary) + Spacer(modifier = Modifier.height(2.dp)) + Text("Tap to configure dot overlay", style = MaterialTheme.typography.bodyMedium, color = TextSecondary, maxLines = 2, overflow = TextOverflow.Ellipsis) } } } @@ -529,11 +700,8 @@ private fun SetupWizardScreen( color = TextSecondary ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.15f)) + GlassCard( + modifier = Modifier.fillMaxWidth() ) { Column( modifier = Modifier.padding(20.dp), @@ -548,7 +716,7 @@ private fun SetupWizardScreen( modifier = Modifier .fillMaxWidth() .height(1.dp) - .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.1f)) + .background(DarkBorder.copy(alpha = 0.3f)) ) PermissionRow( label = "System Notifications", @@ -562,9 +730,9 @@ private fun SetupWizardScreen( modifier = Modifier .fillMaxWidth() .height(50.dp) - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)), RoundedCornerShape(14.dp)) + .clip(RoundedCornerShape(50.dp)) + .background(Color(0x400D1F38)) + .border(BorderStroke(1.dp, DarkBorder.copy(alpha = 0.5f)), RoundedCornerShape(50.dp)) .bounceClick(onRetryPermissions), contentAlignment = Alignment.Center ) { @@ -589,10 +757,14 @@ private fun SetupWizardScreen( modifier = Modifier .fillMaxWidth() .height(54.dp) - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(50.dp)) .background( - if (complete) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) + if (complete) AccentCyan + else Color(0x201A3D6C) + ) + .border( + BorderStroke(1.dp, if (complete) AccentCyan else Color(0x301A3D6C)), + RoundedCornerShape(50.dp) ) .then( if (complete) Modifier.bounceClick(onContinue) @@ -604,7 +776,7 @@ private fun SetupWizardScreen( text = "Continue to App", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.ExtraBold, - color = if (complete) DarkBg else TextSecondary.copy(alpha = 0.5f) + color = if (complete) DarkBg else TextSecondary.copy(alpha = 0.4f) ) } } @@ -673,7 +845,7 @@ private fun SettingsScreen( ) Spacer(modifier = Modifier.weight(1f)) TextButton(onClick = onBack) { - Text("Done", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + Text("Done", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = AccentCyan) } } @@ -681,13 +853,10 @@ private fun SettingsScreen( Text( text = "PREFERENCES", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + color = AccentCyan ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)) + GlassCard( + modifier = Modifier.fillMaxWidth() ) { Column( modifier = Modifier.padding(16.dp), @@ -718,7 +887,7 @@ private fun SettingsScreen( modifier = Modifier .fillMaxWidth() .height(1.dp) - .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.1f)) + .background(DarkBorder.copy(alpha = 0.3f)) ) Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { @@ -741,13 +910,10 @@ private fun SettingsScreen( Text( text = "AUTOSTART INTEGRATION", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + color = AccentCyan ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)) + GlassCard( + modifier = Modifier.fillMaxWidth() ) { Column( modifier = Modifier.padding(16.dp), @@ -762,9 +928,9 @@ private fun SettingsScreen( Column( modifier = Modifier .fillMaxWidth() - .background(DarkBg) + .background(Color(0x30081425)) .padding(12.dp) - .border(1.dp, DarkBorder, RoundedCornerShape(8.dp)), + .border(1.dp, DarkBorder.copy(alpha = 0.5f), RoundedCornerShape(12.dp)), verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( @@ -790,13 +956,10 @@ private fun SettingsScreen( Text( text = "GESTURE MAPPINGS", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + color = AccentCyan ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)) + GlassCard( + modifier = Modifier.fillMaxWidth() ) { Column( modifier = Modifier.padding(16.dp), @@ -814,6 +977,7 @@ private fun SettingsScreen( selected = uiState.settings.selectedPreset == preset, onClick = { onPresetSelected(preset) }, label = { Text(preset.name) }, + shape = RoundedCornerShape(50.dp), colors = FilterChipDefaults.filterChipColors( selectedContainerColor = AccentCyan.copy(alpha = 0.15f), selectedLabelColor = AccentCyan, @@ -845,14 +1009,14 @@ private fun SettingsScreen( modifier = Modifier .fillMaxWidth() .height(54.dp) - .clip(RoundedCornerShape(16.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)), RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(50.dp)) + .background(Color(0x400D1F38)) + .border(BorderStroke(1.dp, DarkBorder.copy(alpha = 0.5f)), RoundedCornerShape(50.dp)) .bounceClick(onGoAdvanced), contentAlignment = Alignment.Center ) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -864,6 +1028,113 @@ private fun SettingsScreen( ) } } + + Spacer(modifier = Modifier.height(8.dp)) + CockpitCard() + } +} + +@Composable +private fun CockpitCard(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val pulseScale by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.05f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "pulseScale" + ) + val pulseAlpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 0.7f, + animationSpec = infiniteRepeatable( + animation = tween(2000, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "pulseAlpha" + ) + + GlassCard( + modifier = modifier + .fillMaxWidth() + .height(180.dp), + shape = RoundedCornerShape(16.dp) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val w = size.width + val h = size.height + val centerX = w / 2f + val centerY = h / 2f + + val horizonY = centerY + 30.dp.toPx() + + for (i in 1..4) { + val r = 50.dp.toPx() + i * 40.dp.toPx() + drawArc( + color = AccentCyan.copy(alpha = 0.06f), + startAngle = 180f, + sweepAngle = 180f, + useCenter = false, + topLeft = Offset(centerX - r, horizonY - r), + size = androidx.compose.ui.geometry.Size(r * 2, r * 2), + style = Stroke(width = 1.dp.toPx()) + ) + } + + val lines = 16 + for (i in 0..lines) { + val fraction = i.toFloat() / lines + val angle = 180f + fraction * 180f + val rad = Math.toRadians(angle.toDouble()) + val endX = centerX + Math.cos(rad).toFloat() * w + val endY = horizonY + Math.sin(rad).toFloat() * w + drawLine( + color = AccentCyan.copy(alpha = 0.04f), + start = Offset(centerX, horizonY), + end = Offset(endX, endY), + strokeWidth = 1.dp.toPx() + ) + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(64.dp) + .scale(pulseScale) + .border( + BorderStroke(2.dp, AccentCyan.copy(alpha = pulseAlpha)), + CircleShape + ) + .background(AccentCyan.copy(alpha = 0.1f), CircleShape) + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = AccentCyan, + modifier = Modifier.size(36.dp) + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "SYSTEM OPTIMIZED", + style = MaterialTheme.typography.labelSmall, + color = AccentCyan, + fontWeight = FontWeight.Bold, + letterSpacing = 2.sp + ) + } + } } } @@ -877,9 +1148,9 @@ private fun MappingEditor( var expanded by remember { mutableStateOf(false) } Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = DarkBg), - border = BorderStroke(1.dp, DarkBorder) + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = Color(0x30081425)), + border = BorderStroke(1.dp, DarkBorder.copy(alpha = 0.5f)) ) { Column(modifier = Modifier.padding(14.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -909,6 +1180,7 @@ private fun MappingEditor( expanded = false }, label = { Text(action.name.replace('_', ' ')) }, + shape = RoundedCornerShape(50.dp), colors = FilterChipDefaults.filterChipColors( selectedContainerColor = AccentCyan.copy(alpha = 0.2f), selectedLabelColor = AccentCyan, @@ -953,7 +1225,7 @@ private fun AdvancedSettingsScreen( ) Spacer(modifier = Modifier.weight(1f)) TextButton(onClick = onBack) { - Text("Back", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + Text("Back", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = AccentCyan) } } @@ -961,13 +1233,10 @@ private fun AdvancedSettingsScreen( Text( text = "CORE LIMITS", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + color = AccentCyan ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)) + GlassCard( + modifier = Modifier.fillMaxWidth() ) { Column( modifier = Modifier.padding(16.dp), @@ -976,7 +1245,7 @@ private fun AdvancedSettingsScreen( TuningSlider("Action Cooldown", "${tuning.actionCooldownMs}ms", tuning.actionCooldownMs.toFloat(), 500f..3000f) { onTuningChanged(tuning.copy(actionCooldownMs = it.toLong())) } - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(MaterialTheme.colorScheme.outline.copy(alpha = 0.08f))) + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(DarkBorder.copy(alpha = 0.3f))) TuningSlider("Volume Tick Speed", "${tuning.volumeTickMs}ms", tuning.volumeTickMs.toFloat(), 200f..1000f) { onTuningChanged(tuning.copy(volumeTickMs = it.toLong())) } @@ -987,13 +1256,10 @@ private fun AdvancedSettingsScreen( Text( text = "SENSITIVITY THRESHOLDS", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + color = AccentCyan ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)) + GlassCard( + modifier = Modifier.fillMaxWidth() ) { Column( modifier = Modifier.padding(16.dp), @@ -1002,11 +1268,11 @@ private fun AdvancedSettingsScreen( TuningSlider("Pinch Threshold", String.format("%.2f", tuning.pinchThreshold), tuning.pinchThreshold, 0.05f..0.15f) { onTuningChanged(tuning.copy(pinchThreshold = it)) } - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(MaterialTheme.colorScheme.outline.copy(alpha = 0.08f))) + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(DarkBorder.copy(alpha = 0.3f))) TuningSlider("Pinch Release", String.format("%.2f", tuning.pinchReleaseThreshold), tuning.pinchReleaseThreshold, 0.10f..0.30f) { onTuningChanged(tuning.copy(pinchReleaseThreshold = it)) } - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(MaterialTheme.colorScheme.outline.copy(alpha = 0.08f))) + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(DarkBorder.copy(alpha = 0.3f))) TuningSlider("Swipe Threshold", String.format("%.2f", tuning.swipeThreshold), tuning.swipeThreshold, 0.10f..0.25f) { onTuningChanged(tuning.copy(swipeThreshold = it)) } @@ -1017,13 +1283,10 @@ private fun AdvancedSettingsScreen( Text( text = "ENGINE INTERVALS", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary + color = AccentCyan ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)) + GlassCard( + modifier = Modifier.fillMaxWidth() ) { Column( modifier = Modifier.padding(16.dp), @@ -1032,11 +1295,11 @@ private fun AdvancedSettingsScreen( TuningSlider("Alerting Burst", "${tuning.alertingBurstMs}ms", tuning.alertingBurstMs.toFloat(), 500f..2500f) { onTuningChanged(tuning.copy(alertingBurstMs = it.toLong())) } - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(MaterialTheme.colorScheme.outline.copy(alpha = 0.08f))) + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(DarkBorder.copy(alpha = 0.3f))) TuningSlider("Active Timeout", "${tuning.activeTimeoutMs}ms", tuning.activeTimeoutMs.toFloat(), 3000f..15000f) { onTuningChanged(tuning.copy(activeTimeoutMs = it.toLong())) } - Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(MaterialTheme.colorScheme.outline.copy(alpha = 0.08f))) + Box(modifier = Modifier.fillMaxWidth().height(1.dp).background(DarkBorder.copy(alpha = 0.3f))) TuningSlider("Idle Polling", "${tuning.idleInferenceIntervalMs}ms", tuning.idleInferenceIntervalMs.toFloat(), 250f..800f) { onTuningChanged(tuning.copy(idleInferenceIntervalMs = it.toLong())) } @@ -1048,9 +1311,9 @@ private fun AdvancedSettingsScreen( modifier = Modifier .fillMaxWidth() .height(50.dp) - .clip(RoundedCornerShape(14.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant) - .border(BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)), RoundedCornerShape(14.dp)) + .clip(RoundedCornerShape(50.dp)) + .background(Color(0x400D1F38)) + .border(BorderStroke(1.dp, DarkBorder.copy(alpha = 0.5f)), RoundedCornerShape(50.dp)) .bounceClick(onResetTuning), contentAlignment = Alignment.Center ) { @@ -1113,7 +1376,7 @@ private fun HistoryScreen(uiState: MainUiState, onBack: () -> Unit) { ) Spacer(modifier = Modifier.weight(1f)) TextButton(onClick = onBack) { - Text("Back", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + Text("Back", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = AccentCyan) } } @@ -1151,13 +1414,10 @@ private fun HistoryScreen(uiState: MainUiState, onBack: () -> Unit) { } } else { val dateFormat = remember { java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()) } - Card( + GlassCard( modifier = Modifier .fillMaxWidth() - .weight(1f), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)) + .weight(1f) ) { Column( modifier = Modifier @@ -1203,9 +1463,9 @@ private fun HistoryScreen(uiState: MainUiState, onBack: () -> Unit) { Box( modifier = Modifier - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(50.dp)) .background(DarkBorder) - .border(BorderStroke(1.dp, AccentCyan.copy(alpha = 0.3f)), RoundedCornerShape(8.dp)) + .border(BorderStroke(1.dp, AccentCyan.copy(alpha = 0.3f)), RoundedCornerShape(50.dp)) .padding(horizontal = 10.dp, vertical = 4.dp) ) { Text( @@ -1222,7 +1482,7 @@ private fun HistoryScreen(uiState: MainUiState, onBack: () -> Unit) { modifier = Modifier .fillMaxWidth() .height(1.dp) - .background(MaterialTheme.colorScheme.outline.copy(alpha = 0.08f)) + .background(DarkBorder.copy(alpha = 0.3f)) ) } } diff --git a/app/src/main/java/com/example/driveswipe/GestureService.kt b/app/src/main/java/com/example/driveswipe/GestureService.kt index bb4d9da..c623d84 100644 --- a/app/src/main/java/com/example/driveswipe/GestureService.kt +++ b/app/src/main/java/com/example/driveswipe/GestureService.kt @@ -91,7 +91,6 @@ class GestureService : LifecycleService(), SensorEventListener { val params = WindowManager.LayoutParams( sizePx, sizePx, type, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or - WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, PixelFormat.TRANSLUCENT ).apply { @@ -246,11 +245,11 @@ class GestureService : LifecycleService(), SensorEventListener { private fun handleGesture(gestureName: String) { Log.d("DriveSwipe", "Handling Gesture: $gestureName") + val action = resolveAction(gestureName) mainHandler.post { - overlayView?.showGestureConfirmation() + overlayView?.showGestureConfirmation(gestureName, action.name) } - val action = resolveAction(gestureName) when (action) { DriveAction.NEXT_TRACK -> dispatchMediaKey(KeyEvent.KEYCODE_MEDIA_NEXT) DriveAction.PREVIOUS_TRACK -> dispatchMediaKey(KeyEvent.KEYCODE_MEDIA_PREVIOUS) diff --git a/app/src/main/java/com/example/driveswipe/StatusOverlayView.kt b/app/src/main/java/com/example/driveswipe/StatusOverlayView.kt index f25117a..eb2dcca 100644 --- a/app/src/main/java/com/example/driveswipe/StatusOverlayView.kt +++ b/app/src/main/java/com/example/driveswipe/StatusOverlayView.kt @@ -5,19 +5,68 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.util.TypedValue +import android.view.MotionEvent import android.view.View +import android.view.WindowManager +import android.view.animation.DecelerateInterpolator import android.view.animation.LinearInterpolator class StatusOverlayView(context: Context) : View(context) { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = Color.parseColor("#E6081425") // Deep navy with 90% opacity (glassmorphism look) + } + + private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + color = Color.parseColor("#3357F1DB") // Teal accent outline with 20% opacity + } + + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#D8E3FB") // TextPrimary + style = Paint.Style.FILL + typeface = android.graphics.Typeface.create("sans-serif-medium", android.graphics.Typeface.NORMAL) + } + + private val subTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.parseColor("#80BACAC5") // Muted TextSecondary + style = Paint.Style.FILL + typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.NORMAL) + } + + private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } private var dotColor = COLOR_IDLE private var pulseAnimator: ValueAnimator? = null + private var transitionAnimator: ValueAnimator? = null + + private var currentWidthDp = NEUTRAL_SIZE_DP + private var currentHeightDp = NEUTRAL_SIZE_DP + private var expanded = false + private var displayText = "" + private var displaySub = "" + private val collapseRunnable = Runnable { collapse() } + + init { + borderPaint.strokeWidth = dpToPx(1f) + textPaint.textSize = dpToPx(13f) + subTextPaint.textSize = dpToPx(10f) + } + + private fun dpToPx(dp: Float): Float { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp, + resources.displayMetrics + ) + } fun setState(state: EngineState) { + if (expanded) return // Keep expanded visible during gesture animations pulseAnimator?.cancel() pulseAnimator = null alpha = 1f @@ -50,51 +99,193 @@ class StatusOverlayView(context: Context) : View(context) { } } - fun showGestureConfirmation() { + fun showGestureConfirmation(gestureName: String, actionName: String) { pulseAnimator?.cancel() - val prevColor = dotColor - val prevAlpha = alpha + transitionAnimator?.cancel() + removeCallbacks(collapseRunnable) - dotColor = Color.WHITE - alpha = 1f - invalidate() + expanded = true + displayText = actionName.replace('_', ' ') + displaySub = gestureName.replace('_', ' ') + + // Measure text and calculate dynamic width + val density = resources.displayMetrics.density + val cornerRadiusPx = dpToPx(EXPANDED_HEIGHT_DP / 2f) + val dotRadiusPx = dpToPx(4f) + val mainTextWidth = textPaint.measureText(displayText) + val subTextWidth = if (displaySub.isNotEmpty()) { + subTextPaint.measureText(displaySub) + } else { + 0f + } - pulseAnimator = ValueAnimator.ofFloat(0f, 1f).apply { - duration = 500 - interpolator = LinearInterpolator() - addUpdateListener { animator -> - val fraction = animator.animatedFraction - if (fraction > 0.7f) { - dotColor = prevColor - this@StatusOverlayView.alpha = prevAlpha - } + val gapPx = if (displaySub.isNotEmpty()) dpToPx(16f) else 0f + val rightPaddingPx = if (displaySub.isNotEmpty()) dpToPx(4f) else 0f + + val totalWidthPx = cornerRadiusPx + + dotRadiusPx + + dpToPx(8f) + + mainTextWidth + + gapPx + + subTextWidth + + rightPaddingPx + + cornerRadiusPx + + val targetWidthDp = (totalWidthPx / density).coerceAtLeast(EXPANDED_WIDTH_DP) + + animateLayout(targetWidthDp, EXPANDED_HEIGHT_DP) { + postDelayed(collapseRunnable, 2000) + } + } + + private fun collapse() { + expanded = false + animateLayout(NEUTRAL_SIZE_DP, NEUTRAL_SIZE_DP) { + // Restore proper status color state + invalidate() + } + } + + private fun animateLayout(targetWidth: Float, targetHeight: Float, onEnd: () -> Unit) { + val startW = currentWidthDp + val startH = currentHeightDp + + transitionAnimator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = 300 + interpolator = DecelerateInterpolator() + addUpdateListener { anim -> + val fraction = anim.animatedValue as Float + currentWidthDp = startW + (targetWidth - startW) * fraction + currentHeightDp = startH + (targetHeight - startH) * fraction + updateWindowLayout() invalidate() } + addListener(object : android.animation.AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: android.animation.Animator) { + onEnd() + } + }) start() } } + private fun updateWindowLayout() { + val wm = context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager ?: return + val params = layoutParams as? WindowManager.LayoutParams ?: return + params.width = dpToPx(currentWidthDp).toInt() + params.height = dpToPx(currentHeightDp).toInt() + runCatching { wm.updateViewLayout(this, params) } + } + override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - paint.color = dotColor - val cx = width / 2f - val cy = height / 2f - val radius = minOf(cx, cy) - canvas.drawCircle(cx, cy, radius, paint) + val w = width.toFloat() + val h = height.toFloat() + val cornerRadius = h / 2f + + // Draw glassmorphic container background + canvas.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, bgPaint) + canvas.drawRoundRect(0f, 0f, w, h, cornerRadius, cornerRadius, borderPaint) + + if (expanded) { + // Active gesture text HUD + val dotRadius = dpToPx(4f) + val leftMargin = cornerRadius + + // 1. Draw pulsing active teal status dot + dotPaint.color = Color.parseColor("#FF57F1DB") + canvas.drawCircle(leftMargin, h / 2f, dotRadius, dotPaint) + + // 2. Draw Main Action Text + val textX = leftMargin + dotRadius + dpToPx(8f) + val textY = h / 2f - (textPaint.descent() + textPaint.ascent()) / 2f + canvas.drawText(displayText, textX, textY, textPaint) + + // 3. Draw Muted Subtext / Gesture (Right) + if (displaySub.isNotEmpty()) { + val subX = w - cornerRadius - subTextPaint.measureText(displaySub) - dpToPx(4f) + val subY = h / 2f - (subTextPaint.descent() + subTextPaint.ascent()) / 2f + canvas.drawText(displaySub, subX, subY, subTextPaint) + } + } else { + // Draw Neutral dot in center + dotPaint.color = dotColor + val prevAlpha = dotPaint.alpha + dotPaint.alpha = (alpha * 255).toInt() + + canvas.drawCircle(w / 2f, h / 2f, dpToPx(4f), dotPaint) + + dotPaint.alpha = prevAlpha + } } override fun onDetachedFromWindow() { super.onDetachedFromWindow() + removeCallbacks(collapseRunnable) pulseAnimator?.cancel() + transitionAnimator?.cancel() pulseAnimator = null + transitionAnimator = null + } + + private var initialX = 0 + private var initialY = 0 + private var initialTouchX = 0f + private var initialTouchY = 0f + private var isDragging = false + + override fun onTouchEvent(event: MotionEvent): Boolean { + val wm = context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager ?: return super.onTouchEvent(event) + val params = layoutParams as? WindowManager.LayoutParams ?: return super.onTouchEvent(event) + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + initialX = params.x + initialY = params.y + initialTouchX = event.rawX + initialTouchY = event.rawY + isDragging = true + return true + } + MotionEvent.ACTION_MOVE -> { + if (isDragging) { + val dx = event.rawX - initialTouchX + val dy = event.rawY - initialTouchY + + // Since gravity is TOP or END (right-aligned), + // moving left (negative dx) increases x (distance from right), + // and moving right (positive dx) decreases x. + params.x = initialX - dx.toInt() + params.y = initialY + dy.toInt() + + // Bound within screen dimensions + val displayMetrics = resources.displayMetrics + params.x = params.x.coerceIn(0, displayMetrics.widthPixels - width) + params.y = params.y.coerceIn(0, displayMetrics.heightPixels - height) + + runCatching { wm.updateViewLayout(this, params) } + } + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + isDragging = false + return true + } + } + return super.onTouchEvent(event) } companion object { private val COLOR_IDLE = Color.parseColor("#80888888") - private val COLOR_ALERTING = Color.parseColor("#FFFFA500") - private val COLOR_ACTIVE = Color.parseColor("#FF4CAF50") + private val COLOR_ALERTING = Color.parseColor("#FFFF9100") + private val COLOR_ACTIVE = Color.parseColor("#FF57F1DB") private const val ALPHA_IDLE = 0.45f - const val DOT_SIZE_DP = 18 + const val NEUTRAL_SIZE_DP = 28f + const val EXPANDED_WIDTH_DP = 220f + const val EXPANDED_HEIGHT_DP = 36f + + // Expose size to service initialization + const val DOT_SIZE_DP = NEUTRAL_SIZE_DP.toInt() } } diff --git a/app/src/main/java/com/example/driveswipe/ui/theme/Color.kt b/app/src/main/java/com/example/driveswipe/ui/theme/Color.kt index deaa59d..05264a5 100644 --- a/app/src/main/java/com/example/driveswipe/ui/theme/Color.kt +++ b/app/src/main/java/com/example/driveswipe/ui/theme/Color.kt @@ -2,27 +2,29 @@ package com.example.driveswipe.ui.theme import androidx.compose.ui.graphics.Color -// Main Color -val MainBlue = Color(0xFF5A9FFF) // #5A9FFF +// Main Color (Primary Teal from Stitch) +val MainBlue = Color(0xFF57F1DB) // #57F1DB // Sub Colors -val SubBlueLight = Color(0xFFBFD1E5) // #BFD1E5 -val SubBlueBright = Color(0xFF0066FF) // #0066FF -val SubBlueDark = Color(0xFF002F6C) // #002F6C +val SubBlueLight = Color(0xFFBACAC5) // #BACAC5 (On Surface Variant) +val SubBlueBright = Color(0xFF3CDDC7) // #3CDDC7 (Surface Tint) +val SubBlueDark = Color(0xFF152031) // #152031 (Surface Container) // Semantic mapping to existing theme variables to maintain codebase compatibility -val DarkBg = Color(0xFF030712) // Deep dark blue-black -val DarkSurface = Color(0xFF071024) // Dark Navy surface -val DarkCard = SubBlueDark // #002F6C -val DarkBorder = Color(0xFF0E2E5C) // Muted blue border +val DarkBg = Color(0xFF081425) // #081425 (Background) +val DarkSurface = Color(0xFF111C2D) // #111C2D (Surface Container Low) +val DarkCard = Color(0x991E293B) // #1E293B with alpha for glass (rgba(30, 41, 59, 0.6)) +val DarkBorder = Color(0xFF3C4A46) // #3C4A46 (Outline Variant) -val AccentCyan = MainBlue // #5A9FFF (Main Color) -val AccentSteel = SubBlueBright // #0066FF (Sub Color) +val AccentCyan = MainBlue // #57F1DB +val AccentSteel = SubBlueBright // #3CDDC7 -val TextPrimary = Color(0xFFF8FAFC) // Off-white for max readability -val TextSecondary = SubBlueLight // #BFD1E5 (Light Slate Blue secondary text) +val TextPrimary = Color(0xFFD8E3FB) // #D8E3FB (On Surface) +val TextSecondary = SubBlueLight // #BACAC5 (On Surface Variant) val StateAlerting = Color(0xFFFF9100) // Safety Amber -val StateActive = Color(0xFF00E676) // Active Green -val StateError = Color(0xFFFF1744) // Coral Rose +val StateActive = Color(0xFF57F1DB) // Active State is Teal +val StateError = Color(0xFFFFB4AB) // #FFB4AB (Error) + + diff --git a/app/src/main/java/com/example/driveswipe/ui/theme/Theme.kt b/app/src/main/java/com/example/driveswipe/ui/theme/Theme.kt index 42e2104..976a8d0 100644 --- a/app/src/main/java/com/example/driveswipe/ui/theme/Theme.kt +++ b/app/src/main/java/com/example/driveswipe/ui/theme/Theme.kt @@ -51,8 +51,10 @@ fun DriveSwipeTheme( if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + window.statusBarColor = colorScheme.background.toArgb() + window.navigationBarColor = colorScheme.background.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false + WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = false } } diff --git a/app/src/test/java/com/example/driveswipe/AppSettingsTest.kt b/app/src/test/java/com/example/driveswipe/AppSettingsTest.kt index bc6819c..00f5568 100644 --- a/app/src/test/java/com/example/driveswipe/AppSettingsTest.kt +++ b/app/src/test/java/com/example/driveswipe/AppSettingsTest.kt @@ -8,8 +8,8 @@ class AppSettingsTest { @Test fun defaultMappingsMatchExpectedMediaActions() { val mappings = AppSettings().mappings - assertEquals(DriveAction.NEXT_TRACK, mappings.pinchDragRight) - assertEquals(DriveAction.PREVIOUS_TRACK, mappings.pinchDragLeft) + assertEquals(DriveAction.PREVIOUS_TRACK, mappings.pinchDragRight) + assertEquals(DriveAction.NEXT_TRACK, mappings.pinchDragLeft) assertEquals(DriveAction.PLAY_PAUSE, mappings.twoFingerPoint) assertEquals(DriveAction.VOLUME_UP, mappings.volumeUp) assertEquals(DriveAction.VOLUME_DOWN, mappings.volumeDown) diff --git a/app/src/test/java/com/example/driveswipe/DriveSwipeModelsTest.kt b/app/src/test/java/com/example/driveswipe/DriveSwipeModelsTest.kt index fb085ae..5317dc6 100644 --- a/app/src/test/java/com/example/driveswipe/DriveSwipeModelsTest.kt +++ b/app/src/test/java/com/example/driveswipe/DriveSwipeModelsTest.kt @@ -44,8 +44,8 @@ class DriveSwipeModelsTest { @Test fun gestureMappingsDefaultValuesAreCorrect() { val m = GestureMappings() - assertEquals(DriveAction.NEXT_TRACK, m.pinchDragRight) - assertEquals(DriveAction.PREVIOUS_TRACK, m.pinchDragLeft) + assertEquals(DriveAction.PREVIOUS_TRACK, m.pinchDragRight) + assertEquals(DriveAction.NEXT_TRACK, m.pinchDragLeft) assertEquals(DriveAction.PLAY_PAUSE, m.twoFingerPoint) assertEquals(DriveAction.VOLUME_UP, m.volumeUp) assertEquals(DriveAction.VOLUME_DOWN, m.volumeDown) diff --git a/gradle.properties b/gradle.properties index fb786fe..79a9fff 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true android.nonTransitiveRClass=true -org.gradle.java.home=C:/Program Files/Android/Android Studio/jbr +# org.gradle.java.home=C:/Program Files/Android/Android Studio/jbr diff --git a/handoff.md b/handoff.md deleted file mode 100644 index 6f8d942..0000000 --- a/handoff.md +++ /dev/null @@ -1,96 +0,0 @@ -# DriveSwipe Handoff - -## Project Context -- **App purpose:** Gesture-based media control for older/normal cars (BMW-like hand gesture experience). -- **Platform:** Android (Kotlin, Jetpack Compose, Material 3, CameraX, MediaPipe). -- **Repository:** `https://github.com/ganzvu/DriveSwipe` -- **Primary branch:** `main` - -## Task Summary -Implemented the UI/UX redesign plan end-to-end and pushed the result to GitHub. -Work covered navigation, onboarding/setup UX, configurable gesture mapping/tuning, persistence, service integration, and validation artifacts. - -## Progress Snapshot -- [x] Multi-screen navigation shell replacing the old single screen -- [x] Setup wizard + permission/readiness flow -- [x] Home quick controls (start/stop, mode, emergency disable, status) -- [x] Gesture settings (presets, mapping, tuning) -- [x] Modes screen and lightweight history screen -- [x] Persistent settings via DataStore -- [x] Runtime-configurable gesture pipeline in service/recognizer -- [x] Build verified (`:app:assembleDebug` successful after fixes) -- [x] Pushed to GitHub `main` - -## Current Codebase Status - -### Architecture/UX -- App now uses a nav-based Compose shell in: - - `app/src/main/java/com/example/driveswipe/DriveSwipeApp.kt` -- Screens included: - - `Home` - - `Setup Wizard` - - `Gestures` (Presets / Mapping / Tuning tabs) - - `Modes` - - `History` - -### State + Persistence -- New settings and domain models: - - `app/src/main/java/com/example/driveswipe/DriveSwipeModels.kt` -- DataStore-backed persistence: - - `app/src/main/java/com/example/driveswipe/SettingsRepository.kt` -- ViewModel refactor with richer UI state: - - `app/src/main/java/com/example/driveswipe/MainViewModel.kt` - -### Service/Recognizer Integration -- Shared contract constants for extras/events: - - `app/src/main/java/com/example/driveswipe/ServiceContract.kt` -- Service now accepts configurable mappings + tuning: - - `app/src/main/java/com/example/driveswipe/GestureService.kt` -- Recognizer thresholds/cooldowns are runtime-tunable: - - `app/src/main/java/com/example/driveswipe/GestureRecognizerHelper.kt` -- Activity updated for new app shell + permission state + gesture event receiver: - - `app/src/main/java/com/example/driveswipe/MainActivity.kt` - -### Validation Artifacts -- UX checklist: - - `UX_VALIDATION_CHECKLIST.md` -- Unit test for defaults: - - `app/src/test/java/com/example/driveswipe/AppSettingsTest.kt` -- Preview scaffold: - - `app/src/main/java/com/example/driveswipe/DriveSwipePreviews.kt` - -## Build / Tooling Status -- Latest verified build command: - - `./gradlew.bat :app:assembleDebug` -- Result: **BUILD SUCCESSFUL** -- Remaining warnings (non-blocking): - - Deprecated CameraX API usage (`setTargetResolution`) - - Unused `mpImage` parameter warning - - Packaging warning tied to `android:extractNativeLibs` - -## Git Status at Handoff -- Repository initialized and connected to remote: - - `origin https://github.com/ganzvu/DriveSwipe.git` -- Branch: - - `main` tracking `origin/main` -- Push status: - - Up to date at handoff time - -## Important Notes for Next Cursor Instance -- Project was initially non-git; now fully initialized and pushed. -- `gh` CLI was not available in this environment; GitHub operations were done via git remote/push. -- Commit used explicit per-command author flags because global git identity was unset in this machine. - -## Suggested Next Steps -1. Run app on target Android device(s) and verify in-car flows against `UX_VALIDATION_CHECKLIST.md`. -2. Replace deprecated CameraX call (`setTargetResolution`) with current recommended API. -3. Decide whether to keep/remove `android:extractNativeLibs` and align packaging config. -4. Add instrumentation/UI tests for setup wizard and gesture settings interactions. -5. Tune defaults based on field testing (false positives, latency, usability in daylight/night). - -## Quick Start Commands (for new workstation) -```bash -git clone https://github.com/ganzvu/DriveSwipe.git -cd DriveSwipe -./gradlew :app:assembleDebug -```