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