fix: resolve route planner keyboard overflow

This commit is contained in:
Developer 2026-09-04 20:57:12 -04:00
parent ae13dd81e1
commit c380f10f21
2 changed files with 279 additions and 117 deletions

View File

@ -573,6 +573,33 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
body: LayoutBuilder( body: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 360; 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( return Column(
children: [ children: [
@ -616,104 +643,112 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
), ),
// Map Preview Section (220dp high) // Map Preview Section
SizedBox( if (mapHeight > 0)
height: 220, SizedBox(
width: double.infinity, height: mapHeight,
child: Stack( width: double.infinity,
children: [ child: Stack(
UnifiedMapView( children: [
center: mapCenter, UnifiedMapView(
zoom: 12.0, center: mapCenter,
markers: markers, zoom: 12.0,
polylines: polylines, markers: markers,
), polylines: polylines,
// Distance & Stops HUD overlay ),
Positioned( // Distance & Stops HUD overlay
top: 8, Positioned(
left: 8, top: isCompactHeight ? 4 : 8,
right: 8, left: 8,
child: Container( right: 8,
padding: const EdgeInsets.symmetric( child: Container(
horizontal: 10, padding: EdgeInsets.symmetric(
vertical: 6, horizontal: 8,
), vertical: isCompactHeight ? 4 : 6,
decoration: BoxDecoration( ),
color: const Color(0xFF13151A).withValues(alpha: 0.9), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8), color:
border: Border.all(color: const Color(0xFF2A2E3D)), const Color(0xFF13151A).withValues(alpha: 0.9),
), borderRadius: BorderRadius.circular(8),
child: Row( border: Border.all(color: const Color(0xFF2A2E3D)),
mainAxisAlignment: MainAxisAlignment.spaceBetween, ),
children: [ child: Row(
Expanded( mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Text( children: [
state.stops.length >= 2 Expanded(
? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})' child: Text(
: 'Add 2+ stops for distance', state.stops.length >= 2
style: const TextStyle( ? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})'
color: Color(0xFF00E5FF), : 'Add 2+ stops for distance',
fontSize: 12, style: TextStyle(
fontWeight: FontWeight.bold, color: const Color(0xFF00E5FF),
fontSize: isCompactHeight ? 11 : 12,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
), ),
overflow: TextOverflow.ellipsis,
), ),
), const SizedBox(width: 8),
const SizedBox(width: 8), Container(
Container( padding: const EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric( horizontal: 6,
horizontal: 6, vertical: 2,
vertical: 2, ),
), decoration: BoxDecoration(
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(
color: isAtLimit color: isAtLimit
? const Color(0xFFFFB300) ? const Color(
: const Color(0xFF00E5FF), 0xFFFFB300,
fontSize: 10, ).withValues(alpha: 0.2)
fontWeight: FontWeight.w900, : 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) // Route Info Inputs (collapsible / compact)
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), padding: EdgeInsets.fromLTRB(
16,
isCompactHeight ? 4 : 12,
16,
isCompactHeight ? 2 : 4,
),
child: Column( child: Column(
children: [ children: [
TextField( TextField(
@ -723,14 +758,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 15, fontSize: 15,
), ),
decoration: const InputDecoration( decoration: InputDecoration(
hintText: 'Route Title (e.g. Skyline Ridge Loop) *', hintText: 'Route Title (e.g. Skyline Ridge Loop) *',
hintStyle: TextStyle( hintStyle: const TextStyle(
color: Colors.white30, color: Colors.white30,
fontSize: 14, fontSize: 14,
), ),
isDense: true, isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 8), contentPadding: EdgeInsets.symmetric(
vertical: isCompactHeight ? 4 : 8,
),
border: InputBorder.none, border: InputBorder.none,
), ),
), ),
@ -745,7 +782,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
onSelected: (v) => setState(() => _visibility = v), onSelected: (v) => setState(() => _visibility = v),
color: const Color(0xFF1B1E26), color: const Color(0xFF1B1E26),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: EdgeInsets.symmetric(
vertical: isCompactHeight ? 2 : 4,
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -795,9 +834,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
// Stop List Controls (Add Stop / Add GPS / Reverse) // Stop List Controls (Add Stop / Add GPS / Reverse)
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 8, vertical: isCompactHeight ? 4 : 8,
), ),
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
@ -884,31 +923,39 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
Expanded( Expanded(
child: state.stops.isEmpty child: state.stops.isEmpty
? Center( ? Center(
child: Column( child: SingleChildScrollView(
mainAxisSize: MainAxisSize.min, physics: const ClampingScrollPhysics(),
children: [ child: Padding(
Icon( padding: const EdgeInsets.symmetric(vertical: 4),
Icons.alt_route, child: Column(
size: 48, mainAxisSize: MainAxisSize.min,
color: Colors.white.withValues(alpha: 0.15), 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( : ReorderableListView.builder(

View File

@ -110,4 +110,119 @@ void main() {
// No RenderFlex overflow // No RenderFlex overflow
expect(tester.takeException(), isNull); 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);
});
} }