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

View File

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

View File

@ -115,9 +115,17 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
if (!isAuthed) { if (!isAuthed) {
return _buildAccountRequiredNotice(context, colors); return _buildAccountRequiredNotice(context, colors);
} }
final showBanner = snapshot.height >= 160;
return Column( return Column(
children: [ children: [
_buildMemberProfileBanner(context, ref, user, colors), if (showBanner)
_buildMemberProfileBanner(
context,
ref,
user,
colors,
isCompact: snapshot.isShortViewport,
),
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
@ -151,8 +159,9 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
AuthUser user, AuthUser user,
MotorSignalThemeColors colors, MotorSignalThemeColors colors, {
) { bool isCompact = false,
}) {
final profileAsync = ref.watch(myRiderProfileProvider); final profileAsync = ref.watch(myRiderProfileProvider);
final isPro = ref.watch(isProSubscriberProvider); final isPro = ref.watch(isProSubscriberProvider);
final profile = profileAsync.valueOrNull; final profile = profileAsync.valueOrNull;
@ -177,7 +186,10 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
bottom: BorderSide(color: colors.surfaceBorder, width: 1.0), 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( child: Column(
children: [ children: [
Row( Row(
@ -575,9 +587,10 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Center( return Center(
child: Padding( child: SingleChildScrollView(
padding: const EdgeInsets.all(32.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
@ -650,19 +663,59 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
final controller = TextEditingController(); final controller = TextEditingController();
showDialog( showDialog(
context: context, 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, 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( title: Row(
children: [ 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 SizedBox(width: 10),
const Text( Flexible(
child: Text(
'Join Group Ride', '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, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -670,19 +723,27 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
'Enter the Ride ID or shared link:', 'Enter the Ride ID or shared link:',
style: TextStyle(fontSize: 13), style: TextStyle(fontSize: 13),
), ),
const SizedBox(height: 12), SizedBox(height: isCompactVertical ? 8 : 12),
TextField( TextField(
key: const ValueKey('join_ride_id_field'), key: const ValueKey('join_ride_id_field'),
controller: controller, controller: controller,
autofocus: true, autofocus: true,
scrollPadding: const EdgeInsets.fromLTRB(20, 20, 20, 80),
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'e.g. ride_1790320722076_4btn4W', hintText: 'e.g. ride_1790320722076_4btn4W',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), isDense: isCompactVertical,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: isCompactVertical ? 8 : 10,
),
), ),
), ),
], ],
), ),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(), onPressed: () => Navigator.of(dialogCtx).pop(),
@ -701,10 +762,14 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
Navigator.of(dialogCtx).pop(); Navigator.of(dialogCtx).pop();
context.push('/group-rides/$rideId'); 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, context: context,
builder: (dialogCtx) => StatefulBuilder( builder: (dialogCtx) => StatefulBuilder(
builder: (context, setDialogState) { builder: (context, setDialogState) {
final screenSize = MediaQuery.sizeOf(context);
final isCompactDialog = screenSize.height < 480;
return AlertDialog( return AlertDialog(
scrollable: true,
backgroundColor: colors.surfaceContainer, 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( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.outlineVariant), side: BorderSide(color: colors.outlineVariant),
@ -161,7 +186,8 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
content: SingleChildScrollView( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -196,6 +222,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
controller: nameCtrl, controller: nameCtrl,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface), style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Stop Name *', labelText: 'Stop Name *',
@ -629,11 +656,14 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
); );
} }
final isCompactScreen = MediaQuery.sizeOf(context).height < 500;
return Scaffold( return Scaffold(
backgroundColor: colors.surfaceContainer, backgroundColor: colors.surfaceContainer,
appBar: AppBar( appBar: AppBar(
backgroundColor: colors.surfaceContainerHigh, backgroundColor: colors.surfaceContainerHigh,
elevation: 0, elevation: 0,
toolbarHeight: isCompactScreen ? 46 : kToolbarHeight,
leading: IconButton( leading: IconButton(
icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface), icon: Icon(Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface),
onPressed: () => context.pop(), onPressed: () => context.pop(),
@ -682,12 +712,13 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
final isNarrow = snapshot.width < 360; final isNarrow = snapshot.width < 360;
final isExpandedLayout = final isExpandedLayout =
snapshot.sizeClass == WindowSizeClass.expanded || snapshot.sizeClass == WindowSizeClass.expanded ||
(snapshot.isLandscape && snapshot.width >= 720); (snapshot.isLandscape && snapshot.width >= 600);
if (isExpandedLayout) { if (isExpandedLayout) {
return _buildExpandedLayout( return _buildExpandedLayout(
context, context,
colors, colors,
snapshot,
state, state,
maxAllowed, maxAllowed,
isAtLimit, isAtLimit,
@ -718,6 +749,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
Widget _buildExpandedLayout( Widget _buildExpandedLayout(
BuildContext context, BuildContext context,
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
ResponsiveSnapshot snapshot,
RoutePlannerState state, RoutePlannerState state,
int maxAllowed, int maxAllowed,
bool isAtLimit, bool isAtLimit,
@ -726,6 +758,10 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
List<AppPolyline> polylines, List<AppPolyline> polylines,
bool isNarrow, bool isNarrow,
) { ) {
final isCompactHeight = snapshot.height < 500;
final isVeryCompactHeight = snapshot.height < 360;
final showDisclaimer = snapshot.height >= 260;
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -748,15 +784,22 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
polylines: polylines, polylines: polylines,
), ),
Positioned( Positioned(
top: 12, top: isCompactHeight ? 6 : 12,
left: 12, left: isCompactHeight ? 8 : 12,
right: 12, right: isCompactHeight ? 8 : 12,
child: _buildHudOverlay(colors, state, maxAllowed, isAtLimit, false), child: _buildHudOverlay(
colors,
state,
maxAllowed,
isAtLimit,
isCompactHeight,
), ),
),
if (showDisclaimer)
Positioned( Positioned(
bottom: 8, bottom: 6,
left: 12, left: 8,
right: 12, right: 8,
child: _buildDisclaimerText(), child: _buildDisclaimerText(),
), ),
], ],
@ -767,7 +810,40 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
// Right Column: Form inputs + Stop list // Right Column: Form inputs + Stop list
Expanded( Expanded(
flex: 6, 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: [ children: [
if (_errorMessage != null) _buildErrorBanner(colors), if (_errorMessage != null) _buildErrorBanner(colors),
Padding( Padding(
@ -829,6 +905,65 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
showDisclaimer = false; 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( return Column(
children: [ children: [
if (_errorMessage != null) _buildErrorBanner(colors), if (_errorMessage != null) _buildErrorBanner(colors),
@ -848,7 +983,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
polylines: polylines, polylines: polylines,
), ),
Positioned( Positioned(
top: isCompactHeight ? 4 : 8, top: 8,
left: 8, left: 8,
right: 8, right: 8,
child: _buildHudOverlay( child: _buildHudOverlay(
@ -856,7 +991,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
state, state,
maxAllowed, maxAllowed,
isAtLimit, isAtLimit,
isCompactHeight, false,
), ),
), ),
if (showDisclaimer) if (showDisclaimer)
@ -872,24 +1007,16 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
// Route Info Inputs // Route Info Inputs
Padding( Padding(
padding: EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
16, child: _buildRouteInfoInputs(colors, false),
isCompactHeight ? 4 : 12,
16,
isCompactHeight ? 2 : 4,
),
child: _buildRouteInfoInputs(colors, isCompactHeight),
), ),
Divider(color: colors.outlineVariant, height: 1), Divider(color: colors.outlineVariant, height: 1),
// Stop List Controls // Stop List Controls
Padding( Padding(
padding: EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
horizontal: 16, child: _buildStopControls(colors, state, isNarrow, false),
vertical: isCompactHeight ? 4 : 8,
),
child: _buildStopControls(colors, state, isNarrow, isCompactHeight),
), ),
// Reorderable Stop List // Reorderable Stop List
@ -897,7 +1024,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
child: _buildStopsList( child: _buildStopsList(
colors, colors,
state, state,
isCompactHeight, false,
isVeryCompactHeight, isVeryCompactHeight,
), ),
), ),
@ -1072,6 +1199,7 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
children: [ children: [
TextField( TextField(
controller: _titleController, controller: _titleController,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle( style: TextStyle(
color: onSurface, color: onSurface,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -1163,15 +1291,17 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
colors: colors, colors: colors,
isCompact: isCompactHeight, isCompact: isCompactHeight,
), ),
const SizedBox(height: 8), SizedBox(height: isCompactHeight ? 6 : 8),
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 6,
alignment: WrapAlignment.spaceBetween, alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
Row( Wrap(
mainAxisSize: MainAxisSize.min, spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
ElevatedButton.icon( ElevatedButton.icon(
key: const ValueKey('route_planner_add_stop_button'), key: const ValueKey('route_planner_add_stop_button'),
@ -1181,17 +1311,20 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colors.cyberCyan, backgroundColor: colors.cyberCyan,
foregroundColor: Colors.black, foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 10, horizontal: 10,
vertical: 8, vertical: isCompactHeight ? 6 : 8,
), ),
minimumSize: isCompactHeight ? const Size(0, 34) : null,
tapTargetSize: isCompactHeight
? MaterialTapTargetSize.shrinkWrap
: null,
textStyle: const TextStyle( textStyle: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
), ),
const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
key: const ValueKey('route_planner_current_gps_button'), key: const ValueKey('route_planner_current_gps_button'),
onPressed: _addCurrentLocationStop, onPressed: _addCurrentLocationStop,
@ -1206,10 +1339,14 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
), ),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
side: BorderSide(color: colors.outlineVariant), side: BorderSide(color: colors.outlineVariant),
padding: const EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 10, 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), textStyle: const TextStyle(fontSize: 12),
), ),
), ),
@ -1248,38 +1385,32 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
); );
} }
Widget _buildStopsList( Widget _buildEmptyStopsContent(
MotorSignalThemeColors colors, MotorSignalThemeColors colors,
RoutePlannerState state,
bool isCompactHeight, bool isCompactHeight,
bool isVeryCompactHeight, bool isVeryCompactHeight,
) { ) {
final theme = Theme.of(context); final theme = Theme.of(context);
final onSurface = theme.colorScheme.onSurface;
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
if (state.stops.isEmpty) { return Padding(
return Center( padding: EdgeInsets.symmetric(vertical: isCompactHeight ? 6 : 12),
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (!isVeryCompactHeight) ...[ if (!isVeryCompactHeight) ...[
Icon( Icon(
Icons.alt_route, Icons.alt_route,
size: isCompactHeight ? 32 : 48, size: isCompactHeight ? 28 : 48,
color: onSurfaceVariant.withValues(alpha: 0.2), color: onSurfaceVariant.withValues(alpha: 0.2),
), ),
SizedBox(height: isCompactHeight ? 6 : 12), SizedBox(height: isCompactHeight ? 4 : 12),
], ],
Text( Text(
'No stops added yet.', 'No stops added yet.',
style: TextStyle( style: TextStyle(
color: onSurfaceVariant, color: onSurfaceVariant,
fontSize: 14, fontSize: isCompactHeight ? 13 : 14,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -1287,17 +1418,41 @@ class _RoutePlannerScreenState extends ConsumerState<RoutePlannerScreen> {
'Tap "Add Stop" or "Current GPS" to begin planning.', 'Tap "Add Stop" or "Current GPS" to begin planning.',
style: TextStyle( style: TextStyle(
color: onSurfaceVariant.withValues(alpha: 0.6), 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( return ReorderableListView.builder(
header: header,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 4, vertical: 4,

View File

@ -302,6 +302,7 @@ class _AddressAutocompleteFieldState
focusNode: _focusNode, focusNode: _focusNode,
enabled: widget.enabled && !_isResolving, enabled: widget.enabled && !_isResolving,
autofocus: widget.autofocus, autofocus: widget.autofocus,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
style: TextStyle(color: onSurface, fontSize: 14), style: TextStyle(color: onSurface, fontSize: 14),
onChanged: _onTextChanged, onChanged: _onTextChanged,
validator: (val) { 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( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text( toolbarHeight: isCompactScreen ? 44 : null,
title: Text(
'Create Club', 'Create Club',
style: TextStyle( style: TextStyle(
fontFamily: AppTypography.fontHeadline, fontFamily: AppTypography.fontHeadline,
fontSize: isCompactScreen ? 16 : null,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
@ -191,104 +196,17 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
body: SafeArea( body: SafeArea(
child: ResponsiveLayout( child: ResponsiveLayout(
builder: (context, responsive) { builder: (context, responsive) {
return Center( final isCompact =
child: ConstrainedBox( responsive.isLandscape || responsive.isShortViewport;
constraints: const BoxConstraints(maxWidth: 700), final isTwoColumn =
child: Form( responsive.isLandscape && responsive.width >= 600;
key: _formKey, final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
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),
// Submission error alert if present final nameField = TextFormField(
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(
controller: _nameController, controller: _nameController,
enabled: !_isSubmitting, enabled: !_isSubmitting,
textCapitalization: TextCapitalization.words, textCapitalization: TextCapitalization.words,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Club Name *', labelText: 'Club Name *',
hintText: 'e.g. Ridge Runners MC', hintText: 'e.g. Ridge Runners MC',
@ -326,11 +244,9 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
} }
return null; return null;
}, },
), );
const SizedBox(height: 18),
// Slug / Handle with live debounce & availability checking final slugField = SlugInputField(
SlugInputField(
controller: _slugController, controller: _slugController,
enabled: !_isSubmitting, enabled: !_isSubmitting,
onValidationChanged: (isValid) { onValidationChanged: (isValid) {
@ -338,16 +254,253 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
_isSlugValidAndAvailable = isValid; _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), const SizedBox(height: 18),
slugField,
],
SizedBox(height: isCompact ? 12 : 18),
// Description // Description
TextFormField( TextFormField(
controller: _descController, controller: _descController,
enabled: !_isSubmitting, enabled: !_isSubmitting,
maxLines: 3, maxLines: isCompact ? 2 : 3,
maxLength: 500, maxLength: 500,
textCapitalization: TextCapitalization.sentences, textCapitalization: TextCapitalization.sentences,
scrollPadding:
const EdgeInsets.only(bottom: 120, top: 24),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Description (Optional)', labelText: 'Description (Optional)',
hintText: 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 // Home base & Rider community
// label and example on narrow phone layouts. if (isTwoColumn)
_buildFieldLabel( Row(
'Home base', crossAxisAlignment: CrossAxisAlignment.start,
'Where this club primarily rides or organizes from.', children: [
colors, Expanded(child: regionSection),
), const SizedBox(width: 16),
const SizedBox(height: 8), Expanded(child: focusSection),
TextFormField( ],
controller: _regionController, )
enabled: !_isSubmitting, else ...[
textCapitalization: TextCapitalization.words, regionSection,
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,
),
),
),
),
const SizedBox(height: 18), const SizedBox(height: 18),
_buildFieldLabel( focusSection,
'Rider community', ],
'Name the motorcycle types or riding style this club is for.', SizedBox(height: isCompact ? 14 : 24),
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),
// Visibility Selection // Visibility & Admission Policy Selection
Text( if (isTwoColumn)
'Club Visibility', Row(
style: TextStyle( crossAxisAlignment: CrossAxisAlignment.start,
fontFamily: AppTypography.fontHeadline, children: [
fontSize: 13, Expanded(child: visibilitySection),
fontWeight: FontWeight.w700, const SizedBox(width: 16),
color: colors.cyberCyan, Expanded(child: admissionSection),
), ],
), )
const SizedBox(height: 8), else ...[
_buildVisibilitySelector(colors, theme), visibilitySection,
const SizedBox(height: 20), const SizedBox(height: 20),
admissionSection,
// Admission Policy Selection ],
Text( SizedBox(height: isCompact ? 16 : 32),
'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),
// Submit Button // Submit Button
SizedBox( SizedBox(
height: 50, height: isCompact ? 46 : 50,
child: FilledButton( child: FilledButton(
key: const ValueKey('create_club_submit_button'), key: const ValueKey('create_club_submit_button'),
onPressed: 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>( return DropdownButtonFormField<ClubVisibility>(
initialValue: _visibility, initialValue: _visibility,
isExpanded: true,
decoration: _choiceDecoration('Who can find this club', colors), decoration: _choiceDecoration('Who can find this club', colors),
dropdownColor: colors.surfaceContainerHigh, dropdownColor: colors.surfaceContainerHigh,
style: const TextStyle(color: Colors.white), style: TextStyle(color: theme.colorScheme.onSurface),
items: ClubVisibility.values items: ClubVisibility.values
.map( .map(
(value) => DropdownMenuItem(value: value, child: Text(value.label)), (value) => DropdownMenuItem(
value: value,
child: Text(
value.label,
overflow: TextOverflow.ellipsis,
),
),
) )
.toList(), .toList(),
onChanged: _isSubmitting onChanged: _isSubmitting
@ -555,12 +654,19 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
) { ) {
return DropdownButtonFormField<ClubAdmissionPolicy>( return DropdownButtonFormField<ClubAdmissionPolicy>(
initialValue: _admissionPolicy, initialValue: _admissionPolicy,
isExpanded: true,
decoration: _choiceDecoration('How riders join', colors), decoration: _choiceDecoration('How riders join', colors),
dropdownColor: colors.surfaceContainerHigh, dropdownColor: colors.surfaceContainerHigh,
style: const TextStyle(color: Colors.white), style: TextStyle(color: theme.colorScheme.onSurface),
items: ClubAdmissionPolicy.values items: ClubAdmissionPolicy.values
.map( .map(
(value) => DropdownMenuItem(value: value, child: Text(value.label)), (value) => DropdownMenuItem(
value: value,
child: Text(
value.label,
overflow: TextOverflow.ellipsis,
),
),
) )
.toList(), .toList(),
onChanged: _isSubmitting onChanged: _isSubmitting

View File

@ -151,6 +151,7 @@ class _SlugInputFieldState extends ConsumerState<SlugInputField> {
enableSuggestions: false, enableSuggestions: false,
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.none, textCapitalization: TextCapitalization.none,
scrollPadding: const EdgeInsets.only(bottom: 120, top: 24),
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9-]')), FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z0-9-]')),
_LowerCaseTextFormatter(), _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:motorcycle_dash/services/social/social_providers.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/features/records/records_service.dart'; import 'package:motorcycle_dash/features/records/records_service.dart';
import 'package:motorcycle_dash/services/routes/route_providers.dart';
class _FakeAuth implements AuthService { class _FakeAuth implements AuthService {
@override Future<void> ensureBackendIdentity() async {} @override Future<void> ensureBackendIdentity() async {}
@ -403,6 +404,106 @@ void main() {
expect(tester.takeException(), isNull); 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', () { group('LiveGroupRideScreen Responsive Matrix', () {

View File

@ -229,5 +229,125 @@ void main() {
); );
expect(find.text('Sign In / Register'), findsOneWidget); 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')); final publishBtn = tester.widget<ElevatedButton>(find.widgetWithText(ElevatedButton, 'PUBLISH CLUB RIDE'));
expect(publishBtn.onPressed, isNull); 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); 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)]) { for (final size in const [Size(800, 360), Size(640, 360), Size(400, 800)]) {
testWidgets('ride card renders without overflow on $size (#68)', testWidgets('ride card renders without overflow on $size (#68)',
(tester) async { (tester) async {

View File

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