feat: unify responsive application shell
This commit is contained in:
parent
bcded2a818
commit
dca353cbef
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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<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 {
|
||||
final StatefulNavigationShell navigationShell;
|
||||
|
||||
|
|
@ -30,8 +96,16 @@ class _MainLayoutState extends ConsumerState<MainLayout> {
|
|||
} 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<RideSession>(rideStateMachineProvider, (previous, next) {
|
||||
if (previous?.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) {
|
||||
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<MainLayout> {
|
|||
),
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue