fix: resolve route planner keyboard overflow
This commit is contained in:
parent
ae13dd81e1
commit
c380f10f21
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue