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:
2026-07-04 07:30:57 +10:00
parent 923c0ad878
commit a36df1c961
15 changed files with 700 additions and 30 deletions

View 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'),
),
],
);
}
}