Fix/forms keyboard batch2 (#87)

* fix(group_rides): make Join Group Ride dialog keyboard-safe in landscape (#69)

* fix(group_rides): responsive landscape form and keyboard scrolling in CreateGroupRideScreen (#70)

* fix(group_rides): constrain and scroll Attach Saved Route sheet in landscape (#75)

* fix(routes): make RoutePlannerScreen keyboard-safe and scrollable in landscape (#76)

* fix(clubs): make CreateClubScreen keyboard-safe and scrollable in landscape (#77)

* fix(forms): address PR #87 review findings on saved-route sheet and stop list auto-scrolling

---------

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-10-26 01:51:12 -04:00 committed by GitHub
parent 2524cd066e
commit 63df8f9a50
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1773 additions and 792 deletions

View File

@ -378,8 +378,11 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
FocusScope.of(context).unfocus();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: colors.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
@ -388,9 +391,26 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
return Consumer(
builder: (context, ref, _) {
final routesAsync = ref.watch(userRoutesProvider(user.uid));
final viewportSize = MediaQuery.sizeOf(sheetCtx);
final viewInsets = MediaQuery.viewInsetsOf(sheetCtx);
final isCompact = viewportSize.height < 480 || viewInsets.bottom > 0;
final availableHeight = (viewportSize.height - viewInsets.bottom)
.clamp(120.0, viewportSize.height);
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.only(bottom: viewInsets.bottom),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: availableHeight * 0.9,
maxWidth: 560,
),
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
16,
isCompact ? 12 : 16,
16,
isCompact ? 12 : 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@ -403,18 +423,18 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
fontSize: 16,
),
),
const SizedBox(height: 8),
const SizedBox(height: 6),
Text(
'Pre-fills title, meetup location, intermediate waypoints, and destination.',
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
),
const SizedBox(height: 12),
const SizedBox(height: 10),
routesAsync.when(
loading: () => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child:
CircularProgressIndicator(color: colors.cyberCyan),
padding: EdgeInsets.all(isCompact ? 12 : 24),
child: CircularProgressIndicator(
color: colors.cyberCyan),
),
),
error: (err, _) => Text(
@ -424,7 +444,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
data: (routes) {
if (routes.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
padding: EdgeInsets.symmetric(
vertical: isCompact ? 12 : 24),
child: Center(
child: Text(
'No saved routes found.',
@ -435,7 +456,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
}
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.4,
maxHeight: availableHeight * 0.5,
),
child: ListView.builder(
shrinkWrap: true,
@ -443,6 +464,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
itemBuilder: (ctx, idx) {
final route = routes[idx];
return ListTile(
dense: isCompact,
leading: Icon(Icons.alt_route,
color: colors.cyberCyan),
title: Text(
@ -469,6 +491,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
],
),
),
),
),
);
},
);
@ -509,9 +533,34 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
final theme = Theme.of(ctx);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
final screenSize = MediaQuery.sizeOf(ctx);
final isCompactDialog = screenSize.height < 480;
return AlertDialog(
scrollable: true,
backgroundColor: colors.surfaceContainerHigh,
insetPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: isCompactDialog ? 8 : 24,
),
titlePadding: EdgeInsets.fromLTRB(
20,
isCompactDialog ? 12 : 20,
20,
0,
),
contentPadding: EdgeInsets.fromLTRB(
20,
isCompactDialog ? 10 : 16,
20,
isCompactDialog ? 8 : 16,
),
actionsPadding: EdgeInsets.fromLTRB(
16,
0,
16,
isCompactDialog ? 8 : 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outlineVariant),
@ -520,7 +569,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
existing != null ? 'Edit Intermediate Stop' : 'Add Intermediate Stop',
style: TextStyle(color: onSurface, fontSize: 16, fontWeight: FontWeight.bold),
),
content: SingleChildScrollView(
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -541,6 +591,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
const SizedBox(height: 12),
TextField(
controller: nameCtrl,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: onSurface),
decoration: InputDecoration(
labelText: 'Custom Stop Label (Optional)',
@ -589,6 +640,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
const SizedBox(height: 12),
TextField(
controller: notesCtrl,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: onSurface),
decoration: InputDecoration(
labelText: 'Notes (optional)',
@ -698,11 +750,14 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
}
}
final isCompactScreen = MediaQuery.sizeOf(context).height < 500;
return Scaffold(
backgroundColor: colors.surfaceContainer,
appBar: AppBar(
backgroundColor: colors.surfaceContainerHigh,
elevation: 0,
toolbarHeight: isCompactScreen ? 46 : kToolbarHeight,
title: Text(
widget.clubId != null ? 'CREATE CLUB RIDE' : 'CREATE GROUP RIDE',
style: const TextStyle(
@ -738,20 +793,39 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
),
],
),
body: Form(
body: SafeArea(
top: false,
child: Form(
key: _formKey,
child: ResponsiveLayout(
builder: (context, snapshot) {
final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
(snapshot.isLandscape && snapshot.width >= 720);
(snapshot.isLandscape && snapshot.width >= 600);
if (isWide) {
return _buildWideForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError);
return _buildWideForm(
context,
colors,
snapshot,
canSubmit,
clubAsync,
memberAsync,
clubValidationError,
);
}
return _buildCompactForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError);
return _buildCompactForm(
context,
colors,
snapshot,
canSubmit,
clubAsync,
memberAsync,
clubValidationError,
);
},
),
),
),
);
}
@ -764,13 +838,24 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
AsyncValue<ClubMember?>? memberAsync,
String? clubValidationError,
) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
final isCompact = snapshot.isLandscape || snapshot.isShortViewport;
final sectionGap = isCompact ? 12.0 : 24.0;
final outerPadding = isCompact
? const EdgeInsets.symmetric(horizontal: 16, vertical: 10)
: const EdgeInsets.all(24);
final colGap = isCompact ? 16.0 : 24.0;
return Scrollbar(
thumbVisibility: isCompact,
child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: outerPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.clubId != null)
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
_buildClubAffiliationHeader(
colors, clubAsync, memberAsync, clubValidationError),
if (_errorMessage != null) _buildErrorBanner(colors),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
@ -781,36 +866,38 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRideDetailsSection(colors),
const SizedBox(height: 24),
_buildScheduleSection(colors),
const SizedBox(height: 24),
_buildPrivacySection(colors),
_buildRideDetailsSection(colors, isCompact: isCompact),
SizedBox(height: sectionGap),
_buildScheduleSection(colors, isCompact: isCompact),
SizedBox(height: sectionGap),
_buildPrivacySection(colors, isCompact: isCompact),
],
),
),
const SizedBox(width: 24),
SizedBox(width: colGap),
// Right Column: Meeting Point, Destination & Stops, Preview, Submit
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMeetingPointSection(colors),
const SizedBox(height: 24),
_buildDestinationAndStopsSection(colors),
const SizedBox(height: 24),
_buildMeetingPointSection(colors, isCompact: isCompact),
SizedBox(height: sectionGap),
_buildDestinationAndStopsSection(colors,
isCompact: isCompact),
SizedBox(height: sectionGap),
_buildMapPreviewSection(colors, snapshot),
const SizedBox(height: 24),
SizedBox(height: sectionGap),
_buildSubmitButton(colors, canSubmit),
],
),
),
],
),
const SizedBox(height: 32),
SizedBox(height: isCompact ? 16 : 32),
],
),
),
);
}
@ -823,27 +910,37 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
AsyncValue<ClubMember?>? memberAsync,
String? clubValidationError,
) {
return ListView(
padding: const EdgeInsets.all(20),
final isCompact = snapshot.isShortViewport;
final sectionGap = isCompact ? 14.0 : 24.0;
return Scrollbar(
thumbVisibility: isCompact,
child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.all(isCompact ? 14 : 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.clubId != null)
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
_buildClubAffiliationHeader(
colors, clubAsync, memberAsync, clubValidationError),
if (_errorMessage != null) _buildErrorBanner(colors),
_buildRideDetailsSection(colors),
const SizedBox(height: 24),
_buildScheduleSection(colors),
const SizedBox(height: 24),
_buildMeetingPointSection(colors),
const SizedBox(height: 24),
_buildDestinationAndStopsSection(colors),
const SizedBox(height: 24),
_buildRideDetailsSection(colors, isCompact: isCompact),
SizedBox(height: sectionGap),
_buildScheduleSection(colors, isCompact: isCompact),
SizedBox(height: sectionGap),
_buildMeetingPointSection(colors, isCompact: isCompact),
SizedBox(height: sectionGap),
_buildDestinationAndStopsSection(colors, isCompact: isCompact),
SizedBox(height: sectionGap),
_buildMapPreviewSection(colors, snapshot),
const SizedBox(height: 24),
_buildPrivacySection(colors),
const SizedBox(height: 32),
SizedBox(height: sectionGap),
_buildPrivacySection(colors, isCompact: isCompact),
SizedBox(height: isCompact ? 16 : 32),
_buildSubmitButton(colors, canSubmit),
const SizedBox(height: 32),
SizedBox(height: isCompact ? 16 : 32),
],
),
),
);
}
@ -858,8 +955,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
if (clubAsync == null || memberAsync == null || clubAsync.isLoading || memberAsync.isLoading) {
return Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(14),
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
@ -893,8 +990,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
if (validationError != null) {
return Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(14),
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.raceRed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
@ -937,8 +1034,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
final member = memberAsync.value;
return Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
@ -1001,7 +1098,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
),
],
),
const SizedBox(height: 10),
const SizedBox(height: 8),
Text(
'This ride will be affiliated with ${club?.name ?? 'the club'}. Club members can discover it on the club page. Note: Club members are not automatically added as participants.',
style: TextStyle(
@ -1017,7 +1114,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
Widget _buildErrorBanner(MotorSignalThemeColors colors) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.raceRed.withValues(alpha: 0.15),
@ -1039,19 +1136,25 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
);
}
Widget _buildRideDetailsSection(MotorSignalThemeColors colors) {
Widget _buildRideDetailsSection(
MotorSignalThemeColors colors, {
bool isCompact = false,
}) {
final fieldGap = isCompact ? 8.0 : 12.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('1. RIDE DETAILS', colors),
const SizedBox(height: 10),
SizedBox(height: isCompact ? 6 : 10),
TextFormField(
controller: _titleController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: _inputDecoration(
'Ride Title',
'e.g. Blood Mountain Loop',
colors,
isCompact: isCompact,
),
validator: (v) {
if (v == null || v.trim().length < 3) {
@ -1060,50 +1163,58 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
return null;
},
),
const SizedBox(height: 12),
SizedBox(height: fieldGap),
TextFormField(
controller: _descriptionController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
maxLines: 3,
maxLines: isCompact ? 2 : 3,
decoration: _inputDecoration(
'Description & Route Notes',
'Pace, skill level, gear recommendations...',
colors,
isCompact: isCompact,
),
),
],
);
}
Widget _buildScheduleSection(MotorSignalThemeColors colors) {
Widget _buildScheduleSection(
MotorSignalThemeColors colors, {
bool isCompact = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('2. DATE & SCHEDULE', colors),
const SizedBox(height: 10),
SizedBox(height: isCompact ? 6 : 10),
Card(
margin: EdgeInsets.zero,
color: colors.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: colors.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.all(isCompact ? 10 : 16),
child: Column(
children: [
ListTile(
dense: isCompact,
visualDensity: isCompact ? VisualDensity.compact : null,
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.calendar_month, color: colors.cyberCyan),
title: Text('Date',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 13)),
fontSize: isCompact ? 12 : 13)),
subtitle: Text(
'${_scheduledDate.month}/${_scheduledDate.day}/${_scheduledDate.year}',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: 16,
fontSize: isCompact ? 14 : 16,
),
),
trailing: TextButton(
@ -1125,11 +1236,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
),
),
),
Divider(color: colors.outlineVariant),
Divider(
color: colors.outlineVariant,
height: isCompact ? 8 : 16,
),
Row(
children: [
Expanded(
child: ListTile(
dense: isCompact,
visualDensity: isCompact ? VisualDensity.compact : null,
contentPadding: EdgeInsets.zero,
title: Text('Meetup Arrival',
style: TextStyle(
@ -1157,6 +1273,8 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
),
Expanded(
child: ListTile(
dense: isCompact,
visualDensity: isCompact ? VisualDensity.compact : null,
contentPadding: EdgeInsets.zero,
title: Text('Kickstands Up (KSU)',
style: TextStyle(
@ -1192,12 +1310,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
);
}
Widget _buildMeetingPointSection(MotorSignalThemeColors colors) {
Widget _buildMeetingPointSection(
MotorSignalThemeColors colors, {
bool isCompact = false,
}) {
final fieldGap = isCompact ? 8.0 : 12.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('3. MEETING LOCATION', colors),
const SizedBox(height: 10),
SizedBox(height: isCompact ? 6 : 10),
AddressAutocompleteField(
labelText: 'Meeting Place Address / Location *',
hintText: 'Search meetup address or landmark...',
@ -1213,14 +1335,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
_triggerRouteCalculation();
},
),
const SizedBox(height: 12),
SizedBox(height: fieldGap),
TextFormField(
controller: _meetupNameController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: _inputDecoration(
'Meeting Place Name',
'e.g. Dahlonega Square / Shell Station',
colors,
isCompact: isCompact,
),
validator: (v) {
if (v == null || v.trim().isEmpty) {
@ -1229,30 +1353,38 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
return null;
},
),
const SizedBox(height: 12),
SizedBox(height: fieldGap),
TextFormField(
controller: _meetupNotesController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: _inputDecoration(
'Meeting Notes (optional)',
'e.g. Park on the north side near pumps',
colors,
isCompact: isCompact,
),
),
],
);
}
Widget _buildDestinationAndStopsSection(MotorSignalThemeColors colors) {
Widget _buildDestinationAndStopsSection(
MotorSignalThemeColors colors, {
bool isCompact = false,
}) {
final canAdd = _canAddStop;
final totalStops = _totalPlannedStops;
final fieldGap = isCompact ? 8.0 : 12.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('4. DESTINATION & STOPS', colors),
const SizedBox(height: 10),
SizedBox(height: isCompact ? 4 : 10),
SwitchListTile(
dense: isCompact,
visualDensity: isCompact ? VisualDensity.compact : null,
contentPadding: EdgeInsets.zero,
activeThumbColor: colors.cyberCyan,
title: Text('Specific Destination',
@ -1266,7 +1398,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
},
),
if (_hasDestination) ...[
const SizedBox(height: 8),
SizedBox(height: isCompact ? 4 : 8),
AddressAutocompleteField(
labelText: 'Destination Address / Location *',
hintText: 'Search destination address or scenic peak...',
@ -1282,22 +1414,24 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
_triggerRouteCalculation();
},
),
const SizedBox(height: 12),
SizedBox(height: fieldGap),
TextFormField(
controller: _destNameController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: _inputDecoration(
'Destination Name',
'e.g. Brasstown Bald Summit',
colors,
isCompact: isCompact,
),
),
],
const SizedBox(height: 16),
SizedBox(height: isCompact ? 10 : 16),
// USE EXISTING ROUTE Card
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
margin: EdgeInsets.only(bottom: isCompact ? 8 : 12),
padding: EdgeInsets.all(isCompact ? 10 : 12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(10),
@ -1349,16 +1483,21 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
},
)
else
ElevatedButton.icon(
Flexible(
child: ElevatedButton.icon(
onPressed: () => _showSelectRouteSheet(colors),
icon: const Icon(Icons.alt_route, size: 14),
label: const Text('USE EXISTING ROUTE'),
label: const Text(
'USE EXISTING ROUTE',
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
foregroundColor: colors.cyberCyan,
side: BorderSide(color: colors.cyberCyan),
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
textStyle: const TextStyle(
@ -1367,6 +1506,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
),
),
),
),
],
),
),
@ -1437,6 +1577,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
for (int i = 0; i < _stops.length; i++)
ListTile(
key: ValueKey('stop_$i'),
dense: isCompact,
tileColor: colors.surfaceContainerHigh,
onTap: () => _addStopDialog(colors, i),
leading: CircleAvatar(
@ -1575,7 +1716,9 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
_destinationPlace?.latLng ??
const AppLatLng(34.5378, -83.9849);
final mapHeight = snapshot.isLandscape ? 170.0 : 210.0;
final mapHeight = snapshot.isShortViewport
? 130.0
: (snapshot.isLandscape ? 160.0 : 210.0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -1602,7 +1745,7 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
),
],
),
const SizedBox(height: 10),
SizedBox(height: snapshot.isShortViewport ? 6 : 10),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Container(
@ -1718,54 +1861,80 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
);
}
Widget _buildPrivacySection(MotorSignalThemeColors colors) {
Widget _buildPrivacySection(
MotorSignalThemeColors colors, {
bool isCompact = false,
}) {
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final fieldGap = isCompact ? 8.0 : 12.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader('5. PRIVACY & PARTICIPATION', colors),
const SizedBox(height: 10),
SizedBox(height: isCompact ? 6 : 10),
DropdownButtonFormField<GroupRideVisibility>(
initialValue: _visibility,
isExpanded: true,
dropdownColor: colors.surfaceContainerHigh,
style: TextStyle(color: onSurface),
decoration: _inputDecoration('Visibility', '', colors),
decoration: _inputDecoration(
'Visibility',
'',
colors,
isCompact: isCompact,
),
items: GroupRideVisibility.values.map((v) {
return DropdownMenuItem(
value: v,
child: Text(v.label, style: TextStyle(color: onSurface)),
child: Text(
v.label,
style: TextStyle(color: onSurface),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (val) {
if (val != null) setState(() => _visibility = val);
},
),
const SizedBox(height: 12),
SizedBox(height: fieldGap),
DropdownButtonFormField<GroupRideJoinPolicy>(
initialValue: _joinPolicy,
isExpanded: true,
dropdownColor: colors.surfaceContainerHigh,
style: TextStyle(color: onSurface),
decoration: _inputDecoration('Join Policy', '', colors),
decoration: _inputDecoration(
'Join Policy',
'',
colors,
isCompact: isCompact,
),
items: GroupRideJoinPolicy.values.map((p) {
return DropdownMenuItem(
value: p,
child: Text(p.label, style: TextStyle(color: onSurface)),
child: Text(
p.label,
style: TextStyle(color: onSurface),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (val) {
if (val != null) setState(() => _joinPolicy = val);
},
),
const SizedBox(height: 12),
SizedBox(height: fieldGap),
TextFormField(
keyboardType: TextInputType.number,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: onSurface),
decoration: _inputDecoration(
'Participant Limit (optional)',
'Leave blank for no limit (min 2)',
colors,
isCompact: isCompact,
),
onChanged: (val) {
final parsed = int.tryParse(val.trim());
@ -1826,11 +1995,16 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
InputDecoration _inputDecoration(
String label,
String hint,
MotorSignalThemeColors colors,
) {
MotorSignalThemeColors colors, {
bool isCompact = false,
}) {
final theme = Theme.of(context);
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
return InputDecoration(
isDense: isCompact,
contentPadding: isCompact
? const EdgeInsets.symmetric(horizontal: 12, vertical: 10)
: null,
labelText: label,
hintText: hint.isNotEmpty ? hint : null,
labelStyle: TextStyle(color: onSurfaceVariant, fontSize: 13),

View File

@ -1439,6 +1439,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: colors.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
@ -1447,9 +1448,24 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
return Consumer(
builder: (context, ref, _) {
final routesAsync = ref.watch(userRoutesProvider(user.uid));
final screenSize = MediaQuery.sizeOf(sheetCtx);
final viewInsets = MediaQuery.viewInsetsOf(sheetCtx);
final isCompact = screenSize.height < 480 || viewInsets.bottom > 0;
final availableHeight = (screenSize.height - viewInsets.bottom)
.clamp(120.0, screenSize.height);
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.only(bottom: viewInsets.bottom),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: availableHeight * 0.9,
maxWidth: 560,
),
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: isCompact ? 10 : 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@ -1462,13 +1478,14 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
fontSize: 16,
),
),
const SizedBox(height: 12),
SizedBox(height: isCompact ? 8 : 12),
routesAsync.when(
loading: () => Center(
child: Padding(
padding: const EdgeInsets.all(24),
child:
CircularProgressIndicator(color: colors.cyberCyan),
padding: EdgeInsets.all(isCompact ? 12 : 24),
child: CircularProgressIndicator(
color: colors.cyberCyan,
),
),
),
error: (err, _) => Text(
@ -1478,9 +1495,12 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
data: (routes) {
if (routes.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
padding: EdgeInsets.symmetric(
vertical: isCompact ? 8 : 24,
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'You have no saved routes yet.',
@ -1490,7 +1510,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
.onSurfaceVariant,
),
),
const SizedBox(height: 12),
SizedBox(height: isCompact ? 8 : 12),
ElevatedButton(
onPressed: () {
Navigator.pop(sheetCtx);
@ -1511,7 +1531,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
}
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.4,
maxHeight: availableHeight * 0.55,
),
child: ListView.builder(
shrinkWrap: true,
@ -1519,6 +1539,7 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
itemBuilder: (ctx, idx) {
final route = routes[idx];
return ListTile(
dense: isCompact,
leading: Icon(
Icons.alt_route,
color: colors.cyberCyan,
@ -1551,10 +1572,12 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
onTap: () async {
Navigator.pop(sheetCtx);
try {
final user = ref.read(currentUserProvider);
final user =
ref.read(currentUserProvider);
if (user == null || user.uid.isEmpty) {
throw StateError(
'Sign-in required to attach route.');
'Sign-in required to attach route.',
);
}
await ref
.read(routeServiceProvider)
@ -1599,6 +1622,8 @@ class _GroupRideDetailScreenState extends ConsumerState<GroupRideDetailScreen> {
],
),
),
),
),
);
},
);

View File

@ -115,9 +115,17 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
if (!isAuthed) {
return _buildAccountRequiredNotice(context, colors);
}
final showBanner = snapshot.height >= 160;
return Column(
children: [
_buildMemberProfileBanner(context, ref, user, colors),
if (showBanner)
_buildMemberProfileBanner(
context,
ref,
user,
colors,
isCompact: snapshot.isShortViewport,
),
Expanded(
child: TabBarView(
controller: _tabController,
@ -151,8 +159,9 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
BuildContext context,
WidgetRef ref,
AuthUser user,
MotorSignalThemeColors colors,
) {
MotorSignalThemeColors colors, {
bool isCompact = false,
}) {
final profileAsync = ref.watch(myRiderProfileProvider);
final isPro = ref.watch(isProSubscriberProvider);
final profile = profileAsync.valueOrNull;
@ -177,7 +186,10 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
bottom: BorderSide(color: colors.surfaceBorder, width: 1.0),
),
),
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
padding: EdgeInsets.symmetric(
horizontal: 16.0,
vertical: isCompact ? 8.0 : 12.0,
),
child: Column(
children: [
Row(
@ -575,9 +587,10 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
}) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
@ -650,19 +663,59 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
final controller = TextEditingController();
showDialog(
context: context,
builder: (dialogCtx) => AlertDialog(
builder: (dialogCtx) {
final size = MediaQuery.sizeOf(dialogCtx);
final viewInsets = MediaQuery.viewInsetsOf(dialogCtx);
final availableHeight = size.height - viewInsets.bottom;
final isCompactVertical = availableHeight < 280;
return AlertDialog(
scrollable: true,
backgroundColor: colors.surfaceContainerHigh,
insetPadding: EdgeInsets.symmetric(
horizontal: 24,
vertical: isCompactVertical ? 8 : 24,
),
titlePadding: EdgeInsets.fromLTRB(
20,
isCompactVertical ? 12 : 20,
20,
isCompactVertical ? 6 : 12,
),
contentPadding: EdgeInsets.fromLTRB(
20,
0,
20,
isCompactVertical ? 8 : 16,
),
actionsPadding: EdgeInsets.fromLTRB(
16,
0,
16,
isCompactVertical ? 8 : 14,
),
title: Row(
children: [
Icon(Icons.group_add, color: colors.cyberCyan, size: 24),
Icon(
Icons.group_add,
color: colors.cyberCyan,
size: isCompactVertical ? 20 : 24,
),
const SizedBox(width: 10),
const Text(
Flexible(
child: Text(
'Join Group Ride',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: isCompactVertical ? 16 : 18,
fontWeight: FontWeight.bold,
),
),
),
],
),
content: Column(
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -670,19 +723,27 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
'Enter the Ride ID or shared link:',
style: TextStyle(fontSize: 13),
),
const SizedBox(height: 12),
SizedBox(height: isCompactVertical ? 8 : 12),
TextField(
key: const ValueKey('join_ride_id_field'),
controller: controller,
autofocus: true,
scrollPadding: const EdgeInsets.fromLTRB(20, 20, 20, 80),
decoration: InputDecoration(
hintText: 'e.g. ride_1790320722076_4btn4W',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
isDense: isCompactVertical,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: isCompactVertical ? 8 : 10,
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
@ -701,10 +762,14 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
Navigator.of(dialogCtx).pop();
context.push('/group-rides/$rideId');
},
child: const Text('VIEW RIDE', style: TextStyle(fontWeight: FontWeight.bold)),
child: const Text(
'VIEW RIDE',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
);
},
);
}

View File

@ -148,8 +148,33 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
context: context,
builder: (dialogCtx) => StatefulBuilder(
builder: (context, setDialogState) {
final screenSize = MediaQuery.sizeOf(context);
final isCompactDialog = screenSize.height < 480;
return AlertDialog(
scrollable: true,
backgroundColor: colors.surfaceContainer,
insetPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: isCompactDialog ? 8 : 24,
),
titlePadding: EdgeInsets.fromLTRB(
20,
isCompactDialog ? 12 : 20,
20,
0,
),
contentPadding: EdgeInsets.fromLTRB(
20,
isCompactDialog ? 10 : 16,
20,
isCompactDialog ? 8 : 16,
),
actionsPadding: EdgeInsets.fromLTRB(
16,
0,
16,
isCompactDialog ? 8 : 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outlineVariant),
@ -161,7 +186,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
fontWeight: FontWeight.bold,
),
),
content: SingleChildScrollView(
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
@ -196,6 +222,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
const SizedBox(height: 12),
TextField(
controller: nameCtrl,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration(
labelText: 'Stop Name *',
@ -629,11 +656,14 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
);
}
final isCompactScreen = MediaQuery.sizeOf(context).height < 500;
return Scaffold(
backgroundColor: colors.surfaceContainer,
appBar: AppBar(
backgroundColor: colors.surfaceContainerHigh,
elevation: 0,
toolbarHeight: isCompactScreen ? 46 : kToolbarHeight,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface),
onPressed: () => context.pop(),
@ -682,12 +712,13 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
final isNarrow = snapshot.width < 360;
final isExpandedLayout =
snapshot.sizeClass == WindowSizeClass.expanded ||
(snapshot.isLandscape && snapshot.width >= 720);
(snapshot.isLandscape && snapshot.width >= 600);
if (isExpandedLayout) {
return _buildExpandedLayout(
context,
colors,
snapshot,
state,
maxAllowed,
isAtLimit,
@ -718,6 +749,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
Widget _buildExpandedLayout(
BuildContext context,
MotorSignalThemeColors colors,
ResponsiveSnapshot snapshot,
RoutePlannerState state,
int maxAllowed,
bool isAtLimit,
@ -726,6 +758,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
List<AppPolyline> polylines,
bool isNarrow,
) {
final isCompactHeight = snapshot.height < 500;
final isVeryCompactHeight = snapshot.height < 360;
final showDisclaimer = snapshot.height >= 260;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -748,15 +784,22 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
polylines: polylines,
),
Positioned(
top: 12,
left: 12,
right: 12,
child: _buildHudOverlay(colors, state, maxAllowed, isAtLimit, false),
top: isCompactHeight ? 6 : 12,
left: isCompactHeight ? 8 : 12,
right: isCompactHeight ? 8 : 12,
child: _buildHudOverlay(
colors,
state,
maxAllowed,
isAtLimit,
isCompactHeight,
),
),
if (showDisclaimer)
Positioned(
bottom: 8,
left: 12,
right: 12,
bottom: 6,
left: 8,
right: 8,
child: _buildDisclaimerText(),
),
],
@ -767,7 +810,40 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
// Right Column: Form inputs + Stop list
Expanded(
flex: 6,
child: Column(
child: isCompactHeight
? _buildStopsList(
colors,
state,
true,
isVeryCompactHeight,
header: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_errorMessage != null) _buildErrorBanner(colors),
Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 4),
child: _buildRouteInfoInputs(colors, true),
),
Divider(color: colors.outlineVariant, height: 1),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child:
_buildStopControls(colors, state, isNarrow, true),
),
if (state.stops.isEmpty)
_buildEmptyStopsContent(
colors,
true,
isVeryCompactHeight,
),
],
),
)
: Column(
children: [
if (_errorMessage != null) _buildErrorBanner(colors),
Padding(
@ -829,6 +905,65 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
showDisclaimer = false;
}
if (isCompactHeight) {
return _buildStopsList(
colors,
state,
true,
isVeryCompactHeight,
header: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_errorMessage != null) _buildErrorBanner(colors),
if (mapHeight > 0)
SizedBox(
height: mapHeight,
width: double.infinity,
child: Stack(
children: [
UnifiedMapView(
key: const ValueKey('route_planner_map_compact'),
center: mapCenter,
zoom: 12.0,
markers: markers,
polylines: polylines,
),
Positioned(
top: 4,
left: 8,
right: 8,
child: _buildHudOverlay(
colors,
state,
maxAllowed,
isAtLimit,
true,
),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: _buildRouteInfoInputs(colors, true),
),
Divider(color: colors.outlineVariant, height: 1),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: _buildStopControls(colors, state, isNarrow, true),
),
if (state.stops.isEmpty)
_buildEmptyStopsContent(
colors,
true,
isVeryCompactHeight,
),
],
),
);
}
return Column(
children: [
if (_errorMessage != null) _buildErrorBanner(colors),
@ -848,7 +983,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
polylines: polylines,
),
Positioned(
top: isCompactHeight ? 4 : 8,
top: 8,
left: 8,
right: 8,
child: _buildHudOverlay(
@ -856,7 +991,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
state,
maxAllowed,
isAtLimit,
isCompactHeight,
false,
),
),
if (showDisclaimer)
@ -872,24 +1007,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
// Route Info Inputs
Padding(
padding: EdgeInsets.fromLTRB(
16,
isCompactHeight ? 4 : 12,
16,
isCompactHeight ? 2 : 4,
),
child: _buildRouteInfoInputs(colors, isCompactHeight),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: _buildRouteInfoInputs(colors, false),
),
Divider(color: colors.outlineVariant, height: 1),
// Stop List Controls
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: isCompactHeight ? 4 : 8,
),
child: _buildStopControls(colors, state, isNarrow, isCompactHeight),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _buildStopControls(colors, state, isNarrow, false),
),
// Reorderable Stop List
@ -897,7 +1024,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
child: _buildStopsList(
colors,
state,
isCompactHeight,
false,
isVeryCompactHeight,
),
),
@ -1072,6 +1199,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
children: [
TextField(
controller: _titleController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(
color: onSurface,
fontWeight: FontWeight.bold,
@ -1163,15 +1291,17 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
colors: colors,
isCompact: isCompactHeight,
),
const SizedBox(height: 8),
SizedBox(height: isCompactHeight ? 6 : 8),
Wrap(
spacing: 8,
runSpacing: 8,
runSpacing: 6,
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
Wrap(
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
ElevatedButton.icon(
key: const ValueKey('route_planner_add_stop_button'),
@ -1181,17 +1311,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(
padding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
vertical: isCompactHeight ? 6 : 8,
),
minimumSize: isCompactHeight ? const Size(0, 34) : null,
tapTargetSize: isCompactHeight
? MaterialTapTargetSize.shrinkWrap
: null,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
key: const ValueKey('route_planner_current_gps_button'),
onPressed: _addCurrentLocationStop,
@ -1206,10 +1339,14 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
),
style: OutlinedButton.styleFrom(
side: BorderSide(color: colors.outlineVariant),
padding: const EdgeInsets.symmetric(
padding: EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
vertical: isCompactHeight ? 6 : 8,
),
minimumSize: isCompactHeight ? const Size(0, 34) : null,
tapTargetSize: isCompactHeight
? MaterialTapTargetSize.shrinkWrap
: null,
textStyle: const TextStyle(fontSize: 12),
),
),
@ -1248,38 +1385,32 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
);
}
Widget _buildStopsList(
Widget _buildEmptyStopsContent(
MotorSignalThemeColors colors,
RoutePlannerState state,
bool isCompactHeight,
bool isVeryCompactHeight,
) {
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
if (state.stops.isEmpty) {
return Center(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
return Padding(
padding: EdgeInsets.symmetric(vertical: isCompactHeight ? 6 : 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isVeryCompactHeight) ...[
Icon(
Icons.alt_route,
size: isCompactHeight ? 32 : 48,
size: isCompactHeight ? 28 : 48,
color: onSurfaceVariant.withValues(alpha: 0.2),
),
SizedBox(height: isCompactHeight ? 6 : 12),
SizedBox(height: isCompactHeight ? 4 : 12),
],
Text(
'No stops added yet.',
style: TextStyle(
color: onSurfaceVariant,
fontSize: 14,
fontSize: isCompactHeight ? 13 : 14,
),
),
const SizedBox(height: 4),
@ -1287,17 +1418,41 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
'Tap "Add Stop" or "Current GPS" to begin planning.',
style: TextStyle(
color: onSurfaceVariant.withValues(alpha: 0.6),
fontSize: 12,
fontSize: isCompactHeight ? 11 : 12,
),
),
],
),
);
}
Widget _buildStopsList(
MotorSignalThemeColors colors,
RoutePlannerState state,
bool isCompactHeight,
bool isVeryCompactHeight, {
Widget? header,
}) {
final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
if (header == null && state.stops.isEmpty) {
return Center(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: _buildEmptyStopsContent(
colors,
isCompactHeight,
isVeryCompactHeight,
),
),
);
}
return ReorderableListView.builder(
header: header,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,

View File

@ -302,6 +302,7 @@ class _AddressAutocompleteFieldState
focusNode: _focusNode,
enabled: widget.enabled && !_isResolving,
autofocus: widget.autofocus,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: onSurface, fontSize: 14),
onChanged: _onTextChanged,
validator: (val) {

View File

@ -178,12 +178,17 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
);
}
final mediaSize = MediaQuery.sizeOf(context);
final isCompactScreen = mediaSize.height < 500;
return Scaffold(
appBar: AppBar(
title: const Text(
toolbarHeight: isCompactScreen ? 44 : null,
title: Text(
'Create Club',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: isCompactScreen ? 16 : null,
fontWeight: FontWeight.w700,
),
),
@ -191,104 +196,17 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
body: SafeArea(
child: ResponsiveLayout(
builder: (context, responsive) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700),
child: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(18.0),
children: [
// Header card explaining Club identity
Container(
padding: const EdgeInsets.all(14.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colors.outlineVariant,
width: 0.8,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.shield_outlined,
color: colors.cyberCyan,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Found a Motorcycle Club',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
'As the founder, you will be designated the Club Owner with administrative authority over admissions, group rides, and club settings.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
height: 1.3,
),
),
],
),
),
],
),
),
const SizedBox(height: 20),
final isCompact =
responsive.isLandscape || responsive.isShortViewport;
final isTwoColumn =
responsive.isLandscape && responsive.width >= 600;
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
// Submission error alert if present
if (_submissionError != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.raceRed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: colors.raceRed.withValues(alpha: 0.4),
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: colors.raceRed,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_submissionError!,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: colors.raceRed,
),
),
),
],
),
),
const SizedBox(height: 16),
],
// Club Name
TextFormField(
final nameField = TextFormField(
controller: _nameController,
enabled: !_isSubmitting,
textCapitalization: TextCapitalization.words,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
decoration: InputDecoration(
labelText: 'Club Name *',
hintText: 'e.g. Ridge Runners MC',
@ -326,11 +244,9 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
}
return null;
},
),
const SizedBox(height: 18),
);
// Slug / Handle with live debounce & availability checking
SlugInputField(
final slugField = SlugInputField(
controller: _slugController,
enabled: !_isSubmitting,
onValidationChanged: (isValid) {
@ -338,16 +254,253 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
_isSlugValidAndAvailable = isValid;
});
},
);
final regionSection = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFieldLabel(
'Home base',
'Where this club primarily rides or organizes from.',
colors,
),
SizedBox(height: isCompact ? 6 : 8),
TextFormField(
controller: _regionController,
enabled: !_isSubmitting,
textCapitalization: TextCapitalization.words,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
decoration: InputDecoration(
labelText: 'Home region (optional)',
hintText: 'e.g. North Georgia, NorCal, Phoenix Metro',
prefixIcon: const Icon(Icons.location_on_outlined),
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
),
),
],
);
final focusSection = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFieldLabel(
'Rider community',
'Name the motorcycle types or riding style this club is for.',
colors,
),
SizedBox(height: isCompact ? 6 : 8),
TextFormField(
controller: _focusController,
enabled: !_isSubmitting,
textCapitalization: TextCapitalization.words,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
decoration: InputDecoration(
labelText: 'Motorcycle focus (optional)',
hintText: 'e.g. Sport bikes, ADV, Cruisers, Mixed rides',
prefixIcon: const Icon(Icons.two_wheeler_outlined),
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
),
),
],
);
final visibilitySection = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Club Visibility',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
SizedBox(height: isCompact ? 6 : 8),
_buildVisibilitySelector(colors, theme),
],
);
final admissionSection = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Admission Policy',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
SizedBox(height: isCompact ? 6 : 8),
_buildAdmissionSelector(colors, theme),
],
);
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: isTwoColumn ? 860 : 700),
child: Form(
key: _formKey,
child: Scrollbar(
thumbVisibility: isCompact,
child: ListView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
isCompact ? 16.0 : 18.0,
isCompact ? 10.0 : 18.0,
isCompact ? 16.0 : 18.0,
(isCompact ? 16.0 : 32.0) +
(bottomInset > 0 ? 16.0 : 0.0),
),
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header card explaining Club identity
Container(
padding: EdgeInsets.all(isCompact ? 10.0 : 14.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colors.outlineVariant,
width: 0.8,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.shield_outlined,
color: colors.cyberCyan,
size: isCompact ? 20 : 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Found a Motorcycle Club',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
'As the founder, you will be designated the Club Owner with administrative authority over admissions, group rides, and club settings.',
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12,
color:
theme.colorScheme.onSurfaceVariant,
height: 1.3,
),
),
],
),
),
],
),
),
SizedBox(height: isCompact ? 12 : 20),
// Submission error alert if present
if (_submissionError != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colors.raceRed.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: colors.raceRed.withValues(alpha: 0.4),
),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: colors.raceRed,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_submissionError!,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 12.5,
color: colors.raceRed,
),
),
),
],
),
),
SizedBox(height: isCompact ? 12 : 16),
],
// Club Name & Slug / Handle
if (isTwoColumn)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: nameField),
const SizedBox(width: 16),
Expanded(child: slugField),
],
)
else ...[
nameField,
const SizedBox(height: 18),
slugField,
],
SizedBox(height: isCompact ? 12 : 18),
// Description
TextFormField(
controller: _descController,
enabled: !_isSubmitting,
maxLines: 3,
maxLines: isCompact ? 2 : 3,
maxLength: 500,
textCapitalization: TextCapitalization.sentences,
scrollPadding:
const EdgeInsets.only(bottom: 120, top: 24),
decoration: InputDecoration(
labelText: 'Description (Optional)',
hintText:
@ -377,109 +530,45 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
),
),
),
const SizedBox(height: 14),
SizedBox(height: isCompact ? 10 : 14),
// Give optional discovery fields enough room for a clear
// label and example on narrow phone layouts.
_buildFieldLabel(
'Home base',
'Where this club primarily rides or organizes from.',
colors,
),
const SizedBox(height: 8),
TextFormField(
controller: _regionController,
enabled: !_isSubmitting,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
labelText: 'Home region (optional)',
hintText: 'e.g. North Georgia, NorCal, Phoenix Metro',
prefixIcon: const Icon(Icons.location_on_outlined),
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
),
),
// Home base & Rider community
if (isTwoColumn)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: regionSection),
const SizedBox(width: 16),
Expanded(child: focusSection),
],
)
else ...[
regionSection,
const SizedBox(height: 18),
_buildFieldLabel(
'Rider community',
'Name the motorcycle types or riding style this club is for.',
colors,
),
const SizedBox(height: 8),
TextFormField(
controller: _focusController,
enabled: !_isSubmitting,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
labelText: 'Motorcycle focus (optional)',
hintText:
'e.g. Sport bikes, ADV, Cruisers, Mixed rides',
prefixIcon: const Icon(Icons.two_wheeler_outlined),
filled: true,
fillColor: colors.surfaceContainer,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colors.outlineVariant,
width: 0.8,
),
),
),
),
const SizedBox(height: 24),
focusSection,
],
SizedBox(height: isCompact ? 14 : 24),
// Visibility Selection
Text(
'Club Visibility',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
const SizedBox(height: 8),
_buildVisibilitySelector(colors, theme),
// Visibility & Admission Policy Selection
if (isTwoColumn)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: visibilitySection),
const SizedBox(width: 16),
Expanded(child: admissionSection),
],
)
else ...[
visibilitySection,
const SizedBox(height: 20),
// Admission Policy Selection
Text(
'Admission Policy',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 13,
fontWeight: FontWeight.w700,
color: colors.cyberCyan,
),
),
const SizedBox(height: 8),
_buildAdmissionSelector(colors, theme),
const SizedBox(height: 32),
admissionSection,
],
SizedBox(height: isCompact ? 16 : 32),
// Submit Button
SizedBox(
height: 50,
height: isCompact ? 46 : 50,
child: FilledButton(
key: const ValueKey('create_club_submit_button'),
onPressed:
@ -515,9 +604,12 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
),
),
),
const SizedBox(height: 32),
SizedBox(height: isCompact ? 12 : 32),
],
),
],
),
),
),
),
);
@ -533,12 +625,19 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
) {
return DropdownButtonFormField<ClubVisibility>(
initialValue: _visibility,
isExpanded: true,
decoration: _choiceDecoration('Who can find this club', colors),
dropdownColor: colors.surfaceContainerHigh,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: theme.colorScheme.onSurface),
items: ClubVisibility.values
.map(
(value) => DropdownMenuItem(value: value, child: Text(value.label)),
(value) => DropdownMenuItem(
value: value,
child: Text(
value.label,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: _isSubmitting
@ -555,12 +654,19 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
) {
return DropdownButtonFormField<ClubAdmissionPolicy>(
initialValue: _admissionPolicy,
isExpanded: true,
decoration: _choiceDecoration('How riders join', colors),
dropdownColor: colors.surfaceContainerHigh,
style: const TextStyle(color: Colors.white),
style: TextStyle(color: theme.colorScheme.onSurface),
items: ClubAdmissionPolicy.values
.map(
(value) => DropdownMenuItem(value: value, child: Text(value.label)),
(value) => DropdownMenuItem(
value: value,
child: Text(
value.label,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: _isSubmitting

View File

@ -151,6 +151,7 @@ class _SlugInputFieldState extends ConsumerState<SlugInputField> {
enableSuggestions: false,
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.none,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9-]')),
_LowerCaseTextFormatter(),

View File

@ -20,6 +20,7 @@ import 'package:motorcycle_dash/services/social/rider_profile_models.dart';
import 'package:motorcycle_dash/services/social/social_providers.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/services/routes/route_providers.dart';
class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {}
@ -403,6 +404,106 @@ void main() {
expect(tester.takeException(), isNull);
});
}
testWidgets(
'Attach Saved Route sheet empty state does not overflow in landscape (800x360) with keyboard inset and portrait (#75)',
(tester) async {
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewInsets);
final fakeAuth = _FakeAuth(user: testUser);
for (final viewportSize in const [
Size(800, 360),
Size(640, 320),
Size(400, 800),
]) {
final isLandscape = viewportSize.width > viewportSize.height;
tester.view.physicalSize = viewportSize;
tester.view.devicePixelRatio = 1.0;
tester.view.resetViewInsets();
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
authServiceProvider.overrideWithValue(fakeAuth),
currentUserProvider.overrideWithValue(testUser),
groupRideStreamProvider('ride_101').overrideWith(
(ref) => Stream.value(testRide),
),
groupRideParticipantsProvider('ride_101').overrideWith(
(ref) => Stream.value(testParticipants),
),
groupRideLivePresenceProvider('ride_101').overrideWith(
(ref) => Stream.value(testPresences),
),
myRiderProfileProvider.overrideWith(
(ref) => Stream.value(testProfile),
),
userRoutesProvider('uid_lead').overrideWith(
(ref) => Stream.value(const []),
),
],
child: MaterialApp(
theme: AppTheme.darkTheme,
home: const GroupRideDetailScreen(rideId: 'ride_101'),
),
),
);
await tester.pumpAndSettle();
final attachBtn =
find.byKey(const ValueKey('attach_saved_route_button'));
await tester.scrollUntilVisible(
attachBtn,
150.0,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
await tester.tap(attachBtn);
await tester.pump();
expect(tester.takeException(), isNull);
await tester.pump(const Duration(milliseconds: 300));
expect(tester.takeException(), isNull);
expect(find.text('ATTACH SAVED ROUTE'), findsWidgets);
expect(find.text('You have no saved routes yet.'), findsOneWidget);
expect(find.text('CREATE A ROUTE'), findsOneWidget);
final createBtnRect =
tester.getRect(find.widgetWithText(ElevatedButton, 'CREATE A ROUTE'));
expect(
createBtnRect.bottom,
lessThanOrEqualTo(viewportSize.height),
reason: 'CREATE A ROUTE button should fit inside $viewportSize',
);
if (isLandscape) {
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
await tester.pump();
expect(tester.takeException(), isNull);
await tester.pump(const Duration(milliseconds: 200));
expect(tester.takeException(), isNull);
await tester.ensureVisible(find.text('CREATE A ROUTE'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final createBtnKeyboardRect = tester
.getRect(find.widgetWithText(ElevatedButton, 'CREATE A ROUTE'));
expect(
createBtnKeyboardRect.bottom,
lessThanOrEqualTo(viewportSize.height - 180 + 1.0),
reason:
'CREATE A ROUTE should remain above keyboard at $viewportSize',
);
}
// Dismiss sheet
Navigator.of(tester.element(find.text('CREATE A ROUTE'))).pop();
await tester.pumpAndSettle();
}
});
});
group('LiveGroupRideScreen Responsive Matrix', () {

View File

@ -229,5 +229,125 @@ void main() {
);
expect(find.text('Sign In / Register'), findsOneWidget);
});
testWidgets(
'landscape (800x360) with keyboard inset scrolls to lower fields and submit button, and portrait renders cleanly (#77)',
(tester) async {
const user = AuthUser(uid: 'alice', isAnonymous: false);
final fakeService = FakeCreateClubService();
// 1. Landscape (800x360) with simulated soft keyboard inset
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
tester.view.padding = const FakeViewPadding(top: 24);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewInsets);
addTearDown(tester.view.resetPadding);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => user),
clubServiceProvider.overrideWithValue(fakeService),
],
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const CreateClubScreen(),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Scrollbar should be present for clear scroll affordance in landscape
expect(find.byType(Scrollbar), findsOneWidget);
// Two-column layout in landscape: Club Name and Club Handle share a row
final nameFinder = find.widgetWithText(
TextFormField,
'Club Name *',
skipOffstage: false,
);
final slugFinder = find.widgetWithText(
TextFormField,
'Club Handle / Slug *',
skipOffstage: false,
);
expect(nameFinder, findsOneWidget);
expect(slugFinder, findsOneWidget);
await tester.ensureVisible(nameFinder);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getTopLeft(nameFinder).dx,
lessThan(tester.getTopLeft(slugFinder).dx),
);
// Enter Club Name & Handle
await tester.enterText(nameFinder, 'Canyon Carvers MC');
await tester.enterText(slugFinder, 'canyon-carvers-mc');
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(tester.takeException(), isNull);
// Lower fields and submit button remain mounted and scrollable into the visible area above keyboard
final regionFinder = find.widgetWithText(
TextFormField,
'Home region (optional)',
skipOffstage: false,
);
final focusFinder = find.widgetWithText(
TextFormField,
'Motorcycle focus (optional)',
skipOffstage: false,
);
final submitFinder = find.byKey(
const ValueKey('create_club_submit_button'),
skipOffstage: false,
);
expect(regionFinder, findsOneWidget);
expect(focusFinder, findsOneWidget);
expect(submitFinder, findsOneWidget);
await tester.ensureVisible(regionFinder);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getRect(regionFinder).bottom,
lessThanOrEqualTo(360.0 - 180.0 + 1.0),
);
await tester.enterText(regionFinder, 'SoCal');
await tester.enterText(focusFinder, 'Sport bikes');
await tester.pump();
// Scroll to and tap the primary submit button while keyboard inset is active
await tester.ensureVisible(submitFinder);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getRect(submitFinder).bottom,
lessThanOrEqualTo(360.0 - 180.0 + 1.0),
);
await tester.tap(submitFinder);
await tester.pump();
expect(fakeService.lastCreatedClub, isNotNull);
expect(fakeService.lastCreatedClub!.name, equals('Canyon Carvers MC'));
expect(fakeService.lastCreatedClub!.homeRegion, equals('SoCal'));
// 2. Portrait (400x800) with keyboard inset check
tester.view.physicalSize = const Size(400, 800);
tester.view.viewInsets = const FakeViewPadding(bottom: 280);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
},
);
});
}

View File

@ -1130,5 +1130,94 @@ void main() {
final publishBtn = tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, 'PUBLISH CLUB RIDE'));
expect(publishBtn.onPressed, isNull);
});
testWidgets(
'renders compact two-column layout in landscape (640x360 and 800x360) with keyboard-safe scrolling and single-column in portrait (#70)',
(tester) async {
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewInsets);
for (final landscapeSize in const [Size(640, 360), Size(800, 360)]) {
tester.view.physicalSize = landscapeSize;
tester.view.devicePixelRatio = 1.0;
tester.view.resetViewInsets();
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
myRiderProfileProvider.overrideWith((ref) => Stream.value(profile)),
],
child: const MaterialApp(
debugShowCheckedModeBanner: false,
home: CreateGroupRideScreen(),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Two-column layout: 3. MEETING LOCATION is to the right of 1. RIDE DETAILS
final detailsTopLeft = tester.getTopLeft(find.text('1. RIDE DETAILS'));
final meetingTopLeft =
tester.getTopLeft(find.text('3. MEETING LOCATION'));
expect(
meetingTopLeft.dx,
greaterThan(detailsTopLeft.dx + 200),
reason: 'Expected two-column layout at $landscapeSize',
);
// Compact vertical spacing in landscape so section 2 starts higher up
final scheduleTopLeft =
tester.getTopLeft(find.text('2. DATE & SCHEDULE'));
expect(
scheduleTopLeft.dy,
lessThan(225.0),
reason: 'Expected compact vertical spacing at $landscapeSize',
);
// Simulate keyboard inset in landscape and focus a lower field
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final notesField =
find.widgetWithText(TextFormField, 'Meeting Notes (optional)');
await tester.ensureVisible(notesField);
await tester.pumpAndSettle();
await tester.enterText(notesField, 'Meet by the north pumps');
await tester.pumpAndSettle();
expect(find.text('Meet by the north pumps'), findsOneWidget);
final notesRect = tester.getRect(notesField);
expect(
notesRect.bottom,
lessThanOrEqualTo(landscapeSize.height - 180 + 1.0),
);
final publishBtn =
find.widgetWithText(ElevatedButton, 'PUBLISH GROUP RIDE');
await tester.ensureVisible(publishBtn);
await tester.pumpAndSettle();
expect(publishBtn, findsOneWidget);
expect(tester.takeException(), isNull);
}
// Portrait check with keyboard inset
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1.0;
tester.view.viewInsets = const FakeViewPadding(bottom: 280);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final publishBtnPortrait =
find.widgetWithText(ElevatedButton, 'PUBLISH GROUP RIDE');
await tester.ensureVisible(publishBtnPortrait);
await tester.pumpAndSettle();
expect(publishBtnPortrait, findsOneWidget);
expect(tester.takeException(), isNull);
});
});
}

View File

@ -130,6 +130,77 @@ void main() {
expect(find.text('3'), findsOneWidget);
});
testWidgets(
'Join Group Ride dialog renders without overflow in landscape with keyboard inset and in portrait (#69)',
(tester) async {
const user = AuthUser(
uid: 'alice',
email: 'alice@example.com',
isAnonymous: false,
);
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
tester.view.viewInsets = const FakeViewPadding(bottom: 180);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetViewInsets);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
upcomingGroupRidesProvider.overrideWith(
(ref) => Stream.value([]),
),
],
child: const MaterialApp(
home: GroupRidesHubScreen(),
),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('group_rides_join_appbar_button')),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final fieldFinder = find.byKey(const ValueKey('join_ride_id_field'));
final submitFinder =
find.byKey(const ValueKey('join_ride_dialog_submit_button'));
await tester.ensureVisible(fieldFinder);
await tester.pumpAndSettle();
await tester.enterText(fieldFinder, 'ride_test_123');
await tester.pumpAndSettle();
expect(find.text('ride_test_123'), findsOneWidget);
await tester.ensureVisible(submitFinder);
await tester.pumpAndSettle();
expect(submitFinder, findsOneWidget);
expect(find.text('CANCEL'), findsOneWidget);
expect(tester.takeException(), isNull);
// Dismiss and verify portrait as well
await tester.tap(find.text('CANCEL'));
await tester.pumpAndSettle();
tester.view.physicalSize = const Size(400, 800);
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const ValueKey('group_rides_join_appbar_button')),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(fieldFinder, findsOneWidget);
expect(submitFinder, findsOneWidget);
});
for (final size in const [Size(800, 360), Size(640, 360), Size(400, 800)]) {
testWidgets('ride card renders without overflow on $size (#68)',
(tester) async {

View File

@ -225,4 +225,77 @@ void main() {
// No RenderFlex overflow
expect(tester.takeException(), isNull);
});
testWidgets(
'RoutePlannerScreen landscape (800x360) with status bar and keyboard inset scrolls cleanly and portrait renders cleanly (#76)',
(tester) async {
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPadding);
addTearDown(tester.view.resetViewInsets);
tester.view.physicalSize = const Size(800, 360);
tester.view.devicePixelRatio = 1.0;
tester.view.padding = const FakeViewPadding(top: 24);
tester.view.resetViewInsets();
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();
// No 2.5px overflow on landscape with status bar padding
expect(tester.takeException(), isNull);
// Simulate keyboard opening in landscape (260.5px inset)
tester.view.viewInsets = const FakeViewPadding(bottom: 200);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final titleField = find.byType(TextField).first;
await tester.ensureVisible(titleField);
await tester.pumpAndSettle();
await tester.enterText(titleField, 'Cherohala Skyway');
await tester.pumpAndSettle();
expect(find.text('Cherohala Skyway'), findsOneWidget);
expect(tester.takeException(), isNull);
// Scroll down to ADD STOP button and open Add Stop dialog with keyboard inset
final addStopBtn =
find.byKey(const ValueKey('route_planner_add_stop_button'));
await tester.ensureVisible(addStopBtn);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
await tester.tap(addStopBtn);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Add Route Stop'), findsOneWidget);
await tester.ensureVisible(find.text('CANCEL'));
await tester.pumpAndSettle();
await tester.tap(find.text('CANCEL'));
await tester.pumpAndSettle();
// Portrait check with keyboard inset
tester.view.physicalSize = const Size(400, 800);
tester.view.padding = const FakeViewPadding(top: 24);
tester.view.viewInsets = const FakeViewPadding(bottom: 280);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('SAVE'), findsOneWidget);
});
}