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,
|
||||
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,9 +937,12 @@ class _GroupRidesHubScreenState extends ConsumerState<GroupRidesHubScreen>
|
|||
),
|
||||
),
|
||||
onPressed: () => context.push('/group-rides/${ride.id}'),
|
||||
child: const Text(
|
||||
'MANAGE RIDE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
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}'),
|
||||
child: const Text(
|
||||
'OPEN HUB',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
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}'),
|
||||
child: const Text(
|
||||
'RIDE DETAILS',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
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}'),
|
||||
child: const Text(
|
||||
'LEAVE PACK',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
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}'),
|
||||
child: const Text(
|
||||
'RIDE DETAILS',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
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}'),
|
||||
child: const Text(
|
||||
'JOIN RIDE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
child: const FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
'JOIN RIDE',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,71 +1030,84 @@ 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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int index = 0; index < statuses.length; index++) ...[
|
||||
if (index > 0) const SizedBox(width: 6),
|
||||
() {
|
||||
final (status, label, icon) = statuses[index];
|
||||
final isSelected = currentStatus == status;
|
||||
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: 4),
|
||||
() {
|
||||
final (status, label, icon) = statuses[index];
|
||||
final isSelected = currentStatus == status;
|
||||
|
||||
Color activeColor = colors.cyberCyan;
|
||||
if (status == LivePresenceStatus.needFuel) {
|
||||
activeColor = Colors.orangeAccent;
|
||||
}
|
||||
if (status == LivePresenceStatus.regrouping) {
|
||||
activeColor = colors.warningAmber;
|
||||
}
|
||||
if (status == LivePresenceStatus.arrived) {
|
||||
activeColor = colors.launchGreen;
|
||||
}
|
||||
Color activeColor = colors.cyberCyan;
|
||||
if (status == LivePresenceStatus.needFuel) {
|
||||
activeColor = Colors.orangeAccent;
|
||||
}
|
||||
if (status == LivePresenceStatus.regrouping) {
|
||||
activeColor = colors.warningAmber;
|
||||
}
|
||||
if (status == LivePresenceStatus.arrived) {
|
||||
activeColor = colors.launchGreen;
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => onStatusSelected(status),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? activeColor.withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: isSelected ? activeColor : Colors.transparent,
|
||||
width: 1.2,
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
onTap: () => onStatusSelected(status),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isShort ? 6 : 8,
|
||||
vertical: isShort ? 3 : 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? activeColor.withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
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,
|
||||
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(
|
||||
horizontal: isCompact ? 6 : 8,
|
||||
vertical: isShort ? 4 : 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black54, blurRadius: 6, offset: Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Ghost mode / Broadcast switch
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
broadcastState.isBroadcasting
|
||||
? Icons.radar
|
||||
: Icons.location_off,
|
||||
color: broadcastState.isBroadcasting
|
||||
? colors.cyberCyan
|
||||
: Colors.white38,
|
||||
size: isCompact ? 18 : 20,
|
||||
),
|
||||
SizedBox(width: isCompact ? 4 : 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isCompact ? 6 : 8,
|
||||
vertical: isShort ? 4 : 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh.withValues(alpha: 0.95),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: colors.outlineVariant),
|
||||
boxShadow: const [
|
||||
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: [
|
||||
// Broadcast state info & switch
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
Icon(
|
||||
broadcastState.isBroadcasting
|
||||
? (broadcastState.isPaused
|
||||
? 'PAUSED'
|
||||
: (broadcastState.isBackground
|
||||
? 'BACKGROUND'
|
||||
: 'SHARING LIVE'))
|
||||
: '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,
|
||||
),
|
||||
? Icons.radar
|
||||
: Icons.location_off,
|
||||
color: broadcastState.isBroadcasting
|
||||
? colors.cyberCyan
|
||||
: Colors.white38,
|
||||
size: isCompact ? 16 : 20,
|
||||
),
|
||||
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 ? 9 : 10,
|
||||
SizedBox(width: isCompact ? 4 : 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
broadcastState.isBroadcasting
|
||||
? (broadcastState.isPaused
|
||||
? 'PAUSED'
|
||||
: (broadcastState.isBackground
|
||||
? 'BACKGROUND'
|
||||
: 'SHARING LIVE'))
|
||||
: '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)
|
||||
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),
|
||||
),
|
||||
icon: Icon(
|
||||
Icons.my_location,
|
||||
color: Colors.white70,
|
||||
size: isCompact ? 18 : 20,
|
||||
),
|
||||
tooltip: 'Center on Me',
|
||||
onPressed: () {
|
||||
if (broadcastState.lastPosition != null) {
|
||||
setState(() {
|
||||
_customMapCenter = AppLatLng(
|
||||
broadcastState.lastPosition!.latitude,
|
||||
broadcastState.lastPosition!.longitude,
|
||||
);
|
||||
_mapZoom = 15.0;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_customMapCenter = AppLatLng(
|
||||
ride.meetingPoint.latitude,
|
||||
ride.meetingPoint.longitude,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
// 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 ? 4 : 8),
|
||||
minimumSize: Size(isCompact ? 36 : 44, isCompact ? 36 : 44),
|
||||
),
|
||||
icon: Icon(
|
||||
Icons.my_location,
|
||||
color: Colors.white70,
|
||||
size: isCompact ? 16 : 20,
|
||||
),
|
||||
tooltip: 'Center on Me',
|
||||
onPressed: () {
|
||||
if (broadcastState.lastPosition != null) {
|
||||
setState(() {
|
||||
_customMapCenter = AppLatLng(
|
||||
broadcastState.lastPosition!.latitude,
|
||||
broadcastState.lastPosition!.longitude,
|
||||
);
|
||||
_mapZoom = 15.0;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_customMapCenter = AppLatLng(
|
||||
ride.meetingPoint.latitude,
|
||||
ride.meetingPoint.longitude,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
SizedBox(width: isCompact ? 4 : 6),
|
||||
SizedBox(width: isCompact ? 4 : 6),
|
||||
|
||||
// Roster Opener Button
|
||||
ElevatedButton.icon(
|
||||
key: const ValueKey('live_ride_roster_button'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||
foregroundColor: colors.cyberCyan,
|
||||
side: BorderSide(color: colors.cyberCyan),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isCompact ? 6 : 10,
|
||||
vertical: isCompact ? 6 : 8,
|
||||
// Roster Opener Button
|
||||
ElevatedButton.icon(
|
||||
key: const ValueKey('live_ride_roster_button'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.cyberCyan.withValues(alpha: 0.15),
|
||||
foregroundColor: colors.cyberCyan,
|
||||
side: BorderSide(color: colors.cyberCyan),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isCompact ? 6 : 10,
|
||||
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(
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -356,11 +356,45 @@ 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,9 +605,19 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
|||
),
|
||||
],
|
||||
),
|
||||
ClubCard(
|
||||
club: _searchedClub!,
|
||||
onTap: () => context.push('/social/clubs/${_searchedClub!.id}'),
|
||||
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,16 +675,46 @@ class _ClubsHubScreenState extends ConsumerState<ClubsHubScreen>
|
|||
);
|
||||
}
|
||||
|
||||
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}'),
|
||||
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(),
|
||||
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 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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,51 +622,56 @@ 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),
|
||||
Text(
|
||||
hasCustomFilters
|
||||
? 'No matching ride sessions'
|
||||
: 'No posts in ${activeFilter.displayName}',
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontHeadline,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: primaryTextColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
hasCustomFilters
|
||||
? '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.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: 13,
|
||||
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: isCompact ? 14 : 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: primaryTextColor,
|
||||
),
|
||||
),
|
||||
SizedBox(height: textGap),
|
||||
Text(
|
||||
hasCustomFilters
|
||||
? '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.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppTypography.fontBody,
|
||||
fontSize: isCompact ? 12 : 13,
|
||||
color: secondaryTextColor,
|
||||
),
|
||||
),
|
||||
SizedBox(height: verticalGap),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
|
|
|
|||
|
|
@ -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,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)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||
child: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Publish CTA Button (pinned at modal bottom in portrait mode)
|
||||
if (!isLandscape)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
||||
child: _buildPublishButton(accentColor, buttonFg),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -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({
|
||||
required Key key,
|
||||
required String label,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue