diff --git a/lib/features/routes/route_detail_screen.dart b/lib/features/routes/route_detail_screen.dart index f23b174..7518eb1 100644 --- a/lib/features/routes/route_detail_screen.dart +++ b/lib/features/routes/route_detail_screen.dart @@ -145,7 +145,7 @@ class _RouteDetailScreenState extends ConsumerState { child: CircularProgressIndicator(color: colors.cyberCyan), ), error: (err, _) => Center( - child: Padding( + child: SingleChildScrollView( padding: const EdgeInsets.all(24.0), child: Column( mainAxisSize: MainAxisSize.min, @@ -383,7 +383,9 @@ class _RouteDetailScreenState extends ConsumerState { List markers, List polylines, ) { - final double mapHeight = snapshot.height < 450 ? 140 : 220; + final double mapHeight = snapshot.height < 360 + ? 96 + : (snapshot.height < 450 ? 140 : 220); return SingleChildScrollView( child: Column( diff --git a/lib/features/routes/route_planner_screen.dart b/lib/features/routes/route_planner_screen.dart index a1608be..fad5000 100644 --- a/lib/features/routes/route_planner_screen.dart +++ b/lib/features/routes/route_planner_screen.dart @@ -707,8 +707,8 @@ class _RoutePlannerScreenState extends ConsumerState { // Generous space (>= 620dp): 220dp // Moderate space (500–619dp): 160dp // Constrained space (380–499dp, e.g. IME keyboard open): 110dp - // Severely constrained space (280–379dp, e.g. landscape): 70dp - // Extreme constraint (< 280dp): 0dp (collapses so inputs stay visible and editable) + // Severely constrained space (340–379dp): 60dp + // Extreme constraint (< 340dp, e.g. 640x320 landscape): 0dp (collapses so inputs stay visible and editable) final double mapHeight; final bool showDisclaimer; if (snapshot.height >= 620) { @@ -720,8 +720,8 @@ class _RoutePlannerScreenState extends ConsumerState { } else if (snapshot.height >= 380) { mapHeight = 110.0; showDisclaimer = false; - } else if (snapshot.height >= 280) { - mapHeight = 70.0; + } else if (snapshot.height >= 340) { + mapHeight = 60.0; showDisclaimer = false; } else { mapHeight = 0.0; diff --git a/lib/features/social/friends_screen.dart b/lib/features/social/friends_screen.dart index 5535081..dcfd55e 100644 --- a/lib/features/social/friends_screen.dart +++ b/lib/features/social/friends_screen.dart @@ -413,44 +413,46 @@ class _FriendsScreenState extends ConsumerState ), data: (friends) { if (friends.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.people_alt_outlined, - size: 48, - color: Colors.white24, - ), - const SizedBox(height: 12), - const Text( - 'NO FRIENDS ADDED YET', - style: TextStyle( - color: Colors.white70, - fontWeight: FontWeight.w800, - fontSize: 14, - letterSpacing: 0.8, + return SingleChildScrollView( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.people_alt_outlined, + size: 48, + color: Colors.white24, ), - ), - const SizedBox(height: 6), - const Text( - 'Switch to "Find Riders" to search for riding buddies by @handle.', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.white38, fontSize: 12), - ), - const SizedBox(height: 16), - ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, + const SizedBox(height: 12), + const Text( + 'NO FRIENDS ADDED YET', + style: TextStyle( + color: Colors.white70, + fontWeight: FontWeight.w800, + fontSize: 14, + letterSpacing: 0.8, + ), ), - icon: const Icon(Icons.search), - label: const Text('FIND RIDERS'), - onPressed: () => _tabController.animateTo(2), - ), - ], + const SizedBox(height: 6), + const Text( + 'Switch to "Find Riders" to search for riding buddies by @handle.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white38, fontSize: 12), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: colors.cyberCyan, + foregroundColor: Colors.black, + ), + icon: const Icon(Icons.search), + label: const Text('FIND RIDERS'), + onPressed: () => _tabController.animateTo(2), + ), + ], + ), ), ), ); diff --git a/test/canonical_whole_app_regression_matrix_test.dart b/test/canonical_whole_app_regression_matrix_test.dart new file mode 100644 index 0000000..2c66028 --- /dev/null +++ b/test/canonical_whole_app_regression_matrix_test.dart @@ -0,0 +1,1168 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:drift/native.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:drift/drift.dart' as drift; + +import 'package:motorcycle_dash/core/router.dart'; +import 'package:motorcycle_dash/core/theme/theme.dart'; +import 'package:motorcycle_dash/core/widgets/main_layout.dart'; +import 'package:motorcycle_dash/database/database.dart' hide FieldTestEvent, FieldTestSummary, TelemetrySample; +import 'package:motorcycle_dash/models/field_test_event.dart'; +import 'package:motorcycle_dash/features/dashboard/dashboard_screen.dart'; +import 'package:motorcycle_dash/features/map/map_screen.dart'; +import 'package:motorcycle_dash/features/history/history_screen.dart'; +import 'package:motorcycle_dash/features/history/ride_detail_screen.dart'; +import 'package:motorcycle_dash/features/records/records_screen.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; +import 'package:motorcycle_dash/features/garage/garage_screen.dart'; +import 'package:motorcycle_dash/features/garage/add_motorcycle_screen.dart'; +import 'package:motorcycle_dash/features/garage/motorcycle_detail_screen.dart'; +import 'package:motorcycle_dash/features/garage/garage_providers.dart'; +import 'package:motorcycle_dash/features/settings/settings_screen.dart'; +import 'package:motorcycle_dash/features/settings/account_screen.dart'; +import 'package:motorcycle_dash/features/entitlements/pro_screen.dart'; +import 'package:motorcycle_dash/features/social/rider_profile_screen.dart'; +import 'package:motorcycle_dash/features/social/friends_screen.dart'; +import 'package:motorcycle_dash/features/settings/diagnostics_screen.dart'; +import 'package:motorcycle_dash/features/settings/field_test_live_log_screen.dart'; +import 'package:motorcycle_dash/features/settings/field_test_report_screen.dart'; +import 'package:motorcycle_dash/features/field_test/field_test_pre_ride_check_screen.dart'; +import 'package:motorcycle_dash/features/group_rides/group_rides_hub_screen.dart'; +import 'package:motorcycle_dash/features/group_rides/create_group_ride_screen.dart'; +import 'package:motorcycle_dash/features/group_rides/group_ride_detail_screen.dart'; +import 'package:motorcycle_dash/features/group_rides/live_group_ride_screen.dart'; +import 'package:motorcycle_dash/features/routes/route_planner_screen.dart'; +import 'package:motorcycle_dash/features/routes/route_detail_screen.dart'; + +import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; +import 'package:motorcycle_dash/models/motorcycle_models.dart'; +import 'package:motorcycle_dash/services/alerts/road_alert_service.dart'; +import 'package:motorcycle_dash/services/motorcycle/motorcycle_repository.dart'; +import 'package:motorcycle_dash/services/auth/auth_providers.dart'; +import 'package:motorcycle_dash/services/auth/auth_service.dart'; +import 'package:motorcycle_dash/services/auth/auth_state.dart'; +import 'package:motorcycle_dash/services/auth/auth_user.dart'; +import 'package:motorcycle_dash/services/billing/billing_providers.dart'; +import 'package:motorcycle_dash/services/billing/fake_billing_service.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_providers.dart'; +import 'package:motorcycle_dash/services/entitlements/entitlement_storage.dart'; +import 'package:motorcycle_dash/services/field_test/field_test_logger.dart'; +import 'package:motorcycle_dash/services/location_service.dart'; +import 'package:motorcycle_dash/services/group_rides/group_ride_models.dart'; +import 'package:motorcycle_dash/services/group_rides/group_ride_providers.dart'; +import 'package:motorcycle_dash/services/group_rides/group_ride_service.dart'; +import 'package:motorcycle_dash/services/routes/route_models.dart'; +import 'package:motorcycle_dash/services/routes/route_providers.dart'; +import 'package:motorcycle_dash/services/routes/route_service.dart'; +import 'package:motorcycle_dash/services/social/rider_profile_models.dart'; +import 'package:motorcycle_dash/services/social/social_providers.dart'; +import 'package:motorcycle_dash/services/map/map_providers.dart'; +import 'package:motorcycle_dash/services/map/map_provider_interface.dart'; + +// Canonical 9-viewport regression matrix definitions +const Map canonicalViewports = { + 'compact_portrait_320x640': Size(320, 640), + 'standard_portrait_400x800': Size(400, 800), + 'pixel7_portrait_412x915': Size(412, 915), + 'compact_landscape_640x320': Size(640, 320), + 'short_landscape_780x360': Size(780, 360), + 'standard_landscape_800x360': Size(800, 360), + 'wide_landscape_840x390': Size(840, 390), + 'portrait_tablet_768x1024': Size(768, 1024), + 'landscape_tablet_1024x768': Size(1024, 768), +}; + +// IME virtual keyboard inset viewports +const Map imeViewports = { + 'compact_portrait_ime': Size(320, 640), + 'standard_portrait_ime': Size(400, 800), + 'compact_landscape_ime': Size(640, 320), +}; + +class _FakeAuth implements AuthService { + AuthUser? user; + final _states = StreamController.broadcast(); + + _FakeAuth({this.user}); + + @override + Stream get authStateChanges => _states.stream; + + @override + AuthState get currentState => user == null + ? const AuthState.unauthenticated(isFirebaseConfigured: true) + : (user!.isAnonymous + ? AuthState.anonymous(user!, isFirebaseConfigured: true) + : AuthState.authenticated(user!, isFirebaseConfigured: true)); + + @override + AuthUser? get currentUser => user; + + @override + bool get isConfigured => true; + + @override + bool get isEmailVerified => user?.emailVerified ?? false; + + @override + Future deleteCurrentUser() async { + user = null; + _states.add(currentState); + } + + @override + void dispose() => _states.close(); + + @override + Future getIdToken([bool forceRefresh = false]) async => 'fake-token'; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeLocationService extends ChangeNotifier implements LocationService { + final StreamController _controller = StreamController.broadcast(); + + @override + Stream getFilteredPositionStream() => _controller.stream; + + @override + Future requestPermissions() async => true; + + @override + Future hasPermissions() async => true; + + @override + void Function(Position, bool, String?)? onGpsDiagnostic; + + @override + void dispose() { + _controller.close(); + super.dispose(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeMotorcycleRepository implements MotorcycleRepository { + @override + Future> getYears() async => [MotorcycleYear(2024)]; + + @override + Future> getMakes(int year) async => [ + MotorcycleMake(id: 'triumph', name: 'Triumph'), + ]; + + @override + Future> getModels(int year, String makeId) async => [ + MotorcycleModel( + id: 'speed-triple', + name: 'Speed Triple 1200 RS', + year: 2024, + makeId: 'triumph', + ), + ]; + + @override + Future> getVariants(int year, String makeId, String modelId) async => []; + + @override + Future getSpecifications(MotorcycleSelection selection) async => null; +} + +class _FakeFieldTestLogger implements FieldTestLogger { + @override + int? get activeRideId => null; + @override + bool get isSessionActive => false; + @override + DateTime? get sessionStartTime => null; + @override + double get currentQualityEstimate => 100.0; + @override + double get currentSampleRateHz => 20.0; + @override + double get lastGpsAccuracy => 2.5; + @override + int get lastGpsAgeMs => 50; + @override + double get averageTimingJitterMs => 1.2; + @override + int get currentQueueDepth => 0; + @override + int get peakQueueDepth => 0; + @override + int get queueCapacity => 1000; + @override + int get totalEnqueued => 0; + @override + int get totalFlushed => 0; + @override + int get droppedEventsCount => 0; + @override + String? get oldestDroppedItem => null; + @override + String? get newestDroppedItem => null; + @override + int get lastFlushDurationMs => 0; + @override + int get failedFlushCount => 0; + int get telemetryGapCount => 0; + int get gpsStaleCount => 0; + int get gpsJumpRejectCount => 0; + int get imuSaturationCount => 0; + int get accelerationEventCount => 0; + int get brakingEventCount => 0; + @override + List get timelineEvents => const []; + @override + Stream> get timelineStream => const Stream.empty(); + @override + void dispose() {} + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeGroupRideService implements GroupRideService { + @override + Stream groupRideStream(String groupRideId) => watchGroupRide(groupRideId); + + @override + Stream> participantsStream(String rideId) => watchParticipants(rideId); + + Stream watchGroupRide(String rideId) { + return Stream.value(GroupRide( + id: rideId, + organizerUid: 'user-1', + organizerHandle: 'canyon_lead', + organizerDisplayName: 'Canyon Lead', + title: 'Weekend Mountain Ride', + description: 'Twisty canyon roads and scenic outlooks', + meetingPoint: const GroupRideLocation( + latitude: 37.0, + longitude: -122.0, + displayName: 'Mountain Viewpoint', + locationType: GroupRideLocationType.meetup, + ), + scheduledStartAt: DateTime.now().add(const Duration(hours: 2)), + departureAt: DateTime.now().add(const Duration(hours: 2, minutes: 30)), + visibility: GroupRideVisibility.public, + joinPolicy: GroupRideJoinPolicy.open, + status: GroupRideStatus.scheduled, + participantCount: 3, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + )); + } + + Stream> watchUpcomingRides() { + return Stream.value([ + GroupRide( + id: 'ride-1', + organizerUid: 'user-1', + organizerHandle: 'canyon_lead', + organizerDisplayName: 'Canyon Lead', + title: 'Morning Cruise', + description: 'Casual morning cruise through the hills', + meetingPoint: const GroupRideLocation( + latitude: 37.0, + longitude: -122.0, + displayName: 'Basecamp', + locationType: GroupRideLocationType.meetup, + ), + scheduledStartAt: DateTime.now().add(const Duration(hours: 4)), + departureAt: DateTime.now().add(const Duration(hours: 4, minutes: 30)), + visibility: GroupRideVisibility.public, + joinPolicy: GroupRideJoinPolicy.open, + status: GroupRideStatus.scheduled, + participantCount: 4, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ]); + } + + Stream> watchActiveRides() => Stream.value([]); + Stream> watchMyRides() => Stream.value([]); + Stream> watchParticipants(String rideId) => Stream.value([ + GroupRideParticipant( + userId: 'user-1', + riderHandle: 'lead_rider', + riderDisplayName: 'Leader Rider', + role: GroupRideRole.lead, + status: ParticipantStatus.accepted, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ]); + + @override + Stream> livePresenceStream(String rideId) => Stream.value([]); + + @override + Future clearLivePresence({ + required String callerUid, + required String groupRideId, + }) async {} + + Future updateLivePresence({ + required String callerUid, + required String groupRideId, + required LiveGroupPresence presence, + }) async {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeRouteService implements RouteService { + final Map _routes = { + 'route-1': MotorSignalRoute( + id: 'route-1', + ownerUid: 'user-1', + ownerHandle: 'testrider', + title: 'Pacific Coast Highway', + description: 'Scenic ocean run', + visibility: RouteVisibility.public, + stops: const [ + RouteStop( + id: 's1', + order: 0, + type: RouteStopType.start, + displayName: 'Santa Monica Pier', + latitude: 34.01, + longitude: -118.49, + address: 'Santa Monica, CA', + notes: 'Meet at pier entrance', + ), + RouteStop( + id: 's2', + order: 1, + type: RouteStopType.destination, + displayName: 'Malibu Overlook', + latitude: 34.03, + longitude: -118.68, + address: 'Malibu, CA', + notes: 'End of ride photo spot', + ), + ], + straightLineDistanceMeters: 25000, + source: RouteSource.planner, + version: 1, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + }; + + @override + Future getRoute(String routeId) async => _routes[routeId]; + Stream watchRoute(String routeId) => Stream.value(_routes[routeId]); + @override + Stream routeStream(String routeId) => watchRoute(routeId); + Stream> watchMyRoutes() => Stream.value(_routes.values.toList()); + Stream> watchPublicRoutes() => Stream.value(_routes.values.toList()); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeRideStateMachine extends StateNotifier + implements RideStateMachine { + _FakeRideStateMachine(super.initialState); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeMapProvider implements AppMapProvider { + @override + AppMapType get type => AppMapType.openStreetMap; + @override + String get name => 'FakeMap'; + + @override + Widget buildMapView({ + required BuildContext context, + required AppLatLng center, + required double zoom, + required List markers, + required List polylines, + required bool isFollowMode, + required MapConfig config, + required ValueChanged? onPositionChanged, + required VoidCallback? onUserInteraction, + }) { + return Container( + key: const ValueKey('fake_map_view'), + color: Colors.black, + child: const Center(child: Text('MAP_VIEW')), + ); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + late SharedPreferences prefs; + late _FakeAuth authService; + late FakeBillingService billingService; + late InMemoryEntitlementStorage entitlementStorage; + late _FakeGroupRideService groupRideService; + late _FakeRouteService routeService; + late _FakeLocationService locationService; + late _FakeMotorcycleRepository motorcycleRepo; + late _FakeFieldTestLogger fieldTestLogger; + + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + SharedPreferences.setMockInitialValues({ + 'app_theme_mode': 'dark', + }); + prefs = await SharedPreferences.getInstance(); + + authService = _FakeAuth( + user: const AuthUser( + uid: 'test-user-123', + email: 'rider@example.com', + displayName: 'Test Rider', + ), + ); + billingService = FakeBillingService(); + entitlementStorage = InMemoryEntitlementStorage(); + groupRideService = _FakeGroupRideService(); + routeService = _FakeRouteService(); + locationService = _FakeLocationService(); + motorcycleRepo = _FakeMotorcycleRepository(); + fieldTestLogger = _FakeFieldTestLogger(); + + // Populate a test motorcycle in Drift DB + await db.into(db.garageMotorcycles).insert( + GarageMotorcyclesCompanion.insert( + year: 2024, + makeId: 'triumph', + makeName: 'Triumph', + modelId: 'speed-triple', + modelName: 'Speed Triple 1200 RS', + dateAdded: DateTime.now(), + lastUpdated: DateTime.now(), + isDefault: const drift.Value(true), + ), + ); + + // Populate a completed test ride in Drift DB + await db.into(db.rides).insert( + RidesCompanion.insert( + startTime: DateTime.now().subtract(const Duration(hours: 1)), + endTime: drift.Value(DateTime.now().subtract(const Duration(minutes: 10))), + distanceMiles: const drift.Value(24.5), + movingTime: const drift.Value(1800), + topSpeedMph: const drift.Value(72.4), + maxLeanLeft: const drift.Value(42.1), + maxLeanRight: const drift.Value(44.8), + maxBrakingG: const drift.Value(0.88), + maxAccelG: const drift.Value(0.65), + ), + ); + }); + + tearDown(() async { + locationService.dispose(); + authService.dispose(); + await db.close(); + }); + + Widget buildTestApp({ + required Widget child, + ThemeMode themeMode = ThemeMode.dark, + AuthUser? customUser, + }) { + final activeAuth = customUser != null ? _FakeAuth(user: customUser) : authService; + + return ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(db), + sharedPreferencesProvider.overrideWithValue(prefs), + authServiceProvider.overrideWithValue(activeAuth), + billingServiceProvider.overrideWithValue(billingService), + entitlementStorageProvider.overrideWithValue(entitlementStorage), + groupRideServiceProvider.overrideWithValue(groupRideService), + routeServiceProvider.overrideWithValue(routeService), + activeMapProvider.overrideWithValue(_FakeMapProvider()), + locationServiceProvider.overrideWithValue(locationService), + motorcycleRepositoryProvider.overrideWithValue(motorcycleRepo), + fieldTestLoggerProvider.overrideWithValue(fieldTestLogger), + activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])), + locationPermissionGrantedProvider.overrideWith((ref) => Future.value(true)), + myRiderProfileProvider.overrideWith((ref) => Stream.value( + RiderProfile( + uid: 'user-1', + handle: 'testrider', + displayName: 'Test Rider', + bio: 'Motorcycle enthusiast', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + )), + myFriendsProvider.overrideWith((ref) => Stream.value([])), + myIncomingRequestsProvider.overrideWith((ref) => Stream.value([])), + myOutgoingRequestsProvider.overrideWith((ref) => Stream.value([])), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, + home: Scaffold(body: child), + ), + ); + } + + Future pumpView(WidgetTester tester, Widget app) async { + await tester.pumpWidget(app); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + } + + Future unmountView(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + } + + // ========================================================================= + // 1. WHOLE-APP CANONICAL 9-VIEWPORT RESPONSIVE MATRIX + // ========================================================================= + group('UI-2I Whole-App 9-Viewport Responsive Matrix', () { + testWidgets('DashboardScreen renders cleanly across all 9 canonical viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const DashboardScreen())); + + expect(find.byType(DashboardScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('MapScreen renders cleanly across all 9 canonical viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const MapScreen())); + + expect(find.byType(MapScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('HistoryScreen renders cleanly across all 9 canonical viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const HistoryScreen())); + + expect(find.byType(HistoryScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('RecordsScreen renders cleanly across all 9 canonical viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const RecordsScreen())); + + expect(find.byType(RecordsScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('GarageScreen renders cleanly across all 9 canonical viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const GarageScreen())); + + expect(find.byType(GarageScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('SettingsScreen renders cleanly across all 9 canonical viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const SettingsScreen())); + + expect(find.byType(SettingsScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('AccountScreen (authenticated) renders cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const AccountScreen())); + + expect(find.byType(AccountScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('AccountScreen (unauthenticated) renders cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const AccountScreen(), customUser: null)); + + expect(find.byType(AccountScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('ProScreen renders cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const ProScreen())); + + expect(find.byType(ProScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('DiagnosticsScreen renders cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const DiagnosticsScreen())); + + expect(find.byType(DiagnosticsScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('FieldTestPreRideCheckScreen renders cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const FieldTestPreRideCheckScreen())); + + expect(find.byType(FieldTestPreRideCheckScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('FieldTestLiveLogScreen renders cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const FieldTestLiveLogScreen())); + + expect(find.byType(FieldTestLiveLogScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('FieldTestReportScreen renders cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const FieldTestReportScreen())); + + expect(find.byType(FieldTestReportScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('GroupRidesHubScreen renders cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const GroupRidesHubScreen())); + + expect(find.byType(GroupRidesHubScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('CreateGroupRideScreen renders cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const CreateGroupRideScreen())); + + expect(find.byType(CreateGroupRideScreen), findsOneWidget, reason: 'Failed on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('GroupRideDetailScreen & LiveGroupRideScreen render cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const GroupRideDetailScreen(rideId: 'ride-1'))); + expect(find.byType(GroupRideDetailScreen), findsOneWidget, reason: 'Failed Detail on ${entry.key}'); + + await pumpView(tester, buildTestApp(child: const LiveGroupRideScreen(rideId: 'ride-1'))); + expect(find.byType(LiveGroupRideScreen), findsOneWidget, reason: 'Failed Live on ${entry.key}'); + expect(tester.takeException(), isNull); + } + await unmountView(tester); + }); + + testWidgets('RoutePlannerScreen & RouteDetailScreen render cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const RoutePlannerScreen())); + expect(find.byType(RoutePlannerScreen), findsOneWidget, reason: 'Failed Planner on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on RoutePlanner on ${entry.key}'); + + await pumpView(tester, buildTestApp(child: const RouteDetailScreen(routeId: 'route-1'))); + expect(find.byType(RouteDetailScreen), findsOneWidget, reason: 'Failed RouteDetail on ${entry.key}'); + expect(tester.takeException(), isNull, reason: 'Exception on RouteDetail on ${entry.key}'); + } + await unmountView(tester); + }); + + testWidgets('AddMotorcycleScreen & MotorcycleDetailScreen render cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const AddMotorcycleScreen())); + expect(find.byType(AddMotorcycleScreen), findsOneWidget, reason: 'Failed AddMotorcycle on ${entry.key}'); + + await pumpView(tester, buildTestApp(child: const MotorcycleDetailScreen(motorcycleId: 1))); + expect(find.byType(MotorcycleDetailScreen), findsOneWidget, reason: 'Failed MotorcycleDetail on ${entry.key}'); + expect(tester.takeException(), isNull); + } + await unmountView(tester); + }); + + testWidgets('RiderProfileScreen & FriendsScreen render cleanly across all 9 viewports', (tester) async { + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: const RiderProfileScreen())); + expect(find.byType(RiderProfileScreen), findsOneWidget, reason: 'Failed RiderProfile on ${entry.key}'); + + await pumpView(tester, buildTestApp(child: const FriendsScreen())); + expect(find.byType(FriendsScreen), findsOneWidget, reason: 'Failed FriendsScreen on ${entry.key}'); + expect(tester.takeException(), isNull); + } + await unmountView(tester); + }); + + testWidgets('RideDetailScreen renders cleanly across all 9 viewports', (tester) async { + final ride = Ride( + id: 1, + startTime: DateTime.now().subtract(const Duration(hours: 1)), + endTime: DateTime.now().subtract(const Duration(minutes: 10)), + distanceMiles: 24.5, + movingTime: 1800, + topSpeedMph: 72.4, + maxLeanLeft: 42.1, + maxLeanRight: 44.8, + maxBrakingG: 0.88, + maxAccelG: 0.65, + distance: 24.5, + stoppedTime: 100, + averageSpeed: 48.0, + topSpeed: 72.4, + elevationGain: 350.0, + elevationLoss: 320.0, + ); + + for (final entry in canonicalViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView(tester, buildTestApp(child: RideDetailScreen(ride: ride))); + + expect(find.byType(RideDetailScreen), findsOneWidget, reason: 'Failed RideDetail on ${entry.key}'); + expect(tester.takeException(), isNull); + } + await unmountView(tester); + }); + }); + + // ========================================================================= + // 2. IME KEYBOARD INSET RESILIENCY MATRIX + // ========================================================================= + group('UI-2I IME Keyboard-Sensitive Form Resiliency Matrix', () { + testWidgets('AddMotorcycleScreen remains scrollable and submit reachable with virtual keyboard', (tester) async { + for (final entry in imeViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + tester.view.viewPadding = const FakeViewPadding(bottom: 300); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetViewPadding(); + }); + + await pumpView(tester, buildTestApp(child: const AddMotorcycleScreen())); + + expect(find.byType(AddMotorcycleScreen), findsOneWidget); + expect(tester.takeException(), isNull, reason: 'Overflow on ${entry.key} with keyboard'); + } + await unmountView(tester); + }); + + testWidgets('CreateGroupRideScreen remains scrollable with virtual keyboard', (tester) async { + for (final entry in imeViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + tester.view.viewPadding = const FakeViewPadding(bottom: 300); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetViewPadding(); + }); + + await pumpView(tester, buildTestApp(child: const CreateGroupRideScreen())); + + expect(find.byType(CreateGroupRideScreen), findsOneWidget); + expect(tester.takeException(), isNull, reason: 'Overflow on ${entry.key} with keyboard'); + } + await unmountView(tester); + }); + + testWidgets('AccountScreen auth form remains scrollable with virtual keyboard', (tester) async { + for (final entry in imeViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + tester.view.viewPadding = const FakeViewPadding(bottom: 300); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetViewPadding(); + }); + + await pumpView(tester, buildTestApp( + child: const AccountScreen(), + customUser: null, + )); + + expect(find.byType(AccountScreen), findsOneWidget); + expect(tester.takeException(), isNull, reason: 'Overflow on ${entry.key} with keyboard'); + } + await unmountView(tester); + }); + + testWidgets('RoutePlannerScreen search & stop input remains reachable with virtual keyboard', (tester) async { + for (final entry in imeViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + tester.view.viewPadding = const FakeViewPadding(bottom: 300); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetViewPadding(); + }); + + await pumpView(tester, buildTestApp(child: const RoutePlannerScreen())); + + expect(find.byType(RoutePlannerScreen), findsOneWidget); + expect(tester.takeException(), isNull, reason: 'Overflow on ${entry.key} with keyboard'); + } + await unmountView(tester); + }); + + testWidgets('RiderProfileScreen handle & bio inputs remain reachable with virtual keyboard', (tester) async { + for (final entry in imeViewports.entries) { + tester.view.physicalSize = entry.value; + tester.view.devicePixelRatio = 1.0; + tester.view.viewPadding = const FakeViewPadding(bottom: 300); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetViewPadding(); + }); + + await pumpView(tester, buildTestApp(child: const RiderProfileScreen())); + + expect(find.byType(RiderProfileScreen), findsOneWidget); + expect(tester.takeException(), isNull, reason: 'Overflow on ${entry.key} with keyboard'); + } + await unmountView(tester); + }); + }); + + // ========================================================================= + // 3. THEME SWITCHING & STATE PRESERVATION MATRIX + // ========================================================================= + group('UI-2I Theme Switching & State Preservation Matrix', () { + testWidgets('Seamless Theme Transition Dark -> Light -> System -> Dark preserves screen state', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + final themeModes = [ThemeMode.dark, ThemeMode.light, ThemeMode.system, ThemeMode.dark]; + + for (final mode in themeModes) { + await pumpView(tester, buildTestApp( + child: const DashboardScreen(), + themeMode: mode, + )); + + expect(find.byType(DashboardScreen), findsOneWidget); + expect(tester.takeException(), isNull); + } + await unmountView(tester); + }); + + testWidgets('MainLayout navigation shell remains intact when resizing across breakpoints', (tester) async { + final sizes = [ + const Size(320, 640), // Compact + const Size(640, 320), // Compact Landscape + const Size(768, 1024), // Medium Tablet + const Size(1024, 768), // Expanded Tablet + const Size(400, 800), // Standard Phone + ]; + + for (final size in sizes) { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + await pumpView( + tester, + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(db), + sharedPreferencesProvider.overrideWithValue(prefs), + authServiceProvider.overrideWithValue(authService), + billingServiceProvider.overrideWithValue(billingService), + entitlementStorageProvider.overrideWithValue(entitlementStorage), + groupRideServiceProvider.overrideWithValue(groupRideService), + routeServiceProvider.overrideWithValue(routeService), + activeMapProvider.overrideWithValue(_FakeMapProvider()), + locationServiceProvider.overrideWithValue(locationService), + motorcycleRepositoryProvider.overrideWithValue(motorcycleRepo), + fieldTestLoggerProvider.overrideWithValue(fieldTestLogger), + activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])), + locationPermissionGrantedProvider.overrideWith((ref) => Future.value(true)), + ], + child: MaterialApp.router( + routerConfig: appRouter, + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: ThemeMode.dark, + ), + ), + ); + + expect(find.byType(MainLayout), findsOneWidget, reason: 'Failed MainLayout at $size'); + expect(tester.takeException(), isNull); + } + await unmountView(tester); + }); + }); + + // ========================================================================= + // 4. RIDER SAFETY UI GATES VERIFICATION + // ========================================================================= + group('UI-2I Rider Safety UI Gate Invariants', () { + testWidgets('Active recording ride does not trigger disruptive modal sheets', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + final activeSession = RideSession( + state: RideState.riding, + startTime: DateTime.now().subtract(const Duration(minutes: 5)), + ); + + await pumpView( + tester, + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(db), + sharedPreferencesProvider.overrideWithValue(prefs), + authServiceProvider.overrideWithValue(authService), + billingServiceProvider.overrideWithValue(billingService), + entitlementStorageProvider.overrideWithValue(entitlementStorage), + groupRideServiceProvider.overrideWithValue(groupRideService), + routeServiceProvider.overrideWithValue(routeService), + activeMapProvider.overrideWithValue(_FakeMapProvider()), + locationServiceProvider.overrideWithValue(locationService), + motorcycleRepositoryProvider.overrideWithValue(motorcycleRepo), + fieldTestLoggerProvider.overrideWithValue(fieldTestLogger), + activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])), + locationPermissionGrantedProvider.overrideWith((ref) => Future.value(true)), + rideStateMachineProvider.overrideWith((ref) => _FakeRideStateMachine(activeSession)), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: AppTheme.darkTheme, + home: const Scaffold(body: DashboardScreen()), + ), + ), + ); + + // Ensure no BottomSheet or AlertDialog is automatically presented + expect(find.byType(BottomSheet), findsNothing); + expect(find.byType(AlertDialog), findsNothing); + expect(find.byType(DashboardScreen), findsOneWidget); + + await unmountView(tester); + }); + }); + + // ========================================================================= + // 5. NAVIGATION FLOWS & DEEP ROUTE TRANSITIONS + // ========================================================================= + group('UI-2I Navigation Flows & Deep Route Transitions', () { + testWidgets('App shell navigates cleanly across all primary destinations', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + final destinations = [ + '/', + '/map', + '/history', + '/records', + '/garage', + '/settings', + '/', + ]; + + for (final path in destinations) { + appRouter.go(path); + await pumpView( + tester, + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(db), + sharedPreferencesProvider.overrideWithValue(prefs), + authServiceProvider.overrideWithValue(authService), + billingServiceProvider.overrideWithValue(billingService), + entitlementStorageProvider.overrideWithValue(entitlementStorage), + groupRideServiceProvider.overrideWithValue(groupRideService), + routeServiceProvider.overrideWithValue(routeService), + activeMapProvider.overrideWithValue(_FakeMapProvider()), + locationServiceProvider.overrideWithValue(locationService), + motorcycleRepositoryProvider.overrideWithValue(motorcycleRepo), + fieldTestLoggerProvider.overrideWithValue(fieldTestLogger), + activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])), + locationPermissionGrantedProvider.overrideWith((ref) => Future.value(true)), + ], + child: MaterialApp.router( + routerConfig: appRouter, + theme: AppTheme.darkTheme, + ), + ), + ); + expect(tester.takeException(), isNull, reason: 'Navigation failed to $path'); + } + await unmountView(tester); + }); + + testWidgets('Deep secondary route transitions render cleanly without exception', (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() => tester.view.resetPhysicalSize()); + + final deepRoutes = [ + '/history/1', + '/garage/1', + '/routes/route-1', + '/group-rides/ride-1', + '/settings/account', + '/settings/pro', + '/settings/diagnostics', + '/settings/diagnostics/live-log', + '/settings/diagnostics/report', + ]; + + for (final path in deepRoutes) { + appRouter.go(path); + await pumpView( + tester, + ProviderScope( + overrides: [ + databaseProvider.overrideWithValue(db), + sharedPreferencesProvider.overrideWithValue(prefs), + authServiceProvider.overrideWithValue(authService), + billingServiceProvider.overrideWithValue(billingService), + entitlementStorageProvider.overrideWithValue(entitlementStorage), + groupRideServiceProvider.overrideWithValue(groupRideService), + routeServiceProvider.overrideWithValue(routeService), + activeMapProvider.overrideWithValue(_FakeMapProvider()), + locationServiceProvider.overrideWithValue(locationService), + motorcycleRepositoryProvider.overrideWithValue(motorcycleRepo), + fieldTestLoggerProvider.overrideWithValue(fieldTestLogger), + activeAlertsStreamProvider.overrideWith((ref) => Stream.value([])), + locationPermissionGrantedProvider.overrideWith((ref) => Future.value(true)), + ], + child: MaterialApp.router( + routerConfig: appRouter, + theme: AppTheme.darkTheme, + ), + ), + ); + expect(tester.takeException(), isNull, reason: 'Deep route failed for $path'); + } + await unmountView(tester); + }); + }); +}