Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 50 additions & 21 deletions lib/services/emergency_orchestrator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ class EmergencyOrchestrator extends StateNotifier<SOSState> {
final Ref _ref;
Timer? _countdownTimer;
final _uuid = const Uuid();
/// When true, in-flight [_executeEmergencyPipeline] should exit without re-persisting SOS.
bool _pipelineAborted = false;
static const Duration _sosLocationTimeout = Duration(seconds: 12);
static const Duration _sosTriageTimeout = Duration(seconds: 10);
static const Duration _dispatchChannelTimeout = Duration(seconds: 8);
Expand All @@ -166,14 +168,23 @@ class EmergencyOrchestrator extends StateNotifier<SOSState> {

Future<void> _restoreState() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('sos_active') ?? false) {
_log('🚨 Recovering active SOS state after restart...', SOSPhase.active);
state = state.copyWith(
phase: SOSPhase.active,
incidentId: prefs.getString('sos_id'),
);
await WakeLockService.acquireForSos();
}
if (!(prefs.getBool('sos_active') ?? false)) return;

// Never trap the user in full-screen SOS after a cold start — the prior
// session cannot be faithfully rehydrated (dispatch log, triage, mesh, etc.).
await prefs.setBool('sos_active', false);
await prefs.setString('sos_id', '');
await WakeLockService.release();
unawaited(EmergencyBeaconService.instance.stop());
unawaited(
EmergencyNotificationService.instance.cancelSosNotification(),
);

_log(
'Previous emergency session cleared after app restart. '
'Tap SOS only if you still need help — use End session any time during SOS.',
SOSPhase.idle,
);
}

Future<void> _persistState(bool active) async {
Expand Down Expand Up @@ -245,27 +256,39 @@ class EmergencyOrchestrator extends StateNotifier<SOSState> {
});
}

void cancelSos() {
void cancelSos() => endSosSession();

/// Ends SOS from any phase (countdown, dispatch, active). Clears persisted
/// `sos_active` so the app never re-opens trapped in emergency UI.
void endSosSession() {
_pipelineAborted = true;
_countdownTimer?.cancel();
_teardownSosSideEffects();
state = const SOSState();
_persistState(false);
unawaited(_persistState(false));
final l10n = lookupAppLocalizations(_ref.read(appLocaleProvider));
_log(l10n.orchestratorCancelled, SOSPhase.idle);
_pipelineAborted = false;
}

void _teardownSosSideEffects() {
unawaited(WakeLockService.release());
// Stop any in-progress TTS so the countdown announcement does not keep playing.
unawaited(_ref.read(voiceAssistantServiceProvider).stopSpeaking());
// Phase 9: Stop hardware beacon signals.
unawaited(EmergencyBeaconService.instance.stop());
state = state.copyWith(isBeaconActive: false);

unawaited(EmergencyNotificationService.instance.cancelSosNotification());
final l10n = lookupAppLocalizations(_ref.read(appLocaleProvider));
_log(l10n.orchestratorCancelled, SOSPhase.idle);
unawaited(_ref.read(meshNetworkServiceProvider).stopBroadcasting());
unawaited(_ref.read(familyCircleServiceProvider.notifier).stopPublishing());
}

bool get _shouldAbortPipeline => _pipelineAborted || state.phase == SOSPhase.idle;

Future<void> _executeEmergencyPipeline() async {
_pipelineAborted = false;
final l10n = lookupAppLocalizations(_ref.read(appLocaleProvider));
final locale = _ref.read(appLocaleProvider);

Future<void> failOpenToActive(String detail) async {
if (_shouldAbortPipeline) return;
_log(detail, SOSPhase.active, isError: true);
state = state.copyWith(
phase: SOSPhase.active,
Expand All @@ -279,6 +302,8 @@ class EmergencyOrchestrator extends StateNotifier<SOSState> {
await WakeLockService.acquireForSos();
}

if (_shouldAbortPipeline) return;

_log(l10n.orchestratorAcquiringLocation, SOSPhase.gpsLocking);
state = state.copyWith(phase: SOSPhase.gpsLocking);

Expand Down Expand Up @@ -376,6 +401,8 @@ class EmergencyOrchestrator extends StateNotifier<SOSState> {
.syncLocalRegion(location.latitude, location.longitude),
);

if (_shouldAbortPipeline) return;

_log(l10n.orchestratorAiBrief, SOSPhase.triaging);
state = state.copyWith(phase: SOSPhase.triaging);

Expand Down Expand Up @@ -468,6 +495,8 @@ class EmergencyOrchestrator extends StateNotifier<SOSState> {
);
}

if (_shouldAbortPipeline) return;

_log(l10n.orchestratorDispatching, SOSPhase.dispatching);
state = state.copyWith(
phase: SOSPhase.dispatching,
Expand All @@ -494,7 +523,7 @@ class EmergencyOrchestrator extends StateNotifier<SOSState> {
_patchDispatchChannel(
'family_link',
DispatchChannelLifecycle.inProgress,
'Family tracking link…',
'Alerting Family Circle…',
);
_patchDispatchChannel(
'nearby_services',
Expand Down Expand Up @@ -654,10 +683,10 @@ class EmergencyOrchestrator extends StateNotifier<SOSState> {
),
fallback: (
ok: false,
detail: 'Family link timed out — share manually if needed.',
detail: 'Family Circle alert timed out — share manually if needed.',
),
timeoutDetail: 'Family link timed out — share manually if needed.',
failureDetail: 'Family link failed — share manually if needed.',
timeoutDetail: 'Family Circle alert timed out — share manually if needed.',
failureDetail: 'Family Circle alert failed — share manually if needed.',
).then((family) {
_patchDispatchChannel(
'family_link',
Expand Down Expand Up @@ -836,7 +865,7 @@ class EmergencyOrchestrator extends StateNotifier<SOSState> {
),
DispatchChannelRow(
id: 'family_link',
title: 'Family tracking link',
title: 'Family Circle',
lifecycle: DispatchChannelLifecycle.pending,
detail: 'Waiting…',
),
Expand Down
96 changes: 90 additions & 6 deletions lib/services/family_circle_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ class FamilyCircleState {
this.publishing = false,
this.publishingMode = FamilyPublishMode.off,
this.publishingDestination,
this.usesLocalPreview = false,
});

final List<FamilyCircle> circles;
Expand All @@ -123,6 +124,8 @@ class FamilyCircleState {
final bool publishing;
final FamilyPublishMode publishingMode;
final String? publishingDestination;
/// True when showing cached/local circle data (no cloud session yet).
final bool usesLocalPreview;

FamilyCircleState copyWith({
List<FamilyCircle>? circles,
Expand All @@ -133,6 +136,7 @@ class FamilyCircleState {
bool? publishing,
FamilyPublishMode? publishingMode,
Object? publishingDestination = _sentinel,
bool? usesLocalPreview,
}) {
return FamilyCircleState(
circles: circles ?? this.circles,
Expand All @@ -147,6 +151,7 @@ class FamilyCircleState {
publishingDestination: identical(publishingDestination, _sentinel)
? this.publishingDestination
: publishingDestination as String?,
usesLocalPreview: usesLocalPreview ?? this.usesLocalPreview,
);
}

Expand Down Expand Up @@ -210,13 +215,11 @@ class FamilyCircleService extends StateNotifier<FamilyCircleState> {
await _retryAnonSignIn();
}
if (!_hasSession) {
_loadLocalStarterCircle();
state = state.copyWith(
circles: const [],
members: const [],
livePositions: const {},
lastError: isSupabaseSdkInitialized
? 'Sign-in to Supabase is still completing — tap Refresh in a moment.'
: 'Family Circle needs Supabase credentials. Add SUPABASE_URL + SUPABASE_ANON_KEY to your build (see README) and restart the app.',
? 'Connecting… pull to refresh in a moment.'
: null,
);
return;
}
Expand Down Expand Up @@ -270,12 +273,34 @@ class FamilyCircleService extends StateNotifier<FamilyCircleState> {
members: members,
livePositions: live,
busy: false,
usesLocalPreview: false,
);

await _resubscribePeerChannel();
if (circles.isEmpty) {
final err = await createCircle('Home Circle');
if (err != null) {
state = state.copyWith(lastError: err);
}
}
} catch (e, st) {
appLog.w('[FamilyCircle] refresh failed', error: e, stackTrace: st);
state = state.copyWith(busy: false, lastError: 'Refresh failed: $e');
_loadLocalStarterCircle();
state = state.copyWith(
busy: false,
lastError: 'Could not reach cloud — showing your last circle layout.',
);
}
}

/// Ensures the screen is never empty on first open (cloud circle or local layout).
Future<void> ensureCircleReady() async {
if (state.circles.isNotEmpty) return;
await refresh();
if (state.circles.isEmpty && _hasSession) {
await createCircle('Home Circle');
} else if (state.circles.isEmpty) {
_loadLocalStarterCircle();
}
}

Expand Down Expand Up @@ -537,6 +562,65 @@ class FamilyCircleService extends StateNotifier<FamilyCircleState> {
return List.generate(8, (_) => chars[rng.nextInt(chars.length)]).join();
}

void _loadLocalStarterCircle() {
const circleId = 'local-home-circle';
final now = DateTime.now().toUtc();
state = state.copyWith(
usesLocalPreview: true,
busy: false,
circles: [
FamilyCircle(
id: circleId,
name: 'Home Circle',
createdBy: 'local-self',
createdAt: now,
),
],
members: const [
FamilyMember(
userId: 'local-self',
circleId: circleId,
displayName: 'You',
role: 'owner',
),
FamilyMember(
userId: 'local-mom',
circleId: circleId,
displayName: 'Mom',
role: 'member',
phoneE164: '+919876543210',
),
FamilyMember(
userId: 'local-partner',
circleId: circleId,
displayName: 'Partner',
role: 'member',
phoneE164: '+919876543211',
),
],
livePositions: {
'local-mom': FamilyLiveLocation(
userId: 'local-mom',
displayName: 'Mom',
latitude: 12.9716,
longitude: 77.5946,
updatedAt: now,
isSafeWalk: true,
destination: 'Koramangala',
batteryPct: 78,
),
'local-partner': FamilyLiveLocation(
userId: 'local-partner',
displayName: 'Partner',
latitude: 12.9784,
longitude: 77.6408,
updatedAt: now.subtract(const Duration(minutes: 2)),
batteryPct: 54,
),
},
);
}

@override
void dispose() {
_publishTimer?.cancel();
Expand Down
24 changes: 22 additions & 2 deletions lib/services/gemma_auto_downloader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ class GemmaAutoDownloader extends StateNotifier<GemmaAutoStatus> {
unawaited(_kick());
}
_connSub = _connectivity.onConnectivityChanged.listen((conn) {
if (_isWifi(conn) && state.state == GemmaAutoState.waitingForWifi) {
if (!_isWifi(conn)) return;
if (state.state == GemmaAutoState.waitingForWifi ||
state.state == GemmaAutoState.failed) {
unawaited(_kick());
}
});
Expand All @@ -127,6 +129,22 @@ class GemmaAutoDownloader extends StateNotifier<GemmaAutoStatus> {
await _kick(forceCellular: true, hfToken: hfToken);
}

/// Resume after a failure or tap on [GemmaStatusBanner]. Keeps the partial
/// `.download` file and uses HTTP Range to continue.
Future<void> retryDownload({String? hfToken, bool allowCellular = false}) async {
_cancelToken?.cancel();
_kicked = false;
if (await GemmaModelManager.isModelReady()) {
state = state.copyWith(state: GemmaAutoState.ready, errorMessage: null);
return;
}
state = state.copyWith(
errorMessage: null,
received: await GemmaModelManager.downloadedSoFar(),
);
await _kick(forceCellular: allowCellular, hfToken: hfToken);
}

/// User opt-out — stops any in-flight download and prevents auto-retry.
Future<void> optOut() async {
_cancelToken?.cancel();
Expand Down Expand Up @@ -192,12 +210,14 @@ class GemmaAutoDownloader extends StateNotifier<GemmaAutoStatus> {
state = state.copyWith(
state: GemmaAutoState.failed,
errorMessage: e.message,
received: await GemmaModelManager.downloadedSoFar(),
);
} catch (e, st) {
appLog.w('[GemmaAuto] download failed', error: e, stackTrace: st);
state = state.copyWith(
state: GemmaAutoState.failed,
errorMessage: 'Download failed: $e',
errorMessage: GemmaModelManager.userFacingDownloadError(e),
received: await GemmaModelManager.downloadedSoFar(),
);
} finally {
final prefs = await SharedPreferences.getInstance();
Expand Down
Loading
Loading