// Version: 1.0.0 | Created: 2026-07-04 // Panic button repository — config in Matrix account data plus the trigger // action: send an alert text message and (when the browser grants it) an // m.location pin to the configured room. // // The alert message is sent FIRST so a denied/slow geolocation permission can // never block the emergency alert itself. // // NOTE web-only geolocation (dart:js_interop + package:web). When native // Android/iOS builds happen, gate this behind a conditional import. import 'dart:async'; import 'dart:js_interop'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:web/web.dart' as web; import '../../../core/network/matrix_client.dart'; part 'panic_repository.g.dart'; /// Account-data event type holding the panic configuration. const kPanicConfigType = 'au.m8chat.panic'; /// Panic button configuration. class PanicConfig { const PanicConfig({required this.roomId, required this.message}); final String roomId; final String message; static PanicConfig? fromAccountData(Client client) { final content = client.accountData[kPanicConfigType]?.content; final roomId = content?['room_id']; if (roomId is! String || roomId.isEmpty) return null; final message = content?['message']; return PanicConfig( roomId: roomId, message: message is String && message.isNotEmpty ? message : 'EMERGENCY: I need help. This is an automated alert from M8Chat.', ); } } class PanicRepository { PanicRepository({required Client client}) : _client = client; final Client _client; PanicConfig? get config => PanicConfig.fromAccountData(_client); Future saveConfig({ required String roomId, required String message, }) async { final userId = _client.userID; if (userId == null) throw Exception('Not logged in'); await _client.setAccountData(userId, kPanicConfigType, { 'room_id': roomId, 'message': message, }); } Future clearConfig() async { final userId = _client.userID; if (userId == null) return; await _client.setAccountData(userId, kPanicConfigType, {}); } /// Fires the panic alert. Returns an error string, or null on success. Future trigger() async { final cfg = config; if (cfg == null) return 'Panic button is not configured.'; final room = _client.getRoomById(cfg.roomId); if (room == null) return 'Configured room is no longer available.'; // 1. The alert text goes out first — never blocked by geolocation. try { await room.sendTextEvent('\u{1F6A8} ${cfg.message}'); } on Exception catch (e) { return 'Could not send alert: $e'; } // 2. Best-effort location pin. final position = await _currentPosition(); if (position != null) { final (lat, lon) = position; final geoUri = 'geo:$lat,$lon'; try { await room.sendEvent({ 'msgtype': 'm.location', 'body': 'Emergency location: $geoUri', 'geo_uri': geoUri, 'org.matrix.msc3488.location': {'uri': geoUri}, 'org.matrix.msc3488.asset': {'type': 'm.pin'}, }, type: 'm.room.message'); } on Exception catch (e) { debugPrint('[Panic] location send failed: $e'); } } else { debugPrint('[Panic] no geolocation available — alert sent without it'); } return null; } /// Browser geolocation with a hard 10s budget. Null when denied/unavailable. Future<(double, double)?> _currentPosition() async { final completer = Completer<(double, double)?>(); try { web.window.navigator.geolocation.getCurrentPosition( (web.GeolocationPosition pos) { if (!completer.isCompleted) { completer.complete((pos.coords.latitude, pos.coords.longitude)); } }.toJS, (web.GeolocationPositionError err) { if (!completer.isCompleted) completer.complete(null); }.toJS, web.PositionOptions( enableHighAccuracy: true, timeout: 8000, maximumAge: 60000, ), ); } catch (e) { debugPrint('[Panic] geolocation API unavailable: $e'); return null; } return completer.future.timeout( const Duration(seconds: 10), onTimeout: () => null, ); } } @Riverpod(keepAlive: true) PanicRepository panicRepository(Ref ref) { return PanicRepository(client: ref.watch(matrixClientProvider)); }