P1 key_restore_prompt: offer recovery-key restore right after login P2 incoming calls: mount overlay in app.dart + detect MSC3401 call.member (Element X signalling) — calls were previously invisible P3 message edit: editMessage/EditMessage + Edit dialog (reactions/delete were already wired) P5 panic button: new feature/panic module — hold-to-send alert text + m.location to a configured room; alert sent before geolocation so a denied permission never blocks it All verified end-to-end in headless Chromium against production build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
143 lines
4.5 KiB
Dart
143 lines
4.5 KiB
Dart
// 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<void> 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<void> 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<String?> 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));
|
|
}
|