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:
parent
2524cd066e
commit
63df8f9a50
|
|
@ -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,91 +391,112 @@ 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: Column(
|
child: ConstrainedBox(
|
||||||
mainAxisSize: MainAxisSize.min,
|
constraints: BoxConstraints(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
maxHeight: availableHeight * 0.9,
|
||||||
children: [
|
maxWidth: 560,
|
||||||
Text(
|
),
|
||||||
'USE EXISTING ROUTE',
|
child: SingleChildScrollView(
|
||||||
style: TextStyle(
|
padding: EdgeInsets.fromLTRB(
|
||||||
color: onSurface,
|
16,
|
||||||
fontWeight: FontWeight.bold,
|
isCompact ? 12 : 16,
|
||||||
fontSize: 16,
|
16,
|
||||||
),
|
isCompact ? 12 : 16,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
child: Column(
|
||||||
Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
'Pre-fills title, meetup location, intermediate waypoints, and destination.',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
|
children: [
|
||||||
),
|
Text(
|
||||||
const SizedBox(height: 12),
|
'USE EXISTING ROUTE',
|
||||||
routesAsync.when(
|
style: TextStyle(
|
||||||
loading: () => Center(
|
color: onSurface,
|
||||||
child: Padding(
|
fontWeight: FontWeight.bold,
|
||||||
padding: const EdgeInsets.all(24),
|
fontSize: 16,
|
||||||
child:
|
|
||||||
CircularProgressIndicator(color: colors.cyberCyan),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
error: (err, _) => Text(
|
const SizedBox(height: 6),
|
||||||
'Failed to load routes: $err',
|
Text(
|
||||||
style: TextStyle(color: colors.raceRed),
|
'Pre-fills title, meetup location, intermediate waypoints, and destination.',
|
||||||
|
style: TextStyle(color: onSurfaceVariant, fontSize: 12),
|
||||||
),
|
),
|
||||||
data: (routes) {
|
const SizedBox(height: 10),
|
||||||
if (routes.isEmpty) {
|
routesAsync.when(
|
||||||
return Padding(
|
loading: () => Center(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
child: Padding(
|
||||||
child: Center(
|
padding: EdgeInsets.all(isCompact ? 12 : 24),
|
||||||
child: Text(
|
child: CircularProgressIndicator(
|
||||||
'No saved routes found.',
|
color: colors.cyberCyan),
|
||||||
style: TextStyle(color: onSurfaceVariant),
|
),
|
||||||
|
),
|
||||||
|
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<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,18 +793,37 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Form(
|
body: SafeArea(
|
||||||
key: _formKey,
|
top: false,
|
||||||
child: ResponsiveLayout(
|
child: Form(
|
||||||
builder: (context, snapshot) {
|
key: _formKey,
|
||||||
final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
|
child: ResponsiveLayout(
|
||||||
(snapshot.isLandscape && snapshot.width >= 720);
|
builder: (context, snapshot) {
|
||||||
|
final isWide = snapshot.sizeClass == WindowSizeClass.expanded ||
|
||||||
|
(snapshot.isLandscape && snapshot.width >= 600);
|
||||||
|
|
||||||
if (isWide) {
|
if (isWide) {
|
||||||
return _buildWideForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError);
|
return _buildWideForm(
|
||||||
}
|
context,
|
||||||
return _buildCompactForm(context, colors, snapshot, canSubmit, clubAsync, memberAsync, clubValidationError);
|
colors,
|
||||||
},
|
snapshot,
|
||||||
|
canSubmit,
|
||||||
|
clubAsync,
|
||||||
|
memberAsync,
|
||||||
|
clubValidationError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _buildCompactForm(
|
||||||
|
context,
|
||||||
|
colors,
|
||||||
|
snapshot,
|
||||||
|
canSubmit,
|
||||||
|
clubAsync,
|
||||||
|
memberAsync,
|
||||||
|
clubValidationError,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -764,52 +838,65 @@ 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;
|
||||||
child: Column(
|
final outerPadding = isCompact
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
? const EdgeInsets.symmetric(horizontal: 16, vertical: 10)
|
||||||
children: [
|
: const EdgeInsets.all(24);
|
||||||
if (widget.clubId != null)
|
final colGap = isCompact ? 16.0 : 24.0;
|
||||||
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
|
|
||||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
return Scrollbar(
|
||||||
Row(
|
thumbVisibility: isCompact,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: SingleChildScrollView(
|
||||||
children: [
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
// Left Column: Details, Schedule, Privacy
|
padding: outerPadding,
|
||||||
Expanded(
|
child: Column(
|
||||||
flex: 5,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
if (widget.clubId != null)
|
||||||
children: [
|
_buildClubAffiliationHeader(
|
||||||
_buildRideDetailsSection(colors),
|
colors, clubAsync, memberAsync, clubValidationError),
|
||||||
const SizedBox(height: 24),
|
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||||
_buildScheduleSection(colors),
|
Row(
|
||||||
const SizedBox(height: 24),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
_buildPrivacySection(colors),
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(width: colGap),
|
||||||
const SizedBox(width: 24),
|
// 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, isCompact: isCompact),
|
||||||
_buildMeetingPointSection(colors),
|
SizedBox(height: sectionGap),
|
||||||
const SizedBox(height: 24),
|
_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),
|
||||||
_buildSubmitButton(colors, canSubmit),
|
_buildSubmitButton(colors, canSubmit),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
SizedBox(height: isCompact ? 16 : 32),
|
||||||
const SizedBox(height: 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;
|
||||||
children: [
|
return Scrollbar(
|
||||||
if (widget.clubId != null)
|
thumbVisibility: isCompact,
|
||||||
_buildClubAffiliationHeader(colors, clubAsync, memberAsync, clubValidationError),
|
child: SingleChildScrollView(
|
||||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
_buildRideDetailsSection(colors),
|
padding: EdgeInsets.all(isCompact ? 14 : 20),
|
||||||
const SizedBox(height: 24),
|
child: Column(
|
||||||
_buildScheduleSection(colors),
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
const SizedBox(height: 24),
|
children: [
|
||||||
_buildMeetingPointSection(colors),
|
if (widget.clubId != null)
|
||||||
const SizedBox(height: 24),
|
_buildClubAffiliationHeader(
|
||||||
_buildDestinationAndStopsSection(colors),
|
colors, clubAsync, memberAsync, clubValidationError),
|
||||||
const SizedBox(height: 24),
|
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||||
_buildMapPreviewSection(colors, snapshot),
|
_buildRideDetailsSection(colors, isCompact: isCompact),
|
||||||
const SizedBox(height: 24),
|
SizedBox(height: sectionGap),
|
||||||
_buildPrivacySection(colors),
|
_buildScheduleSection(colors, isCompact: isCompact),
|
||||||
const SizedBox(height: 32),
|
SizedBox(height: sectionGap),
|
||||||
_buildSubmitButton(colors, canSubmit),
|
_buildMeetingPointSection(colors, isCompact: isCompact),
|
||||||
const SizedBox(height: 32),
|
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<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,21 +1483,27 @@ class _CreateGroupRideScreenState extends ConsumerState<CreateGroupRideScreen> {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ElevatedButton.icon(
|
Flexible(
|
||||||
onPressed: () => _showSelectRouteSheet(colors),
|
child: ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.alt_route, size: 14),
|
onPressed: () => _showSelectRouteSheet(colors),
|
||||||
label: const Text('USE EXISTING ROUTE'),
|
icon: const Icon(Icons.alt_route, size: 14),
|
||||||
style: ElevatedButton.styleFrom(
|
label: const Text(
|
||||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
'USE EXISTING ROUTE',
|
||||||
foregroundColor: colors.cyberCyan,
|
overflow: TextOverflow.ellipsis,
|
||||||
side: BorderSide(color: colors.cyberCyan),
|
maxLines: 1,
|
||||||
padding:
|
),
|
||||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
style: ElevatedButton.styleFrom(
|
||||||
minimumSize: Size.zero,
|
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
foregroundColor: colors.cyberCyan,
|
||||||
textStyle: const TextStyle(
|
side: BorderSide(color: colors.cyberCyan),
|
||||||
fontSize: 11,
|
padding: const EdgeInsets.symmetric(
|
||||||
fontWeight: FontWeight.bold,
|
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<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),
|
||||||
|
|
|
||||||
|
|
@ -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,156 +1448,180 @@ 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: Column(
|
child: ConstrainedBox(
|
||||||
mainAxisSize: MainAxisSize.min,
|
constraints: BoxConstraints(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
maxHeight: availableHeight * 0.9,
|
||||||
children: [
|
maxWidth: 560,
|
||||||
Text(
|
),
|
||||||
'ATTACH SAVED ROUTE',
|
child: SingleChildScrollView(
|
||||||
style: TextStyle(
|
padding: EdgeInsets.symmetric(
|
||||||
color: Theme.of(context).colorScheme.onSurface,
|
horizontal: 16,
|
||||||
fontWeight: FontWeight.bold,
|
vertical: isCompact ? 10 : 16,
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
child: Column(
|
||||||
routesAsync.when(
|
mainAxisSize: MainAxisSize.min,
|
||||||
loading: () => Center(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Padding(
|
children: [
|
||||||
padding: const EdgeInsets.all(24),
|
Text(
|
||||||
child:
|
'ATTACH SAVED ROUTE',
|
||||||
CircularProgressIndicator(color: colors.cyberCyan),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(height: isCompact ? 8 : 12),
|
||||||
error: (err, _) => Text(
|
routesAsync.when(
|
||||||
'Failed to load routes: $err',
|
loading: () => Center(
|
||||||
style: TextStyle(color: colors.raceRed),
|
child: Padding(
|
||||||
),
|
padding: EdgeInsets.all(isCompact ? 12 : 24),
|
||||||
data: (routes) {
|
child: CircularProgressIndicator(
|
||||||
if (routes.isEmpty) {
|
color: colors.cyberCyan,
|
||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
return ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxHeight: MediaQuery.of(context).size.height * 0.4,
|
|
||||||
),
|
),
|
||||||
child: ListView.builder(
|
error: (err, _) => Text(
|
||||||
shrinkWrap: true,
|
'Failed to load routes: $err',
|
||||||
itemCount: routes.length,
|
style: TextStyle(color: colors.raceRed),
|
||||||
itemBuilder: (ctx, idx) {
|
),
|
||||||
final route = routes[idx];
|
data: (routes) {
|
||||||
return ListTile(
|
if (routes.isEmpty) {
|
||||||
leading: Icon(
|
return Padding(
|
||||||
Icons.alt_route,
|
padding: EdgeInsets.symmetric(
|
||||||
color: colors.cyberCyan,
|
vertical: isCompact ? 8 : 24,
|
||||||
),
|
),
|
||||||
title: Text(
|
child: Center(
|
||||||
route.title,
|
child: Column(
|
||||||
style: TextStyle(
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: Theme.of(context)
|
children: [
|
||||||
.colorScheme
|
Text(
|
||||||
.onSurface,
|
'You have no saved routes yet.',
|
||||||
fontWeight: FontWeight.bold,
|
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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,61 +663,113 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogCtx) => AlertDialog(
|
builder: (dialogCtx) {
|
||||||
backgroundColor: colors.surfaceContainerHigh,
|
final size = MediaQuery.sizeOf(dialogCtx);
|
||||||
title: Row(
|
final viewInsets = MediaQuery.viewInsetsOf(dialogCtx);
|
||||||
children: [
|
final availableHeight = size.height - viewInsets.bottom;
|
||||||
Icon(Icons.group_add, color: colors.cyberCyan, size: 24),
|
final isCompactVertical = availableHeight < 280;
|
||||||
const SizedBox(width: 10),
|
|
||||||
const Text(
|
return AlertDialog(
|
||||||
'Join Group Ride',
|
scrollable: true,
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
actions: [
|
||||||
content: Column(
|
TextButton(
|
||||||
mainAxisSize: MainAxisSize.min,
|
onPressed: () => Navigator.of(dialogCtx).pop(),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: const Text('CANCEL'),
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Enter the Ride ID or shared link:',
|
|
||||||
style: TextStyle(fontSize: 13),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
ElevatedButton(
|
||||||
TextField(
|
key: const ValueKey('join_ride_dialog_submit_button'),
|
||||||
key: const ValueKey('join_ride_id_field'),
|
style: ElevatedButton.styleFrom(
|
||||||
controller: controller,
|
backgroundColor: colors.cyberCyan,
|
||||||
autofocus: true,
|
foregroundColor: colors.onBrand,
|
||||||
decoration: InputDecoration(
|
),
|
||||||
hintText: 'e.g. ride_1790320722076_4btn4W',
|
onPressed: () {
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
final raw = controller.text.trim();
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
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)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,17 +784,24 @@ 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,
|
||||||
Positioned(
|
state,
|
||||||
bottom: 8,
|
maxAllowed,
|
||||||
left: 12,
|
isAtLimit,
|
||||||
right: 12,
|
isCompactHeight,
|
||||||
child: _buildDisclaimerText(),
|
),
|
||||||
),
|
),
|
||||||
|
if (showDisclaimer)
|
||||||
|
Positioned(
|
||||||
|
bottom: 6,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
child: _buildDisclaimerText(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -767,23 +810,56 @@ 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
|
||||||
children: [
|
? _buildStopsList(
|
||||||
if (_errorMessage != null) _buildErrorBanner(colors),
|
colors,
|
||||||
Padding(
|
state,
|
||||||
padding: const EdgeInsets.all(16),
|
true,
|
||||||
child: _buildRouteInfoInputs(colors, false),
|
isVeryCompactHeight,
|
||||||
),
|
header: Column(
|
||||||
Divider(color: colors.outlineVariant, height: 1),
|
mainAxisSize: MainAxisSize.min,
|
||||||
Padding(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
padding: const EdgeInsets.all(12),
|
children: [
|
||||||
child: _buildStopControls(colors, state, isNarrow, false),
|
if (_errorMessage != null) _buildErrorBanner(colors),
|
||||||
),
|
Padding(
|
||||||
Expanded(
|
padding: const EdgeInsets.fromLTRB(12, 6, 12, 4),
|
||||||
child: _buildStopsList(colors, state, false, false),
|
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<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,56 +1385,74 @@ 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 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 theme = Theme.of(context);
|
||||||
final onSurface = theme.colorScheme.onSurface;
|
final onSurface = theme.colorScheme.onSurface;
|
||||||
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
final onSurfaceVariant = theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
if (state.stops.isEmpty) {
|
if (header == null && state.stops.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
child: Padding(
|
child: _buildEmptyStopsContent(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
colors,
|
||||||
child: Column(
|
isCompactHeight,
|
||||||
mainAxisSize: MainAxisSize.min,
|
isVeryCompactHeight,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,332 +196,419 @@ class _CreateClubScreenState extends ConsumerState<CreateClubScreen> {
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: ResponsiveLayout(
|
child: ResponsiveLayout(
|
||||||
builder: (context, responsive) {
|
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(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 700),
|
constraints: BoxConstraints(maxWidth: isTwoColumn ? 860 : 700),
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: ListView(
|
child: Scrollbar(
|
||||||
padding: const EdgeInsets.all(18.0),
|
thumbVisibility: isCompact,
|
||||||
children: [
|
child: ListView(
|
||||||
// Header card explaining Club identity
|
keyboardDismissBehavior:
|
||||||
Container(
|
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
padding: const EdgeInsets.all(14.0),
|
padding: EdgeInsets.fromLTRB(
|
||||||
decoration: BoxDecoration(
|
isCompact ? 16.0 : 18.0,
|
||||||
color: colors.surfaceContainer,
|
isCompact ? 10.0 : 18.0,
|
||||||
borderRadius: BorderRadius.circular(12),
|
isCompact ? 16.0 : 18.0,
|
||||||
border: Border.all(
|
(isCompact ? 16.0 : 32.0) +
|
||||||
color: colors.outlineVariant,
|
(bottomInset > 0 ? 16.0 : 0.0),
|
||||||
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),
|
children: [
|
||||||
|
Column(
|
||||||
// Submission error alert if present
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
if (_submissionError != null) ...[
|
children: [
|
||||||
|
// Header card explaining Club identity
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: EdgeInsets.all(isCompact ? 10.0 : 14.0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.raceRed.withValues(alpha: 0.12),
|
color: colors.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: colors.raceRed.withValues(alpha: 0.4),
|
color: colors.outlineVariant,
|
||||||
|
width: 0.8,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.error_outline,
|
Icons.shield_outlined,
|
||||||
color: colors.raceRed,
|
color: colors.cyberCyan,
|
||||||
size: 20,
|
size: isCompact ? 20 : 24,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
_submissionError!,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(
|
children: [
|
||||||
fontFamily: AppTypography.fontBody,
|
const Text(
|
||||||
fontSize: 12.5,
|
'Found a Motorcycle Club',
|
||||||
color: colors.raceRed,
|
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
|
// Submission error alert if present
|
||||||
TextFormField(
|
if (_submissionError != null) ...[
|
||||||
controller: _nameController,
|
Container(
|
||||||
enabled: !_isSubmitting,
|
padding: const EdgeInsets.all(12),
|
||||||
textCapitalization: TextCapitalization.words,
|
decoration: BoxDecoration(
|
||||||
decoration: InputDecoration(
|
color: colors.raceRed.withValues(alpha: 0.12),
|
||||||
labelText: 'Club Name *',
|
borderRadius: BorderRadius.circular(10),
|
||||||
hintText: 'e.g. Ridge Runners MC',
|
border: Border.all(
|
||||||
filled: true,
|
color: colors.raceRed.withValues(alpha: 0.4),
|
||||||
fillColor: colors.surfaceContainer,
|
),
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: colors.outlineVariant,
|
|
||||||
width: 0.8,
|
|
||||||
),
|
),
|
||||||
),
|
child: Row(
|
||||||
enabledBorder: OutlineInputBorder(
|
children: [
|
||||||
borderRadius: BorderRadius.circular(12),
|
Icon(
|
||||||
borderSide: BorderSide(
|
Icons.error_outline,
|
||||||
color: colors.outlineVariant,
|
color: colors.raceRed,
|
||||||
width: 0.8,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
focusedBorder: OutlineInputBorder(
|
Expanded(
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: Text(
|
||||||
borderSide: BorderSide(
|
_submissionError!,
|
||||||
color: colors.cyberCyan,
|
style: TextStyle(
|
||||||
width: 1.5,
|
fontFamily: AppTypography.fontBody,
|
||||||
),
|
fontSize: 12.5,
|
||||||
),
|
color: colors.raceRed,
|
||||||
),
|
|
||||||
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<Color>(
|
|
||||||
Colors.black,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
SizedBox(height: isCompact ? 10 : 14),
|
||||||
const SizedBox(height: 32),
|
|
||||||
],
|
// 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<Color>(
|
||||||
|
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<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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue