399 lines
12 KiB
Dart
399 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import '../auth/auth_providers.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 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.status = LivePresenceStatus.riding,
|
|
this.lastPosition,
|
|
this.lastPublishedAt,
|
|
this.errorMessage,
|
|
this.isRegroupActive = false,
|
|
});
|
|
|
|
LiveBroadcastState copyWith({
|
|
bool? isOptedIn,
|
|
bool? isMasterAllowed,
|
|
bool? isEligible,
|
|
bool? isBroadcasting,
|
|
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,
|
|
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<LiveBroadcastState> {
|
|
final String rideId;
|
|
final String callerUid;
|
|
final GroupRideService service;
|
|
final Stream<Position>? positionStream;
|
|
final Stream<GroupRide?>? rideStream;
|
|
final Stream<List<GroupRideParticipant>>? participantsStream;
|
|
final Stream<List<LiveGroupPresence>>? presenceStream;
|
|
|
|
StreamSubscription<Position>? _positionSub;
|
|
StreamSubscription<GroupRide?>? _rideSub;
|
|
StreamSubscription<List<GroupRideParticipant>>? _participantsSub;
|
|
StreamSubscription<List<LiveGroupPresence>>? _presenceSub;
|
|
Timer? _heartbeatTimer;
|
|
|
|
Position? _lastBroadcastPosition;
|
|
DateTime? _lastBroadcastTime;
|
|
bool _isDisposed = false;
|
|
|
|
LiveBroadcastController({
|
|
required this.rideId,
|
|
required this.callerUid,
|
|
required this.service,
|
|
this.positionStream,
|
|
this.rideStream,
|
|
this.participantsStream,
|
|
this.presenceStream,
|
|
bool initialMasterAllowed = false,
|
|
}) : 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<GroupRideParticipant> 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<LiveGroupPresence> 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();
|
|
}
|
|
|
|
/// Toggles per-ride opt-in for location broadcasting.
|
|
Future<void> setOptIn(bool optIn) async {
|
|
if (_isDisposed) return;
|
|
if (state.isOptedIn == optIn) return;
|
|
|
|
state = state.copyWith(isOptedIn: optIn);
|
|
_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<void> 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<void> triggerRegroup() async {
|
|
await setStatus(LivePresenceStatus.regrouping);
|
|
}
|
|
|
|
/// Clears regroup call by switching back to riding.
|
|
Future<void> clearRegroup() async {
|
|
await setStatus(LivePresenceStatus.riding);
|
|
}
|
|
|
|
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;
|
|
|
|
// Filter accuracy: reject fixes with accuracy > 30m
|
|
if (position.accuracy > 30.0) {
|
|
return;
|
|
}
|
|
|
|
state = state.copyWith(lastPosition: position);
|
|
|
|
final speedMph = position.speed * 2.23694;
|
|
final isMoving = speedMph > 3.0;
|
|
|
|
final now = DateTime.now();
|
|
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 >= 5 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 elapsed = DateTime.now().difference(lastTime).inSeconds;
|
|
// If stopped or no fix received, keep heartbeat within 30s
|
|
if (elapsed >= 30) {
|
|
_publishPosition(state.lastPosition!, force: true);
|
|
}
|
|
}
|
|
|
|
Future<void> _publishPosition(Position position, {bool force = false}) async {
|
|
if (!state.isBroadcasting && !force) return;
|
|
|
|
final speedMph = position.speed * 2.23694;
|
|
final now = DateTime.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;
|
|
state = state.copyWith(
|
|
lastPublishedAt: now,
|
|
errorMessage: null,
|
|
);
|
|
} catch (e) {
|
|
state = state.copyWith(errorMessage: 'Publish failed: $e');
|
|
}
|
|
}
|
|
|
|
void _revokeBroadcasting(String reason) {
|
|
state = state.copyWith(
|
|
isEligible: false,
|
|
isBroadcasting: false,
|
|
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<LiveBroadcastController, LiveBroadcastState, String>(
|
|
(ref, rideId) {
|
|
final user = ref.watch(currentUserProvider);
|
|
final service = ref.watch(groupRideServiceProvider);
|
|
final masterAllowed = ref.watch(groupRideLiveSharingAllowedProvider);
|
|
final callerUid = user?.uid ?? '';
|
|
|
|
final controller = LiveBroadcastController(
|
|
rideId: rideId,
|
|
callerUid: callerUid,
|
|
service: service,
|
|
initialMasterAllowed: masterAllowed,
|
|
);
|
|
|
|
// Sync master privacy changes to controller
|
|
ref.listen<bool>(groupRideLiveSharingAllowedProvider, (prev, next) {
|
|
controller.updateMasterAllowed(next);
|
|
});
|
|
|
|
return controller;
|
|
});
|