fix(ui): landscape layout fixes for clubs, group rides and feed share (batch 3) (#88)

* fix(group_rides): prevent card bottom overflow in landscape grid (#68)

* fix(feed): ensure feed content and empty state scroll clear of share button (#71)

* fix(feed): make ShareRecentSessionModal keyboard-safe and scrollable in landscape

* fix(clubs): responsive grid and constrained card sizing in landscape (#73)

* fix(group_rides): prevent horizontal control clipping in Live Formation landscape (#74)

---------

Co-authored-by: Developer <dev@example.com>
This commit is contained in:
Billie Ponder 2026-10-18 05:00:38 -04:00 committed by GitHub
parent 74943a8420
commit 2524cd066e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 977 additions and 351 deletions

View File

@ -415,12 +415,18 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
crossAxisCount: crossAxisCount,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
mainAxisExtent: 260,
mainAxisExtent: 300,
),
itemCount: rides.length,
itemBuilder: (context, index) {
final ride = rides[index];
return _buildRideCard(context, ride, currentUid, colors);
return _buildRideCard(
context,
ride,
currentUid,
colors,
inGrid: true,
);
},
);
}
@ -479,12 +485,18 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
crossAxisCount: crossAxisCount,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
mainAxisExtent: 260,
mainAxisExtent: 300,
),
itemCount: myRides.length,
itemBuilder: (context, index) {
final ride = myRides[index];
return _buildRideCard(context, ride, currentUid, colors);
return _buildRideCard(
context,
ride,
currentUid,
colors,
inGrid: true,
);
},
);
}
@ -700,13 +712,14 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
BuildContext context,
GroupRide ride,
String currentUid,
MotorSignalThemeColors colors,
) {
MotorSignalThemeColors colors, {
bool inGrid = false,
}) {
final isOrganizer = ride.organizerUid == currentUid;
final isParticipant = ride.accessUids.contains(currentUid);
return Card(
margin: const EdgeInsets.only(bottom: 12),
margin: inGrid ? EdgeInsets.zero : const EdgeInsets.only(bottom: 12),
color: colors.surfaceCardPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@ -924,13 +937,16 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
),
),
onPressed: () => context.push('/group-rides/${ride.id}'),
child: const Text(
child: const FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'MANAGE RIDE',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
@ -945,13 +961,16 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
),
),
onPressed: () => context.push('/group-rides/${ride.id}'),
child: const Text(
child: const FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'OPEN HUB',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
),
),
),
],
),
] else if (isParticipant) ...[
@ -971,13 +990,16 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
),
),
onPressed: () => context.push('/group-rides/${ride.id}'),
child: const Text(
child: const FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'RIDE DETAILS',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
@ -994,13 +1016,16 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
),
),
onPressed: () => context.push('/group-rides/${ride.id}'),
child: const Text(
child: const FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'LEAVE PACK',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
),
),
),
],
),
] else ...[
@ -1019,13 +1044,16 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
),
),
onPressed: () => context.push('/group-rides/${ride.id}'),
child: const Text(
child: const FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'RIDE DETAILS',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
@ -1040,13 +1068,16 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
),
),
onPressed: () => context.push('/group-rides/${ride.id}'),
child: const Text(
child: const FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'JOIN RIDE',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
),
),
),
),
],
),
],

View File

@ -295,10 +295,10 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
body: ResponsiveLayout(
builder: (context, snapshot) {
final isShort = snapshot.height < 450;
final isLandscapeWide =
snapshot.isLandscape && snapshot.width >= 720;
final isLandscape =
snapshot.isLandscape && snapshot.width >= 560;
final mapStack = Stack(
Widget buildMapStack({required bool inLandscape}) => Stack(
children: [
// 1. Unified Map
Positioned.fill(
@ -336,7 +336,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
// 5. Map HUD Bottom: Map Mode Selector & Regroup Point
Positioned(
bottom: 8,
bottom: inLandscape ? (isShort ? 94 : 108) : 8,
left: 8,
right: 8,
child: FittedBox(
@ -360,7 +360,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
Positioned(
top: 54,
left: 8,
right: isLandscapeWide ? snapshot.width * 0.35 : 8,
right: inLandscape ? snapshot.width * 0.35 : 8,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -386,15 +386,17 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
],
);
if (isLandscapeWide) {
if (isLandscape) {
final leftFlex = snapshot.width >= 720 ? 65 : 60;
final rightFlex = 100 - leftFlex;
return Row(
children: [
// Left: Tactical Map Canvas
Expanded(
flex: 65,
flex: leftFlex,
child: Stack(
children: [
mapStack,
buildMapStack(inLandscape: true),
Positioned(
bottom: isShort ? 6 : 10,
left: 8,
@ -435,7 +437,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
),
// Right: Pack Telemetry Stream & Formation Safety Metrics
Expanded(
flex: 35,
flex: rightFlex,
child: Container(
decoration: BoxDecoration(
color: colors.surfaceContainer,
@ -477,7 +479,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
// Top Section: Map Canvas with HUDs
Expanded(
flex: 52,
child: mapStack,
child: buildMapStack(inLandscape: false),
),
// Cockpit Navigation Tab Bar
@ -1019,7 +1021,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
];
return Container(
height: isShort ? 40 : 46,
height: isShort ? 38 : 46,
decoration: BoxDecoration(
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(10),
@ -1028,14 +1030,13 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
],
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: Row(
child: LayoutBuilder(
builder: (context, constraints) {
final statusItems = Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int index = 0; index < statuses.length; index++) ...[
if (index > 0) const SizedBox(width: 6),
if (index > 0) const SizedBox(width: 4),
() {
final (status, label, icon) = statuses[index];
final isSelected = currentStatus == status;
@ -1055,7 +1056,10 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
borderRadius: BorderRadius.circular(6),
onTap: () => onStatusSelected(status),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
padding: EdgeInsets.symmetric(
horizontal: isShort ? 6 : 8,
vertical: isShort ? 3 : 4,
),
decoration: BoxDecoration(
color: isSelected
? activeColor.withValues(alpha: 0.2)
@ -1071,10 +1075,10 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
children: [
Icon(
icon,
size: 16,
size: isShort ? 14 : 16,
color: isSelected ? activeColor : Colors.white60,
),
const SizedBox(width: 5),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
@ -1082,7 +1086,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
fontWeight: isSelected
? FontWeight.bold
: FontWeight.w500,
fontSize: 11,
fontSize: isShort ? 10 : 11,
),
),
],
@ -1092,7 +1096,18 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
}(),
],
],
);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: statusItems,
),
);
},
),
);
}
@ -1109,8 +1124,10 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
required MotorSignalThemeColors colors,
bool isShort = false,
}) {
final screenWidth = MediaQuery.of(context).size.width;
final isCompact = screenWidth < 360;
return LayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.maxWidth;
final isCompact = availableWidth < 420;
return Container(
padding: EdgeInsets.symmetric(
@ -1125,11 +1142,19 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
],
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: availableWidth > 16 ? availableWidth - 16 : 0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Ghost mode / Broadcast switch
Expanded(
child: Row(
// Broadcast state info & switch
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
broadcastState.isBroadcasting
@ -1138,11 +1163,10 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
color: broadcastState.isBroadcasting
? colors.cyberCyan
: Colors.white38,
size: isCompact ? 18 : 20,
size: isCompact ? 16 : 20,
),
SizedBox(width: isCompact ? 4 : 8),
Expanded(
child: Column(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
@ -1167,6 +1191,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
letterSpacing: 0.5,
),
),
if (!isShort)
Text(
broadcastState.isBroadcasting
? (broadcastState.isPaused
@ -1179,13 +1204,15 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white38,
fontSize: isCompact ? 9 : 10,
fontSize: isCompact ? 8.5 : 10,
),
),
],
),
),
Switch(
SizedBox(width: isCompact ? 4 : 6),
Transform.scale(
scale: isCompact ? 0.8 : 1.0,
child: Switch(
key: const ValueKey('live_ride_broadcast_switch'),
value: broadcastState.isOptedIn,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
@ -1197,24 +1224,28 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
broadcastController.setOptIn(val);
},
),
),
],
),
),
SizedBox(width: isCompact ? 4 : 8),
SizedBox(width: isCompact ? 6 : 10),
// Recenter Map Button (min 48x48 tap target)
// Action buttons
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Recenter Map Button
IconButton(
key: const ValueKey('live_ride_recenter_button'),
style: IconButton.styleFrom(
backgroundColor: colors.surfaceContainer,
padding: EdgeInsets.all(isCompact ? 6 : 8),
minimumSize: Size(isCompact ? 40 : 48, isCompact ? 40 : 48),
padding: EdgeInsets.all(isCompact ? 4 : 8),
minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
),
icon: Icon(
Icons.my_location,
color: Colors.white70,
size: isCompact ? 18 : 20,
size: isCompact ? 16 : 20,
),
tooltip: 'Center on Me',
onPressed: () {
@ -1248,14 +1279,17 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
side: BorderSide(color: colors.cyberCyan),
padding: EdgeInsets.symmetric(
horizontal: isCompact ? 6 : 10,
vertical: isCompact ? 6 : 8,
vertical: isCompact ? 4 : 8,
),
minimumSize: Size(isCompact ? 40 : 48, isCompact ? 40 : 48),
minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
),
icon: const Icon(Icons.format_list_bulleted, size: 16),
icon: const Icon(Icons.format_list_bulleted, size: 14),
label: Text(
'${participants.length}',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
style: TextStyle(
fontSize: isCompact ? 10 : 11,
fontWeight: FontWeight.bold,
),
),
onPressed: () => _showRosterSheet(
context: context,
@ -1268,6 +1302,12 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
),
],
),
],
),
),
),
);
},
);
}
@ -2171,16 +2211,16 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.stream, size: 13, color: colors.cyberCyan),
const SizedBox(width: 5),
Flexible(
Expanded(
child: Text(
'PACK TELEMETRY STREAM',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
color: colors.cyberCyan,
fontSize: 10,

View File

@ -353,8 +353,10 @@ class _ClubDetailScreenState extends ConsumerState<ClubDetailScreen> {
),
_buildPill(
icon: club.admissionPolicy == ClubAdmissionPolicy.open
? Icons.lock_open_outlined
: Icons.how_to_reg_outlined,
? Icons.group_add_outlined
: (club.admissionPolicy == ClubAdmissionPolicy.request
? Icons.how_to_reg_outlined
: Icons.mail_outline),
label: club.admissionPolicy.label,
colors: colors,
theme: theme,

View File

@ -356,10 +356,44 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
),
),
),
for (final member in memberships)
ClubMemberCard(
key: ValueKey('my_club_${member.clubId}'),
member: member,
LayoutBuilder(
builder: (context, constraints) {
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
final useGrid = isLandscape || constraints.maxWidth >= 600;
if (useGrid) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
mainAxisExtent: 215,
),
itemCount: memberships.length,
itemBuilder: (context, index) {
return ClubMemberCard(
key: ValueKey('my_club_${memberships[index].clubId}'),
member: memberships[index],
inGrid: true,
);
},
);
}
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: memberships.length,
itemBuilder: (context, index) {
return ClubMemberCard(
key: ValueKey('my_club_${memberships[index].clubId}'),
member: memberships[index],
);
},
);
},
),
],
],
@ -571,10 +605,20 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
),
],
),
ClubCard(
LayoutBuilder(
builder: (context, constraints) {
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isLandscape ? 400 : double.infinity,
),
child: ClubCard(
club: _searchedClub!,
onTap: () => context.push('/social/clubs/${_searchedClub!.id}'),
),
);
},
),
],
);
}
@ -631,6 +675,34 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
);
}
return LayoutBuilder(
builder: (context, constraints) {
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
final useGrid = isLandscape || constraints.maxWidth >= 600;
if (useGrid) {
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
mainAxisExtent: 215,
),
itemCount: clubs.length,
itemBuilder: (context, index) {
final club = clubs[index];
return ClubCard(
key: ValueKey('discover_club_${club.id}'),
club: club,
inGrid: true,
onTap: () => context.push('/social/clubs/${club.id}'),
);
},
);
}
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@ -645,6 +717,8 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
},
);
},
);
},
loading: () => Center(
child: Padding(
padding: const EdgeInsets.all(32.0),

View File

@ -10,12 +10,14 @@ class ClubCard extends StatelessWidget {
final Club club;
final ClubRole? role;
final VoidCallback? onTap;
final bool inGrid;
const ClubCard({
super.key,
required this.club,
this.role,
this.onTap,
this.inGrid = false,
});
@override
@ -24,7 +26,7 @@ class ClubCard extends StatelessWidget {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.symmetric(vertical: 6.0),
margin: inGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6.0),
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(14),
@ -215,7 +217,7 @@ class ClubCard extends StatelessWidget {
switch (policy) {
case ClubAdmissionPolicy.open:
color = colors.launchGreen;
icon = Icons.lock_open_outlined;
icon = Icons.group_add_outlined;
break;
case ClubAdmissionPolicy.request:
color = colors.cyberCyan;

View File

@ -10,10 +10,12 @@ import 'club_card.dart';
/// Club document in real time.
class ClubMemberCard extends ConsumerWidget {
final ClubMember member;
final bool inGrid;
const ClubMemberCard({
super.key,
required this.member,
this.inGrid = false,
});
@override
@ -28,6 +30,7 @@ class ClubMemberCard extends ConsumerWidget {
return ClubCard(
club: club,
role: member.role,
inGrid: inGrid,
onTap: () => context.push('/social/clubs/${club.id}'),
);
},
@ -40,7 +43,7 @@ class ClubMemberCard extends ConsumerWidget {
final colors = context.motorColors;
return Container(
margin: const EdgeInsets.symmetric(vertical: 6.0),
margin: inGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6.0),
height: 96,
decoration: BoxDecoration(
color: colors.surfaceContainer,

View File

@ -53,6 +53,10 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
final activeDiscipline = ref.watch(feedDisciplineFilterProvider);
final searchQuery = ref.watch(feedSearchQueryProvider);
final items = ref.watch(socialFeedNotifierProvider);
final safeBottom = MediaQuery.paddingOf(context).bottom;
const buttonHeight = 52.0;
const buttonBottom = 12.0;
final bottomClearance = buttonHeight + buttonBottom + 16.0 + safeBottom;
final content = SafeArea(
child: ResponsiveLayout(
@ -83,10 +87,16 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
activeDiscipline,
searchQuery,
colors,
bottomClearance: bottomClearance,
)
: ListView.separated(
key: const ValueKey('social_feed_list_view'),
padding: const EdgeInsets.fromLTRB(16, 10, 16, 100),
padding: EdgeInsets.fromLTRB(
16,
10,
16,
bottomClearance + 20.0,
),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 16),
itemBuilder: (context, index) {
@ -106,7 +116,7 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
Positioned(
left: 16,
right: 16,
bottom: 12,
bottom: 12 + safeBottom,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
@ -597,8 +607,9 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
FeedFilter activeFilter,
RidingDiscipline activeDiscipline,
String searchQuery,
MotorSignalThemeColors colors,
) {
MotorSignalThemeColors colors, {
double bottomClearance = 88.0,
}) {
final hasCustomFilters =
activeDiscipline != RidingDiscipline.all || searchQuery.isNotEmpty;
final isLight = Theme.of(context).brightness == Brightness.light;
@ -611,39 +622,44 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
return LayoutBuilder(
builder: (context, constraints) {
final isCompact = constraints.maxHeight < 280;
final iconSize = isCompact ? 36.0 : 56.0;
final verticalGap = isCompact ? 8.0 : 16.0;
final textGap = isCompact ? 4.0 : 6.0;
return SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(bottom: 80),
padding: EdgeInsets.only(bottom: bottomClearance),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight > 80
? constraints.maxHeight - 80
minHeight: constraints.maxHeight > bottomClearance
? constraints.maxHeight - bottomClearance
: constraints.maxHeight,
),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.dynamic_feed_outlined,
size: 56,
size: iconSize,
color: secondaryTextColor,
),
const SizedBox(height: 16),
SizedBox(height: verticalGap),
Text(
hasCustomFilters
? 'No matching ride sessions'
: 'No posts in ${activeFilter.displayName}',
style: TextStyle(
fontFamily: AppTypography.fontHeadline,
fontSize: 16,
fontSize: isCompact ? 14 : 16,
fontWeight: FontWeight.w700,
color: primaryTextColor,
),
),
const SizedBox(height: 6),
SizedBox(height: textGap),
Text(
hasCustomFilters
? 'Try relaxing your style filters or search query to discover more community rides.'
@ -651,11 +667,11 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppTypography.fontBody,
fontSize: 13,
fontSize: isCompact ? 12 : 13,
color: secondaryTextColor,
),
),
const SizedBox(height: 16),
SizedBox(height: verticalGap),
Wrap(
spacing: 8,
runSpacing: 8,

View File

@ -413,11 +413,13 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
final accentColor =
isLight ? AppColors.brandBlueStrong : AppColors.brandBlue;
final buttonFg = AppColors.onBrand;
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final size = MediaQuery.sizeOf(context);
final isLandscape = size.width > size.height && size.height <= 500;
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.9,
maxHeight: size.height * (isLandscape ? 0.98 : 0.9),
),
child: Container(
decoration: BoxDecoration(
@ -442,14 +444,19 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
children: [
// Modal Header & Handle
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
padding: EdgeInsets.fromLTRB(
20,
isLandscape ? 8 : 12,
20,
isLandscape ? 8 : 12,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Center(
child: Container(
margin: const EdgeInsets.only(bottom: 12),
margin: EdgeInsets.only(bottom: isLandscape ? 6 : 12),
width: 44,
height: 4,
decoration: BoxDecoration(
@ -763,15 +770,32 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
),
],
),
if (isLandscape) ...[
const SizedBox(height: 18),
_buildPublishButton(accentColor, buttonFg),
const SizedBox(height: 8),
],
],
),
),
),
// Publish CTA Button (pinned at modal bottom)
// Publish CTA Button (pinned at modal bottom in portrait mode)
if (!isLandscape)
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: SizedBox(
child: _buildPublishButton(accentColor, buttonFg),
),
],
),
),
),
),
);
}
Widget _buildPublishButton(Color accentColor, Color buttonFg) {
return SizedBox(
height: 52,
width: double.infinity,
child: ElevatedButton(
@ -814,15 +838,8 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
],
),
),
),
),
],
),
),
),
),
);
}
);
}
Widget _buildReadOnlyTelemetryTile({
required Key key,

View File

@ -53,7 +53,7 @@ void main() {
expect(tapped, isTrue);
});
testWidgets('renders open admission badge correctly', (tester) async {
testWidgets('renders open admission badge correctly with group_add_outlined icon', (tester) async {
final openClub = sampleClub.copyWith(admissionPolicy: ClubAdmissionPolicy.open);
await tester.pumpWidget(
@ -68,6 +68,8 @@ void main() {
);
expect(find.text('Open to Join'), findsOneWidget);
expect(find.byIcon(Icons.group_add_outlined), findsOneWidget);
expect(find.byIcon(Icons.lock_open_outlined), findsNothing);
});
testWidgets('suppresses role pill for standard members and shows for admin/owner', (tester) async {

View File

@ -225,5 +225,103 @@ void main() {
expect(find.text('Accept'), findsOneWidget);
expect(find.text('Decline'), findsOneWidget);
});
for (final size in const [Size(800, 360), Size(640, 360), Size(400, 800)]) {
testWidgets('renders responsive layout without overflow on $size (#73)', (tester) async {
await tester.binding.setSurfaceSize(size);
addTearDown(() => tester.binding.setSurfaceSize(null));
const user = AuthUser(uid: 'alice', isAnonymous: false);
final club1 = Club(
id: 'club_1',
name: 'Bay Area Motos',
slug: 'bay-area-motos',
description: 'San Francisco & Bay Area motorcycle rides.',
ownerUid: 'alice',
memberCount: 15,
admissionPolicy: ClubAdmissionPolicy.open,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final club2 = Club(
id: 'club_2',
name: 'NorCal Twisties',
slug: 'norcal-twisties',
description: 'Carving Northern California canyon roads.',
ownerUid: 'bob',
memberCount: 24,
admissionPolicy: ClubAdmissionPolicy.request,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
final member1 = ClubMember(
uid: 'alice',
clubId: 'club_1',
status: ClubMemberStatus.active,
role: ClubRole.owner,
updatedAt: DateTime(2026, 1, 1),
);
final member2 = ClubMember(
uid: 'alice',
clubId: 'club_2',
status: ClubMemberStatus.active,
role: ClubRole.member,
updatedAt: DateTime(2026, 1, 1),
);
final fakeService = FakeClubServiceForHub(
clubs: {'club_1': club1, 'club_2': club2},
handles: {'bay-area-motos': 'club_1', 'norcal-twisties': 'club_2'},
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWith((ref) => user),
clubServiceProvider.overrideWithValue(fakeService),
clubDetailProvider('club_1').overrideWith((ref) => Stream.value(club1)),
clubDetailProvider('club_2').overrideWith((ref) => Stream.value(club2)),
myClubsProvider.overrideWith((ref) => Stream.value([member1, member2])),
myClubInvitesProvider.overrideWith((ref) => Stream.value(const [])),
publicClubsProvider.overrideWith((ref) => Future.value([club1, club2])),
],
child: MediaQuery(
data: MediaQueryData(size: size),
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: [MotorSignalThemeColors.dark],
),
home: const ClubsHubScreen(),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final isLandscape = size.width > size.height;
if (isLandscape) {
// Verify GridView is used for memberships in landscape
expect(find.byType(GridView), findsOneWidget);
} else {
expect(find.byType(GridView), findsNothing);
}
// Tap Discover Tab
await tester.tap(find.text('DISCOVER'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
if (isLandscape) {
// Verify GridView is used for discovery in landscape
expect(find.byType(GridView), findsOneWidget);
} else {
expect(find.byType(GridView), findsNothing);
}
});
}
});
}

View File

@ -1134,6 +1134,222 @@ void main() {
}
});
group('Issue #71 Landscape Feed Floating Button Overlap', () {
for (final viewport in const [
Size(800, 360),
Size(640, 360),
Size(400, 800),
]) {
testWidgets(
'Empty feed message and action buttons are not covered by share button on ${viewport.width.toInt()}x${viewport.height.toInt()}',
(tester) async {
await tester.binding.setSurfaceSize(viewport);
addTearDown(() => tester.binding.setSurfaceSize(null));
final service = SocialFeedService();
addTearDown(service.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
feedSearchQueryProvider.overrideWith((ref) => 'nomatchxyz'),
],
child: MediaQuery(
data: MediaQueryData(size: viewport),
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [MotorSignalThemeColors.dark],
),
home: const SocialFeedScreen(),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final shareBtnFinder = find.byKey(const ValueKey('feed_share_session_button'));
final emptyActionFinder = find.byKey(const ValueKey('empty_feed_group_rides_button'));
expect(shareBtnFinder, findsOneWidget);
expect(emptyActionFinder, findsOneWidget);
// Scroll until visible if needed in compact landscape
final scrollableFinder = find.byType(Scrollable).last;
await tester.scrollUntilVisible(emptyActionFinder, 100, scrollable: scrollableFinder);
await tester.pumpAndSettle();
final shareBtnRect = tester.getRect(shareBtnFinder);
final emptyActionRect = tester.getRect(emptyActionFinder);
expect(emptyActionRect.overlaps(shareBtnRect), isFalse,
reason: 'Empty action button $emptyActionRect overlaps share button $shareBtnRect');
expect(emptyActionRect.bottom <= shareBtnRect.top, isTrue,
reason: 'Empty action button bottom ${emptyActionRect.bottom} should be <= share button top ${shareBtnRect.top}');
},
);
testWidgets(
'Populated feed list scrolls so last item is above share button on ${viewport.width.toInt()}x${viewport.height.toInt()}',
(tester) async {
await tester.binding.setSurfaceSize(viewport);
addTearDown(() => tester.binding.setSurfaceSize(null));
final service = SocialFeedService();
addTearDown(service.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [
socialFeedServiceProvider.overrideWithValue(service),
],
child: MediaQuery(
data: MediaQueryData(size: viewport),
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [MotorSignalThemeColors.dark],
),
home: const SocialFeedScreen(),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final shareBtnFinder = find.byKey(const ValueKey('feed_share_session_button'));
expect(shareBtnFinder, findsOneWidget);
// Scroll until the bottom actions of the last post are visible
final lastActionFinder = find.byKey(const ValueKey('inspect_telemetry_btn_feed_post_4'));
final feedListFinder = find.byKey(const ValueKey('social_feed_list_view'));
final feedScrollableFinder = find.descendant(
of: feedListFinder,
matching: find.byType(Scrollable),
);
await tester.scrollUntilVisible(
lastActionFinder,
100,
scrollable: feedScrollableFinder,
);
await tester.pumpAndSettle();
expect(lastActionFinder, findsOneWidget);
final lastActionRect = tester.getRect(lastActionFinder);
final shareBtnRect = tester.getRect(shareBtnFinder);
expect(lastActionRect.overlaps(shareBtnRect), isFalse,
reason: 'Last item bottom action $lastActionRect should not overlap share button $shareBtnRect');
expect(lastActionRect.bottom <= shareBtnRect.top, isTrue,
reason: 'Last item bottom action bottom ${lastActionRect.bottom} should be <= share button top ${shareBtnRect.top}');
},
);
}
});
group('ShareSessionModal Landscape Keyboard & Scroll Layout', () {
final testRide = Ride(
id: 99,
startTime: DateTime(2026, 10, 15, 9, 0),
endTime: DateTime(2026, 10, 15, 10, 15),
distance: 35.5,
distanceMiles: 35.5,
movingTime: 3600,
stoppedTime: 900,
topSpeed: 65.0,
topSpeedMph: 65.0,
averageSpeed: 35.5,
maxLeanLeft: 38.0,
maxLeanRight: 42.0,
maxAccelG: 0.6,
maxBrakingG: 0.7,
elevationGain: 1200.0,
elevationLoss: 1100.0,
);
for (final viewport in const [
Size(800, 360),
Size(640, 360),
Size(400, 800),
]) {
final isLandscape = viewport.width > viewport.height;
for (final keyboardInset in isLandscape ? [0.0, 180.0] : [0.0]) {
testWidgets(
'ShareSessionModal on ${viewport.width.toInt()}x${viewport.height.toInt()} with keyboard inset $keyboardInset has scrollable fields and publish button does not cover title',
(tester) async {
await tester.binding.setSurfaceSize(viewport);
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ProviderScope(
child: MediaQuery(
data: MediaQueryData(
size: viewport,
viewInsets: EdgeInsets.only(bottom: keyboardInset),
),
child: MaterialApp(
theme: ThemeData.dark().copyWith(
extensions: const [MotorSignalThemeColors.dark],
),
home: Scaffold(
body: Builder(
builder: (ctx) => ElevatedButton(
key: const ValueKey('open_modal_btn'),
onPressed: () => ShareSessionModal.show(ctx, initialRide: testRide),
child: const Text('Open Modal'),
),
),
),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('open_modal_btn')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
final titleFinder = find.byType(TextFormField).first;
expect(titleFinder, findsOneWidget);
final publishBtnFinder = find.widgetWithText(ElevatedButton, 'PUBLISH TO COMMUNITY FEED');
expect(publishBtnFinder, findsOneWidget);
// In landscape or when keyboard is open, title rect must not be overlapped by publish button rect
final titleRect = tester.getRect(titleFinder);
final publishRect = tester.getRect(publishBtnFinder);
expect(
titleRect.overlaps(publishRect),
isFalse,
reason: 'Title field $titleRect is covered by publish button $publishRect',
);
// Verify scrolling inside modal allows reaching the publish button and fields
final modalScrollable = find.descendant(
of: find.byType(ShareSessionModal),
matching: find.byType(Scrollable),
);
expect(modalScrollable, findsWidgets);
await tester.scrollUntilVisible(
publishBtnFinder,
100,
scrollable: modalScrollable.first,
);
await tester.pumpAndSettle();
expect(publishBtnFinder.hitTestable(), findsOneWidget);
},
);
}
}
});
group('Sprint 1 Remediation: BUG-003 / Sub-Issue #81 Starting Location & Canyon Removal', () {
testWidgets('ShareSessionModal displays STARTING LOCATION, removes Canyon label, and defaults to Morning Ride', (tester) async {
await tester.binding.setSurfaceSize(const Size(400, 800));

View File

@ -129,6 +129,66 @@ void main() {
expect(find.textContaining('Dahlonega Chevron'), findsOneWidget);
expect(find.text('3'), findsOneWidget);
});
for (final size in const [Size(800, 360), Size(640, 360), Size(400, 800)]) {
testWidgets('ride card renders without overflow on $size (#68)',
(tester) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
const user = AuthUser(
uid: 'alice',
email: 'alice@example.com',
isAnonymous: false,
);
final now = DateTime.now();
final ride = GroupRide(
id: 'ride_1',
organizerUid: 'alice',
organizerHandle: 'alice_lead',
organizerDisplayName: 'Alice Lead',
title: 'Sunday Morning Twisties',
scheduledStartAt: now.add(const Duration(days: 1)),
departureAt: now.add(const Duration(days: 1, minutes: 30)),
participantCount: 3,
meetingPoint: const GroupRideLocation(
latitude: 34.0,
longitude: -84.0,
displayName: 'Dahlonega Chevron',
),
destination: const GroupRideLocation(
latitude: 34.5,
longitude: -83.8,
displayName: 'Blood Mountain Summit',
),
createdAt: now,
updatedAt: now,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
upcomingGroupRidesProvider.overrideWith(
(ref) => Stream.value([ride]),
),
],
child: const MaterialApp(
home: GroupRidesHubScreen(),
),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('group_ride_card_ride_1')), findsOneWidget);
expect(find.byKey(const ValueKey('manage_ride_ride_1')), findsOneWidget);
expect(find.byKey(const ValueKey('open_hub_ride_1')), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
});
group('CreateGroupRideScreen Widget Tests', () {

View File

@ -329,5 +329,70 @@ void main() {
);
expect(find.text('3 RIDERS'), findsOneWidget);
});
for (final size in const [Size(800, 360), Size(640, 360), Size(400, 800)]) {
testWidgets('renders landscape controls and panel without overflow on $size (#74)',
(tester) async {
await tester.binding.setSurfaceSize(size);
addTearDown(() => tester.binding.setSurfaceSize(null));
const user = AuthUser(
uid: 'alice',
email: 'alice@example.com',
isAnonymous: false,
);
await tester.pumpWidget(
ProviderScope(
overrides: [
currentUserProvider.overrideWithValue(user),
groupRideStreamProvider('ride_live_test')
.overrideWith((ref) => Stream.value(testRide)),
groupRideParticipantsProvider('ride_live_test')
.overrideWith((ref) => Stream.value(participants)),
groupRideLivePresenceProvider('ride_live_test')
.overrideWith((ref) => Stream.value(presences)),
groupRideLiveSharingAllowedProvider.overrideWith((ref) => true),
],
child: MediaQuery(
data: MediaQueryData(size: size),
child: const MaterialApp(
home: LiveGroupRideScreen(rideId: 'ride_live_test'),
),
),
),
);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
// Verify key controls are present and reachable
expect(
find.byKey(const ValueKey('live_ride_broadcast_switch')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('live_ride_recenter_button')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('live_ride_roster_button')),
findsOneWidget,
);
// Status bar items
expect(find.text('RIDING'), findsWidgets);
expect(find.text('STOPPED'), findsOneWidget);
expect(find.text('NEED FUEL'), findsWidgets);
expect(find.text('REGROUP'), findsOneWidget);
final isLandscape = size.width > size.height;
if (isLandscape) {
// In landscape, verify side-by-side pack telemetry stream panel is present
expect(find.text('PACK TELEMETRY STREAM'), findsOneWidget);
}
});
}
});
}