feat: encrypted-history prompt, incoming-call alerts, message edit, panic button (v1.7.0+11)
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>
This commit is contained in:
142
lib/features/panic/data/panic_repository.dart
Normal file
142
lib/features/panic/data/panic_repository.dart
Normal file
@@ -0,0 +1,142 @@
|
||||
// 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));
|
||||
}
|
||||
70
lib/features/panic/presentation/panic_button.dart
Normal file
70
lib/features/panic/presentation/panic_button.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
// Version: 1.0.0 | Created: 2026-07-04
|
||||
// Panic trigger button for the rooms AppBar. Push-to-talk style:
|
||||
// HOLD to fire the alert; a plain tap only shows the hint. Only rendered
|
||||
// when a panic configuration exists in account data.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../data/panic_repository.dart';
|
||||
|
||||
class PanicButton extends ConsumerStatefulWidget {
|
||||
const PanicButton({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PanicButton> createState() => _PanicButtonState();
|
||||
}
|
||||
|
||||
class _PanicButtonState extends ConsumerState<PanicButton> {
|
||||
bool _sending = false;
|
||||
|
||||
Future<void> _trigger() async {
|
||||
if (_sending) return;
|
||||
setState(() => _sending = true);
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('Sending emergency alert…')),
|
||||
);
|
||||
final error = await ref.read(panicRepositoryProvider).trigger();
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
messenger.hideCurrentSnackBar();
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: error == null ? Colors.green.shade800 : null,
|
||||
content: Text(
|
||||
error ?? 'Emergency alert sent with your location.',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Hide when not configured. Config lives in account data, which is
|
||||
// available once sync has run; rebuilds happen with the parent screen.
|
||||
final configured = ref.watch(panicRepositoryProvider).config != null;
|
||||
if (!configured) return const SizedBox.shrink();
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: _trigger,
|
||||
child: IconButton(
|
||||
tooltip: 'Hold to send emergency alert',
|
||||
icon: _sending
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.emergency_share, color: Colors.red),
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Hold the button to send the emergency alert.'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
166
lib/features/panic/presentation/panic_settings_dialog.dart
Normal file
166
lib/features/panic/presentation/panic_settings_dialog.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
// Version: 1.0.0 | Created: 2026-07-04
|
||||
// Panic button settings — choose the alert room and message.
|
||||
// Opened from Profile > Emergency panic button.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import '../../../core/network/matrix_client.dart';
|
||||
import '../data/panic_repository.dart';
|
||||
|
||||
Future<void> showPanicSettingsDialog(BuildContext context, WidgetRef ref) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const _PanicSettingsDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
class _PanicSettingsDialog extends ConsumerStatefulWidget {
|
||||
const _PanicSettingsDialog();
|
||||
|
||||
@override
|
||||
ConsumerState<_PanicSettingsDialog> createState() =>
|
||||
_PanicSettingsDialogState();
|
||||
}
|
||||
|
||||
class _PanicSettingsDialogState extends ConsumerState<_PanicSettingsDialog> {
|
||||
String? _roomId;
|
||||
late final TextEditingController _messageController;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final cfg = ref.read(panicRepositoryProvider).config;
|
||||
_roomId = cfg?.roomId;
|
||||
_messageController = TextEditingController(
|
||||
text: cfg?.message ??
|
||||
'EMERGENCY: I need help. This is an automated alert from M8Chat.',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_messageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final roomId = _roomId;
|
||||
if (roomId == null) return;
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(panicRepositoryProvider).saveConfig(
|
||||
roomId: roomId,
|
||||
message: _messageController.text.trim(),
|
||||
);
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Panic button armed. Hold the alert icon '
|
||||
'on the rooms screen to trigger it.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _saving = false);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('Could not save: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disable() async {
|
||||
setState(() => _saving = true);
|
||||
await ref.read(panicRepositoryProvider).clearConfig();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Panic button disabled.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = ref.watch(matrixClientProvider);
|
||||
final rooms = client.rooms
|
||||
.where((r) => r.membership == Membership.join)
|
||||
.toList()
|
||||
..sort((a, b) => a
|
||||
.getLocalizedDisplayname()
|
||||
.toLowerCase()
|
||||
.compareTo(b.getLocalizedDisplayname().toLowerCase()));
|
||||
final hasConfig = ref.read(panicRepositoryProvider).config != null;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Emergency panic button'),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'When you hold the alert icon, M8Chat sends this message '
|
||||
'and your current location to the room you choose here.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue:
|
||||
rooms.any((r) => r.id == _roomId) ? _roomId : null,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Send alert to room',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
for (final room in rooms)
|
||||
DropdownMenuItem(
|
||||
value: room.id,
|
||||
child: Text(
|
||||
room.getLocalizedDisplayname(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() => _roomId = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _messageController,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Alert message',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (hasConfig)
|
||||
TextButton(
|
||||
onPressed: _saving ? null : _disable,
|
||||
child: const Text('Disable', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _saving || _roomId == null ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user