feat: unify responsive application shell

This commit is contained in:
Developer 2026-09-04 23:19:05 -04:00
parent bcded2a818
commit dca353cbef
5 changed files with 832 additions and 145 deletions

View File

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

View File

@ -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));
}

View File

@ -2,9 +2,75 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../features/rides/ride_state_machine.dart'; import '../../features/rides/ride_state_machine.dart';
import '../../services/location_service.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<AppNavDestination> 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 { class MainLayout extends ConsumerStatefulWidget {
final StatefulNavigationShell navigationShell; final StatefulNavigationShell navigationShell;
@ -30,8 +96,16 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
} catch (_) {} } catch (_) {}
} }
void _onDestinationSelected(int index) {
widget.navigationShell.goBranch(
index,
initialLocation: index == widget.navigationShell.currentIndex,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Eagerly switch back to dashboard when an active ride begins
ref.listen<RideSession>(rideStateMachineProvider, (previous, next) { ref.listen<RideSession>(rideStateMachineProvider, (previous, next) {
if (previous?.state != RideState.riding && if (previous?.state != RideState.riding &&
next.state == RideState.riding) { next.state == RideState.riding) {
@ -40,18 +114,22 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
} }
} }
}); });
final isLandscape =
MediaQuery.of(context).orientation == Orientation.landscape;
if (isLandscape) { 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( return Scaffold(
body: Row( body: Row(
children: [ children: [
SafeArea( SafeArea(
right: false, right: false,
child: LayoutBuilder( child: SingleChildScrollView(
builder: (context, constraints) {
return SingleChildScrollView(
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
@ -59,102 +137,50 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
), ),
child: IntrinsicHeight( child: IntrinsicHeight(
child: NavigationRail( child: NavigationRail(
minWidth: 54, minWidth: 58,
groupAlignment: 0.0, groupAlignment: 0.0,
selectedIndex: widget.navigationShell.currentIndex, selectedIndex: selectedIndex,
onDestinationSelected: (index) { onDestinationSelected: _onDestinationSelected,
widget.navigationShell.goBranch( backgroundColor: colorScheme.surface,
index, indicatorColor: colorScheme.primary.withValues(alpha: 0.18),
initialLocation: selectedIconTheme: IconThemeData(
index == widget.navigationShell.currentIndex, color: colorScheme.primary,
);
},
backgroundColor: const Color(0xFF0A0A0A),
indicatorColor: const Color(
0xFF00E5FF,
).withValues(alpha: 0.18),
selectedIconTheme: const IconThemeData(
color: Color(0xFF00E5FF),
size: 20, size: 20,
), ),
unselectedIconTheme: const IconThemeData( unselectedIconTheme: IconThemeData(
color: Colors.white54, color: colorScheme.onSurfaceVariant,
size: 18, size: 18,
), ),
selectedLabelTextStyle: const TextStyle( selectedLabelTextStyle: AppTypography.telemetryLabel.copyWith(
fontFamily: 'Inter', color: colorScheme.primary,
color: Color(0xFF00E5FF),
fontSize: 9, fontSize: 9,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
unselectedLabelTextStyle: const TextStyle( unselectedLabelTextStyle: AppTypography.telemetryLabel.copyWith(
fontFamily: 'Inter', color: colorScheme.onSurfaceVariant,
color: Colors.white54,
fontSize: 8.5, fontSize: 8.5,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
labelType: NavigationRailLabelType.all, labelType: NavigationRailLabelType.all,
destinations: const [ destinations: kAppNavDestinations.map((dest) {
NavigationRailDestination( return NavigationRailDestination(
padding: EdgeInsets.symmetric(vertical: 2), padding: const EdgeInsets.symmetric(vertical: 2),
icon: Icon( icon: Icon(dest.icon, key: dest.railKey),
Icons.speed, selectedIcon: dest.selectedIcon != null
key: ValueKey('rail_dash_icon'), ? Icon(dest.selectedIcon, key: dest.railKey)
), : null,
label: Text('Dash'), label: Text(dest.label),
),
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'),
),
],
),
),
),
); );
}, }).toList(),
), ),
), ),
const VerticalDivider( ),
),
),
VerticalDivider(
width: 1, width: 1,
thickness: 1, thickness: 1,
color: Color(0xFF1F1F1F), color: colorScheme.outlineVariant,
), ),
Expanded(child: widget.navigationShell), Expanded(child: widget.navigationShell),
], ],
@ -166,50 +192,33 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
body: widget.navigationShell, body: widget.navigationShell,
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
key: const ValueKey('main_bottom_nav_bar'), key: const ValueKey('main_bottom_nav_bar'),
currentIndex: widget.navigationShell.currentIndex, currentIndex: selectedIndex,
onTap: (index) { onTap: _onDestinationSelected,
widget.navigationShell.goBranch( selectedItemColor: colorScheme.primary,
index, unselectedItemColor: colorScheme.onSurfaceVariant,
initialLocation: index == widget.navigationShell.currentIndex, 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(),
),
); );
}, },
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',
),
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',
),
],
),
); );
} }
} }

View File

@ -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 = <String, Size>{
'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);
});
});
}

View File

@ -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);
});
});
}