feat: refine MotorSignal visual system and navigation

This commit is contained in:
Developer 2026-09-05 09:12:16 -04:00
parent a362e7311f
commit a14d45fac7
26 changed files with 1199 additions and 393 deletions

View File

@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../features/dashboard/dashboard_screen.dart';
import '../features/map/map_screen.dart';
import '../features/history/history_screen.dart';
import '../features/records/records_screen.dart';
import '../features/settings/settings_screen.dart';
import '../features/settings/diagnostics_screen.dart';
import '../features/garage/garage_screen.dart';
@ -17,6 +15,8 @@ import '../features/field_test/field_test_pre_ride_check_screen.dart';
import '../features/settings/oauth_callback_screen.dart';
import '../features/settings/account_screen.dart';
import '../features/entitlements/pro_screen.dart';
import '../features/activity/activity_screen.dart';
import '../features/social/social_hub_screen.dart';
import '../features/social/rider_profile_screen.dart';
import '../features/social/friends_screen.dart';
import '../features/group_rides/group_rides_hub_screen.dart';
@ -88,16 +88,14 @@ final appRouter = GoRouter(
StatefulShellBranch(
routes: [
GoRoute(
path: '/history',
builder: (context, state) => const HistoryScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/records',
builder: (context, state) => const RecordsScreen(),
path: '/activity',
builder: (context, state) {
final tabParam = state.uri.queryParameters['tab'];
final tab = tabParam == 'records'
? ActivityTab.records
: ActivityTab.rides;
return ActivityScreen(initialTab: tab);
},
),
],
),
@ -122,6 +120,24 @@ final appRouter = GoRouter(
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/social',
builder: (context, state) => const SocialHubScreen(),
routes: [
GoRoute(
path: 'profile',
builder: (context, state) => const RiderProfileScreen(),
),
GoRoute(
path: 'friends',
builder: (context, state) => const FriendsScreen(),
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
@ -172,6 +188,16 @@ final appRouter = GoRouter(
),
],
),
GoRoute(
path: '/history',
builder: (context, state) =>
const ActivityScreen(initialTab: ActivityTab.rides),
),
GoRoute(
path: '/records',
builder: (context, state) =>
const ActivityScreen(initialTab: ActivityTab.records),
),
GoRoute(
path: '/group-rides',
builder: (context, state) => const GroupRidesHubScreen(),

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
/// Authoritative semantic color tokens for MotorSignal.
///
@ -6,41 +6,41 @@
/// - Dark Mode (Cockpit OLED): deep matte blacks and vibrant saturated accents.
/// - Light Mode (Daylight HUD): high-contrast daylight surfaces for outdoor sunlight readability.
abstract final class AppColors {
// Brand & High-Contrast Accents
static const Color cyberCyan = Color(0xFF00E5FF);
static const Color technicalBlue = Color(0xFF124580);
static const Color velocityBlue = Color(0xFF2E7DFF);
static const Color raceRed = Color(0xFFFF2A2A);
// Brand & High-Contrast Accents (Unified MotorSignal Palette)
static const Color cyberCyan = Color(0xFF00B4D8);
static const Color technicalBlue = Color(0xFF0077A8);
static const Color velocityBlue = Color(0xFF0096C7);
static const Color raceRed = Color(0xFFFF3B30);
static const Color hazardRed = Color(0xFFFF3B30);
static const Color launchGreen = Color(0xFF34C759);
static const Color telemetryGreen = Color(0xFF69F0AE);
static const Color successGreen = Color(0xFF008547);
static const Color warningAmber = Color(0xFFFFD740);
static const Color telemetryGreen = Color(0xFF30D158);
static const Color successGreen = Color(0xFF28A745);
static const Color warningAmber = Color(0xFFFFB300);
static const Color darkAmber = Color(0xFFC48100);
// Dark Mode Palette (Cockpit OLED)
static const Color darkBackground = Color(0xFF0A0A0A);
static const Color darkSurface = Color(0xFF0D0D0D);
static const Color darkSurfaceContainer = Color(0xFF141414);
static const Color darkSurfaceContainerHigh = Color(0xFF1E1E1E);
static const Color darkSurfaceContainerHighest = Color(0xFF282828);
static const Color darkOutline = Color(0xFF2A2A2A);
static const Color darkOutlineVariant = Color(0xFF1F1F1F);
static const Color darkOnSurface = Color(0xFFFFFFFF);
static const Color darkOnSurfaceVariant = Color(0xFFB3B3B3);
static const Color darkPrimary = Color(0xFF00E5FF);
static const Color darkSecondary = Color(0xFFFF2A2A);
// Dark Mode Palette (Graphite & Technical Cockpit)
static const Color darkBackground = Color(0xFF0D0F13);
static const Color darkSurface = Color(0xFF13171E);
static const Color darkSurfaceContainer = Color(0xFF1A1F28);
static const Color darkSurfaceContainerHigh = Color(0xFF222935);
static const Color darkSurfaceContainerHighest = Color(0xFF2B3342);
static const Color darkOutline = Color(0xFF2B3342);
static const Color darkOutlineVariant = Color(0xFF1F242F);
static const Color darkOnSurface = Color(0xFFF1F3F5);
static const Color darkOnSurfaceVariant = Color(0xFF9EABB8);
static const Color darkPrimary = Color(0xFF00B4D8);
static const Color darkSecondary = Color(0xFFFF3B30);
// Light Mode Palette (Daylight HUD)
static const Color lightBackground = Color(0xFFF0F0F0);
static const Color lightSurface = Color(0xFFF5F5F5);
// Light Mode Palette (Daylight Technical HUD)
static const Color lightBackground = Color(0xFFF3F5F8);
static const Color lightSurface = Color(0xFFE9EDF2);
static const Color lightSurfaceContainer = Color(0xFFFFFFFF);
static const Color lightSurfaceContainerHigh = Color(0xFFEAEAEA);
static const Color lightSurfaceContainerHighest = Color(0xFFDFDFDF);
static const Color lightOutline = Color(0xFFD1D1D1);
static const Color lightOutlineVariant = Color(0xFFE0E0E0);
static const Color lightOnSurface = Color(0xFF1A1C1C);
static const Color lightOnSurfaceVariant = Color(0xFF555555);
static const Color lightPrimary = Color(0xFF124580);
static const Color lightSurfaceContainerHigh = Color(0xFFE1E6EC);
static const Color lightSurfaceContainerHighest = Color(0xFFD6DCE4);
static const Color lightOutline = Color(0xFFCBD2DC);
static const Color lightOutlineVariant = Color(0xFFDFE4EC);
static const Color lightOnSurface = Color(0xFF13171E);
static const Color lightOnSurfaceVariant = Color(0xFF5A6675);
static const Color lightPrimary = Color(0xFF0077A8);
static const Color lightSecondary = Color(0xFFD32F2F);
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
/// Authoritative typography tokens for MotorSignal.
///
@ -38,28 +38,28 @@ abstract final class AppTypography {
// Headlines
static const TextStyle headingCompass = TextStyle(
fontFamily: fontHeadline,
fontSize: 28,
fontWeight: FontWeight.w800,
letterSpacing: 0.05 * 28,
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
);
static const TextStyle headlineLg = TextStyle(
fontFamily: fontHeadline,
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
);
static const TextStyle headlineMd = TextStyle(
fontFamily: fontHeadline,
fontSize: 18,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
);
static const TextStyle headlineSm = TextStyle(
fontFamily: fontHeadline,
fontSize: 15,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
);
// Telemetry Labels & Readouts
@ -71,16 +71,16 @@ abstract final class AppTypography {
static const TextStyle telemetryLabel = TextStyle(
fontFamily: fontBody,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
);
static const TextStyle badgeLabel = TextStyle(
fontFamily: fontTelemetry,
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
);
// Body text
@ -108,7 +108,7 @@ abstract final class AppTypography {
static const TextStyle button = TextStyle(
fontFamily: fontHeadline,
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 1.0,
fontWeight: FontWeight.w600,
letterSpacing: 0.4,
);
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'app_spacing.dart';
import 'app_typography.dart';
@ -49,15 +49,15 @@ class AppTheme {
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.lgBorderRadius,
side: const BorderSide(color: AppColors.darkOutline, width: 1),
side: const BorderSide(color: AppColors.darkOutlineVariant, width: 0.8),
),
),
dialogTheme: DialogThemeData(
backgroundColor: AppColors.darkSurfaceContainerHigh,
elevation: 8,
elevation: 6,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.lgBorderRadius,
side: const BorderSide(color: AppColors.darkOutline, width: 1),
side: const BorderSide(color: AppColors.darkOutlineVariant, width: 0.8),
),
titleTextStyle: AppTypography.headlineMd.copyWith(color: AppColors.darkOnSurface),
contentTextStyle: AppTypography.bodyMd.copyWith(color: AppColors.darkOnSurfaceVariant),
@ -65,7 +65,7 @@ class AppTheme {
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: AppColors.darkSurfaceContainerHigh,
modalBackgroundColor: AppColors.darkSurfaceContainerHigh,
elevation: 16,
elevation: 12,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
),
@ -79,19 +79,19 @@ class AppTheme {
),
side: WidgetStateProperty.resolveWith<BorderSide>((states) {
if (states.contains(WidgetState.selected)) {
return const BorderSide(color: AppColors.cyberCyan, width: 1.5);
return const BorderSide(color: AppColors.darkPrimary, width: 1.2);
}
return const BorderSide(color: AppColors.darkOutline, width: 1);
return const BorderSide(color: AppColors.darkOutlineVariant, width: 0.8);
}),
backgroundColor: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.cyberCyan.withValues(alpha: 0.15);
return AppColors.darkPrimary.withValues(alpha: 0.12);
}
return AppColors.darkSurfaceContainer;
}),
foregroundColor: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.cyberCyan;
return AppColors.darkPrimary;
}
return AppColors.darkOnSurfaceVariant;
}),
@ -101,7 +101,7 @@ class AppTheme {
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: AppColors.darkPrimary,
foregroundColor: const Color(0xFF000000),
foregroundColor: const Color(0xFF0D0F13),
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorderRadius),
textStyle: AppTypography.button,
@ -114,12 +114,12 @@ class AppTheme {
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorderRadius),
textStyle: AppTypography.button,
elevation: 2,
elevation: 1,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.cyberCyan,
foregroundColor: AppColors.darkPrimary,
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
side: const BorderSide(color: AppColors.darkOutline, width: 1),
shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorderRadius),
@ -128,7 +128,7 @@ class AppTheme {
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppColors.cyberCyan,
foregroundColor: AppColors.darkPrimary,
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
textStyle: AppTypography.button,
),
@ -175,20 +175,20 @@ class AppTheme {
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.darkSurface,
indicatorColor: AppColors.cyberCyan.withValues(alpha: 0.15),
indicatorColor: AppColors.darkPrimary.withValues(alpha: 0.14),
elevation: 0,
labelTextStyle: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return AppTypography.telemetryLabel.copyWith(color: AppColors.cyberCyan);
return AppTypography.telemetryLabel.copyWith(color: AppColors.darkPrimary);
}
return AppTypography.telemetryLabel.copyWith(color: AppColors.darkOnSurfaceVariant);
}),
),
navigationRailTheme: NavigationRailThemeData(
backgroundColor: AppColors.darkSurface,
indicatorColor: AppColors.cyberCyan.withValues(alpha: 0.15),
indicatorColor: AppColors.darkPrimary.withValues(alpha: 0.14),
elevation: 0,
selectedLabelTextStyle: AppTypography.telemetryLabel.copyWith(color: AppColors.cyberCyan),
selectedLabelTextStyle: AppTypography.telemetryLabel.copyWith(color: AppColors.darkPrimary),
unselectedLabelTextStyle: AppTypography.telemetryLabel.copyWith(color: AppColors.darkOnSurfaceVariant),
),
snackBarTheme: SnackBarThemeData(
@ -196,7 +196,7 @@ class AppTheme {
contentTextStyle: AppTypography.bodyMd.copyWith(color: AppColors.darkOnSurface),
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorderRadius,
side: const BorderSide(color: AppColors.darkOutline, width: 1),
side: const BorderSide(color: AppColors.darkOutlineVariant, width: 0.8),
),
behavior: SnackBarBehavior.floating,
),
@ -246,15 +246,15 @@ class AppTheme {
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.lgBorderRadius,
side: const BorderSide(color: AppColors.lightOutline, width: 1),
side: const BorderSide(color: AppColors.lightOutlineVariant, width: 0.8),
),
),
dialogTheme: DialogThemeData(
backgroundColor: AppColors.lightSurfaceContainer,
elevation: 8,
elevation: 6,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.lgBorderRadius,
side: const BorderSide(color: AppColors.lightOutline, width: 1),
side: const BorderSide(color: AppColors.lightOutlineVariant, width: 0.8),
),
titleTextStyle: AppTypography.headlineMd.copyWith(color: AppColors.lightOnSurface),
contentTextStyle: AppTypography.bodyMd.copyWith(color: AppColors.lightOnSurfaceVariant),
@ -262,7 +262,7 @@ class AppTheme {
bottomSheetTheme: BottomSheetThemeData(
backgroundColor: AppColors.lightSurfaceContainer,
modalBackgroundColor: AppColors.lightSurfaceContainer,
elevation: 16,
elevation: 12,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
),
@ -276,13 +276,13 @@ class AppTheme {
),
side: WidgetStateProperty.resolveWith<BorderSide>((states) {
if (states.contains(WidgetState.selected)) {
return const BorderSide(color: AppColors.lightPrimary, width: 1.5);
return const BorderSide(color: AppColors.lightPrimary, width: 1.2);
}
return const BorderSide(color: AppColors.lightOutline, width: 1);
return const BorderSide(color: AppColors.lightOutlineVariant, width: 0.8);
}),
backgroundColor: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.selected)) {
return AppColors.lightPrimary.withValues(alpha: 0.12);
return AppColors.lightPrimary.withValues(alpha: 0.10);
}
return AppColors.lightSurfaceContainer;
}),
@ -311,7 +311,7 @@ class AppTheme {
minimumSize: const Size(AppSpacing.minTouchTarget, AppSpacing.minTouchTarget),
shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorderRadius),
textStyle: AppTypography.button,
elevation: 2,
elevation: 1,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'app_colors.dart';
/// Semantic ThemeExtension for MotorSignal telemetry, indicators, and UI surfaces.
@ -44,48 +44,48 @@ class MotorSignalThemeColors extends ThemeExtension<MotorSignalThemeColors> {
required this.glowShadow,
});
/// Dark Mode default telemetry tokens (Cockpit OLED).
/// Dark Mode default telemetry tokens (Cockpit Precision).
static const MotorSignalThemeColors dark = MotorSignalThemeColors(
cyberCyan: AppColors.cyberCyan,
cyberCyan: AppColors.darkPrimary,
velocityBlue: AppColors.velocityBlue,
raceRed: AppColors.raceRed,
launchGreen: AppColors.launchGreen,
warningAmber: AppColors.warningAmber,
telemetryGreen: AppColors.telemetryGreen,
leanAngleLeft: AppColors.cyberCyan,
leanAngleLeft: AppColors.darkPrimary,
leanAngleRight: AppColors.velocityBlue,
gForcePositive: AppColors.launchGreen,
gForceNegative: AppColors.raceRed,
speedometerDial: AppColors.cyberCyan,
speedometerGlow: Color(0x3300E5FF),
speedometerDial: AppColors.darkPrimary,
speedometerGlow: Color(0x1400B4D8),
surfaceContainer: AppColors.darkSurfaceContainer,
surfaceContainerHigh: AppColors.darkSurfaceContainerHigh,
surfaceContainerHighest: AppColors.darkSurfaceContainerHighest,
outline: AppColors.darkOutline,
outlineVariant: AppColors.darkOutlineVariant,
glowShadow: Color(0x6600E5FF),
glowShadow: Color(0x1F00B4D8),
);
/// Light Mode default telemetry tokens (Daylight HUD).
static const MotorSignalThemeColors light = MotorSignalThemeColors(
cyberCyan: AppColors.technicalBlue,
cyberCyan: AppColors.lightPrimary,
velocityBlue: AppColors.velocityBlue,
raceRed: AppColors.lightSecondary,
launchGreen: AppColors.successGreen,
warningAmber: AppColors.darkAmber,
telemetryGreen: AppColors.successGreen,
leanAngleLeft: AppColors.technicalBlue,
launchGreen: AppColors.launchGreen,
warningAmber: AppColors.warningAmber,
telemetryGreen: AppColors.telemetryGreen,
leanAngleLeft: AppColors.lightPrimary,
leanAngleRight: AppColors.velocityBlue,
gForcePositive: AppColors.successGreen,
gForcePositive: AppColors.launchGreen,
gForceNegative: AppColors.lightSecondary,
speedometerDial: AppColors.technicalBlue,
speedometerGlow: Color(0x1F124580),
speedometerDial: AppColors.lightPrimary,
speedometerGlow: Color(0x0F0077A8),
surfaceContainer: AppColors.lightSurfaceContainer,
surfaceContainerHigh: AppColors.lightSurfaceContainerHigh,
surfaceContainerHighest: AppColors.lightSurfaceContainerHighest,
outline: AppColors.lightOutline,
outlineVariant: AppColors.lightOutlineVariant,
glowShadow: Color(0x22000000),
glowShadow: Color(0x14000000),
);
@override

View File

@ -39,24 +39,26 @@ const List<AppNavDestination> kAppNavDestinations = [
railKey: ValueKey('rail_map_icon'),
),
AppNavDestination(
icon: Icons.history,
label: 'History',
navKey: ValueKey('nav_history_icon'),
railKey: ValueKey('rail_history_icon'),
icon: Icons.show_chart_rounded,
selectedIcon: Icons.show_chart,
label: 'Activity',
navKey: ValueKey('nav_activity_icon'),
railKey: ValueKey('rail_activity_icon'),
),
AppNavDestination(
icon: Icons.emoji_events_outlined,
selectedIcon: Icons.emoji_events,
label: 'Records',
navKey: ValueKey('nav_records_icon'),
railKey: ValueKey('rail_records_icon'),
),
AppNavDestination(
icon: Icons.two_wheeler,
icon: Icons.two_wheeler_outlined,
selectedIcon: Icons.two_wheeler,
label: 'Garage',
navKey: ValueKey('nav_garage_icon'),
railKey: ValueKey('rail_garage_icon'),
),
AppNavDestination(
icon: Icons.people_outline,
selectedIcon: Icons.people,
label: 'Social',
navKey: ValueKey('nav_social_icon'),
railKey: ValueKey('rail_social_icon'),
),
AppNavDestination(
icon: Icons.settings_outlined,
selectedIcon: Icons.settings,
@ -142,29 +144,30 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
selectedIndex: selectedIndex,
onDestinationSelected: _onDestinationSelected,
backgroundColor: colorScheme.surface,
indicatorColor: colorScheme.primary.withValues(alpha: 0.18),
indicatorColor: colorScheme.primary.withValues(alpha: 0.14),
selectedIconTheme: IconThemeData(
color: colorScheme.primary,
size: 20,
),
unselectedIconTheme: IconThemeData(
color: colorScheme.onSurfaceVariant,
size: 18,
size: 19,
),
selectedLabelTextStyle: AppTypography.telemetryLabel.copyWith(
color: colorScheme.primary,
fontSize: 9,
fontWeight: FontWeight.w800,
),
unselectedLabelTextStyle: AppTypography.telemetryLabel.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 8.5,
selectedLabelTextStyle: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 9.5,
fontWeight: FontWeight.w600,
),
unselectedLabelTextStyle: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 9.0,
fontWeight: FontWeight.w500,
color: colorScheme.onSurfaceVariant,
),
labelType: NavigationRailLabelType.all,
destinations: kAppNavDestinations.map((dest) {
return NavigationRailDestination(
padding: const EdgeInsets.symmetric(vertical: 2),
padding: const EdgeInsets.symmetric(vertical: 3),
icon: Icon(dest.icon, key: dest.railKey),
selectedIcon: dest.selectedIcon != null
? Icon(dest.selectedIcon, key: dest.railKey)
@ -198,14 +201,20 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
unselectedItemColor: colorScheme.onSurfaceVariant,
backgroundColor: colorScheme.surface,
type: BottomNavigationBarType.fixed,
selectedLabelStyle: AppTypography.telemetryLabel.copyWith(
iconSize: 20,
selectedFontSize: 10,
unselectedFontSize: 9.5,
selectedLabelStyle: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 10,
fontWeight: FontWeight.w800,
),
unselectedLabelStyle: AppTypography.telemetryLabel.copyWith(
fontSize: 9.5,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 9.5,
fontWeight: FontWeight.w500,
color: colorScheme.onSurfaceVariant,
),
items: kAppNavDestinations.map((dest) {
return BottomNavigationBarItem(
icon: Icon(dest.icon, key: dest.navKey),

View File

@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme/app_typography.dart';
import '../../core/theme/theme_extensions.dart';
import '../history/history_screen.dart';
import '../records/records_screen.dart';
enum ActivityTab { rides, records }
/// Canonical unified Activity screen for MotorSignal.
///
/// Houses chronological ride history and personal performance records
/// under a single top-level destination with segmented navigation,
/// preserving information architecture and full responsiveness.
class ActivityScreen extends ConsumerStatefulWidget {
final ActivityTab initialTab;
const ActivityScreen({
super.key,
this.initialTab = ActivityTab.rides,
});
@override
ConsumerState<ActivityScreen> createState() => _ActivityScreenState();
}
class _ActivityScreenState extends ConsumerState<ActivityScreen> {
late ActivityTab _currentTab;
@override
void initState() {
super.initState();
_currentTab = widget.initialTab;
}
@override
void didUpdateWidget(covariant ActivityScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialTab != widget.initialTab) {
_currentTab = widget.initialTab;
}
}
@override
Widget build(BuildContext context) {
final colors = context.motorColors;
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(
title: Text(
_currentTab == ActivityTab.rides ? 'Ride History' : 'Personal Records',
style: const TextStyle(fontWeight: FontWeight.w700),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(48),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: SizedBox(
width: double.infinity,
height: 36,
child: SegmentedButton<ActivityTab>(
segments: const [
ButtonSegment<ActivityTab>(
value: ActivityTab.rides,
icon: Icon(Icons.history, size: 16),
label: Text(
'Rides',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
ButtonSegment<ActivityTab>(
value: ActivityTab.records,
icon: Icon(Icons.emoji_events_outlined, size: 16),
label: Text(
'Records',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
selected: {_currentTab},
onSelectionChanged: (selection) {
if (selection.isNotEmpty) {
setState(() {
_currentTab = selection.first;
});
}
},
style: ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
side: WidgetStateProperty.all(
BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
backgroundColor: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.selected)) {
return colors.cyberCyan.withValues(alpha: isDark ? 0.16 : 0.12);
}
return isDark ? colors.surfaceContainer : Colors.transparent;
}),
foregroundColor: WidgetStateProperty.resolveWith<Color?>((states) {
if (states.contains(WidgetState.selected)) {
return colors.cyberCyan;
}
return isDark ? Colors.white70 : Colors.black87;
}),
),
),
),
),
),
),
body: IndexedStack(
index: _currentTab == ActivityTab.rides ? 0 : 1,
children: const [
HistoryScreen(isEmbedded: true),
RecordsScreen(isEmbedded: true),
],
),
);
}
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
@ -22,6 +22,8 @@ class GPSStatusIndicator extends ConsumerWidget {
telemetryStreamProvider.select((s) => s.value?.gpsAccuracy),
);
final theme = Theme.of(context);
final IconData icon;
final Color color;
final String label;
@ -29,7 +31,7 @@ class GPSStatusIndicator extends ConsumerWidget {
if (gpsAcc != null) {
if (gpsAcc <= 15.0) {
icon = Icons.gps_fixed;
color = AppColors.cyberCyan;
color = AppColors.telemetryGreen;
label = 'GPS 3D FIX';
} else if (gpsAcc <= 35.0) {
icon = Icons.gps_fixed;
@ -42,7 +44,7 @@ class GPSStatusIndicator extends ConsumerWidget {
}
} else {
icon = Icons.gps_off;
color = Colors.white38;
color = theme.colorScheme.onSurfaceVariant;
label = 'NO GPS';
}

View File

@ -38,8 +38,8 @@ class HeadingIndicator extends ConsumerWidget {
headingStr,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 16,
fontWeight: FontWeight.w800,
fontSize: 14,
fontWeight: FontWeight.w700,
color: cardinalColor,
),
),
@ -48,8 +48,8 @@ class HeadingIndicator extends ConsumerWidget {
'${heading.toStringAsFixed(0)}°',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 12,
fontWeight: FontWeight.w700,
fontSize: 11,
fontWeight: FontWeight.w600,
color: degreesColor,
),
),
@ -66,18 +66,18 @@ class HeadingIndicator extends ConsumerWidget {
headingStr,
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 26,
fontWeight: FontWeight.w900,
fontSize: 20,
fontWeight: FontWeight.w700,
color: cardinalColor,
),
),
const SizedBox(width: 6),
const SizedBox(width: 5),
Text(
'${heading.toStringAsFixed(0)}°',
style: TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 16,
fontWeight: FontWeight.w700,
fontSize: 14,
fontWeight: FontWeight.w600,
color: degreesColor,
),
),

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
@ -31,11 +31,11 @@ class RideActionButton extends ConsumerWidget {
if (isRiding) {
buttonColor = AppColors.raceRed;
label = 'STOP SESSION';
label = 'Stop Session';
icon = Icons.stop_circle_outlined;
} else {
buttonColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
label = 'START SESSION';
label = 'Start Session';
icon = Icons.play_circle_outlined;
}
@ -46,7 +46,7 @@ class RideActionButton extends ConsumerWidget {
style: ElevatedButton.styleFrom(
backgroundColor: buttonColor.withValues(alpha: isRiding ? 0.16 : 0.12),
foregroundColor: buttonColor,
side: BorderSide(color: buttonColor, width: 2.0),
side: BorderSide(color: buttonColor, width: 1.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
@ -66,8 +66,8 @@ class RideActionButton extends ConsumerWidget {
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: compact ? 15 : 18,
fontWeight: FontWeight.w800,
letterSpacing: 1.5,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
),

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
@ -20,6 +20,9 @@ class RideStatusChip extends ConsumerWidget {
final isRecording = state == RideState.riding;
final isPaused = state == RideState.paused;
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final Color badgeColor;
final String label;
@ -30,10 +33,10 @@ class RideStatusChip extends ConsumerWidget {
badgeColor = AppColors.warningAmber;
label = 'PAUSED';
} else if (state == RideState.ready) {
badgeColor = AppColors.cyberCyan;
badgeColor = isDark ? AppColors.cyberCyan : AppColors.lightPrimary;
label = 'READY';
} else {
badgeColor = Colors.white54;
badgeColor = theme.colorScheme.onSurfaceVariant;
label = 'STANDBY';
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
@ -87,14 +87,6 @@ class SpeedReadout extends ConsumerWidget {
fontStyle: FontStyle.italic,
height: 0.95,
color: speedColor,
shadows: isDark
? [
Shadow(
color: AppColors.cyberCyan.withValues(alpha: 0.4),
blurRadius: 16,
),
]
: null,
),
),
),

View File

@ -82,8 +82,8 @@ class GarageScreen extends ConsumerWidget {
foregroundColor: Colors.black,
icon: const Icon(Icons.two_wheeler),
label: const Text(
'ADD MOTORCYCLE',
style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 0.8),
'Add Motorcycle',
style: TextStyle(fontWeight: FontWeight.w600, letterSpacing: 0.2),
),
onPressed: () => _onAddMotorcycle(context, ref, bikesAsync.value),
)
@ -127,12 +127,12 @@ class GarageScreen extends ConsumerWidget {
),
const SizedBox(height: 24),
Text(
'YOUR GARAGE IS EMPTY',
'Your Garage is Empty',
style: TextStyle(
fontFamily: 'Sora',
fontSize: 18,
fontWeight: FontWeight.w900,
letterSpacing: 1.2,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: theme.colorScheme.onSurface,
),
),
@ -156,15 +156,15 @@ class GarageScreen extends ConsumerWidget {
vertical: 14,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(10),
),
),
icon: const Icon(Icons.add),
label: const Text(
'ADD MOTORCYCLE',
'Add Motorcycle',
style: TextStyle(
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
),
onPressed: () => context.push('/garage/add'),
@ -204,9 +204,9 @@ class GarageScreen extends ConsumerWidget {
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: bike.isDefault
? colors.cyberCyan
? colors.cyberCyan.withValues(alpha: 0.6)
: colors.outlineVariant,
width: bike.isDefault ? 1.8 : 1.0,
width: bike.isDefault ? 1.0 : 0.8,
),
),
child: Material(
@ -242,7 +242,7 @@ class GarageScreen extends ConsumerWidget {
style: TextStyle(
fontFamily: 'Sora',
fontSize: 16,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
@ -251,23 +251,24 @@ class GarageScreen extends ConsumerWidget {
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
vertical: 3,
),
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
color: colors.cyberCyan.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: colors.cyberCyan,
color: colors.cyberCyan.withValues(alpha: 0.4),
width: 0.8,
),
),
child: Text(
'CURRENT',
'Current',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 10,
fontWeight: FontWeight.w900,
fontWeight: FontWeight.w600,
color: colors.cyberCyan,
letterSpacing: 0.5,
letterSpacing: 0.3,
),
),
),
@ -282,7 +283,7 @@ class GarageScreen extends ConsumerWidget {
fontFamily: 'JetBrains Mono',
color: colors.cyberCyan,
fontSize: 12.5,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
),
),
],

View File

@ -9,7 +9,12 @@ import '../garage/garage_providers.dart';
import 'ride_detail_screen.dart';
class HistoryScreen extends ConsumerStatefulWidget {
const HistoryScreen({super.key});
final bool isEmbedded;
const HistoryScreen({
super.key,
this.isEmbedded = false,
});
@override
ConsumerState<HistoryScreen> createState() => _HistoryScreenState();
@ -26,7 +31,9 @@ class _HistoryScreenState extends ConsumerState<HistoryScreen> {
final colors = context.motorColors;
return Scaffold(
appBar: AppBar(
appBar: widget.isEmbedded
? null
: AppBar(
title: const Text(
'Ride History',
style: TextStyle(fontWeight: FontWeight.bold),

View File

@ -380,12 +380,12 @@ class _SetupView extends StatelessWidget {
),
onPressed: onArm,
child: const Text(
'ARM LAUNCH',
'Arm Launch',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 16,
fontWeight: FontWeight.w900,
letterSpacing: 1.2,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
),
@ -541,7 +541,7 @@ class _WaitingView extends ConsumerWidget {
onPressed: () {
ref.read(performanceLaunchProvider.notifier).reset();
},
child: const Text('CANCEL'),
child: const Text('Cancel'),
),
],
),
@ -644,12 +644,12 @@ class _ReadyView extends ConsumerWidget {
ref.read(performanceLaunchProvider.notifier).startCountdown();
},
child: const Text(
'START COUNTDOWN',
'Start Countdown',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 17,
fontWeight: FontWeight.w900,
letterSpacing: 1.2,
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
),
@ -773,7 +773,7 @@ class _CountdownViewState extends ConsumerState<_CountdownView>
onPressed: () {
ref.read(performanceLaunchProvider.notifier).cancel();
},
child: const Text('ABORT'),
child: const Text('Abort'),
),
],
),
@ -1316,7 +1316,7 @@ class _ResultView extends ConsumerWidget {
onPressed: () {
ref.read(performanceLaunchProvider.notifier).reset();
},
child: const Text('TRY AGAIN'),
child: const Text('Try Again'),
),
),
),
@ -1338,7 +1338,7 @@ class _ResultView extends ConsumerWidget {
Navigator.of(context).pop();
}
},
child: const Text('DONE'),
child: const Text('Done'),
),
),
),
@ -1436,7 +1436,7 @@ class _AbortedView extends ConsumerWidget {
onPressed: () {
ref.read(performanceLaunchProvider.notifier).reset();
},
child: const Text('TRY AGAIN'),
child: const Text('Try Again'),
),
),
],

View File

@ -7,7 +7,12 @@ import '../../core/theme/theme_extensions.dart';
import 'records_service.dart';
class RecordsScreen extends ConsumerWidget {
const RecordsScreen({super.key});
final bool isEmbedded;
const RecordsScreen({
super.key,
this.isEmbedded = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -15,7 +20,9 @@ class RecordsScreen extends ConsumerWidget {
final colors = context.motorColors;
return Scaffold(
appBar: AppBar(
appBar: isEmbedded
? null
: AppBar(
title: const Text(
'Personal Records',
style: TextStyle(fontWeight: FontWeight.bold),
@ -42,11 +49,11 @@ class RecordsScreen extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('SPEED', colors),
_buildSectionHeader('Speed', colors),
const SizedBox(height: 8),
_buildHeroTopSpeedCard(context, records.topSpeedMph, colors),
const SizedBox(height: 16),
_buildSectionHeader('PERFORMANCE TIMES', colors),
_buildSectionHeader('Performance Times', colors),
const SizedBox(height: 8),
_buildPerformanceTimesCard(context, records, colors),
],
@ -57,11 +64,11 @@ class RecordsScreen extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('HANDLING & DYNAMICS', colors),
_buildSectionHeader('Handling & Dynamics', colors),
const SizedBox(height: 8),
_buildHandlingCard(context, records, colors),
const SizedBox(height: 16),
_buildSectionHeader('ENDURANCE & ROUTE', colors),
_buildSectionHeader('Endurance & Route', colors),
const SizedBox(height: 8),
_buildEnduranceCard(context, records, colors),
],
@ -77,19 +84,19 @@ class RecordsScreen extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('SPEED', colors),
_buildSectionHeader('Speed', colors),
const SizedBox(height: 8),
_buildHeroTopSpeedCard(context, records.topSpeedMph, colors),
const SizedBox(height: 18),
_buildSectionHeader('PERFORMANCE TIMES', colors),
_buildSectionHeader('Performance Times', colors),
const SizedBox(height: 8),
_buildPerformanceTimesCard(context, records, colors),
const SizedBox(height: 18),
_buildSectionHeader('HANDLING & DYNAMICS', colors),
_buildSectionHeader('Handling & Dynamics', colors),
const SizedBox(height: 8),
_buildHandlingCard(context, records, colors),
const SizedBox(height: 18),
_buildSectionHeader('ENDURANCE & ROUTE', colors),
_buildSectionHeader('Endurance & Route', colors),
const SizedBox(height: 8),
_buildEnduranceCard(context, records, colors),
const SizedBox(height: 24),
@ -246,12 +253,12 @@ class RecordsScreen extends ConsumerWidget {
key: const ValueKey('btn_open_launch_mode'),
icon: Icon(Icons.rocket_launch, size: 16, color: colors.launchGreen),
label: Text(
'PERFORMANCE LAUNCH MODE',
'Performance Launch Mode',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
color: colors.launchGreen,
),
),

View File

@ -97,8 +97,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_buildSectionHeader('Account & Cloud Identity'),
const SizedBox(height: 10),
_buildAccountCard(context),
const SizedBox(height: 12),
_buildRiderSocialLinks(context),
const SizedBox(height: 24),
_buildSectionHeader('Appearance & Display Theme'),
const SizedBox(height: 10),
@ -165,8 +163,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
_buildSectionHeader('Account & Cloud Identity'),
const SizedBox(height: 10),
_buildAccountCard(context),
const SizedBox(height: 12),
_buildRiderSocialLinks(context),
const SizedBox(height: 24),
_buildSectionHeader('Appearance & Display Theme'),
const SizedBox(height: 10),
@ -208,12 +204,12 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Widget _buildSectionHeader(String title) {
return Text(
title.toUpperCase(),
title,
style: TextStyle(
fontFamily: 'Sora',
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: context.motorColors.cyberCyan,
),
);
@ -310,97 +306,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
}
Widget _buildRiderSocialLinks(BuildContext context) {
final colors = context.motorColors;
return LayoutBuilder(
builder: (context, constraints) {
final useRow = constraints.maxWidth > 340;
final buttons = [
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
side: BorderSide(color: colors.outlineVariant),
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: const Icon(Icons.person_outline, size: 16),
label: const FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'PROFILE',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
),
onPressed: () => context.push('/settings/profile'),
),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
side: BorderSide(color: colors.outlineVariant),
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: const Icon(Icons.people_outline, size: 16),
label: const FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'FRIENDS',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
),
onPressed: () => context.push('/settings/friends'),
),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: colors.cyberCyan,
side: BorderSide(color: colors.cyberCyan.withValues(alpha: 0.6)),
minimumSize: const Size(0, 48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
icon: const Icon(Icons.groups_outlined, size: 16),
label: const FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'GROUP RIDES',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
),
onPressed: () => context.push('/group-rides'),
),
];
if (useRow) {
return Row(
children: [
Expanded(child: buttons[0]),
const SizedBox(width: 8),
Expanded(child: buttons[1]),
const SizedBox(width: 8),
Expanded(child: buttons[2]),
],
);
}
return Wrap(
spacing: 8,
runSpacing: 8,
children: buttons
.map((b) => SizedBox(
width: (constraints.maxWidth - 8) / 2,
child: b,
))
.toList(),
);
},
);
}
Widget _buildAppearanceCard(BuildContext context) {
final colors = context.motorColors;
return Card(

View File

@ -117,24 +117,24 @@ class CurrentPlanCard extends ConsumerWidget {
Icon(
isPro ? Icons.verified : Icons.shield_outlined,
color: isPro ? colors.cyberCyan : colors.outline,
size: 20,
size: 18,
),
const SizedBox(width: 10),
const SizedBox(width: 8),
Text(
planTitle.toUpperCase(),
planTitle,
style: TextStyle(
fontFamily: 'Sora',
fontSize: 13,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
color: isPro ? colors.cyberCyan : Theme.of(context).colorScheme.onSurface,
letterSpacing: 0.5,
letterSpacing: 0.2,
),
),
if (isPro) ...[const SizedBox(width: 8), const ProBadge()],
if (isPro) ...[const SizedBox(width: 6), const ProBadge()],
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2.5),
decoration: BoxDecoration(
color: badgeColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
@ -148,7 +148,7 @@ class CurrentPlanCard extends ConsumerWidget {
style: TextStyle(
fontFamily: 'Inter',
fontSize: 9,
fontWeight: FontWeight.w900,
fontWeight: FontWeight.w700,
color: badgeColor,
letterSpacing: 0.5,
),
@ -167,8 +167,8 @@ class CurrentPlanCard extends ConsumerWidget {
),
const SizedBox(height: 16),
Wrap(
spacing: 10,
runSpacing: 10,
spacing: 8,
runSpacing: 8,
children: [
if (!isPro)
ElevatedButton(
@ -176,19 +176,19 @@ class CurrentPlanCard extends ConsumerWidget {
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
minimumSize: const Size(140, 48),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
minimumSize: const Size(130, 44),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(10),
),
),
onPressed: () => context.push('/settings/pro'),
child: const Text(
'UPGRADE TO PRO',
'Upgrade to Pro',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
fontSize: 11.5,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
@ -197,18 +197,18 @@ class CurrentPlanCard extends ConsumerWidget {
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
side: BorderSide(color: colors.outlineVariant),
minimumSize: const Size(160, 48),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
minimumSize: const Size(140, 44),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(10),
),
),
onPressed: _openGooglePlaySubscriptions,
child: const Text(
'MANAGE SUBSCRIPTION',
'Manage Subscription',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
fontSize: 11.5,
fontWeight: FontWeight.w600,
),
),
),
@ -217,16 +217,16 @@ class CurrentPlanCard extends ConsumerWidget {
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
side: BorderSide(color: colors.outlineVariant),
minimumSize: const Size(100, 48),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
minimumSize: const Size(90, 44),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(10),
),
),
onPressed: () => context.push('/settings/pro'),
child: Text(
isPro ? 'VIEW PLANS' : 'DETAILS',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
isPro ? 'View Plans' : 'Details',
style: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.w600),
),
),
],

View File

@ -0,0 +1,326 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/responsive/responsive_layout.dart';
import '../../core/theme/app_typography.dart';
import '../../core/theme/theme_extensions.dart';
import '../../services/auth/auth_providers.dart';
import '../../services/social/social_providers.dart';
import 'widgets/avatar_badge.dart';
/// The authoritative landing screen for MotorSignal Social & Community.
///
/// Unifies Rider Profile, Friends, and Group Rides under a dedicated,
/// restrained automotive social home completely decoupled from Settings.
class SocialHubScreen extends ConsumerWidget {
const SocialHubScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colors = context.motorColors;
final user = ref.watch(currentUserProvider);
final profileAsync = ref.watch(myRiderProfileProvider);
final requestsAsync = ref.watch(myIncomingRequestsProvider);
final pendingCount = requestsAsync.value?.length ?? 0;
return Scaffold(
appBar: AppBar(
title: const Text(
'Social',
style: TextStyle(fontWeight: FontWeight.w700),
),
),
body: SafeArea(
child: ResponsiveLayout(
builder: (context, responsive) {
final isWide = responsive.isExpanded || (responsive.isMedium && responsive.isLandscape);
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Rider Identity Header Card
_buildRiderHeroCard(context, profileAsync.value, user, colors),
const SizedBox(height: 18),
Text(
'Rider Network',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: colors.cyberCyan,
),
),
const SizedBox(height: 10),
if (isWide) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildNavigationCard(
context,
key: const ValueKey('social_card_profile'),
icon: Icons.badge_outlined,
title: 'Rider Profile',
subtitle: 'Manage public handle, bio, region & visibility preferences',
colors: colors,
onTap: () => context.push('/social/profile'),
),
),
const SizedBox(width: 14),
Expanded(
child: _buildNavigationCard(
context,
key: const ValueKey('social_card_friends'),
icon: Icons.people_outline,
title: 'Friends',
subtitle: 'Find fellow riders, manage requests & connections',
badgeCount: pendingCount,
colors: colors,
onTap: () => context.push('/social/friends'),
),
),
],
),
const SizedBox(height: 14),
_buildNavigationCard(
context,
key: const ValueKey('social_card_group_rides'),
icon: Icons.groups_outlined,
title: 'Group Rides',
subtitle: 'Join active sessions, explore local pack rides & track group telemetry',
colors: colors,
onTap: () => context.push('/group-rides'),
),
] else ...[
_buildNavigationCard(
context,
key: const ValueKey('social_card_profile'),
icon: Icons.badge_outlined,
title: 'Rider Profile',
subtitle: 'Manage public handle, bio, region & visibility preferences',
colors: colors,
onTap: () => context.push('/social/profile'),
),
const SizedBox(height: 12),
_buildNavigationCard(
context,
key: const ValueKey('social_card_friends'),
icon: Icons.people_outline,
title: 'Friends',
subtitle: 'Find fellow riders, manage requests & connections',
badgeCount: pendingCount,
colors: colors,
onTap: () => context.push('/social/friends'),
),
const SizedBox(height: 12),
_buildNavigationCard(
context,
key: const ValueKey('social_card_group_rides'),
icon: Icons.groups_outlined,
title: 'Group Rides',
subtitle: 'Join active sessions, explore local pack rides & track group telemetry',
colors: colors,
onTap: () => context.push('/group-rides'),
),
],
const SizedBox(height: 24),
],
),
),
),
);
},
),
),
);
}
Widget _buildRiderHeroCard(
BuildContext context,
dynamic profile,
dynamic user,
MotorSignalThemeColors colors,
) {
final hasProfile = profile != null;
final displayName = hasProfile ? profile.displayName : (user?.displayLabel ?? 'Rider');
final handle = hasProfile ? '@${profile.handle}' : 'Tap to setup rider profile';
final avatarPreset = hasProfile ? profile.avatarPreset : null;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: colors.outlineVariant, width: 0.8),
),
child: Row(
children: [
AvatarBadge(
avatarPreset: avatarPreset,
displayName: displayName,
radius: 22,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
handle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 11.5,
fontWeight: FontWeight.w500,
color: colors.cyberCyan,
),
),
],
),
),
const SizedBox(width: 8),
OutlinedButton(
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
side: BorderSide(color: colors.outlineVariant, width: 0.8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
onPressed: () => context.push('/social/profile'),
child: const Text(
'Edit',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
Widget _buildNavigationCard(
BuildContext context, {
required Key key,
required IconData icon,
required String title,
required String subtitle,
required MotorSignalThemeColors colors,
required VoidCallback onTap,
int badgeCount = 0,
}) {
return Container(
key: key,
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: colors.outlineVariant, width: 0.8),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colors.cyberCyan.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: colors.cyberCyan, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
if (badgeCount > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: colors.cyberCyan,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$badgeCount',
style: const TextStyle(
fontFamily: AppTypography.fontTelemetry,
fontSize: 10,
fontWeight: FontWeight.w800,
color: Colors.black,
),
),
),
],
],
),
const SizedBox(height: 3),
Text(
subtitle,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
height: 1.3,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
size: 20,
),
],
),
),
),
),
);
}
}

View File

@ -0,0 +1,251 @@
import 'dart:convert';
import 'package:drift/drift.dart' as drift;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:motorcycle_dash/database/database.dart';
import 'package:motorcycle_dash/features/activity/activity_screen.dart';
import 'package:motorcycle_dash/features/garage/garage_providers.dart';
import 'package:motorcycle_dash/features/history/history_screen.dart';
import 'package:motorcycle_dash/features/records/records_screen.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/models/motorcycle_models.dart';
import 'package:motorcycle_dash/services/github/github_sync_service.dart';
import 'package:motorcycle_dash/services/motorcycle/motorcycle_repository.dart';
class _FakeGitHubSyncService extends ChangeNotifier implements GitHubSyncService {
@override
GitHubUploadState get state => GitHubUploadState.idle;
@override
String get statusMessage => '';
@override
Set<int> get queuedRideIds => const {};
@override
bool isRideUploaded(int rideId) => false;
@override
String? getRideCommitSha(int rideId) => null;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeMotorcycleRepository implements MotorcycleRepository {
@override
Future<List<MotorcycleYear>> getYears() async => [MotorcycleYear(2024)];
@override
Future<List<MotorcycleMake>> getMakes(int year) async => [MotorcycleMake(id: 'yamaha', name: 'Yamaha')];
@override
Future<List<MotorcycleModel>> getModels(int year, String makeId) async => [MotorcycleModel(id: 'r1', name: 'YZF-R1', makeId: makeId)];
@override
Future<List<MotorcycleVariant>> getVariants(int year, String makeId, String modelId) async => [MotorcycleVariant(id: 'std', name: 'Standard')];
@override
Future<MotorcycleSpecification?> getSpecifications(MotorcycleSelection selection) async => null;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late SharedPreferences prefs;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
SharedPreferences.setMockInitialValues({
'topSpeedMph': 120.0,
'fastest0To60': 3.5,
'fastest0To100': 7.2,
'maxLeanLeft': 42.0,
'maxLeanRight': 44.0,
});
prefs = await SharedPreferences.getInstance();
final spec = MotorcycleSpecification(
year: 2024,
manufacturer: 'Yamaha',
model: 'YZF-R1',
displacementCc: 998,
horsepower: 198,
torqueLbFt: 83.0,
wetWeightLbs: 448,
claimedTopSpeedMph: 186,
claimedZeroToSixtySec: 2.8,
);
await db.into(db.garageMotorcycles).insert(
GarageMotorcyclesCompanion.insert(
year: 2024,
makeId: 'yamaha',
makeName: 'Yamaha',
modelId: 'r1',
modelName: 'YZF-R1',
nickname: const drift.Value('Blue Thunder'),
isDefault: const drift.Value(true),
dateAdded: DateTime.now(),
lastUpdated: DateTime.now(),
specificationJson: drift.Value(json.encode(spec.toJson())),
),
);
await db.into(db.rides).insert(
RidesCompanion.insert(
startTime: DateTime(2026, 1, 1, 10, 0),
endTime: drift.Value(DateTime(2026, 1, 1, 10, 45)),
distanceMiles: const drift.Value(25.5),
topSpeedMph: const drift.Value(65.0),
movingTime: const drift.Value(2400),
stoppedTime: const drift.Value(300),
distance: const drift.Value(25.5),
topSpeed: const drift.Value(65.0),
averageSpeed: const drift.Value(42.0),
elevationGain: const drift.Value(350.0),
elevationLoss: const drift.Value(300.0),
maxLeanLeft: const drift.Value(34.0),
maxLeanRight: const drift.Value(36.0),
maxAccelG: const drift.Value(0.75),
maxBrakingG: const drift.Value(0.85),
motorcycleId: const drift.Value(1),
motorcycleYear: const drift.Value(2024),
motorcycleMake: const drift.Value('Yamaha'),
motorcycleModel: const drift.Value('YZF-R1'),
),
);
});
tearDown(() async {
await db.close();
});
Widget buildTestActivityScreen({
ActivityTab initialTab = ActivityTab.rides,
ThemeData? theme,
Size size = const Size(400, 800),
}) {
return ProviderScope(
overrides: [
databaseProvider.overrideWithValue(db),
sharedPreferencesProvider.overrideWithValue(prefs),
githubSyncServiceProvider.overrideWith((ref) => _FakeGitHubSyncService()),
motorcycleRepositoryProvider.overrideWith((ref) => _FakeMotorcycleRepository()),
],
child: MaterialApp(
theme: theme ?? AppTheme.darkTheme,
home: MediaQuery(
data: MediaQueryData(size: size),
child: ActivityScreen(initialTab: initialTab),
),
),
);
}
group('ActivityScreen Widget & Presentation Tests', () {
testWidgets('Renders segmented button with Rides and Records tabs', (tester) async {
await tester.pumpWidget(buildTestActivityScreen());
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(find.byType(SegmentedButton<ActivityTab>), findsOneWidget);
expect(find.text('Rides'), findsOneWidget);
expect(find.text('Records'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('Defaults to Rides tab hosting embedded HistoryScreen without standalone AppBar', (tester) async {
await tester.pumpWidget(buildTestActivityScreen());
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(find.byType(HistoryScreen), findsOneWidget);
expect(find.byType(RecordsScreen), findsNothing);
// Embedded history screen suppresses standalone AppBar
final historyFinder = find.byType(HistoryScreen);
expect(find.descendant(of: historyFinder, matching: find.byType(AppBar)), findsNothing);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('Switches to Records tab when tapped', (tester) async {
await tester.pumpWidget(buildTestActivityScreen());
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(find.byType(HistoryScreen), findsOneWidget);
expect(find.byType(RecordsScreen), findsNothing);
// Tap Records segment
await tester.tap(find.text('Records'));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(find.byType(RecordsScreen), findsOneWidget);
expect(find.byType(HistoryScreen), findsNothing);
// Tap Rides segment back
await tester.tap(find.text('Rides'));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(find.byType(HistoryScreen), findsOneWidget);
expect(find.byType(RecordsScreen), findsNothing);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('Initializes directly to Records tab when requested', (tester) async {
await tester.pumpWidget(buildTestActivityScreen(initialTab: ActivityTab.records));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(find.byType(RecordsScreen), findsOneWidget);
expect(find.byType(HistoryScreen), findsNothing);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('Renders cleanly under Light Theme without exceptions', (tester) async {
await tester.pumpWidget(buildTestActivityScreen(theme: AppTheme.lightTheme));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byType(ActivityScreen), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
});
testWidgets('Adapts cleanly across multiple viewports without overflow', (tester) async {
const viewports = [
Size(320, 640),
Size(412, 915),
Size(800, 360),
Size(1024, 768),
];
for (final vp in viewports) {
tester.view.physicalSize = vp;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(buildTestActivityScreen(size: vp));
await tester.pump(const Duration(milliseconds: 50));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull, reason: 'No overflow on $vp');
expect(find.byType(ActivityScreen), findsOneWidget);
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(milliseconds: 100));
}
});
});
}

View File

@ -118,7 +118,7 @@ void main() {
expect(tester.takeException(), isNull);
expect(find.byType(SpeedReadout), findsOneWidget);
expect(find.text('START SESSION'), findsOneWidget);
expect(find.text('Start Session'), findsOneWidget);
});
testWidgets('Renders properly in Dark Mode', (tester) async {
@ -130,7 +130,7 @@ void main() {
expect(tester.takeException(), isNull);
expect(find.byType(SpeedReadout), findsOneWidget);
expect(find.text('START SESSION'), findsOneWidget);
expect(find.text('Start Session'), findsOneWidget);
});
});
@ -223,7 +223,7 @@ void main() {
await tester.tap(find.byType(RideActionButton));
await tester.pump();
expect(find.text('STOP SESSION'), findsOneWidget);
expect(find.text('Stop Session'), findsOneWidget);
expect(find.text('RECORDING'), findsOneWidget);
// Rotate to landscape
@ -231,7 +231,7 @@ void main() {
await tester.pump();
// Verify ride did NOT stop or reset
expect(find.text('STOP SESSION'), findsOneWidget);
expect(find.text('Stop Session'), findsOneWidget);
expect(find.text('RECORDING'), findsOneWidget);
});

View File

@ -17,9 +17,9 @@ void main() {
final router = GoRouter(
initialLocation: switch (initialIndex) {
1 => '/map',
2 => '/history',
3 => '/records',
4 => '/garage',
2 => '/activity',
3 => '/garage',
4 => '/social',
5 => '/settings',
_ => '/',
},
@ -52,19 +52,9 @@ void main() {
StatefulShellBranch(
routes: [
GoRoute(
path: '/history',
path: '/activity',
builder: (context, state) => const Scaffold(
body: Center(child: Text('History Screen Content')),
),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/records',
builder: (context, state) => const Scaffold(
body: Center(child: Text('Records Screen Content')),
body: Center(child: Text('Activity Screen Content')),
),
),
],
@ -79,6 +69,16 @@ void main() {
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/social',
builder: (context, state) => const Scaffold(
body: Center(child: Text('Social Screen Content')),
),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
@ -131,9 +131,9 @@ void main() {
// Verify all 6 navigation labels exist regardless of layout
expect(find.text('Dash'), findsOneWidget);
expect(find.text('Map'), findsOneWidget);
expect(find.text('History'), findsOneWidget);
expect(find.text('Records'), findsOneWidget);
expect(find.text('Activity'), findsOneWidget);
expect(find.text('Garage'), findsOneWidget);
expect(find.text('Social'), findsOneWidget);
expect(find.text('Settings'), findsOneWidget);
});
}
@ -223,10 +223,10 @@ void main() {
expect(find.text('Map Screen Content'), findsOneWidget);
await tester.tap(find.text('Records'));
await tester.tap(find.text('Activity'));
await tester.pumpAndSettle();
expect(find.text('Records Screen Content'), findsOneWidget);
expect(find.text('Activity Screen Content'), findsOneWidget);
});
testWidgets('NavigationRail touch targets have adequate interactive hit bounds', (tester) async {
@ -257,29 +257,29 @@ void main() {
await tester.pumpWidget(buildTestRouter(surfaceSize: const Size(400, 800)));
await tester.pumpAndSettle();
// Navigate to History
await tester.tap(find.text('History'));
// Navigate to Activity
await tester.tap(find.text('Activity'));
await tester.pumpAndSettle();
expect(find.text('History Screen Content'), findsOneWidget);
expect(find.text('Activity Screen Content'), findsOneWidget);
// Rotate / Resize to short landscape (800x360)
tester.view.physicalSize = const Size(800, 360);
await tester.pumpAndSettle();
// Shell adapts to NavigationRail, while maintaining History selection!
// Shell adapts to NavigationRail, while maintaining Activity selection!
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.text('History Screen Content'), findsOneWidget);
expect(find.text('Activity Screen Content'), findsOneWidget);
// Resize to large tablet (1024x768)
tester.view.physicalSize = const Size(1024, 768);
await tester.pumpAndSettle();
expect(find.text('History Screen Content'), findsOneWidget);
expect(find.text('Activity Screen Content'), findsOneWidget);
// Resize back to portrait (320x640)
tester.view.physicalSize = const Size(320, 640);
await tester.pumpAndSettle();
expect(find.byType(BottomNavigationBar), findsOneWidget);
expect(find.text('History Screen Content'), findsOneWidget);
expect(find.text('Activity Screen Content'), findsOneWidget);
});
testWidgets('Renders cleanly under Light Theme without visual regressions', (tester) async {

View File

@ -419,6 +419,17 @@ void main() {
telemetryController.add(makeSample(speed: 5.0, monotonicMs: 3750));
await tester.pump(const Duration(milliseconds: 20));
// Timing continues actively despite false start
expect(stateMachine.state.state, PerformanceLaunchState.launched);
expect(stateMachine.state.isFalseStart, isTrue);
expect(find.byKey(const ValueKey('speed_readout')), findsOneWidget);
expect(find.byKey(const ValueKey('elapsed_timer_readout')), findsOneWidget);
// Interim sample while timing
telemetryController.add(makeSample(speed: 35.0, monotonicMs: 5000));
await tester.pump(const Duration(milliseconds: 20));
expect(stateMachine.state.state, PerformanceLaunchState.launched);
// Reaches 60 at t=6950ms
telemetryController.add(makeSample(speed: 62.0, monotonicMs: 6950));
await tester.pump(const Duration(milliseconds: 20));
@ -426,6 +437,7 @@ void main() {
expect(stateMachine.state.state, PerformanceLaunchState.completed);
expect(stateMachine.state.lastResult!.isFalseStart, isTrue);
expect(stateMachine.state.lastResult!.accelerationTimeSec, closeTo(3.20, 0.05));
// Verify false start badge and negative reaction
expect(find.text('FALSE START'), findsOneWidget);

View File

@ -167,9 +167,9 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('MOTORSIGNAL FREE'), findsOneWidget);
expect(find.text('MotorSignal Free'), findsOneWidget);
expect(find.text('FREE'), findsOneWidget);
expect(find.text('UPGRADE TO PRO'), findsOneWidget);
expect(find.text('Upgrade to Pro'), findsOneWidget);
expect(
find.text('Basic telemetry & local recording active'),
findsOneWidget,
@ -193,8 +193,8 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('MOTORSIGNAL PRO'), findsOneWidget);
expect(find.text('MANAGE SUBSCRIPTION'), findsOneWidget);
expect(find.text('MotorSignal Pro'), findsOneWidget);
expect(find.text('Manage Subscription'), findsOneWidget);
expect(find.text('MONTHLY'), findsOneWidget);
});
@ -214,7 +214,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('MOTORSIGNAL PRO'), findsOneWidget);
expect(find.text('MotorSignal Pro'), findsOneWidget);
expect(
find.text('Lifetime access • Permanent ownership'),
findsOneWidget,
@ -247,7 +247,7 @@ void main() {
find.text('Payment grace period • Action needed in Google Play'),
findsOneWidget,
);
expect(find.text('MANAGE SUBSCRIPTION'), findsOneWidget);
expect(find.text('Manage Subscription'), findsOneWidget);
});
testWidgets('renders on narrow viewport without overflow', (tester) async {

View File

@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:motorcycle_dash/core/theme/theme.dart';
import 'package:motorcycle_dash/features/social/social_hub_screen.dart';
import 'package:motorcycle_dash/features/social/widgets/avatar_badge.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Widget buildTestSocialHub({
ThemeData? theme,
Size size = const Size(400, 800),
GoRouter? router,
}) {
final testRouter = router ??
GoRouter(
initialLocation: '/social',
routes: [
GoRoute(
path: '/social',
builder: (context, state) => const SocialHubScreen(),
routes: [
GoRoute(
path: 'profile',
builder: (context, state) => const Scaffold(body: Center(child: Text('Profile Target'))),
),
GoRoute(
path: 'friends',
builder: (context, state) => const Scaffold(body: Center(child: Text('Friends Target'))),
),
],
),
GoRoute(
path: '/group-rides',
builder: (context, state) => const Scaffold(body: Center(child: Text('Group Rides Target'))),
),
GoRoute(
path: '/garage',
builder: (context, state) => const Scaffold(body: Center(child: Text('Garage Target'))),
),
],
);
return ProviderScope(
child: MaterialApp.router(
theme: theme ?? AppTheme.darkTheme,
routerConfig: testRouter,
),
);
}
group('SocialHubScreen Widget Tests', () {
testWidgets('Renders Rider Hero Card with avatar and handle', (tester) async {
await tester.pumpWidget(buildTestSocialHub());
await tester.pumpAndSettle();
expect(find.text('Social'), findsOneWidget);
expect(find.text('Rider'), findsOneWidget);
expect(find.byType(AvatarBadge), findsOneWidget);
});
testWidgets('Renders all social destination cards', (tester) async {
await tester.pumpWidget(buildTestSocialHub());
await tester.pumpAndSettle();
expect(find.text('Rider Profile'), findsOneWidget);
expect(find.text('Friends'), findsOneWidget);
expect(find.text('Group Rides'), findsOneWidget);
});
testWidgets('Navigates to Rider Profile when profile card is tapped', (tester) async {
await tester.pumpWidget(buildTestSocialHub());
await tester.pumpAndSettle();
await tester.tap(find.text('Rider Profile'));
await tester.pumpAndSettle();
expect(find.text('Profile Target'), findsOneWidget);
});
testWidgets('Navigates to Friends when friends card is tapped', (tester) async {
await tester.pumpWidget(buildTestSocialHub());
await tester.pumpAndSettle();
await tester.tap(find.text('Friends'));
await tester.pumpAndSettle();
expect(find.text('Friends Target'), findsOneWidget);
});
testWidgets('Navigates to Group Rides when group rides card is tapped', (tester) async {
await tester.pumpWidget(buildTestSocialHub());
await tester.pumpAndSettle();
await tester.tap(find.text('Group Rides'));
await tester.pumpAndSettle();
expect(find.text('Group Rides Target'), findsOneWidget);
});
testWidgets('Renders cleanly under Light Theme without errors', (tester) async {
await tester.pumpWidget(buildTestSocialHub(theme: AppTheme.lightTheme));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Social'), findsOneWidget);
});
testWidgets('Adapts to multiple responsive viewports without overflow', (tester) async {
const viewports = [
Size(320, 640),
Size(412, 915),
Size(800, 360),
Size(1024, 768),
];
for (final vp in viewports) {
tester.view.physicalSize = vp;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(buildTestSocialHub(size: vp));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull, reason: 'No overflow on $vp');
expect(find.text('Social'), findsOneWidget);
}
});
});
}

View File

@ -13,13 +13,13 @@ void main() {
group('MotorSignal Design System - Tokens & Hierarchy', () {
test('AppColors verifies dark and light semantic palette definitions', () {
expect(AppColors.cyberCyan, const Color(0xFF00E5FF));
expect(AppColors.velocityBlue, const Color(0xFF2E7DFF));
expect(AppColors.raceRed, const Color(0xFFFF2A2A));
expect(AppColors.darkBackground, const Color(0xFF0A0A0A));
expect(AppColors.darkSurface, const Color(0xFF0D0D0D));
expect(AppColors.lightBackground, const Color(0xFFF0F0F0));
expect(AppColors.lightSurface, const Color(0xFFF5F5F5));
expect(AppColors.cyberCyan, const Color(0xFF00B4D8));
expect(AppColors.velocityBlue, const Color(0xFF0096C7));
expect(AppColors.raceRed, const Color(0xFFFF3B30));
expect(AppColors.darkBackground, const Color(0xFF0D0F13));
expect(AppColors.darkSurface, const Color(0xFF13171E));
expect(AppColors.lightBackground, const Color(0xFFF3F5F8));
expect(AppColors.lightSurface, const Color(0xFFE9EDF2));
});
test('AppTypography specifies authoritative multi-font system', () {
@ -61,7 +61,7 @@ void main() {
const light = MotorSignalThemeColors.light;
expect(light.cyberCyan, AppColors.technicalBlue);
expect(light.launchGreen, AppColors.successGreen);
expect(light.launchGreen, AppColors.launchGreen);
expect(light.surfaceContainer, AppColors.lightSurfaceContainer);
});