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(
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<RoutePlannerScreen> {
),
),
// 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<RoutePlannerScreen> {
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<RoutePlannerScreen> {
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<RoutePlannerScreen> {
// 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<RoutePlannerScreen> {
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(

View File

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