diff --git a/lib/core/responsive/responsive_layout.dart b/lib/core/responsive/responsive_layout.dart new file mode 100644 index 0000000..b030456 --- /dev/null +++ b/lib/core/responsive/responsive_layout.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'viewport_size.dart'; + +typedef ResponsiveWidgetBuilder = Widget Function( + BuildContext context, + ResponsiveSnapshot snapshot, +); + +/// A declarative layout container that evaluates available [BoxConstraints] +/// and builds responsive variants with strongly typed [ResponsiveSnapshot]. +class ResponsiveLayout extends StatelessWidget { + /// General builder callback receiving the context and calculated [ResponsiveSnapshot]. + final ResponsiveWidgetBuilder? builder; + + /// Optional variant for compact viewports (< 600dp). + final WidgetBuilder? compact; + + /// Optional variant for medium viewports (600dp - 840dp). + final WidgetBuilder? medium; + + /// Optional variant for expanded viewports (> 840dp). + final WidgetBuilder? expanded; + + /// Optional variant specifically tailored for short viewports (< 420dp height). + final WidgetBuilder? shortViewport; + + const ResponsiveLayout({ + super.key, + this.builder, + this.compact, + this.medium, + this.expanded, + this.shortViewport, + }) : assert( + builder != null || compact != null, + 'ResponsiveLayout requires either a builder or at least a compact widget builder.', + ); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final snapshot = ResponsiveSnapshot.fromConstraints(constraints); + + if (builder != null) { + return builder!(context, snapshot); + } + + if (snapshot.isShortViewport && shortViewport != null) { + return shortViewport!(context); + } + + switch (snapshot.sizeClass) { + case WindowSizeClass.expanded: + if (expanded != null) return expanded!(context); + if (medium != null) return medium!(context); + return compact!(context); + case WindowSizeClass.medium: + if (medium != null) return medium!(context); + return compact!(context); + case WindowSizeClass.compact: + return compact!(context); + } + }, + ); + } +} diff --git a/lib/core/responsive/viewport_size.dart b/lib/core/responsive/viewport_size.dart new file mode 100644 index 0000000..b50bff7 --- /dev/null +++ b/lib/core/responsive/viewport_size.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; + +/// Standard Material 3 window size class classifications. +enum WindowSizeClass { + /// Width < 600dp (standard portrait phones). + compact, + + /// Width >= 600dp and <= 840dp (tablets in portrait, large phones in landscape, foldables). + medium, + + /// Width > 840dp (large tablets, desktop monitors). + expanded; + + bool get isCompact => this == WindowSizeClass.compact; + bool get isMedium => this == WindowSizeClass.medium; + bool get isExpanded => this == WindowSizeClass.expanded; +} + +/// Centralized authoritative breakpoints for MotorSignal responsive layouts. +abstract final class ResponsiveBreakpoints { + /// Width breakpoint below which a screen is compact (599.99dp). + static const double compactMaxWidth = 599.99; + + /// Width breakpoint at which a screen becomes medium (600dp). + static const double mediumMinWidth = 600.0; + + /// Width breakpoint at which a screen ceases to be medium (840dp). + static const double mediumMaxWidth = 840.0; + + /// Width breakpoint at which a screen becomes expanded (840.01dp). + static const double expandedMinWidth = 840.01; + + /// Height threshold under which a viewport is classified as short (< 420dp), + /// typical of horizontally mounted motorcycle phones (e.g., 800x360, 780x360, 840x390). + static const double shortViewportThreshold = 420.0; +} + +/// Strongly typed responsive snapshot representing available geometry and size class. +@immutable +class ResponsiveSnapshot { + final double width; + final double height; + final WindowSizeClass sizeClass; + final bool isShortViewport; + + const ResponsiveSnapshot({ + required this.width, + required this.height, + required this.sizeClass, + required this.isShortViewport, + }); + + /// Factory creating a snapshot from explicit width and height dimensions. + factory ResponsiveSnapshot.fromDimensions({ + required double width, + required double height, + }) { + final WindowSizeClass sizeClass; + if (width < ResponsiveBreakpoints.mediumMinWidth) { + sizeClass = WindowSizeClass.compact; + } else if (width <= ResponsiveBreakpoints.mediumMaxWidth) { + sizeClass = WindowSizeClass.medium; + } else { + sizeClass = WindowSizeClass.expanded; + } + + final isShortViewport = height < ResponsiveBreakpoints.shortViewportThreshold; + + return ResponsiveSnapshot( + width: width, + height: height, + sizeClass: sizeClass, + isShortViewport: isShortViewport, + ); + } + + /// Factory creating a snapshot from [BoxConstraints]. + factory ResponsiveSnapshot.fromConstraints(BoxConstraints constraints) { + return ResponsiveSnapshot.fromDimensions( + width: constraints.maxWidth, + height: constraints.maxHeight, + ); + } + + /// Factory creating a snapshot from [MediaQueryData]. + factory ResponsiveSnapshot.fromMediaQuery(MediaQueryData mediaQuery) { + return ResponsiveSnapshot.fromDimensions( + width: mediaQuery.size.width, + height: mediaQuery.size.height, + ); + } + + bool get isCompact => sizeClass.isCompact; + bool get isMedium => sizeClass.isMedium; + bool get isExpanded => sizeClass.isExpanded; + + /// Whether the shell navigation should present as a side [NavigationRail]. + /// + /// Driven by constraints: + /// - Medium or expanded width (width >= 600dp), OR + /// - Short viewport height (< 420dp) with wider-than-tall aspect ratio (motorcycle landscape mount). + bool get useNavigationRail => + isMedium || isExpanded || (isShortViewport && width > height); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ResponsiveSnapshot && + runtimeType == other.runtimeType && + width == other.width && + height == other.height && + sizeClass == other.sizeClass && + isShortViewport == other.isShortViewport; + + @override + int get hashCode => Object.hash(width, height, sizeClass, isShortViewport); + + @override + String toString() => + 'ResponsiveSnapshot(width: ${width.toStringAsFixed(1)}, height: ${height.toStringAsFixed(1)}, sizeClass: ${sizeClass.name}, shortViewport: $isShortViewport)'; +} + +/// Convenience extension on [BuildContext] for accessing responsive layout snapshot. +extension ResponsiveContextExtension on BuildContext { + /// Responsive snapshot computed from [MediaQuery.of(context)]. + ResponsiveSnapshot get responsive => + ResponsiveSnapshot.fromMediaQuery(MediaQuery.of(this)); +} diff --git a/lib/core/widgets/main_layout.dart b/lib/core/widgets/main_layout.dart index 2304127..d9573b8 100644 --- a/lib/core/widgets/main_layout.dart +++ b/lib/core/widgets/main_layout.dart @@ -2,9 +2,75 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../features/rides/ride_state_machine.dart'; - import '../../services/location_service.dart'; +import '../responsive/viewport_size.dart'; +import '../theme/app_typography.dart'; +/// Single authoritative navigation destination definition. +class AppNavDestination { + final IconData icon; + final IconData? selectedIcon; + final String label; + final Key navKey; + final Key railKey; + + const AppNavDestination({ + required this.icon, + this.selectedIcon, + required this.label, + required this.navKey, + required this.railKey, + }); +} + +/// Authoritative primary navigation destination list for MotorSignal. +const List kAppNavDestinations = [ + AppNavDestination( + icon: Icons.speed, + label: 'Dash', + navKey: ValueKey('nav_dash_icon'), + railKey: ValueKey('rail_dash_icon'), + ), + AppNavDestination( + icon: Icons.map_outlined, + selectedIcon: Icons.map, + label: 'Map', + navKey: ValueKey('nav_map_icon'), + railKey: ValueKey('rail_map_icon'), + ), + AppNavDestination( + icon: Icons.history, + label: 'History', + navKey: ValueKey('nav_history_icon'), + railKey: ValueKey('rail_history_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, + label: 'Garage', + navKey: ValueKey('nav_garage_icon'), + railKey: ValueKey('rail_garage_icon'), + ), + AppNavDestination( + icon: Icons.settings_outlined, + selectedIcon: Icons.settings, + label: 'Settings', + navKey: ValueKey('nav_settings_icon'), + railKey: ValueKey('rail_settings_icon'), + ), +]; + +/// Unified responsive application shell for MotorSignal. +/// +/// Adapts navigation presentation (BottomNavigationBar vs. NavigationRail) +/// based strictly on available layout constraints and viewport geometry, +/// preserving shared state, routes, and ride lifecycle seamlessly. class MainLayout extends ConsumerStatefulWidget { final StatefulNavigationShell navigationShell; @@ -30,8 +96,16 @@ class _MainLayoutState extends ConsumerState { } catch (_) {} } + void _onDestinationSelected(int index) { + widget.navigationShell.goBranch( + index, + initialLocation: index == widget.navigationShell.currentIndex, + ); + } + @override Widget build(BuildContext context) { + // Eagerly switch back to dashboard when an active ride begins ref.listen(rideStateMachineProvider, (previous, next) { if (previous?.state != RideState.riding && next.state == RideState.riding) { @@ -40,18 +114,22 @@ class _MainLayoutState extends ConsumerState { } } }); - final isLandscape = - MediaQuery.of(context).orientation == Orientation.landscape; - if (isLandscape) { - return Scaffold( - body: Row( - children: [ - SafeArea( - right: false, - child: LayoutBuilder( - builder: (context, constraints) { - return SingleChildScrollView( + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final selectedIndex = widget.navigationShell.currentIndex; + + return LayoutBuilder( + builder: (context, constraints) { + final snapshot = ResponsiveSnapshot.fromConstraints(constraints); + + if (snapshot.useNavigationRail) { + return Scaffold( + body: Row( + children: [ + SafeArea( + right: false, + child: SingleChildScrollView( physics: const ClampingScrollPhysics(), child: ConstrainedBox( constraints: BoxConstraints( @@ -59,157 +137,88 @@ class _MainLayoutState extends ConsumerState { ), child: IntrinsicHeight( child: NavigationRail( - minWidth: 54, + minWidth: 58, groupAlignment: 0.0, - selectedIndex: widget.navigationShell.currentIndex, - onDestinationSelected: (index) { - widget.navigationShell.goBranch( - index, - initialLocation: - index == widget.navigationShell.currentIndex, - ); - }, - backgroundColor: const Color(0xFF0A0A0A), - indicatorColor: const Color( - 0xFF00E5FF, - ).withValues(alpha: 0.18), - selectedIconTheme: const IconThemeData( - color: Color(0xFF00E5FF), + selectedIndex: selectedIndex, + onDestinationSelected: _onDestinationSelected, + backgroundColor: colorScheme.surface, + indicatorColor: colorScheme.primary.withValues(alpha: 0.18), + selectedIconTheme: IconThemeData( + color: colorScheme.primary, size: 20, ), - unselectedIconTheme: const IconThemeData( - color: Colors.white54, + unselectedIconTheme: IconThemeData( + color: colorScheme.onSurfaceVariant, size: 18, ), - selectedLabelTextStyle: const TextStyle( - fontFamily: 'Inter', - color: Color(0xFF00E5FF), + selectedLabelTextStyle: AppTypography.telemetryLabel.copyWith( + color: colorScheme.primary, fontSize: 9, fontWeight: FontWeight.w800, ), - unselectedLabelTextStyle: const TextStyle( - fontFamily: 'Inter', - color: Colors.white54, + unselectedLabelTextStyle: AppTypography.telemetryLabel.copyWith( + color: colorScheme.onSurfaceVariant, fontSize: 8.5, fontWeight: FontWeight.w600, ), labelType: NavigationRailLabelType.all, - destinations: const [ - NavigationRailDestination( - padding: EdgeInsets.symmetric(vertical: 2), - icon: Icon( - Icons.speed, - key: ValueKey('rail_dash_icon'), - ), - label: Text('Dash'), - ), - NavigationRailDestination( - padding: EdgeInsets.symmetric(vertical: 2), - icon: Icon( - Icons.map_outlined, - key: ValueKey('rail_map_icon'), - ), - label: Text('Map'), - ), - NavigationRailDestination( - padding: EdgeInsets.symmetric(vertical: 2), - icon: Icon( - Icons.history, - key: ValueKey('rail_history_icon'), - ), - label: Text('History'), - ), - NavigationRailDestination( - padding: EdgeInsets.symmetric(vertical: 2), - icon: Icon( - Icons.emoji_events_outlined, - key: ValueKey('rail_records_icon'), - ), - label: Text('Records'), - ), - NavigationRailDestination( - padding: EdgeInsets.symmetric(vertical: 2), - icon: Icon( - Icons.two_wheeler, - key: ValueKey('rail_garage_icon'), - ), - label: Text('Garage'), - ), - NavigationRailDestination( - padding: EdgeInsets.symmetric(vertical: 2), - icon: Icon( - Icons.settings_outlined, - key: ValueKey('rail_settings_icon'), - ), - label: Text('Settings'), - ), - ], + destinations: kAppNavDestinations.map((dest) { + return NavigationRailDestination( + padding: const EdgeInsets.symmetric(vertical: 2), + icon: Icon(dest.icon, key: dest.railKey), + selectedIcon: dest.selectedIcon != null + ? Icon(dest.selectedIcon, key: dest.railKey) + : null, + label: Text(dest.label), + ); + }).toList(), ), ), ), - ); - }, - ), + ), + ), + VerticalDivider( + width: 1, + thickness: 1, + color: colorScheme.outlineVariant, + ), + Expanded(child: widget.navigationShell), + ], ), - const VerticalDivider( - width: 1, - thickness: 1, - color: Color(0xFF1F1F1F), - ), - Expanded(child: widget.navigationShell), - ], - ), - ); - } - - return Scaffold( - body: widget.navigationShell, - bottomNavigationBar: BottomNavigationBar( - key: const ValueKey('main_bottom_nav_bar'), - currentIndex: widget.navigationShell.currentIndex, - onTap: (index) { - widget.navigationShell.goBranch( - index, - initialLocation: index == widget.navigationShell.currentIndex, ); - }, - selectedItemColor: Theme.of(context).colorScheme.primary, - unselectedItemColor: Colors.grey, - backgroundColor: Theme.of(context).colorScheme.surface, - type: BottomNavigationBarType.fixed, - items: const [ - BottomNavigationBarItem( - icon: Icon(Icons.speed, key: ValueKey('nav_dash_icon')), - label: 'Dash', - tooltip: 'Dash', + } + + return Scaffold( + body: widget.navigationShell, + bottomNavigationBar: BottomNavigationBar( + key: const ValueKey('main_bottom_nav_bar'), + currentIndex: selectedIndex, + onTap: _onDestinationSelected, + selectedItemColor: colorScheme.primary, + unselectedItemColor: colorScheme.onSurfaceVariant, + backgroundColor: colorScheme.surface, + type: BottomNavigationBarType.fixed, + selectedLabelStyle: AppTypography.telemetryLabel.copyWith( + fontSize: 10, + fontWeight: FontWeight.w800, + ), + unselectedLabelStyle: AppTypography.telemetryLabel.copyWith( + fontSize: 9.5, + fontWeight: FontWeight.w600, + ), + items: kAppNavDestinations.map((dest) { + return BottomNavigationBarItem( + icon: Icon(dest.icon, key: dest.navKey), + activeIcon: dest.selectedIcon != null + ? Icon(dest.selectedIcon, key: dest.navKey) + : null, + label: dest.label, + tooltip: dest.label, + ); + }).toList(), ), - BottomNavigationBarItem( - icon: Icon(Icons.map, key: ValueKey('nav_map_icon')), - label: 'Map', - tooltip: 'Map', - ), - BottomNavigationBarItem( - icon: Icon(Icons.history, key: ValueKey('nav_history_icon')), - label: 'History', - tooltip: 'History', - ), - BottomNavigationBarItem( - icon: Icon(Icons.emoji_events, key: ValueKey('nav_records_icon')), - label: 'Records', - tooltip: 'Records', - ), - BottomNavigationBarItem( - icon: Icon(Icons.two_wheeler, key: ValueKey('nav_garage_icon')), - label: 'Garage', - tooltip: 'Garage', - ), - BottomNavigationBarItem( - icon: Icon(Icons.settings, key: ValueKey('nav_settings_icon')), - label: 'Settings', - tooltip: 'Settings', - ), - ], - ), + ); + }, ); } } diff --git a/test/main_layout_responsive_test.dart b/test/main_layout_responsive_test.dart new file mode 100644 index 0000000..b0d03b9 --- /dev/null +++ b/test/main_layout_responsive_test.dart @@ -0,0 +1,303 @@ +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/core/widgets/main_layout.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Widget buildTestRouter({ + required Size surfaceSize, + ThemeData? theme, + int initialIndex = 0, + void Function(int)? onIndexChanged, + }) { + final router = GoRouter( + initialLocation: switch (initialIndex) { + 1 => '/map', + 2 => '/history', + 3 => '/records', + 4 => '/garage', + 5 => '/settings', + _ => '/', + }, + routes: [ + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) { + return MainLayout(navigationShell: navigationShell); + }, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const Scaffold( + body: Center(child: Text('Dash Screen Content')), + ), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/map', + builder: (context, state) => const Scaffold( + body: Center(child: Text('Map Screen Content')), + ), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/history', + 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')), + ), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/garage', + builder: (context, state) => const Scaffold( + body: Center(child: Text('Garage Screen Content')), + ), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/settings', + builder: (context, state) => const Scaffold( + body: Center(child: Text('Settings Screen Content')), + ), + ), + ], + ), + ], + ), + ], + ); + + return ProviderScope( + child: MaterialApp.router( + theme: theme ?? AppTheme.darkTheme, + routerConfig: router, + ), + ); + } + + group('MainLayout Viewport Matrix Tests', () { + const testViewports = { + '320x640 (Narrow Portrait)': Size(320, 640), + '400x800 (Standard Portrait)': Size(400, 800), + '412x915 (Pixel 8a Portrait)': Size(412, 915), + '840x390 (Short Landscape)': Size(840, 390), + '780x360 (Short Landscape)': Size(780, 360), + '800x360 (Motorcycle Cockpit Mount)': Size(800, 360), + '600x900 (Medium Tablet)': Size(600, 900), + '840x600 (Medium Landscape)': Size(840, 600), + '1024x768 (Expanded Tablet)': Size(1024, 768), + }; + + for (final entry in testViewports.entries) { + testWidgets('Renders cleanly without overflow on ${entry.key}', (tester) async { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildTestRouter(surfaceSize: entry.value)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull, reason: 'No RenderFlex overflow on ${entry.key}'); + expect(find.text('Dash Screen Content'), findsOneWidget); + + // 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('Garage'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + }); + } + }); + + group('MainLayout Navigation Presentation Adaptation', () { + testWidgets('Presents BottomNavigationBar on compact portrait (400x800)', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildTestRouter(surfaceSize: const Size(400, 800))); + await tester.pumpAndSettle(); + + expect(find.byType(BottomNavigationBar), findsOneWidget); + expect(find.byType(NavigationRail), findsNothing); + expect(find.byKey(const ValueKey('nav_dash_icon')), findsOneWidget); + expect(find.byKey(const ValueKey('nav_map_icon')), findsOneWidget); + }); + + testWidgets('Presents NavigationRail on short landscape phone mount (800x360)', (tester) async { + tester.view.physicalSize = const Size(800, 360); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildTestRouter(surfaceSize: const Size(800, 360))); + await tester.pumpAndSettle(); + + expect(find.byType(NavigationRail), findsOneWidget); + expect(find.byType(BottomNavigationBar), findsNothing); + expect(find.byKey(const ValueKey('rail_dash_icon')), findsOneWidget); + expect(find.byKey(const ValueKey('rail_map_icon')), findsOneWidget); + }); + + testWidgets('Presents NavigationRail on medium width (600x900)', (tester) async { + tester.view.physicalSize = const Size(600, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildTestRouter(surfaceSize: const Size(600, 900))); + await tester.pumpAndSettle(); + + expect(find.byType(NavigationRail), findsOneWidget); + expect(find.byType(BottomNavigationBar), findsNothing); + }); + }); + + group('MainLayout Navigation Interactions & Touch Targets', () { + testWidgets('Tapping destinations switches routes cleanly on BottomNavigationBar', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildTestRouter(surfaceSize: const Size(400, 800))); + await tester.pumpAndSettle(); + + expect(find.text('Dash Screen Content'), findsOneWidget); + + await tester.tap(find.text('Garage')); + await tester.pumpAndSettle(); + + expect(find.text('Garage Screen Content'), findsOneWidget); + + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + + expect(find.text('Settings Screen Content'), findsOneWidget); + }); + + testWidgets('Tapping destinations switches routes cleanly on NavigationRail', (tester) async { + tester.view.physicalSize = const Size(800, 360); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildTestRouter(surfaceSize: const Size(800, 360))); + await tester.pumpAndSettle(); + + expect(find.text('Dash Screen Content'), findsOneWidget); + + await tester.tap(find.text('Map')); + await tester.pumpAndSettle(); + + expect(find.text('Map Screen Content'), findsOneWidget); + + await tester.tap(find.text('Records')); + await tester.pumpAndSettle(); + + expect(find.text('Records Screen Content'), findsOneWidget); + }); + + testWidgets('NavigationRail touch targets have adequate interactive hit bounds', (tester) async { + tester.view.physicalSize = const Size(800, 360); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildTestRouter(surfaceSize: const Size(800, 360))); + await tester.pumpAndSettle(); + + final railFinder = find.byType(NavigationRail); + expect(railFinder, findsOneWidget); + + final railSize = tester.getSize(railFinder); + expect(railSize.width, greaterThanOrEqualTo(48.0)); + }); + }); + + group('MainLayout State & Theme Preservation Across Resize', () { + testWidgets('Preserves selected route and active content across viewport resize', (tester) async { + // Start in portrait (400x800) + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(buildTestRouter(surfaceSize: const Size(400, 800))); + await tester.pumpAndSettle(); + + // Navigate to History + await tester.tap(find.text('History')); + await tester.pumpAndSettle(); + expect(find.text('History 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! + expect(find.byType(NavigationRail), findsOneWidget); + expect(find.text('History 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); + + // 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); + }); + + testWidgets('Renders cleanly under Light Theme without visual regressions', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + buildTestRouter( + surfaceSize: const Size(400, 800), + theme: AppTheme.lightTheme, + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Dash Screen Content'), findsOneWidget); + }); + }); +} diff --git a/test/responsive_layout_test.dart b/test/responsive_layout_test.dart new file mode 100644 index 0000000..d78a460 --- /dev/null +++ b/test/responsive_layout_test.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/core/responsive/viewport_size.dart'; +import 'package:motorcycle_dash/core/responsive/responsive_layout.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('ResponsiveBreakpoints & ResponsiveSnapshot Classification', () { + test('Classifies compact width (< 600dp) correctly', () { + final snap320 = ResponsiveSnapshot.fromDimensions(width: 320, height: 640); + expect(snap320.sizeClass, WindowSizeClass.compact); + expect(snap320.isCompact, isTrue); + expect(snap320.isMedium, isFalse); + expect(snap320.isExpanded, isFalse); + expect(snap320.isShortViewport, isFalse); + expect(snap320.useNavigationRail, isFalse); + + final snap400 = ResponsiveSnapshot.fromDimensions(width: 400, height: 800); + expect(snap400.sizeClass, WindowSizeClass.compact); + expect(snap400.isCompact, isTrue); + + final snap599 = ResponsiveSnapshot.fromDimensions(width: 599.9, height: 800); + expect(snap599.sizeClass, WindowSizeClass.compact); + }); + + test('Classifies medium width (600dp - 840dp) correctly', () { + final snap600 = ResponsiveSnapshot.fromDimensions(width: 600, height: 900); + expect(snap600.sizeClass, WindowSizeClass.medium); + expect(snap600.isCompact, isFalse); + expect(snap600.isMedium, isTrue); + expect(snap600.isExpanded, isFalse); + expect(snap600.useNavigationRail, isTrue); + + final snap840 = ResponsiveSnapshot.fromDimensions(width: 840, height: 600); + expect(snap840.sizeClass, WindowSizeClass.medium); + expect(snap840.isMedium, isTrue); + expect(snap840.useNavigationRail, isTrue); + }); + + test('Classifies expanded width (> 840dp) correctly', () { + final snap841 = ResponsiveSnapshot.fromDimensions(width: 841, height: 600); + expect(snap841.sizeClass, WindowSizeClass.expanded); + expect(snap841.isCompact, isFalse); + expect(snap841.isMedium, isFalse); + expect(snap841.isExpanded, isTrue); + expect(snap841.useNavigationRail, isTrue); + + final snap1200 = ResponsiveSnapshot.fromDimensions(width: 1200, height: 800); + expect(snap1200.sizeClass, WindowSizeClass.expanded); + }); + + test('Classifies short viewports (< 420dp height) for motorcycle mounts', () { + final mount800x360 = ResponsiveSnapshot.fromDimensions(width: 800, height: 360); + expect(mount800x360.isShortViewport, isTrue); + expect(mount800x360.useNavigationRail, isTrue); + + final mount780x360 = ResponsiveSnapshot.fromDimensions(width: 780, height: 360); + expect(mount780x360.isShortViewport, isTrue); + expect(mount780x360.useNavigationRail, isTrue); + + final mount840x390 = ResponsiveSnapshot.fromDimensions(width: 840, height: 390); + expect(mount840x390.isShortViewport, isTrue); + expect(mount840x390.useNavigationRail, isTrue); + + final smallLandscape = ResponsiveSnapshot.fromDimensions(width: 500, height: 320); + expect(smallLandscape.sizeClass, WindowSizeClass.compact); + expect(smallLandscape.isShortViewport, isTrue); + expect(smallLandscape.useNavigationRail, isTrue); + }); + + test('BoxConstraints and MediaQuery factory constructors produce consistent snapshots', () { + const constraints = BoxConstraints(maxWidth: 720, maxHeight: 400); + final fromConstraints = ResponsiveSnapshot.fromConstraints(constraints); + expect(fromConstraints.width, 720); + expect(fromConstraints.height, 400); + expect(fromConstraints.sizeClass, WindowSizeClass.medium); + expect(fromConstraints.isShortViewport, isTrue); + + const mediaQuery = MediaQueryData(size: Size(412, 915)); + final fromMq = ResponsiveSnapshot.fromMediaQuery(mediaQuery); + expect(fromMq.width, 412); + expect(fromMq.height, 915); + expect(fromMq.sizeClass, WindowSizeClass.compact); + expect(fromMq.isShortViewport, isFalse); + }); + }); + + group('ResponsiveLayout Declarative Widget', () { + testWidgets('Builds compact, medium, expanded, or shortViewport builders', (tester) async { + // 1. Compact + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveLayout( + compact: (_) => const Text('Compact View'), + medium: (_) => const Text('Medium View'), + expanded: (_) => const Text('Expanded View'), + ), + ), + ), + ); + expect(find.text('Compact View'), findsOneWidget); + + // 2. Medium + tester.view.physicalSize = const Size(700, 640); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveLayout( + compact: (_) => const Text('Compact View'), + medium: (_) => const Text('Medium View'), + expanded: (_) => const Text('Expanded View'), + ), + ), + ), + ); + expect(find.text('Medium View'), findsOneWidget); + + // 3. Expanded + tester.view.physicalSize = const Size(1200, 800); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveLayout( + compact: (_) => const Text('Compact View'), + medium: (_) => const Text('Medium View'), + expanded: (_) => const Text('Expanded View'), + ), + ), + ), + ); + expect(find.text('Expanded View'), findsOneWidget); + + // 4. Short Viewport + tester.view.physicalSize = const Size(800, 360); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveLayout( + compact: (_) => const Text('Compact View'), + shortViewport: (_) => const Text('Short View'), + ), + ), + ), + ); + expect(find.text('Short View'), findsOneWidget); + }); + + testWidgets('General builder receives computed ResponsiveSnapshot', (tester) async { + tester.view.physicalSize = const Size(800, 360); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + ResponsiveSnapshot? captured; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ResponsiveLayout( + builder: (context, snapshot) { + captured = snapshot; + return Text('Size: ${snapshot.sizeClass.name}'); + }, + ), + ), + ), + ); + + expect(find.text('Size: medium'), findsOneWidget); + expect(captured?.isShortViewport, isTrue); + expect(captured?.useNavigationRail, isTrue); + }); + }); +}