diff --git a/lib/features/group_rides/group_rides_hub_screen.dart b/lib/features/group_rides/group_rides_hub_screen.dart index 946a67e..f951332 100644 --- a/lib/features/group_rides/group_rides_hub_screen.dart +++ b/lib/features/group_rides/group_rides_hub_screen.dart @@ -415,12 +415,18 @@ class _GroupRidesHubScreenState extends ConsumerState 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 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 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 ), ), 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 ), ), 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 ), ), 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 ), ), 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 ), ), 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 ), ), 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), + ), ), ), ), diff --git a/lib/features/group_rides/live_group_ride_screen.dart b/lib/features/group_rides/live_group_ride_screen.dart index 6b0ef91..5b40d47 100644 --- a/lib/features/group_rides/live_group_ride_screen.dart +++ b/lib/features/group_rides/live_group_ride_screen.dart @@ -295,10 +295,10 @@ class _LiveGroupRideScreenState extends ConsumerState 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 // 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 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 ], ); - 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 ), // 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 // 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 ]; 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 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 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 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, diff --git a/lib/features/social/clubs/club_detail_screen.dart b/lib/features/social/clubs/club_detail_screen.dart index f363b6a..de4e1a2 100644 --- a/lib/features/social/clubs/club_detail_screen.dart +++ b/lib/features/social/clubs/club_detail_screen.dart @@ -353,8 +353,10 @@ class _ClubDetailScreenState extends ConsumerState { ), _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, diff --git a/lib/features/social/clubs/clubs_hub_screen.dart b/lib/features/social/clubs/clubs_hub_screen.dart index 63cbd90..d4cc47a 100644 --- a/lib/features/social/clubs/clubs_hub_screen.dart +++ b/lib/features/social/clubs/clubs_hub_screen.dart @@ -356,11 +356,45 @@ class _ClubsHubScreenState extends ConsumerState ), ), ), - 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 ), ], ), - 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 ); } - 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}'), + ); + }, ); }, ); diff --git a/lib/features/social/clubs/widgets/club_card.dart b/lib/features/social/clubs/widgets/club_card.dart index f8936a6..934a3df 100644 --- a/lib/features/social/clubs/widgets/club_card.dart +++ b/lib/features/social/clubs/widgets/club_card.dart @@ -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; diff --git a/lib/features/social/clubs/widgets/club_member_card.dart b/lib/features/social/clubs/widgets/club_member_card.dart index dabb253..dd41bed 100644 --- a/lib/features/social/clubs/widgets/club_member_card.dart +++ b/lib/features/social/clubs/widgets/club_member_card.dart @@ -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, diff --git a/lib/features/social/feed/social_feed_screen.dart b/lib/features/social/feed/social_feed_screen.dart index f5edee6..83d1c8e 100644 --- a/lib/features/social/feed/social_feed_screen.dart +++ b/lib/features/social/feed/social_feed_screen.dart @@ -53,6 +53,10 @@ class _SocialFeedScreenState extends ConsumerState { 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 { 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 { 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 { 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 { 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, diff --git a/lib/features/social/feed/widgets/share_session_modal.dart b/lib/features/social/feed/widgets/share_session_modal.dart index 0c3fa91..45a8ac2 100644 --- a/lib/features/social/feed/widgets/share_session_modal.dart +++ b/lib/features/social/feed/widgets/share_session_modal.dart @@ -413,11 +413,13 @@ class _ShareSessionModalState extends ConsumerState { 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 { 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 { ), ], ), + 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 { ); } + 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, diff --git a/test/club_card_test.dart b/test/club_card_test.dart index 4895aa3..f2478ae 100644 --- a/test/club_card_test.dart +++ b/test/club_card_test.dart @@ -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 { diff --git a/test/clubs_hub_screen_test.dart b/test/clubs_hub_screen_test.dart index 8718043..78d401a 100644 --- a/test/clubs_hub_screen_test.dart +++ b/test/clubs_hub_screen_test.dart @@ -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); + } + }); + } }); } diff --git a/test/community_social_feed_test.dart b/test/community_social_feed_test.dart index f390f08..0ef4aaa 100644 --- a/test/community_social_feed_test.dart +++ b/test/community_social_feed_test.dart @@ -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)); diff --git a/test/group_rides_widget_test.dart b/test/group_rides_widget_test.dart index 407d812..a848229 100644 --- a/test/group_rides_widget_test.dart +++ b/test/group_rides_widget_test.dart @@ -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', () { diff --git a/test/live_group_ride_screen_widget_test.dart b/test/live_group_ride_screen_widget_test.dart index a90fe5b..1f96818 100644 --- a/test/live_group_ride_screen_widget_test.dart +++ b/test/live_group_ride_screen_widget_test.dart @@ -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); + } + }); + } }); }