diff --git a/lib/features/routes/route_planner_screen.dart b/lib/features/routes/route_planner_screen.dart index fb4ea53..3439414 100644 --- a/lib/features/routes/route_planner_screen.dart +++ b/lib/features/routes/route_planner_screen.dart @@ -573,6 +573,33 @@ class _RoutePlannerScreenState extends ConsumerState { body: LayoutBuilder( builder: (context, constraints) { final isNarrow = constraints.maxWidth < 360; + final isCompactHeight = constraints.maxHeight < 500; + final isVeryCompactHeight = constraints.maxHeight < 360; + + // Dynamically compute map preview height based on available vertical space: + // 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) + final double mapHeight; + final bool showDisclaimer; + if (constraints.maxHeight >= 620) { + mapHeight = 220.0; + showDisclaimer = true; + } else if (constraints.maxHeight >= 500) { + mapHeight = 160.0; + showDisclaimer = true; + } else if (constraints.maxHeight >= 380) { + mapHeight = 110.0; + showDisclaimer = false; + } else if (constraints.maxHeight >= 280) { + mapHeight = 70.0; + showDisclaimer = false; + } else { + mapHeight = 0.0; + showDisclaimer = false; + } return Column( children: [ @@ -616,104 +643,112 @@ class _RoutePlannerScreenState extends ConsumerState { ), ), - // Map Preview Section (220dp high) - SizedBox( - height: 220, - width: double.infinity, - child: Stack( - children: [ - UnifiedMapView( - center: mapCenter, - zoom: 12.0, - markers: markers, - polylines: polylines, - ), - // Distance & Stops HUD overlay - Positioned( - top: 8, - left: 8, - right: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: const Color(0xFF13151A).withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFF2A2E3D)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - state.stops.length >= 2 - ? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})' - : 'Add 2+ stops for distance', - style: const TextStyle( - color: Color(0xFF00E5FF), - fontSize: 12, - fontWeight: FontWeight.bold, + // Map Preview Section + if (mapHeight > 0) + SizedBox( + height: mapHeight, + width: double.infinity, + child: Stack( + children: [ + UnifiedMapView( + center: mapCenter, + zoom: 12.0, + markers: markers, + polylines: polylines, + ), + // Distance & Stops HUD overlay + Positioned( + top: isCompactHeight ? 4 : 8, + left: 8, + right: 8, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: isCompactHeight ? 4 : 6, + ), + decoration: BoxDecoration( + color: + const Color(0xFF13151A).withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF2A2E3D)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + state.stops.length >= 2 + ? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})' + : 'Add 2+ stops for distance', + style: TextStyle( + color: const Color(0xFF00E5FF), + fontSize: isCompactHeight ? 11 : 12, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, ), - overflow: TextOverflow.ellipsis, ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: isAtLimit - ? const Color( - 0xFFFFB300, - ).withValues(alpha: 0.2) - : const Color( - 0xFF00E5FF, - ).withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - '${state.stops.length}/$maxAllowed STOPS', - style: TextStyle( + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( color: isAtLimit - ? const Color(0xFFFFB300) - : const Color(0xFF00E5FF), - fontSize: 10, - fontWeight: FontWeight.w900, + ? const Color( + 0xFFFFB300, + ).withValues(alpha: 0.2) + : const Color( + 0xFF00E5FF, + ).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${state.stops.length}/$maxAllowed STOPS', + style: TextStyle( + color: isAtLimit + ? const Color(0xFFFFB300) + : const Color(0xFF00E5FF), + fontSize: 10, + fontWeight: FontWeight.w900, + ), ), ), + ], + ), + ), + ), + // Straight-line disclaimer note + if (showDisclaimer) + Positioned( + bottom: 4, + left: 8, + right: 8, + child: Text( + 'Straight-line preview. Navigation handoff launches in Google Maps / Waze.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 9, + shadows: const [ + Shadow(color: Colors.black, blurRadius: 4), + ], ), - ], + ), ), - ), - ), - // Straight-line disclaimer note - Positioned( - bottom: 4, - left: 8, - right: 8, - child: Text( - 'Straight-line preview. Navigation handoff launches in Google Maps / Waze.', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white.withValues(alpha: 0.65), - fontSize: 9, - shadows: const [ - Shadow(color: Colors.black, blurRadius: 4), - ], - ), - ), - ), - ], + ], + ), ), - ), // Route Info Inputs (collapsible / compact) Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: EdgeInsets.fromLTRB( + 16, + isCompactHeight ? 4 : 12, + 16, + isCompactHeight ? 2 : 4, + ), child: Column( children: [ TextField( @@ -723,14 +758,16 @@ class _RoutePlannerScreenState extends ConsumerState { fontWeight: FontWeight.bold, fontSize: 15, ), - decoration: const InputDecoration( + decoration: InputDecoration( hintText: 'Route Title (e.g. Skyline Ridge Loop) *', - hintStyle: TextStyle( + hintStyle: const TextStyle( color: Colors.white30, fontSize: 14, ), isDense: true, - contentPadding: EdgeInsets.symmetric(vertical: 8), + contentPadding: EdgeInsets.symmetric( + vertical: isCompactHeight ? 4 : 8, + ), border: InputBorder.none, ), ), @@ -745,7 +782,9 @@ class _RoutePlannerScreenState extends ConsumerState { onSelected: (v) => setState(() => _visibility = v), color: const Color(0xFF1B1E26), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), + padding: EdgeInsets.symmetric( + vertical: isCompactHeight ? 2 : 4, + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -795,9 +834,9 @@ class _RoutePlannerScreenState extends ConsumerState { // Stop List Controls (Add Stop / Add GPS / Reverse) Padding( - padding: const EdgeInsets.symmetric( + padding: EdgeInsets.symmetric( horizontal: 16, - vertical: 8, + vertical: isCompactHeight ? 4 : 8, ), child: Wrap( spacing: 8, @@ -884,31 +923,39 @@ class _RoutePlannerScreenState extends ConsumerState { Expanded( child: state.stops.isEmpty ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.alt_route, - size: 48, - color: Colors.white.withValues(alpha: 0.15), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isVeryCompactHeight) ...[ + Icon( + Icons.alt_route, + size: isCompactHeight ? 32 : 48, + color: Colors.white.withValues(alpha: 0.15), + ), + SizedBox(height: isCompactHeight ? 6 : 12), + ], + const Text( + 'No stops added yet.', + style: TextStyle( + color: Colors.white54, + fontSize: 14, + ), + ), + const SizedBox(height: 4), + const Text( + 'Tap "Add Stop" or "Current GPS" to begin planning.', + style: TextStyle( + color: Colors.white30, + fontSize: 12, + ), + ), + ], ), - const SizedBox(height: 12), - const Text( - 'No stops added yet.', - style: TextStyle( - color: Colors.white54, - fontSize: 14, - ), - ), - const SizedBox(height: 6), - const Text( - 'Tap "Add Stop" or "Current GPS" to begin planning.', - style: TextStyle( - color: Colors.white30, - fontSize: 12, - ), - ), - ], + ), ), ) : ReorderableListView.builder( diff --git a/test/route_planner_screen_test.dart b/test/route_planner_screen_test.dart index 0c7174f..48b41d6 100644 --- a/test/route_planner_screen_test.dart +++ b/test/route_planner_screen_test.dart @@ -110,4 +110,119 @@ void main() { // No RenderFlex overflow expect(tester.takeException(), isNull); }); + + testWidgets('RoutePlannerScreen handles title editing under Pixel 8a IME keyboard inset without overflow', (tester) async { + // Pixel 8a viewport with keyboard open (height reduced from 857 to 440) + await tester.binding.setSurfaceSize(const Size(385, 440)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser( + uid: 'alice', + isAnonymous: false, + )), + isProSubscriberProvider.overrideWith((ref) => false), + ], + child: const MaterialApp( + home: RoutePlannerScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Find Route Title TextField + final titleField = find.byType(TextField).first; + expect(titleField, findsOneWidget); + + // Enter title text while constrained + await tester.enterText(titleField, 'Marionette Route Validation'); + await tester.pumpAndSettle(); + + expect(find.text('Marionette Route Validation'), findsOneWidget); + + // Verify SAVE button in AppBar and stop controls remain visible and reachable + expect(find.text('SAVE'), findsOneWidget); + expect(find.byKey(const ValueKey('route_planner_add_stop_button')), findsOneWidget); + expect(find.byKey(const ValueKey('route_planner_current_gps_button')), findsOneWidget); + expect(find.text('No stops added yet.'), findsOneWidget); + + // No RenderFlex overflow + expect(tester.takeException(), isNull); + }); + + testWidgets('RoutePlannerScreen handles constrained 360x360 and 320x320 dimensions without overflow', (tester) async { + // Severely constrained 360x360 viewport + await tester.binding.setSurfaceSize(const Size(360, 360)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser( + uid: 'alice', + isAnonymous: false, + )), + isProSubscriberProvider.overrideWith((ref) => false), + ], + child: const MaterialApp( + home: RoutePlannerScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + final titleField = find.byType(TextField).first; + await tester.enterText(titleField, 'Constrained 360'); + await tester.pumpAndSettle(); + + expect(find.text('Constrained 360'), findsOneWidget); + expect(tester.takeException(), isNull); + + // Extremely constrained 320x320 viewport + await tester.binding.setSurfaceSize(const Size(320, 320)); + await tester.pumpAndSettle(); + + await tester.enterText(titleField, 'Constrained 320'); + await tester.pumpAndSettle(); + + expect(find.text('Constrained 320'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('RoutePlannerScreen landscape layout (800x360) renders without RenderFlex overflow', (tester) async { + // Landscape orientation on wide screen + await tester.binding.setSurfaceSize(const Size(800, 360)); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentUserProvider.overrideWith((ref) => const AuthUser( + uid: 'alice', + isAnonymous: false, + )), + isProSubscriberProvider.overrideWith((ref) => false), + ], + child: const MaterialApp( + home: RoutePlannerScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Verify title field and controls render properly + final titleField = find.byType(TextField).first; + await tester.enterText(titleField, 'Skyline Ridge Landscape'); + await tester.pumpAndSettle(); + + expect(find.text('Skyline Ridge Landscape'), findsOneWidget); + expect(find.text('SAVE'), findsOneWidget); + expect(find.byKey(const ValueKey('route_planner_add_stop_button')), findsOneWidget); + expect(find.byKey(const ValueKey('route_planner_current_gps_button')), findsOneWidget); + + // No RenderFlex overflow + expect(tester.takeException(), isNull); + }); }