import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import '../auth/auth_providers.dart'; import '../location_service.dart'; import 'group_ride_models.dart'; import 'group_ride_providers.dart'; import 'group_ride_service.dart'; /// State of the local rider's live presence broadcast for a specific Group Ride. @immutable class LiveBroadcastState { final bool isOptedIn; final bool isMasterAllowed; final bool isEligible; final bool isBroadcasting; final bool isPaused; final int retryCount; final bool isBackground; final LivePresenceStatus status; final Position? lastPosition; final DateTime? lastPublishedAt; final String? errorMessage; final bool isRegroupActive; const LiveBroadcastState({ this.isOptedIn = false, this.isMasterAllowed = false, this.isEligible = false, this.isBroadcasting = false, this.isPaused = false, this.retryCount = 0, this.isBackground = false, this.status = LivePresenceStatus.riding, this.lastPosition, this.lastPublishedAt, this.errorMessage, this.isRegroupActive = false, }); LiveBroadcastState copyWith({ bool? isOptedIn, bool? isMasterAllowed, bool? isEligible, bool? isBroadcasting, bool? isPaused, int? retryCount, bool? isBackground, LivePresenceStatus? status, Position? lastPosition, DateTime? lastPublishedAt, String? errorMessage, bool? isRegroupActive, }) { return LiveBroadcastState( isOptedIn: isOptedIn ?? this.isOptedIn, isMasterAllowed: isMasterAllowed ?? this.isMasterAllowed, isEligible: isEligible ?? this.isEligible, isBroadcasting: isBroadcasting ?? this.isBroadcasting, isPaused: isPaused ?? this.isPaused, retryCount: retryCount ?? this.retryCount, isBackground: isBackground ?? this.isBackground, status: status ?? this.status, lastPosition: lastPosition ?? this.lastPosition, lastPublishedAt: lastPublishedAt ?? this.lastPublishedAt, errorMessage: errorMessage, isRegroupActive: isRegroupActive ?? this.isRegroupActive, ); } } /// Controller responsible for ephemeral live location broadcasts in an active Group Ride. /// /// Implements dual-gate privacy (global + per-ride), 5s moving / 30s stopped heartbeat, /// 30m accuracy filtering, and immediate termination on authorization loss. class LiveBroadcastController extends StateNotifier { final String rideId; final String callerUid; final GroupRideService service; final Stream? positionStream; final Stream? rideStream; final Stream>? participantsStream; final Stream>? presenceStream; StreamSubscription? _positionSub; StreamSubscription? _rideSub; StreamSubscription>? _participantsSub; StreamSubscription>? _presenceSub; Timer? _heartbeatTimer; DateTime? _nextRetryTime; int _consecutiveErrors = 0; Position? _lastBroadcastPosition; DateTime? _lastBroadcastTime; bool _isDisposed = false; final DateTime Function() _now; LiveBroadcastController({ required this.rideId, required this.callerUid, required this.service, this.positionStream, this.rideStream, this.participantsStream, this.presenceStream, bool initialMasterAllowed = false, DateTime Function()? now, }) : _now = now ?? DateTime.now, super(LiveBroadcastState(isMasterAllowed: initialMasterAllowed)) { _initSubscriptions(); } void _initSubscriptions() { // 1. Listen to Ride Lifecycle final activeRideStream = rideStream ?? service.groupRideStream(rideId); _rideSub = activeRideStream.listen(_handleRideUpdate, onError: (e) { debugPrint('[LiveBroadcastController] Ride stream error: $e'); }); // 2. Listen to Participants (authorization check) final activeParticipantsStream = participantsStream ?? service.participantsStream(rideId); _participantsSub = activeParticipantsStream.listen(_handleParticipantsUpdate, onError: (e) { debugPrint('[LiveBroadcastController] Participants stream error: $e'); }); // 3. Listen to Live Presence (for regroup awareness) final activePresenceStream = presenceStream ?? service.livePresenceStream(rideId); _presenceSub = activePresenceStream.listen(_handlePresenceUpdate, onError: (e) { debugPrint('[LiveBroadcastController] Presence stream error: $e'); }); } void _handleRideUpdate(GroupRide? ride) { if (_isDisposed) return; if (ride == null) { _revokeBroadcasting('Ride not found'); return; } // Ride must be assembling or underway final isRideActive = ride.status == GroupRideStatus.assembling || ride.status == GroupRideStatus.underway; if (!isRideActive) { // Completed, canceled, or scheduled -> stop broadcast _revokeBroadcasting('Ride is not in active state (${ride.status.name})'); return; } _evaluateEligibility(); } void _handleParticipantsUpdate(List participants) { if (_isDisposed) return; final myParticipant = participants.where((p) => p.userId == callerUid).toList(); if (myParticipant.isEmpty || myParticipant.first.status != ParticipantStatus.accepted) { // User removed, declined, or left -> immediate revocation _revokeBroadcasting('Caller is not an accepted participant'); return; } _evaluateEligibility(hasAcceptedMembership: true); } void _handlePresenceUpdate(List presences) { if (_isDisposed) return; final regroupActive = presences.any((p) => p.status == LivePresenceStatus.regrouping && p.isFresh); if (regroupActive != state.isRegroupActive) { state = state.copyWith(isRegroupActive: regroupActive); } } void _evaluateEligibility({bool? hasAcceptedMembership}) { final eligible = hasAcceptedMembership ?? state.isEligible; final broadcasting = state.isOptedIn && state.isMasterAllowed && eligible; state = state.copyWith( isEligible: eligible, isBroadcasting: broadcasting, ); if (broadcasting && _positionSub == null) { _startBroadcasting(); } else if (!broadcasting && _positionSub != null) { _stopBroadcasting(); } } /// Updates the master privacy setting (global app setting). void updateMasterAllowed(bool allowed) { if (_isDisposed || state.isMasterAllowed == allowed) return; state = state.copyWith(isMasterAllowed: allowed); _evaluateEligibility(); if (!allowed) { service.clearLivePresence( callerUid: callerUid, groupRideId: rideId, ); } } /// Toggles per-ride opt-in for location broadcasting. Future setOptIn(bool optIn) async { if (_isDisposed) return; if (state.isOptedIn == optIn) return; _consecutiveErrors = 0; _nextRetryTime = null; state = state.copyWith( isOptedIn: optIn, isPaused: false, retryCount: 0, ); _evaluateEligibility(); if (!optIn) { // Immediately clear presence document in Firestore await service.clearLivePresence( callerUid: callerUid, groupRideId: rideId, ); } else if (state.isBroadcasting && state.lastPosition != null) { // Force immediate publish on opt-in await _publishPosition(state.lastPosition!, force: true); } } /// Updates rider quick status (e.g. riding, needFuel, regrouping, stopped, arrived). Future setStatus(LivePresenceStatus newStatus) async { if (_isDisposed || state.status == newStatus) return; state = state.copyWith(status: newStatus); if (state.isBroadcasting && state.lastPosition != null) { await _publishPosition(state.lastPosition!, force: true); } } /// Calls for a group regroup. Future triggerRegroup() async { await setStatus(LivePresenceStatus.regrouping); } /// Clears regroup call by switching back to riding. Future clearRegroup() async { await setStatus(LivePresenceStatus.riding); } /// Updates app lifecycle state (e.g. background vs foreground). void updateAppLifecycle(AppLifecycleState lifecycle) { if (_isDisposed) return; final isBg = lifecycle == AppLifecycleState.paused || lifecycle == AppLifecycleState.hidden; if (state.isBackground != isBg) { state = state.copyWith(isBackground: isBg); } } void _startBroadcasting() { _positionSub?.cancel(); final stream = positionStream ?? Geolocator.getPositionStream( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, distanceFilter: 5, ), ); _positionSub = stream.listen( _onPositionUpdate, onError: (e) { state = state.copyWith(errorMessage: 'Location stream error: $e'); }, ); _heartbeatTimer?.cancel(); _heartbeatTimer = Timer.periodic(const Duration(seconds: 10), (_) { _checkHeartbeat(); }); } void _stopBroadcasting() { _positionSub?.cancel(); _positionSub = null; _heartbeatTimer?.cancel(); _heartbeatTimer = null; } void _onPositionUpdate(Position position) { if (_isDisposed || !state.isBroadcasting) return; // Reject invalid coordinates, out of bounds, or Null Island (0,0) if (position.latitude.isNaN || position.longitude.isNaN || position.latitude < -90.0 || position.latitude > 90.0 || position.longitude < -180.0 || position.longitude > 180.0 || (position.latitude == 0.0 && position.longitude == 0.0)) { return; } // Filter accuracy: reject fixes with accuracy > 30m if (position.accuracy > 30.0) { return; } state = state.copyWith(lastPosition: position); final now = _now(); // Respect exponential backoff throttle during transient errors if (_nextRetryTime != null && now.isBefore(_nextRetryTime!)) { return; } final speedMph = position.speed * 2.23694; final isMoving = speedMph > 3.0; final lastTime = _lastBroadcastTime; if (lastTime == null) { _publishPosition(position, force: true); return; } final elapsedSeconds = now.difference(lastTime).inSeconds; if (isMoving) { // Moving cadence: 5 seconds with displacement > 15m or >= 10 seconds interval if (elapsedSeconds >= 5) { double displacement = 0; if (_lastBroadcastPosition != null) { displacement = Geolocator.distanceBetween( _lastBroadcastPosition!.latitude, _lastBroadcastPosition!.longitude, position.latitude, position.longitude, ); } if (displacement >= 15.0 || elapsedSeconds >= 10) { _publishPosition(position); } } } else { // Stopped cadence: 30-second heartbeat if (elapsedSeconds >= 30) { _publishPosition(position); } } } void _checkHeartbeat() { if (_isDisposed || !state.isBroadcasting || state.lastPosition == null) return; final lastTime = _lastBroadcastTime; if (lastTime == null) return; final now = _now(); if (_nextRetryTime != null && now.isBefore(_nextRetryTime!)) { return; } final elapsed = now.difference(lastTime).inSeconds; // If stopped or no fix received, keep heartbeat within 30s if (elapsed >= 30) { _publishPosition(state.lastPosition!, force: true); } } Future _publishPosition(Position position, {bool force = false}) async { if (!state.isBroadcasting && !force) return; final speedMph = position.speed * 2.23694; final now = _now(); try { await service.publishLivePresence( callerUid: callerUid, groupRideId: rideId, latitude: position.latitude, longitude: position.longitude, heading: position.heading, speedMph: speedMph, accuracyMeters: position.accuracy, status: state.status, sharingEnabled: true, ); _lastBroadcastPosition = position; _lastBroadcastTime = now; _consecutiveErrors = 0; _nextRetryTime = null; state = state.copyWith( lastPublishedAt: now, errorMessage: null, isPaused: false, retryCount: 0, ); } catch (e) { _handlePublishError(e); } } void _handlePublishError(Object error) { if (_isDisposed) return; final errStr = error.toString().toLowerCase(); final isPermissionDenied = errStr.contains('permission-denied') || errStr.contains('permission_denied') || errStr.contains('unauthenticated'); if (isPermissionDenied) { // Terminal: user removed, blocked, ride completed, or rules denied write. // Immediately stop broadcasting, cancel timers, and do not retry. _revokeBroadcasting('Authorization lost: permission denied'); return; } // Transient network/offline error: _consecutiveErrors++; // Exponential backoff capped at 30 seconds final backoffSeconds = (5 * (1 << (_consecutiveErrors - 1))).clamp(5, 30); _nextRetryTime = _now().add(Duration(seconds: backoffSeconds)); state = state.copyWith( errorMessage: 'Publish failed: $error', isPaused: true, retryCount: _consecutiveErrors, ); } void _revokeBroadcasting(String reason) { state = state.copyWith( isEligible: false, isBroadcasting: false, isOptedIn: false, isPaused: false, retryCount: 0, errorMessage: reason, ); _stopBroadcasting(); // Clean up presence document service.clearLivePresence( callerUid: callerUid, groupRideId: rideId, ); } @override void dispose() { _isDisposed = true; _stopBroadcasting(); _rideSub?.cancel(); _participantsSub?.cancel(); _presenceSub?.cancel(); // Clean up ephemeral presence on exit service.clearLivePresence( callerUid: callerUid, groupRideId: rideId, ); super.dispose(); } } /// Provider for LiveBroadcastController family parameterized by group ride ID. final liveBroadcastControllerProvider = StateNotifierProvider.autoDispose .family( (ref, rideId) { final user = ref.watch(currentUserProvider); final service = ref.watch(groupRideServiceProvider); final masterAllowed = ref.watch(groupRideLiveSharingAllowedProvider); final locationService = ref.watch(locationServiceProvider); final callerUid = user?.uid ?? ''; final controller = LiveBroadcastController( rideId: rideId, callerUid: callerUid, service: service, positionStream: locationService.getFilteredPositionStream(), initialMasterAllowed: masterAllowed, ); // Sync master privacy changes to controller ref.listen(groupRideLiveSharingAllowedProvider, (prev, next) { controller.updateMasterAllowed(next); }); return controller; });