fix(group-rides): address PR #27 review findings and responsive dashboard layout

This commit is contained in:
Developer 2026-09-25 15:35:32 -04:00
parent 67c25b5c50
commit 30539108fa
5 changed files with 376 additions and 180 deletions

View File

@ -148,6 +148,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
), ),
SizedBox(height: isShortPortrait ? 6 : 16), SizedBox(height: isShortPortrait ? 6 : 16),
RideActionButton(compact: isShortPortrait), RideActionButton(compact: isShortPortrait),
if (!isShortPortrait) ...[
const SizedBox(height: 6),
const PerformanceLaunchShortcut(compact: true),
],
] else if (mode == DashboardLayout.touring) ...[ ] else if (mode == DashboardLayout.touring) ...[
SizedBox(height: isShortPortrait ? 2 : 6), SizedBox(height: isShortPortrait ? 2 : 6),
SpeedReadout( SpeedReadout(
@ -211,6 +215,10 @@ class ResponsiveDashboardLayout extends ConsumerWidget {
), ),
SizedBox(height: isShortPortrait ? 4 : 10), SizedBox(height: isShortPortrait ? 4 : 10),
RideActionButton(compact: isShortPortrait), RideActionButton(compact: isShortPortrait),
if (!isShortPortrait) ...[
const SizedBox(height: 6),
const PerformanceLaunchShortcut(compact: true),
],
] else ...[ ] else ...[
// Sport Cockpit Mode // Sport Cockpit Mode
if (!isShortPortrait) const Spacer(flex: 1), if (!isShortPortrait) const Spacer(flex: 1),

File diff suppressed because it is too large Load Diff

View File

@ -583,7 +583,7 @@ class GroupRideService {
final response = await httpClient.get( final response = await httpClient.get(
uri, uri,
headers: { headers: {
'Authorization': 'Bearer $idToken', 'Authorization': 'Bearer $token',
'Accept': 'application/json', 'Accept': 'application/json',
}, },
).timeout(const Duration(seconds: 15)); ).timeout(const Duration(seconds: 15));

View File

@ -75,6 +75,8 @@ class LiveBroadcastState {
/// Implements dual-gate privacy (global + per-ride), 5s moving / 30s stopped heartbeat, /// Implements dual-gate privacy (global + per-ride), 5s moving / 30s stopped heartbeat,
/// 30m accuracy filtering, and immediate termination on authorization loss. /// 30m accuracy filtering, and immediate termination on authorization loss.
class LiveBroadcastController extends StateNotifier<LiveBroadcastState> { class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
static final Set<String> _persistedOptedInRideIds = <String>{};
final String rideId; final String rideId;
final String callerUid; final String callerUid;
final GroupRideService service; final GroupRideService service;
@ -107,9 +109,13 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
this.participantsStream, this.participantsStream,
this.presenceStream, this.presenceStream,
bool initialMasterAllowed = false, bool initialMasterAllowed = false,
bool? initialOptIn,
DateTime Function()? now, DateTime Function()? now,
}) : _now = now ?? DateTime.now, }) : _now = now ?? DateTime.now,
super(LiveBroadcastState(isMasterAllowed: initialMasterAllowed)) { super(LiveBroadcastState(
isMasterAllowed: initialMasterAllowed,
isOptedIn: initialOptIn ?? _persistedOptedInRideIds.contains(rideId),
)) {
_initSubscriptions(); _initSubscriptions();
} }
@ -151,12 +157,11 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
return; return;
} }
// Ride must be assembling or underway // Ride must be live-eligible (scheduled, assembling, or underway)
final isRideActive = ride.status == GroupRideStatus.assembling || final isRideActive = ride.isLiveEligible;
ride.status == GroupRideStatus.underway;
if (!isRideActive) { if (!isRideActive) {
// Completed, canceled, or scheduled -> stop broadcast // Completed or canceled -> stop broadcast
_revokeBroadcasting('Ride is not in active state (${ride.status.name})'); _revokeBroadcasting('Ride is not in active state (${ride.status.name})');
return; return;
} }
@ -232,14 +237,49 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
_evaluateEligibility(); _evaluateEligibility();
if (!optIn) { if (!optIn) {
_persistedOptedInRideIds.remove(rideId);
// Immediately clear presence document in Firestore // Immediately clear presence document in Firestore
await service.clearLivePresence( await service.clearLivePresence(
callerUid: callerUid, callerUid: callerUid,
groupRideId: rideId, groupRideId: rideId,
); );
} else if (state.isBroadcasting && state.lastPosition != null) { } else {
// Force immediate publish on opt-in _persistedOptedInRideIds.add(rideId);
await _publishPosition(state.lastPosition!, force: true); if (state.isBroadcasting) {
if (state.lastPosition != null) {
// Force immediate publish on opt-in
await _publishPosition(state.lastPosition!, force: true);
} else {
await refreshAndPublishCurrentLocation();
}
}
}
}
/// Forces an immediate location sample and presence publish if broadcasting.
Future<void> refreshAndPublishCurrentLocation() async {
if (_isDisposed) return;
try {
Position? pos;
try {
pos = await Geolocator.getLastKnownPosition();
pos ??= await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 4),
),
);
} catch (_) {
// Fallback or ignore in headless test environments
}
if (pos != null && !_isDisposed) {
_onPositionUpdate(pos);
if (state.isBroadcasting) {
await _publishPosition(pos, force: true);
}
}
} catch (e) {
debugPrint('[LiveBroadcastController] refreshAndPublishCurrentLocation error: $e');
} }
} }
@ -446,6 +486,7 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
} }
void _revokeBroadcasting(String reason) { void _revokeBroadcasting(String reason) {
_persistedOptedInRideIds.remove(rideId);
state = state.copyWith( state = state.copyWith(
isEligible: false, isEligible: false,
isBroadcasting: false, isBroadcasting: false,
@ -481,7 +522,7 @@ class LiveBroadcastController extends StateNotifier<LiveBroadcastState> {
} }
/// Provider for LiveBroadcastController family parameterized by group ride ID. /// Provider for LiveBroadcastController family parameterized by group ride ID.
final liveBroadcastControllerProvider = StateNotifierProvider.autoDispose final liveBroadcastControllerProvider = StateNotifierProvider
.family<LiveBroadcastController, LiveBroadcastState, String>( .family<LiveBroadcastController, LiveBroadcastState, String>(
(ref, rideId) { (ref, rideId) {
final user = ref.watch(currentUserProvider); final user = ref.watch(currentUserProvider);

View File

@ -84,14 +84,50 @@ class _RecordingGroupRideService extends Fake implements GroupRideService {
Future<void> respondToJoinRequest({ Future<void> respondToJoinRequest({
required String callerUid, required String callerUid,
required String groupRideId, required String groupRideId,
required String requesterUid,
required bool approve, required bool approve,
required String requesterUid,
}) async { }) async {
lastModeratedCallerUid = callerUid; lastModeratedCallerUid = callerUid;
lastModeratedGroupRideId = groupRideId; lastModeratedGroupRideId = groupRideId;
lastModeratedRequesterUid = requesterUid; lastModeratedRequesterUid = requesterUid;
lastModeratedApprove = approve; lastModeratedApprove = approve;
} }
@override
Stream<GroupRideParticipant?> participantDocStream({
required String groupRideId,
required String participantUid,
}) =>
Stream.value(null);
@override
Stream<GroupRide?> groupRideStream(String groupRideId) => Stream.value(null);
@override
Stream<LiveGroupPresence?> livePresenceDocStream({
required String groupRideId,
required String targetUid,
}) =>
Stream.value(null);
@override
Future<void> publishLivePresence({
required String callerUid,
required String groupRideId,
required double latitude,
required double longitude,
double? heading,
double? speedMph,
double? accuracyMeters,
LivePresenceStatus status = LivePresenceStatus.riding,
bool sharingEnabled = true,
}) async {}
@override
Future<void> clearLivePresence({
required String callerUid,
required String groupRideId,
}) async {}
} }
void main() { void main() {