diff --git a/lib/features/group_rides/create_group_ride_screen.dart b/lib/features/group_rides/create_group_ride_screen.dart index 763ad4e..781a181 100644 --- a/lib/features/group_rides/create_group_ride_screen.dart +++ b/lib/features/group_rides/create_group_ride_screen.dart @@ -378,8 +378,11 @@ class _CreateGroupRideScreenState extends ConsumerState { 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,91 +391,112 @@ class _CreateGroupRideScreenState extends ConsumerState { 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), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'USE EXISTING ROUTE', - style: TextStyle( - color: onSurface, - fontWeight: FontWeight.bold, - fontSize: 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, ), - const SizedBox(height: 8), - Text( - 'Pre-fills title, meetup location, intermediate waypoints, and destination.', - style: TextStyle(color: onSurfaceVariant, fontSize: 12), - ), - const SizedBox(height: 12), - routesAsync.when( - loading: () => Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: - CircularProgressIndicator(color: colors.cyberCyan), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'USE EXISTING ROUTE', + style: TextStyle( + color: onSurface, + fontWeight: FontWeight.bold, + fontSize: 16, ), ), - error: (err, _) => Text( - 'Failed to load routes: $err', - style: TextStyle(color: colors.raceRed), + const SizedBox(height: 6), + Text( + 'Pre-fills title, meetup location, intermediate waypoints, and destination.', + style: TextStyle(color: onSurfaceVariant, fontSize: 12), ), - data: (routes) { - if (routes.isEmpty) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Center( - child: Text( - 'No saved routes found.', - style: TextStyle(color: onSurfaceVariant), + const SizedBox(height: 10), + routesAsync.when( + loading: () => Center( + child: Padding( + padding: EdgeInsets.all(isCompact ? 12 : 24), + child: CircularProgressIndicator( + color: colors.cyberCyan), + ), + ), + error: (err, _) => Text( + 'Failed to load routes: $err', + style: TextStyle(color: colors.raceRed), + ), + data: (routes) { + if (routes.isEmpty) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: isCompact ? 12 : 24), + child: Center( + child: Text( + 'No saved routes found.', + style: TextStyle(color: onSurfaceVariant), + ), ), + ); + } + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: availableHeight * 0.5, + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: routes.length, + itemBuilder: (ctx, idx) { + final route = routes[idx]; + return ListTile( + dense: isCompact, + leading: Icon(Icons.alt_route, + color: colors.cyberCyan), + title: Text( + route.title, + style: TextStyle( + color: onSurface, + fontWeight: FontWeight.bold), + ), + subtitle: Text( + '${route.orderedStops.length} stops • ${route.formattedDistanceMiles}', + style: TextStyle( + color: onSurfaceVariant, fontSize: 12), + ), + onTap: () { + Navigator.pop(sheetCtx); + _applySavedRoute(route); + }, + ); + }, ), ); - } - return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.4, - ), - child: ListView.builder( - shrinkWrap: true, - itemCount: routes.length, - itemBuilder: (ctx, idx) { - final route = routes[idx]; - return ListTile( - leading: Icon(Icons.alt_route, - color: colors.cyberCyan), - title: Text( - route.title, - style: TextStyle( - color: onSurface, - fontWeight: FontWeight.bold), - ), - subtitle: Text( - '${route.orderedStops.length} stops • ${route.formattedDistanceMiles}', - style: TextStyle( - color: onSurfaceVariant, fontSize: 12), - ), - onTap: () { - Navigator.pop(sheetCtx); - _applySavedRoute(route); - }, - ); - }, - ), - ); - }, - ), - ], + }, + ), + ], + ), ), ), - ); - }, - ); - }, + ), + ); + }, + ); + }, ); } @@ -509,9 +533,34 @@ class _CreateGroupRideScreenState extends ConsumerState { 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 { 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 { 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 { 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 { } } + 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,18 +793,37 @@ class _CreateGroupRideScreenState extends ConsumerState { ), ], ), - body: Form( - key: _formKey, - child: ResponsiveLayout( - builder: (context, snapshot) { - final isWide = snapshot.sizeClass == WindowSizeClass.expanded || - (snapshot.isLandscape && snapshot.width >= 720); + body: SafeArea( + top: false, + child: Form( + key: _formKey, + child: ResponsiveLayout( + builder: (context, snapshot) { + final isWide = snapshot.sizeClass == WindowSizeClass.expanded || + (snapshot.isLandscape && snapshot.width >= 600); - if (isWide) { - return _buildWideForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError); - } - return _buildCompactForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError); - }, + if (isWide) { + return _buildWideForm( + context, + colors, + snapshot, + canSubmit, + clubAsync, + memberAsync, + clubValidationError, + ); + } + return _buildCompactForm( + context, + colors, + snapshot, + canSubmit, + clubAsync, + memberAsync, + clubValidationError, + ); + }, + ), ), ), ); @@ -764,52 +838,65 @@ class _CreateGroupRideScreenState extends ConsumerState { AsyncValue? memberAsync, String? clubValidationError, ) { - return SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.clubId != null) - _buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError), - if (_errorMessage != null) _buildErrorBanner(colors), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Left Column: Details, Schedule, Privacy - Expanded( - flex: 5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildRideDetailsSection(colors), - const SizedBox(height: 24), - _buildScheduleSection(colors), - const SizedBox(height: 24), - _buildPrivacySection(colors), - ], + 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), + if (_errorMessage != null) _buildErrorBanner(colors), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left Column: Details, Schedule, Privacy + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildRideDetailsSection(colors, isCompact: isCompact), + SizedBox(height: sectionGap), + _buildScheduleSection(colors, isCompact: isCompact), + SizedBox(height: sectionGap), + _buildPrivacySection(colors, isCompact: isCompact), + ], + ), ), - ), - const SizedBox(width: 24), - // 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), - _buildMapPreviewSection(colors, snapshot), - const SizedBox(height: 24), - _buildSubmitButton(colors, canSubmit), - ], + SizedBox(width: colGap), + // Right Column: Meeting Point, Destination & Stops, Preview, Submit + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildMeetingPointSection(colors, isCompact: isCompact), + SizedBox(height: sectionGap), + _buildDestinationAndStopsSection(colors, + isCompact: isCompact), + SizedBox(height: sectionGap), + _buildMapPreviewSection(colors, snapshot), + SizedBox(height: sectionGap), + _buildSubmitButton(colors, canSubmit), + ], + ), ), - ), - ], - ), - const SizedBox(height: 32), - ], + ], + ), + SizedBox(height: isCompact ? 16 : 32), + ], + ), ), ); } @@ -823,27 +910,37 @@ class _CreateGroupRideScreenState extends ConsumerState { AsyncValue? memberAsync, String? clubValidationError, ) { - return ListView( - padding: const EdgeInsets.all(20), - children: [ - if (widget.clubId != null) - _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), - _buildMapPreviewSection(colors, snapshot), - const SizedBox(height: 24), - _buildPrivacySection(colors), - const SizedBox(height: 32), - _buildSubmitButton(colors, canSubmit), - const SizedBox(height: 32), - ], + 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), + if (_errorMessage != null) _buildErrorBanner(colors), + _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), + SizedBox(height: sectionGap), + _buildPrivacySection(colors, isCompact: isCompact), + SizedBox(height: isCompact ? 16 : 32), + _buildSubmitButton(colors, canSubmit), + SizedBox(height: isCompact ? 16 : 32), + ], + ), + ), ); } @@ -858,8 +955,8 @@ class _CreateGroupRideScreenState extends ConsumerState { 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 { 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 { 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 { ), ], ), - 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 { 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 { ); } - 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 { 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 { ), ), ), - 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 { ), 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 { ); } - 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 { _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 { 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 { }, ), 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 { _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,21 +1483,27 @@ class _CreateGroupRideScreenState extends ConsumerState { }, ) else - ElevatedButton.icon( - onPressed: () => _showSelectRouteSheet(colors), - icon: const Icon(Icons.alt_route, size: 14), - label: const Text('USE EXISTING ROUTE'), - 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), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - textStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, + Flexible( + child: ElevatedButton.icon( + onPressed: () => _showSelectRouteSheet(colors), + icon: const Icon(Icons.alt_route, size: 14), + 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), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), ), ), ), @@ -1437,6 +1577,7 @@ class _CreateGroupRideScreenState extends ConsumerState { 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 { _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 { ), ], ), - 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 { ); } - 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( 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( 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 { 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), diff --git a/lib/features/group_rides/group_ride_detail_screen.dart b/lib/features/group_rides/group_ride_detail_screen.dart index d7ec7cd..d150f9e 100644 --- a/lib/features/group_rides/group_ride_detail_screen.dart +++ b/lib/features/group_rides/group_ride_detail_screen.dart @@ -1439,6 +1439,7 @@ class _GroupRideDetailScreenState extends ConsumerState { showModalBottomSheet( context: context, + isScrollControlled: true, backgroundColor: colors.surfaceContainerHigh, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), @@ -1447,156 +1448,180 @@ class _GroupRideDetailScreenState extends ConsumerState { 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), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'ATTACH SAVED ROUTE', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, - fontSize: 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, ), - const SizedBox(height: 12), - routesAsync.when( - loading: () => Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: - CircularProgressIndicator(color: colors.cyberCyan), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ATTACH SAVED ROUTE', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: 16, + ), ), - ), - error: (err, _) => Text( - 'Failed to load routes: $err', - style: TextStyle(color: colors.raceRed), - ), - data: (routes) { - if (routes.isEmpty) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Center( - child: Column( - children: [ - Text( - 'You have no saved routes yet.', - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - ), - ), - const SizedBox(height: 12), - ElevatedButton( - onPressed: () { - Navigator.pop(sheetCtx); - context.push( - '/routes/planner?groupRideId=${ride.id}', - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: colors.cyberCyan, - foregroundColor: colors.onBrand, - ), - child: const Text('CREATE A ROUTE'), - ), - ], + SizedBox(height: isCompact ? 8 : 12), + routesAsync.when( + loading: () => Center( + child: Padding( + padding: EdgeInsets.all(isCompact ? 12 : 24), + child: CircularProgressIndicator( + color: colors.cyberCyan, ), ), - ); - } - return ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.4, ), - child: ListView.builder( - shrinkWrap: true, - itemCount: routes.length, - itemBuilder: (ctx, idx) { - final route = routes[idx]; - return ListTile( - leading: Icon( - Icons.alt_route, - color: colors.cyberCyan, + error: (err, _) => Text( + 'Failed to load routes: $err', + style: TextStyle(color: colors.raceRed), + ), + data: (routes) { + if (routes.isEmpty) { + return Padding( + padding: EdgeInsets.symmetric( + vertical: isCompact ? 8 : 24, ), - title: Text( - route.title, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onSurface, - fontWeight: FontWeight.bold, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'You have no saved routes yet.', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + SizedBox(height: isCompact ? 8 : 12), + ElevatedButton( + onPressed: () { + Navigator.pop(sheetCtx); + context.push( + '/routes/planner?groupRideId=${ride.id}', + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: colors.cyberCyan, + foregroundColor: colors.onBrand, + ), + child: const Text('CREATE A ROUTE'), + ), + ], ), ), - subtitle: Text( - '${route.stops.length} stops • ${route.formattedDistanceMiles}', - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - fontSize: 12, - ), - ), - trailing: Icon( - Icons.chevron_right, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant - .withValues(alpha: 0.4), - ), - onTap: () async { - Navigator.pop(sheetCtx); - try { - final user = ref.read(currentUserProvider); - if (user == null || user.uid.isEmpty) { - throw StateError( - 'Sign-in required to attach route.'); - } - await ref - .read(routeServiceProvider) - .attachRouteToGroupRide( - groupRideId: ride.id, - route: route, - callerUid: user.uid, - ); - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text( - 'Attached "${route.title}" to group ride.', - ), - backgroundColor: - colors.launchGreen, - ), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar( - SnackBar( - content: Text( - 'Failed to attach route: $e', - ), - ), - ); - } - } - }, ); - }, - ), - ); - }, + } + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: availableHeight * 0.55, + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: routes.length, + itemBuilder: (ctx, idx) { + final route = routes[idx]; + return ListTile( + dense: isCompact, + leading: Icon( + Icons.alt_route, + color: colors.cyberCyan, + ), + title: Text( + route.title, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface, + fontWeight: FontWeight.bold, + ), + ), + subtitle: Text( + '${route.stops.length} stops • ${route.formattedDistanceMiles}', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + fontSize: 12, + ), + ), + trailing: Icon( + Icons.chevron_right, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.4), + ), + onTap: () async { + Navigator.pop(sheetCtx); + try { + final user = + ref.read(currentUserProvider); + if (user == null || user.uid.isEmpty) { + throw StateError( + 'Sign-in required to attach route.', + ); + } + await ref + .read(routeServiceProvider) + .attachRouteToGroupRide( + groupRideId: ride.id, + route: route, + callerUid: user.uid, + ); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + 'Attached "${route.title}" to group ride.', + ), + backgroundColor: + colors.launchGreen, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar( + SnackBar( + content: Text( + 'Failed to attach route: $e', + ), + ), + ); + } + } + }, + ); + }, + ), + ); + }, + ), + ], ), - ], + ), ), ), ); diff --git a/lib/features/group_rides/group_rides_hub_screen.dart b/lib/features/group_rides/group_rides_hub_screen.dart index f951332..713b6d5 100644 --- a/lib/features/group_rides/group_rides_hub_screen.dart +++ b/lib/features/group_rides/group_rides_hub_screen.dart @@ -115,9 +115,17 @@ class _GroupRidesHubScreenState extends ConsumerState 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 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 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 }) { 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,61 +663,113 @@ class _GroupRidesHubScreenState extends ConsumerState final controller = TextEditingController(); showDialog( context: context, - builder: (dialogCtx) => AlertDialog( - backgroundColor: colors.surfaceContainerHigh, - title: Row( - children: [ - Icon(Icons.group_add, color: colors.cyberCyan, size: 24), - const SizedBox(width: 10), - const Text( - 'Join Group Ride', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + 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: isCompactVertical ? 20 : 24, + ), + const SizedBox(width: 10), + Flexible( + child: Text( + 'Join Group Ride', + style: TextStyle( + fontSize: isCompactVertical ? 16 : 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Enter the Ride ID or shared link:', + style: TextStyle(fontSize: 13), + ), + 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', + isDense: isCompactVertical, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: isCompactVertical ? 8 : 10, + ), + ), + ), + ], ), - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'Enter the Ride ID or shared link:', - style: TextStyle(fontSize: 13), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(), + child: const Text('CANCEL'), ), - const SizedBox(height: 12), - TextField( - key: const ValueKey('join_ride_id_field'), - controller: controller, - autofocus: true, - decoration: InputDecoration( - hintText: 'e.g. ride_1790320722076_4btn4W', - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ElevatedButton( + key: const ValueKey('join_ride_dialog_submit_button'), + style: ElevatedButton.styleFrom( + backgroundColor: colors.cyberCyan, + foregroundColor: colors.onBrand, + ), + onPressed: () { + final raw = controller.text.trim(); + if (raw.isEmpty) return; + final rideId = raw.contains('/') ? raw.split('/').last : raw; + Navigator.of(dialogCtx).pop(); + context.push('/group-rides/$rideId'); + }, + child: const Text( + 'VIEW RIDE', + style: TextStyle(fontWeight: FontWeight.bold), ), ), ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogCtx).pop(), - child: const Text('CANCEL'), - ), - ElevatedButton( - key: const ValueKey('join_ride_dialog_submit_button'), - style: ElevatedButton.styleFrom( - backgroundColor: colors.cyberCyan, - foregroundColor: colors.onBrand, - ), - onPressed: () { - final raw = controller.text.trim(); - if (raw.isEmpty) return; - final rideId = raw.contains('/') ? raw.split('/').last : raw; - Navigator.of(dialogCtx).pop(); - context.push('/group-rides/$rideId'); - }, - child: const Text('VIEW RIDE', style: TextStyle(fontWeight: FontWeight.bold)), - ), - ], - ), + ); + }, ); } diff --git a/lib/features/routes/route_planner_screen.dart b/lib/features/routes/route_planner_screen.dart index eab7287..e57958d 100644 --- a/lib/features/routes/route_planner_screen.dart +++ b/lib/features/routes/route_planner_screen.dart @@ -148,8 +148,33 @@ class _RoutePlannerScreenState extends ConsumerState { 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 { 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 { 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 { ); } + 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 { 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 { Widget _buildExpandedLayout( BuildContext context, MotorSignalThemeColors colors, + ResponsiveSnapshot snapshot, RoutePlannerState state, int maxAllowed, bool isAtLimit, @@ -726,6 +758,10 @@ class _RoutePlannerScreenState extends ConsumerState { List 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,17 +784,24 @@ class _RoutePlannerScreenState extends ConsumerState { polylines: polylines, ), Positioned( - top: 12, - left: 12, - right: 12, - child: _buildHudOverlay(colors, state, maxAllowed, isAtLimit, false), - ), - Positioned( - bottom: 8, - left: 12, - right: 12, - child: _buildDisclaimerText(), + top: isCompactHeight ? 6 : 12, + left: isCompactHeight ? 8 : 12, + right: isCompactHeight ? 8 : 12, + child: _buildHudOverlay( + colors, + state, + maxAllowed, + isAtLimit, + isCompactHeight, + ), ), + if (showDisclaimer) + Positioned( + bottom: 6, + left: 8, + right: 8, + child: _buildDisclaimerText(), + ), ], ), ), @@ -767,23 +810,56 @@ class _RoutePlannerScreenState extends ConsumerState { // Right Column: Form inputs + Stop list Expanded( flex: 6, - child: Column( - children: [ - if (_errorMessage != null) _buildErrorBanner(colors), - Padding( - padding: const EdgeInsets.all(16), - child: _buildRouteInfoInputs(colors, false), - ), - Divider(color: colors.outlineVariant, height: 1), - Padding( - padding: const EdgeInsets.all(12), - child: _buildStopControls(colors, state, isNarrow, false), - ), - Expanded( - child: _buildStopsList(colors, state, false, false), - ), - ], - ), + 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( + padding: const EdgeInsets.all(16), + child: _buildRouteInfoInputs(colors, false), + ), + Divider(color: colors.outlineVariant, height: 1), + Padding( + padding: const EdgeInsets.all(12), + child: _buildStopControls(colors, state, isNarrow, false), + ), + Expanded( + child: _buildStopsList(colors, state, false, false), + ), + ], + ), ), ], ); @@ -829,6 +905,65 @@ class _RoutePlannerScreenState extends ConsumerState { 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 { polylines: polylines, ), Positioned( - top: isCompactHeight ? 4 : 8, + top: 8, left: 8, right: 8, child: _buildHudOverlay( @@ -856,7 +991,7 @@ class _RoutePlannerScreenState extends ConsumerState { state, maxAllowed, isAtLimit, - isCompactHeight, + false, ), ), if (showDisclaimer) @@ -872,24 +1007,16 @@ class _RoutePlannerScreenState extends ConsumerState { // 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 { child: _buildStopsList( colors, state, - isCompactHeight, + false, isVeryCompactHeight, ), ), @@ -1072,6 +1199,7 @@ class _RoutePlannerScreenState extends ConsumerState { 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 { 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 { 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 { ), 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,56 +1385,74 @@ class _RoutePlannerScreenState extends ConsumerState { ); } - Widget _buildStopsList( + Widget _buildEmptyStopsContent( MotorSignalThemeColors colors, - RoutePlannerState state, bool isCompactHeight, bool isVeryCompactHeight, ) { + final theme = Theme.of(context); + final onSurfaceVariant = theme.colorScheme.onSurfaceVariant; + + return Padding( + padding: EdgeInsets.symmetric(vertical: isCompactHeight ? 6 : 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isVeryCompactHeight) ...[ + Icon( + Icons.alt_route, + size: isCompactHeight ? 28 : 48, + color: onSurfaceVariant.withValues(alpha: 0.2), + ), + SizedBox(height: isCompactHeight ? 4 : 12), + ], + Text( + 'No stops added yet.', + style: TextStyle( + color: onSurfaceVariant, + fontSize: isCompactHeight ? 13 : 14, + ), + ), + const SizedBox(height: 4), + Text( + 'Tap "Add Stop" or "Current GPS" to begin planning.', + style: TextStyle( + color: onSurfaceVariant.withValues(alpha: 0.6), + 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 (state.stops.isEmpty) { + if (header == null && state.stops.isEmpty) { return Center( child: SingleChildScrollView( physics: const ClampingScrollPhysics(), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isVeryCompactHeight) ...[ - Icon( - Icons.alt_route, - size: isCompactHeight ? 32 : 48, - color: onSurfaceVariant.withValues(alpha: 0.2), - ), - SizedBox(height: isCompactHeight ? 6 : 12), - ], - Text( - 'No stops added yet.', - style: TextStyle( - color: onSurfaceVariant, - fontSize: 14, - ), - ), - const SizedBox(height: 4), - Text( - 'Tap "Add Stop" or "Current GPS" to begin planning.', - style: TextStyle( - color: onSurfaceVariant.withValues(alpha: 0.6), - fontSize: 12, - ), - ), - ], - ), + child: _buildEmptyStopsContent( + colors, + isCompactHeight, + isVeryCompactHeight, ), ), ); } return ReorderableListView.builder( + header: header, + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 4, diff --git a/lib/features/routes/widgets/address_autocomplete_field.dart b/lib/features/routes/widgets/address_autocomplete_field.dart index a111619..1d5abab 100644 --- a/lib/features/routes/widgets/address_autocomplete_field.dart +++ b/lib/features/routes/widgets/address_autocomplete_field.dart @@ -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) { diff --git a/lib/features/social/clubs/create_club_screen.dart b/lib/features/social/clubs/create_club_screen.dart index d97fdeb..565fbae 100644 --- a/lib/features/social/clubs/create_club_screen.dart +++ b/lib/features/social/clubs/create_club_screen.dart @@ -178,12 +178,17 @@ class _CreateClubScreenState extends ConsumerState { ); } + 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,332 +196,419 @@ class _CreateClubScreenState extends ConsumerState { body: SafeArea( child: ResponsiveLayout( builder: (context, responsive) { + final isCompact = + responsive.isLandscape || responsive.isShortViewport; + final isTwoColumn = + responsive.isLandscape && responsive.width >= 600; + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + + 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', + 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, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colors.cyberCyan, + width: 1.5, + ), + ), + ), + validator: (val) { + final clean = val?.trim() ?? ''; + if (clean.length < 3) { + return 'Name must be at least 3 characters'; + } + if (clean.length > 50) { + return 'Name cannot exceed 50 characters'; + } + return null; + }, + ); + + final slugField = SlugInputField( + controller: _slugController, + enabled: !_isSubmitting, + onValidationChanged: (isValid) { + setState(() { + _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: const BoxConstraints(maxWidth: 700), + constraints: BoxConstraints(maxWidth: isTwoColumn ? 860 : 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, - ), - ), - ], - ), - ), - ], - ), + 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), ), - const SizedBox(height: 20), - - // Submission error alert if present - if (_submissionError != null) ...[ + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header card explaining Club identity Container( - padding: const EdgeInsets.all(12), + padding: EdgeInsets.all(isCompact ? 10.0 : 14.0), decoration: BoxDecoration( - color: colors.raceRed.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), + color: colors.surfaceContainer, + borderRadius: BorderRadius.circular(12), border: Border.all( - color: colors.raceRed.withValues(alpha: 0.4), + color: colors.outlineVariant, + width: 0.8, ), ), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - Icons.error_outline, - color: colors.raceRed, - size: 20, + Icons.shield_outlined, + color: colors.cyberCyan, + size: isCompact ? 20 : 24, ), - const SizedBox(width: 8), + const SizedBox(width: 12), Expanded( - child: Text( - _submissionError!, - style: TextStyle( - fontFamily: AppTypography.fontBody, - fontSize: 12.5, - color: colors.raceRed, - ), + 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: 16), - ], + SizedBox(height: isCompact ? 12 : 20), - // Club Name - TextFormField( - controller: _nameController, - enabled: !_isSubmitting, - textCapitalization: TextCapitalization.words, - decoration: InputDecoration( - labelText: 'Club Name *', - hintText: 'e.g. Ridge Runners MC', - filled: true, - fillColor: colors.surfaceContainer, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colors.outlineVariant, - width: 0.8, + // 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), + ), ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colors.outlineVariant, - width: 0.8, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colors.cyberCyan, - width: 1.5, - ), - ), - ), - validator: (val) { - final clean = val?.trim() ?? ''; - if (clean.length < 3) { - return 'Name must be at least 3 characters'; - } - if (clean.length > 50) { - return 'Name cannot exceed 50 characters'; - } - return null; - }, - ), - const SizedBox(height: 18), - - // Slug / Handle with live debounce & availability checking - SlugInputField( - controller: _slugController, - enabled: !_isSubmitting, - onValidationChanged: (isValid) { - setState(() { - _isSlugValidAndAvailable = isValid; - }); - }, - ), - const SizedBox(height: 18), - - // Description - TextFormField( - controller: _descController, - enabled: !_isSubmitting, - maxLines: 3, - maxLength: 500, - textCapitalization: TextCapitalization.sentences, - decoration: InputDecoration( - labelText: 'Description (Optional)', - hintText: - 'Share your club mission, riding style, or club guidelines...', - 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, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: colors.cyberCyan, - width: 1.5, - ), - ), - ), - ), - const SizedBox(height: 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, - ), - ), - ), - ), - 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), - - // 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), - 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), - - // Submit Button - SizedBox( - height: 50, - child: FilledButton( - key: const ValueKey('create_club_submit_button'), - onPressed: - (_isSubmitting || !_isSlugValidAndAvailable) - ? null - : _submitForm, - style: FilledButton.styleFrom( - backgroundColor: colors.cyberCyan, - foregroundColor: Colors.black, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: _isSubmitting - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2.5, - valueColor: AlwaysStoppedAnimation( - Colors.black, + 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 Text( - 'Create Club', - style: TextStyle( - fontFamily: AppTypography.fontHeadline, - fontSize: 15, - fontWeight: FontWeight.w800, - letterSpacing: 0.3, - ), ), + ], + ), + ), + 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: isCompact ? 2 : 3, + maxLength: 500, + textCapitalization: TextCapitalization.sentences, + scrollPadding: + const EdgeInsets.only(bottom: 120, top: 24), + decoration: InputDecoration( + labelText: 'Description (Optional)', + hintText: + 'Share your club mission, riding style, or club guidelines...', + 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, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colors.cyberCyan, + width: 1.5, + ), + ), + ), ), - ), - const SizedBox(height: 32), - ], + SizedBox(height: isCompact ? 10 : 14), + + // 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), + focusSection, + ], + SizedBox(height: isCompact ? 14 : 24), + + // 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), + admissionSection, + ], + SizedBox(height: isCompact ? 16 : 32), + + // Submit Button + SizedBox( + height: isCompact ? 46 : 50, + child: FilledButton( + key: const ValueKey('create_club_submit_button'), + onPressed: + (_isSubmitting || !_isSlugValidAndAvailable) + ? null + : _submitForm, + style: FilledButton.styleFrom( + backgroundColor: colors.cyberCyan, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: _isSubmitting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation( + Colors.black, + ), + ), + ) + : const Text( + 'Create Club', + style: TextStyle( + fontFamily: AppTypography.fontHeadline, + fontSize: 15, + fontWeight: FontWeight.w800, + letterSpacing: 0.3, + ), + ), + ), + ), + SizedBox(height: isCompact ? 12 : 32), + ], + ), + ], + ), ), ), ), @@ -533,12 +625,19 @@ class _CreateClubScreenState extends ConsumerState { ) { return DropdownButtonFormField( 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 { ) { return DropdownButtonFormField( 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 diff --git a/lib/features/social/clubs/widgets/slug_input_field.dart b/lib/features/social/clubs/widgets/slug_input_field.dart index a67f85c..8aa8380 100644 --- a/lib/features/social/clubs/widgets/slug_input_field.dart +++ b/lib/features/social/clubs/widgets/slug_input_field.dart @@ -151,6 +151,7 @@ class _SlugInputFieldState extends ConsumerState { 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(), diff --git a/test/canonical_group_rides_social_responsive_test.dart b/test/canonical_group_rides_social_responsive_test.dart index b08a08b..3e97fc2 100644 --- a/test/canonical_group_rides_social_responsive_test.dart +++ b/test/canonical_group_rides_social_responsive_test.dart @@ -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 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', () { diff --git a/test/create_club_screen_test.dart b/test/create_club_screen_test.dart index 7d8c577..ac66ff9 100644 --- a/test/create_club_screen_test.dart +++ b/test/create_club_screen_test.dart @@ -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); + }, + ); }); } diff --git a/test/create_group_ride_screen_test.dart b/test/create_group_ride_screen_test.dart index 09273ff..1c04c60 100644 --- a/test/create_group_ride_screen_test.dart +++ b/test/create_group_ride_screen_test.dart @@ -1130,5 +1130,94 @@ void main() { final publishBtn = tester.widget(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); + }); }); } diff --git a/test/group_rides_widget_test.dart b/test/group_rides_widget_test.dart index a848229..b76ba29 100644 --- a/test/group_rides_widget_test.dart +++ b/test/group_rides_widget_test.dart @@ -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 { diff --git a/test/route_planner_screen_test.dart b/test/route_planner_screen_test.dart index 48b41d6..787b508 100644 --- a/test/route_planner_screen_test.dart +++ b/test/route_planner_screen_test.dart @@ -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); + }); }