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:
parent
74943a8420
commit
2524cd066e
|
|
@ -415,12 +415,18 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
crossAxisCount: crossAxisCount,
|
crossAxisCount: crossAxisCount,
|
||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
mainAxisExtent: 260,
|
mainAxisExtent: 300,
|
||||||
),
|
),
|
||||||
itemCount: rides.length,
|
itemCount: rides.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final ride = rides[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,
|
crossAxisCount: crossAxisCount,
|
||||||
crossAxisSpacing: 16,
|
crossAxisSpacing: 16,
|
||||||
mainAxisSpacing: 16,
|
mainAxisSpacing: 16,
|
||||||
mainAxisExtent: 260,
|
mainAxisExtent: 300,
|
||||||
),
|
),
|
||||||
itemCount: myRides.length,
|
itemCount: myRides.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final ride = myRides[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,
|
BuildContext context,
|
||||||
GroupRide ride,
|
GroupRide ride,
|
||||||
String currentUid,
|
String currentUid,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors, {
|
||||||
) {
|
bool inGrid = false,
|
||||||
|
}) {
|
||||||
final isOrganizer = ride.organizerUid == currentUid;
|
final isOrganizer = ride.organizerUid == currentUid;
|
||||||
final isParticipant = ride.accessUids.contains(currentUid);
|
final isParticipant = ride.accessUids.contains(currentUid);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: inGrid ? EdgeInsets.zero : const EdgeInsets.only(bottom: 12),
|
||||||
color: colors.surfaceCardPrimary,
|
color: colors.surfaceCardPrimary,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -924,9 +937,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||||
child: const Text(
|
child: const FittedBox(
|
||||||
'MANAGE RIDE',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
child: Text(
|
||||||
|
'MANAGE RIDE',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -945,9 +961,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||||
child: const Text(
|
child: const FittedBox(
|
||||||
'OPEN HUB',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
child: Text(
|
||||||
|
'OPEN HUB',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -971,9 +990,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||||
child: const Text(
|
child: const FittedBox(
|
||||||
'RIDE DETAILS',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
child: Text(
|
||||||
|
'RIDE DETAILS',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -994,9 +1016,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||||
child: const Text(
|
child: const FittedBox(
|
||||||
'LEAVE PACK',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
child: Text(
|
||||||
|
'LEAVE PACK',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1019,9 +1044,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||||
child: const Text(
|
child: const FittedBox(
|
||||||
'RIDE DETAILS',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
child: Text(
|
||||||
|
'RIDE DETAILS',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -1040,9 +1068,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||||
child: const Text(
|
child: const FittedBox(
|
||||||
'JOIN RIDE',
|
fit: BoxFit.scaleDown,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
child: Text(
|
||||||
|
'JOIN RIDE',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -295,10 +295,10 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
body: ResponsiveLayout(
|
body: ResponsiveLayout(
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final isShort = snapshot.height < 450;
|
final isShort = snapshot.height < 450;
|
||||||
final isLandscapeWide =
|
final isLandscape =
|
||||||
snapshot.isLandscape && snapshot.width >= 720;
|
snapshot.isLandscape && snapshot.width >= 560;
|
||||||
|
|
||||||
final mapStack = Stack(
|
Widget buildMapStack({required bool inLandscape}) => Stack(
|
||||||
children: [
|
children: [
|
||||||
// 1. Unified Map
|
// 1. Unified Map
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
|
|
@ -336,7 +336,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
|
|
||||||
// 5. Map HUD Bottom: Map Mode Selector & Regroup Point
|
// 5. Map HUD Bottom: Map Mode Selector & Regroup Point
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 8,
|
bottom: inLandscape ? (isShort ? 94 : 108) : 8,
|
||||||
left: 8,
|
left: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
|
|
@ -360,7 +360,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 54,
|
top: 54,
|
||||||
left: 8,
|
left: 8,
|
||||||
right: isLandscapeWide ? snapshot.width * 0.35 : 8,
|
right: inLandscape ? snapshot.width * 0.35 : 8,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// Left: Tactical Map Canvas
|
// Left: Tactical Map Canvas
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 65,
|
flex: leftFlex,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
mapStack,
|
buildMapStack(inLandscape: true),
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: isShort ? 6 : 10,
|
bottom: isShort ? 6 : 10,
|
||||||
left: 8,
|
left: 8,
|
||||||
|
|
@ -435,7 +437,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
),
|
),
|
||||||
// Right: Pack Telemetry Stream & Formation Safety Metrics
|
// Right: Pack Telemetry Stream & Formation Safety Metrics
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 35,
|
flex: rightFlex,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainer,
|
color: colors.surfaceContainer,
|
||||||
|
|
@ -477,7 +479,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
// Top Section: Map Canvas with HUDs
|
// Top Section: Map Canvas with HUDs
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 52,
|
flex: 52,
|
||||||
child: mapStack,
|
child: buildMapStack(inLandscape: false),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Cockpit Navigation Tab Bar
|
// Cockpit Navigation Tab Bar
|
||||||
|
|
@ -1019,7 +1021,7 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
];
|
];
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: isShort ? 40 : 46,
|
height: isShort ? 38 : 46,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
|
@ -1028,71 +1030,84 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
|
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: LayoutBuilder(
|
||||||
scrollDirection: Axis.horizontal,
|
builder: (context, constraints) {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
final statusItems = Row(
|
||||||
child: Row(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
for (int index = 0; index < statuses.length; index++) ...[
|
||||||
for (int index = 0; index < statuses.length; index++) ...[
|
if (index > 0) const SizedBox(width: 4),
|
||||||
if (index > 0) const SizedBox(width: 6),
|
() {
|
||||||
() {
|
final (status, label, icon) = statuses[index];
|
||||||
final (status, label, icon) = statuses[index];
|
final isSelected = currentStatus == status;
|
||||||
final isSelected = currentStatus == status;
|
|
||||||
|
|
||||||
Color activeColor = colors.cyberCyan;
|
Color activeColor = colors.cyberCyan;
|
||||||
if (status == LivePresenceStatus.needFuel) {
|
if (status == LivePresenceStatus.needFuel) {
|
||||||
activeColor = Colors.orangeAccent;
|
activeColor = Colors.orangeAccent;
|
||||||
}
|
}
|
||||||
if (status == LivePresenceStatus.regrouping) {
|
if (status == LivePresenceStatus.regrouping) {
|
||||||
activeColor = colors.warningAmber;
|
activeColor = colors.warningAmber;
|
||||||
}
|
}
|
||||||
if (status == LivePresenceStatus.arrived) {
|
if (status == LivePresenceStatus.arrived) {
|
||||||
activeColor = colors.launchGreen;
|
activeColor = colors.launchGreen;
|
||||||
}
|
}
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
onTap: () => onStatusSelected(status),
|
onTap: () => onStatusSelected(status),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
padding: EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(
|
horizontal: isShort ? 6 : 8,
|
||||||
color: isSelected
|
vertical: isShort ? 3 : 4,
|
||||||
? activeColor.withValues(alpha: 0.2)
|
),
|
||||||
: Colors.transparent,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(6),
|
color: isSelected
|
||||||
border: Border.all(
|
? activeColor.withValues(alpha: 0.2)
|
||||||
color: isSelected ? activeColor : Colors.transparent,
|
: Colors.transparent,
|
||||||
width: 1.2,
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? activeColor : Colors.transparent,
|
||||||
|
width: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
size: isShort ? 14 : 16,
|
||||||
|
color: isSelected ? activeColor : Colors.white60,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? activeColor : Colors.white70,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.w500,
|
||||||
|
fontSize: isShort ? 10 : 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
);
|
||||||
mainAxisSize: MainAxisSize.min,
|
}(),
|
||||||
children: [
|
],
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
size: 16,
|
|
||||||
color: isSelected ? activeColor : Colors.white60,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 5),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isSelected ? activeColor : Colors.white70,
|
|
||||||
fontWeight: isSelected
|
|
||||||
? FontWeight.bold
|
|
||||||
: FontWeight.w500,
|
|
||||||
fontSize: 11,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}(),
|
|
||||||
],
|
],
|
||||||
],
|
);
|
||||||
),
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: statusItems,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1109,165 +1124,190 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
required MotorSignalThemeColors colors,
|
required MotorSignalThemeColors colors,
|
||||||
bool isShort = false,
|
bool isShort = false,
|
||||||
}) {
|
}) {
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
return LayoutBuilder(
|
||||||
final isCompact = screenWidth < 360;
|
builder: (context, constraints) {
|
||||||
|
final availableWidth = constraints.maxWidth;
|
||||||
|
final isCompact = availableWidth < 420;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: isCompact ? 6 : 8,
|
horizontal: isCompact ? 6 : 8,
|
||||||
vertical: isShort ? 4 : 6,
|
vertical: isShort ? 4 : 6,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(color: colors.outlineVariant),
|
border: Border.all(color: colors.outlineVariant),
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
|
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: SingleChildScrollView(
|
||||||
children: [
|
scrollDirection: Axis.horizontal,
|
||||||
// Ghost mode / Broadcast switch
|
child: ConstrainedBox(
|
||||||
Expanded(
|
constraints: BoxConstraints(
|
||||||
child: Row(
|
minWidth: availableWidth > 16 ? availableWidth - 16 : 0,
|
||||||
children: [
|
),
|
||||||
Icon(
|
child: Row(
|
||||||
broadcastState.isBroadcasting
|
mainAxisSize: MainAxisSize.min,
|
||||||
? Icons.radar
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
: Icons.location_off,
|
children: [
|
||||||
color: broadcastState.isBroadcasting
|
// Broadcast state info & switch
|
||||||
? colors.cyberCyan
|
Row(
|
||||||
: Colors.white38,
|
|
||||||
size: isCompact ? 18 : 20,
|
|
||||||
),
|
|
||||||
SizedBox(width: isCompact ? 4 : 8),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Icon(
|
||||||
broadcastState.isBroadcasting
|
broadcastState.isBroadcasting
|
||||||
? (broadcastState.isPaused
|
? Icons.radar
|
||||||
? 'PAUSED'
|
: Icons.location_off,
|
||||||
: (broadcastState.isBackground
|
color: broadcastState.isBroadcasting
|
||||||
? 'BACKGROUND'
|
? colors.cyberCyan
|
||||||
: 'SHARING LIVE'))
|
: Colors.white38,
|
||||||
: 'GHOST MODE',
|
size: isCompact ? 16 : 20,
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
color: broadcastState.isBroadcasting
|
|
||||||
? (broadcastState.isPaused
|
|
||||||
? colors.warningAmber
|
|
||||||
: colors.cyberCyan)
|
|
||||||
: Colors.white60,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: isCompact ? 10 : 11,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Text(
|
SizedBox(width: isCompact ? 4 : 8),
|
||||||
broadcastState.isBroadcasting
|
Column(
|
||||||
? (broadcastState.isPaused
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? 'Reconnecting...'
|
mainAxisSize: MainAxisSize.min,
|
||||||
: (broadcastState.isBackground
|
children: [
|
||||||
? 'Foreground active'
|
Text(
|
||||||
: 'Broadcasting'))
|
broadcastState.isBroadcasting
|
||||||
: 'Location paused',
|
? (broadcastState.isPaused
|
||||||
maxLines: 1,
|
? 'PAUSED'
|
||||||
overflow: TextOverflow.ellipsis,
|
: (broadcastState.isBackground
|
||||||
style: TextStyle(
|
? 'BACKGROUND'
|
||||||
color: Colors.white38,
|
: 'SHARING LIVE'))
|
||||||
fontSize: isCompact ? 9 : 10,
|
: 'GHOST MODE',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: broadcastState.isBroadcasting
|
||||||
|
? (broadcastState.isPaused
|
||||||
|
? colors.warningAmber
|
||||||
|
: colors.cyberCyan)
|
||||||
|
: Colors.white60,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: isCompact ? 10 : 11,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isShort)
|
||||||
|
Text(
|
||||||
|
broadcastState.isBroadcasting
|
||||||
|
? (broadcastState.isPaused
|
||||||
|
? 'Reconnecting...'
|
||||||
|
: (broadcastState.isBackground
|
||||||
|
? 'Foreground active'
|
||||||
|
: 'Broadcasting'))
|
||||||
|
: 'Location paused',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white38,
|
||||||
|
fontSize: isCompact ? 8.5 : 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
activeThumbColor: colors.cyberCyan,
|
||||||
|
onChanged: (val) {
|
||||||
|
if (val && !broadcastState.isMasterAllowed) {
|
||||||
|
ref.read(groupRideLiveSharingAllowedProvider.notifier).state = true;
|
||||||
|
}
|
||||||
|
broadcastController.setOptIn(val);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Switch(
|
|
||||||
key: const ValueKey('live_ride_broadcast_switch'),
|
|
||||||
value: broadcastState.isOptedIn,
|
|
||||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
||||||
activeThumbColor: colors.cyberCyan,
|
|
||||||
onChanged: (val) {
|
|
||||||
if (val && !broadcastState.isMasterAllowed) {
|
|
||||||
ref.read(groupRideLiveSharingAllowedProvider.notifier).state = true;
|
|
||||||
}
|
|
||||||
broadcastController.setOptIn(val);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
SizedBox(width: isCompact ? 4 : 8),
|
SizedBox(width: isCompact ? 6 : 10),
|
||||||
|
|
||||||
// Recenter Map Button (min 48x48 tap target)
|
// Action buttons
|
||||||
IconButton(
|
Row(
|
||||||
key: const ValueKey('live_ride_recenter_button'),
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: IconButton.styleFrom(
|
children: [
|
||||||
backgroundColor: colors.surfaceContainer,
|
// Recenter Map Button
|
||||||
padding: EdgeInsets.all(isCompact ? 6 : 8),
|
IconButton(
|
||||||
minimumSize: Size(isCompact ? 40 : 48, isCompact ? 40 : 48),
|
key: const ValueKey('live_ride_recenter_button'),
|
||||||
),
|
style: IconButton.styleFrom(
|
||||||
icon: Icon(
|
backgroundColor: colors.surfaceContainer,
|
||||||
Icons.my_location,
|
padding: EdgeInsets.all(isCompact ? 4 : 8),
|
||||||
color: Colors.white70,
|
minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
|
||||||
size: isCompact ? 18 : 20,
|
),
|
||||||
),
|
icon: Icon(
|
||||||
tooltip: 'Center on Me',
|
Icons.my_location,
|
||||||
onPressed: () {
|
color: Colors.white70,
|
||||||
if (broadcastState.lastPosition != null) {
|
size: isCompact ? 16 : 20,
|
||||||
setState(() {
|
),
|
||||||
_customMapCenter = AppLatLng(
|
tooltip: 'Center on Me',
|
||||||
broadcastState.lastPosition!.latitude,
|
onPressed: () {
|
||||||
broadcastState.lastPosition!.longitude,
|
if (broadcastState.lastPosition != null) {
|
||||||
);
|
setState(() {
|
||||||
_mapZoom = 15.0;
|
_customMapCenter = AppLatLng(
|
||||||
});
|
broadcastState.lastPosition!.latitude,
|
||||||
} else {
|
broadcastState.lastPosition!.longitude,
|
||||||
setState(() {
|
);
|
||||||
_customMapCenter = AppLatLng(
|
_mapZoom = 15.0;
|
||||||
ride.meetingPoint.latitude,
|
});
|
||||||
ride.meetingPoint.longitude,
|
} else {
|
||||||
);
|
setState(() {
|
||||||
});
|
_customMapCenter = AppLatLng(
|
||||||
}
|
ride.meetingPoint.latitude,
|
||||||
},
|
ride.meetingPoint.longitude,
|
||||||
),
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
SizedBox(width: isCompact ? 4 : 6),
|
SizedBox(width: isCompact ? 4 : 6),
|
||||||
|
|
||||||
// Roster Opener Button
|
// Roster Opener Button
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
key: const ValueKey('live_ride_roster_button'),
|
key: const ValueKey('live_ride_roster_button'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||||
foregroundColor: colors.cyberCyan,
|
foregroundColor: colors.cyberCyan,
|
||||||
side: BorderSide(color: colors.cyberCyan),
|
side: BorderSide(color: colors.cyberCyan),
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: isCompact ? 6 : 10,
|
horizontal: isCompact ? 6 : 10,
|
||||||
vertical: isCompact ? 6 : 8,
|
vertical: isCompact ? 4 : 8,
|
||||||
|
),
|
||||||
|
minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.format_list_bulleted, size: 14),
|
||||||
|
label: Text(
|
||||||
|
'${participants.length}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: isCompact ? 10 : 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () => _showRosterSheet(
|
||||||
|
context: context,
|
||||||
|
participants: participants,
|
||||||
|
presences: presences,
|
||||||
|
leadPresence: leadPresence,
|
||||||
|
currentUid: currentUid,
|
||||||
|
colors: colors,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
minimumSize: Size(isCompact ? 40 : 48, isCompact ? 40 : 48),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.format_list_bulleted, size: 16),
|
|
||||||
label: Text(
|
|
||||||
'${participants.length}',
|
|
||||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
onPressed: () => _showRosterSheet(
|
|
||||||
context: context,
|
|
||||||
participants: participants,
|
|
||||||
presences: presences,
|
|
||||||
leadPresence: leadPresence,
|
|
||||||
currentUid: currentUid,
|
|
||||||
colors: colors,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2171,16 +2211,16 @@ class _LiveGroupRideScreenState extends ConsumerState<LiveGroupRideScreen>
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.stream, size: 13, color: colors.cyberCyan),
|
Icon(Icons.stream, size: 13, color: colors.cyberCyan),
|
||||||
const SizedBox(width: 5),
|
const SizedBox(width: 5),
|
||||||
Flexible(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'PACK TELEMETRY STREAM',
|
'PACK TELEMETRY STREAM',
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colors.cyberCyan,
|
color: colors.cyberCyan,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
|
|
|
||||||
|
|
@ -353,8 +353,10 @@ class _ClubDetailScreenState extends ConsumerState<ClubDetailScreen> {
|
||||||
),
|
),
|
||||||
_buildPill(
|
_buildPill(
|
||||||
icon: club.admissionPolicy == ClubAdmissionPolicy.open
|
icon: club.admissionPolicy == ClubAdmissionPolicy.open
|
||||||
? Icons.lock_open_outlined
|
? Icons.group_add_outlined
|
||||||
: Icons.how_to_reg_outlined,
|
: (club.admissionPolicy == ClubAdmissionPolicy.request
|
||||||
|
? Icons.how_to_reg_outlined
|
||||||
|
: Icons.mail_outline),
|
||||||
label: club.admissionPolicy.label,
|
label: club.admissionPolicy.label,
|
||||||
colors: colors,
|
colors: colors,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
|
|
|
||||||
|
|
@ -356,11 +356,45 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
for (final member in memberships)
|
LayoutBuilder(
|
||||||
ClubMemberCard(
|
builder: (context, constraints) {
|
||||||
key: ValueKey('my_club_${member.clubId}'),
|
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
|
||||||
member: member,
|
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,9 +605,19 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
ClubCard(
|
LayoutBuilder(
|
||||||
club: _searchedClub!,
|
builder: (context, constraints) {
|
||||||
onTap: () => context.push('/social/clubs/${_searchedClub!.id}'),
|
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,16 +675,46 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return LayoutBuilder(
|
||||||
shrinkWrap: true,
|
builder: (context, constraints) {
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
final isLandscape = MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height;
|
||||||
itemCount: clubs.length,
|
final useGrid = isLandscape || constraints.maxWidth >= 600;
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final club = clubs[index];
|
if (useGrid) {
|
||||||
return ClubCard(
|
return GridView.builder(
|
||||||
key: ValueKey('discover_club_${club.id}'),
|
shrinkWrap: true,
|
||||||
club: club,
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
onTap: () => context.push('/social/clubs/${club.id}'),
|
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(),
|
||||||
|
itemCount: clubs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final club = clubs[index];
|
||||||
|
return ClubCard(
|
||||||
|
key: ValueKey('discover_club_${club.id}'),
|
||||||
|
club: club,
|
||||||
|
onTap: () => context.push('/social/clubs/${club.id}'),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@ class ClubCard extends StatelessWidget {
|
||||||
final Club club;
|
final Club club;
|
||||||
final ClubRole? role;
|
final ClubRole? role;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final bool inGrid;
|
||||||
|
|
||||||
const ClubCard({
|
const ClubCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.club,
|
required this.club,
|
||||||
this.role,
|
this.role,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.inGrid = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -24,7 +26,7 @@ class ClubCard extends StatelessWidget {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
margin: inGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6.0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainer,
|
color: colors.surfaceContainer,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
|
@ -215,7 +217,7 @@ class ClubCard extends StatelessWidget {
|
||||||
switch (policy) {
|
switch (policy) {
|
||||||
case ClubAdmissionPolicy.open:
|
case ClubAdmissionPolicy.open:
|
||||||
color = colors.launchGreen;
|
color = colors.launchGreen;
|
||||||
icon = Icons.lock_open_outlined;
|
icon = Icons.group_add_outlined;
|
||||||
break;
|
break;
|
||||||
case ClubAdmissionPolicy.request:
|
case ClubAdmissionPolicy.request:
|
||||||
color = colors.cyberCyan;
|
color = colors.cyberCyan;
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ import 'club_card.dart';
|
||||||
/// Club document in real time.
|
/// Club document in real time.
|
||||||
class ClubMemberCard extends ConsumerWidget {
|
class ClubMemberCard extends ConsumerWidget {
|
||||||
final ClubMember member;
|
final ClubMember member;
|
||||||
|
final bool inGrid;
|
||||||
|
|
||||||
const ClubMemberCard({
|
const ClubMemberCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.member,
|
required this.member,
|
||||||
|
this.inGrid = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -28,6 +30,7 @@ class ClubMemberCard extends ConsumerWidget {
|
||||||
return ClubCard(
|
return ClubCard(
|
||||||
club: club,
|
club: club,
|
||||||
role: member.role,
|
role: member.role,
|
||||||
|
inGrid: inGrid,
|
||||||
onTap: () => context.push('/social/clubs/${club.id}'),
|
onTap: () => context.push('/social/clubs/${club.id}'),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -40,7 +43,7 @@ class ClubMemberCard extends ConsumerWidget {
|
||||||
final colors = context.motorColors;
|
final colors = context.motorColors;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 6.0),
|
margin: inGrid ? EdgeInsets.zero : const EdgeInsets.symmetric(vertical: 6.0),
|
||||||
height: 96,
|
height: 96,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colors.surfaceContainer,
|
color: colors.surfaceContainer,
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
||||||
final activeDiscipline = ref.watch(feedDisciplineFilterProvider);
|
final activeDiscipline = ref.watch(feedDisciplineFilterProvider);
|
||||||
final searchQuery = ref.watch(feedSearchQueryProvider);
|
final searchQuery = ref.watch(feedSearchQueryProvider);
|
||||||
final items = ref.watch(socialFeedNotifierProvider);
|
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(
|
final content = SafeArea(
|
||||||
child: ResponsiveLayout(
|
child: ResponsiveLayout(
|
||||||
|
|
@ -83,10 +87,16 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
||||||
activeDiscipline,
|
activeDiscipline,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
colors,
|
colors,
|
||||||
|
bottomClearance: bottomClearance,
|
||||||
)
|
)
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
key: const ValueKey('social_feed_list_view'),
|
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,
|
itemCount: items.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 16),
|
separatorBuilder: (_, _) => const SizedBox(height: 16),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|
@ -106,7 +116,7 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 12,
|
bottom: 12 + safeBottom,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 720),
|
constraints: const BoxConstraints(maxWidth: 720),
|
||||||
|
|
@ -597,8 +607,9 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
||||||
FeedFilter activeFilter,
|
FeedFilter activeFilter,
|
||||||
RidingDiscipline activeDiscipline,
|
RidingDiscipline activeDiscipline,
|
||||||
String searchQuery,
|
String searchQuery,
|
||||||
MotorSignalThemeColors colors,
|
MotorSignalThemeColors colors, {
|
||||||
) {
|
double bottomClearance = 88.0,
|
||||||
|
}) {
|
||||||
final hasCustomFilters =
|
final hasCustomFilters =
|
||||||
activeDiscipline != RidingDiscipline.all || searchQuery.isNotEmpty;
|
activeDiscipline != RidingDiscipline.all || searchQuery.isNotEmpty;
|
||||||
final isLight = Theme.of(context).brightness == Brightness.light;
|
final isLight = Theme.of(context).brightness == Brightness.light;
|
||||||
|
|
@ -611,51 +622,56 @@ class _SocialFeedScreenState extends ConsumerState<SocialFeedScreen> {
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
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(
|
return SingleChildScrollView(
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
padding: const EdgeInsets.only(bottom: 80),
|
padding: EdgeInsets.only(bottom: bottomClearance),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
minHeight: constraints.maxHeight > 80
|
minHeight: constraints.maxHeight > bottomClearance
|
||||||
? constraints.maxHeight - 80
|
? constraints.maxHeight - bottomClearance
|
||||||
: constraints.maxHeight,
|
: constraints.maxHeight,
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.dynamic_feed_outlined,
|
Icons.dynamic_feed_outlined,
|
||||||
size: 56,
|
size: iconSize,
|
||||||
color: secondaryTextColor,
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: verticalGap),
|
||||||
Text(
|
Text(
|
||||||
hasCustomFilters
|
hasCustomFilters
|
||||||
? 'No matching ride sessions'
|
? 'No matching ride sessions'
|
||||||
: 'No posts in ${activeFilter.displayName}',
|
: 'No posts in ${activeFilter.displayName}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontHeadline,
|
fontFamily: AppTypography.fontHeadline,
|
||||||
fontSize: 16,
|
fontSize: isCompact ? 14 : 16,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: primaryTextColor,
|
color: primaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
SizedBox(height: textGap),
|
||||||
Text(
|
Text(
|
||||||
hasCustomFilters
|
hasCustomFilters
|
||||||
? 'Try relaxing your style filters or search query to discover more community rides.'
|
? 'Try relaxing your style filters or search query to discover more community rides.'
|
||||||
: 'Hit "Share Recent Session" below to publish telemetry, or check out group rides and clubs.',
|
: 'Hit "Share Recent Session" below to publish telemetry, or check out group rides and clubs.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: AppTypography.fontBody,
|
fontFamily: AppTypography.fontBody,
|
||||||
fontSize: 13,
|
fontSize: isCompact ? 12 : 13,
|
||||||
color: secondaryTextColor,
|
color: secondaryTextColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: verticalGap),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
|
|
|
||||||
|
|
@ -413,11 +413,13 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
||||||
final accentColor =
|
final accentColor =
|
||||||
isLight ? AppColors.brandBlueStrong : AppColors.brandBlue;
|
isLight ? AppColors.brandBlueStrong : AppColors.brandBlue;
|
||||||
final buttonFg = AppColors.onBrand;
|
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(
|
return ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
maxHeight: size.height * (isLandscape ? 0.98 : 0.9),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -442,14 +444,19 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
||||||
children: [
|
children: [
|
||||||
// Modal Header & Handle
|
// Modal Header & Handle
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
padding: EdgeInsets.fromLTRB(
|
||||||
|
20,
|
||||||
|
isLandscape ? 8 : 12,
|
||||||
|
20,
|
||||||
|
isLandscape ? 8 : 12,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Drag handle
|
// Drag handle
|
||||||
Center(
|
Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: EdgeInsets.only(bottom: isLandscape ? 6 : 12),
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 4,
|
height: 4,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|
@ -763,59 +770,22 @@ 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)
|
||||||
Padding(
|
if (!isLandscape)
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
Padding(
|
||||||
child: SizedBox(
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||||
height: 52,
|
child: _buildPublishButton(accentColor, buttonFg),
|
||||||
width: double.infinity,
|
|
||||||
child: ElevatedButton(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: accentColor,
|
|
||||||
foregroundColor: buttonFg,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
elevation: 4,
|
|
||||||
),
|
|
||||||
onPressed:
|
|
||||||
(_isSubmitting || _selectedRide == null) ? null : _handlePublish,
|
|
||||||
child: _isSubmitting
|
|
||||||
? SizedBox(
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.5,
|
|
||||||
color: buttonFg,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: const [
|
|
||||||
Icon(Icons.share_rounded, size: 20),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
'PUBLISH TO COMMUNITY FEED',
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: AppTypography.fontHeadline,
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -824,6 +794,53 @@ class _ShareSessionModalState extends ConsumerState<ShareSessionModal> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPublishButton(Color accentColor, Color buttonFg) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 52,
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: accentColor,
|
||||||
|
foregroundColor: buttonFg,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 4,
|
||||||
|
),
|
||||||
|
onPressed:
|
||||||
|
(_isSubmitting || _selectedRide == null) ? null : _handlePublish,
|
||||||
|
child: _isSubmitting
|
||||||
|
? SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: buttonFg,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.share_rounded, size: 20),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
'PUBLISH TO COMMUNITY FEED',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: AppTypography.fontHeadline,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildReadOnlyTelemetryTile({
|
Widget _buildReadOnlyTelemetryTile({
|
||||||
required Key key,
|
required Key key,
|
||||||
required String label,
|
required String label,
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ void main() {
|
||||||
expect(tapped, isTrue);
|
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);
|
final openClub = sampleClub.copyWith(admissionPolicy: ClubAdmissionPolicy.open);
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|
@ -68,6 +68,8 @@ void main() {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(find.text('Open to Join'), findsOneWidget);
|
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 {
|
testWidgets('suppresses role pill for standard members and shows for admin/owner', (tester) async {
|
||||||
|
|
|
||||||
|
|
@ -225,5 +225,103 @@ void main() {
|
||||||
expect(find.text('Accept'), findsOneWidget);
|
expect(find.text('Accept'), findsOneWidget);
|
||||||
expect(find.text('Decline'), 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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', () {
|
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 {
|
testWidgets('ShareSessionModal displays STARTING LOCATION, removes Canyon label, and defaults to Morning Ride', (tester) async {
|
||||||
await tester.binding.setSurfaceSize(const Size(400, 800));
|
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,66 @@ void main() {
|
||||||
expect(find.textContaining('Dahlonega Chevron'), findsOneWidget);
|
expect(find.textContaining('Dahlonega Chevron'), findsOneWidget);
|
||||||
expect(find.text('3'), 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', () {
|
group('CreateGroupRideScreen Widget Tests', () {
|
||||||
|
|
|
||||||
|
|
@ -329,5 +329,70 @@ void main() {
|
||||||
);
|
);
|
||||||
expect(find.text('3 RIDERS'), findsOneWidget);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue