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,9 +643,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
), ),
// Map Preview Section (220dp high) // Map Preview Section
if (mapHeight > 0)
SizedBox( SizedBox(
height: 220, height: mapHeight,
width: double.infinity, width: double.infinity,
child: Stack( child: Stack(
children: [ children: [
@ -630,16 +658,17 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
// Distance & Stops HUD overlay // Distance & Stops HUD overlay
Positioned( Positioned(
top: 8, top: isCompactHeight ? 4 : 8,
left: 8, left: 8,
right: 8, right: 8,
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 10, horizontal: 8,
vertical: 6, vertical: isCompactHeight ? 4 : 6,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF13151A).withValues(alpha: 0.9), color:
const Color(0xFF13151A).withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF2A2E3D)), border: Border.all(color: const Color(0xFF2A2E3D)),
), ),
@ -651,9 +680,9 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
state.stops.length >= 2 state.stops.length >= 2
? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})' ? 'Straight-line: ${state.formattedDistanceMiles} (${state.formattedDistanceKm})'
: 'Add 2+ stops for distance', : 'Add 2+ stops for distance',
style: const TextStyle( style: TextStyle(
color: Color(0xFF00E5FF), color: const Color(0xFF00E5FF),
fontSize: 12, fontSize: isCompactHeight ? 11 : 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@ -691,6 +720,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
), ),
// Straight-line disclaimer note // Straight-line disclaimer note
if (showDisclaimer)
Positioned( Positioned(
bottom: 4, bottom: 4,
left: 8, left: 8,
@ -713,7 +743,12 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
// 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,15 +923,21 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
Expanded( Expanded(
child: state.stops.isEmpty child: state.stops.isEmpty
? Center( ? Center(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (!isVeryCompactHeight) ...[
Icon( Icon(
Icons.alt_route, Icons.alt_route,
size: 48, size: isCompactHeight ? 32 : 48,
color: Colors.white.withValues(alpha: 0.15), color: Colors.white.withValues(alpha: 0.15),
), ),
const SizedBox(height: 12), SizedBox(height: isCompactHeight ? 6 : 12),
],
const Text( const Text(
'No stops added yet.', 'No stops added yet.',
style: TextStyle( style: TextStyle(
@ -900,7 +945,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
fontSize: 14, fontSize: 14,
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
const Text( const Text(
'Tap "Add Stop" or "Current GPS" to begin planning.', 'Tap "Add Stop" or "Current GPS" to begin planning.',
style: TextStyle( style: TextStyle(
@ -910,6 +955,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
], ],
), ),
),
),
) )
: ReorderableListView.builder( : ReorderableListView.builder(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(

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);
});
} }