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:
@@ -82,3 +82,12 @@
|
|||||||
- **Verified:** headless Chromium on production build: iframe renders, prejoin page shows, devices pass, name entry works, reaches the Prosody JWT auth gate (expected for a tokenless room). COEP require-corp turned out NOT to block conf.m8chat.au in current Chromium.
|
- **Verified:** headless Chromium on production build: iframe renders, prejoin page shows, devices pass, name entry works, reaches the Prosody JWT auth gate (expected for a tokenless room). COEP require-corp turned out NOT to block conf.m8chat.au in current Chromium.
|
||||||
- **Files affected:** lib/features/jitsi/data/jitsi_web_service.dart (1.2.0), lib/features/jitsi/presentation/jitsi_screen.dart (1.3.0), app_config 1.6.2, pubspec 1.6.2+10.
|
- **Files affected:** lib/features/jitsi/data/jitsi_web_service.dart (1.2.0), lib/features/jitsi/presentation/jitsi_screen.dart (1.3.0), app_config 1.6.2, pubspec 1.6.2+10.
|
||||||
- **Status:** Done — deployed to app2.m8chat.au (backups pre160/pre161 tarballs on server).
|
- **Status:** Done — deployed to app2.m8chat.au (backups pre160/pre161 tarballs on server).
|
||||||
|
|
||||||
|
### 2026-07-04 07:35 — Feature build round 1 (v1.7.0+11)
|
||||||
|
- **P1 Encrypted history:** new key_restore_prompt.dart offers recovery-key restore right after login when the account has secret storage and the fresh device can't read history (was buried in Profile). Fires once per session from rooms_screen initState.
|
||||||
|
- **P2 Incoming calls:** overlay existed but (a) was never mounted and (b) only listened for legacy m.call.invite. Fixed: IncomingCallOverlayHost mounted in app.dart MaterialApp.builder; matrixrtc_repository now also detects MSC3401 org.matrix.msc3401.call.member (what Element X sends), with stale-event + dedupe guards. Accept uses routerProvider (overlay is above the router navigator).
|
||||||
|
- **P3 Reactions/edit/delete:** reactions + delete were already wired (notes were stale). Added editMessage (sendTextEvent editEventId) + EditMessage notifier + Edit tile/dialog. Verified full context menu renders Reply/React/Copy/Edit/Delete.
|
||||||
|
- **P4 File/image send:** already implemented (sendFile + file_picker + UploadFile). Confirmed, no change needed.
|
||||||
|
- **P5 Panic button:** NEW feature/panic module. Config in account data au.m8chat.panic (room + message). Hold-to-fire button in rooms AppBar (only shows when armed); Profile tile to arm/disarm. Alert text sent FIRST, then best-effort m.location pin (geo: uri, msc3488) with 10s geolocation budget so a denied permission never blocks the alert. VERIFIED end-to-end: text + location with exact GPS landed in the target room via mock geolocation.
|
||||||
|
- **Status:** built, deployed app2.m8chat.au v1.7.0+11 (backup pre170 tarball on server). Analyze clean, tests pass.
|
||||||
|
- **Deferred:** ghost-device cleanup, rooms-provider rebuild perf, native session persistence — still open from original notes.
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
// Version: 1.0.0 | Created: 2026-04-01
|
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||||
// Root MaterialApp widget. Wires together theme + router.
|
// Root MaterialApp widget. Wires together theme + router + incoming-call
|
||||||
|
// overlay (v1.1.0 — the overlay existed but was never mounted, so incoming
|
||||||
|
// calls showed nothing).
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../features/calls/presentation/incoming_call_overlay.dart';
|
||||||
import 'router.dart';
|
import 'router.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
@@ -26,6 +29,8 @@ class M8ChatApp extends ConsumerWidget {
|
|||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
|
builder: (context, child) =>
|
||||||
|
IncomingCallOverlayHost(child: child ?? const SizedBox.shrink()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ abstract final class AppConfig {
|
|||||||
'turns:matrix.m8chat.au:5349',
|
'turns:matrix.m8chat.au:5349',
|
||||||
];
|
];
|
||||||
static const String appName = 'M8Chat';
|
static const String appName = 'M8Chat';
|
||||||
static const String appVersion = '1.6.2';
|
static const String appVersion = '1.7.0';
|
||||||
|
|
||||||
// Jitsi conferencing
|
// Jitsi conferencing
|
||||||
static const String jitsiDomain = 'conf.m8chat.au';
|
static const String jitsiDomain = 'conf.m8chat.au';
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02
|
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||||
// MatrixRTC repository — handles outgoing call invites and detects incoming
|
// MatrixRTC repository — handles outgoing call invites and detects incoming
|
||||||
// calls via m.call.invite events per MSC4143 (MatrixRTC spec).
|
// calls. v1.3.0 adds MSC3401 call.member state-event detection: Element X
|
||||||
|
// (and app2 itself) signals group calls with org.matrix.msc3401.call.member,
|
||||||
|
// not legacy m.call.invite, so incoming calls from Element X were invisible.
|
||||||
|
// Both paths now guard against stale events replayed during initial sync.
|
||||||
//
|
//
|
||||||
// Incoming calls are surfaced via the incomingCallStream so the
|
// Incoming calls are surfaced via the incomingCallStream so the
|
||||||
// IncomingCallOverlay can react to them.
|
// IncomingCallOverlay can react to them.
|
||||||
@@ -79,13 +82,39 @@ class MatrixRtcRepository {
|
|||||||
}, type: EventTypes.CallInvite);
|
}, type: EventTypes.CallInvite);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onEvent(EventUpdate update) {
|
/// MSC3401 MatrixRTC call membership state event (what Element X sends).
|
||||||
if (update.type != EventUpdateType.timeline) return;
|
static const _kCallMemberEventType = 'org.matrix.msc3401.call.member';
|
||||||
if (update.content['type'] != EventTypes.CallInvite) return;
|
|
||||||
|
|
||||||
|
/// Events older than this are sync replays, not live calls.
|
||||||
|
static const _maxEventAge = Duration(seconds: 90);
|
||||||
|
|
||||||
|
/// Membership keys we have already alerted on (roomId|sender|membership).
|
||||||
|
final Set<String> _seenMembershipKeys = {};
|
||||||
|
|
||||||
|
void _onEvent(EventUpdate update) {
|
||||||
|
final eventType = update.content['type'] as String?;
|
||||||
|
if (eventType == EventTypes.CallInvite &&
|
||||||
|
update.type == EventUpdateType.timeline) {
|
||||||
|
_onCallInvite(update);
|
||||||
|
} else if (eventType == _kCallMemberEventType &&
|
||||||
|
(update.type == EventUpdateType.timeline ||
|
||||||
|
update.type == EventUpdateType.state)) {
|
||||||
|
_onCallMember(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isStale(EventUpdate update) {
|
||||||
|
final ts = update.content['origin_server_ts'] as int?;
|
||||||
|
if (ts == null) return true;
|
||||||
|
final age = DateTime.now().millisecondsSinceEpoch - ts;
|
||||||
|
return age > _maxEventAge.inMilliseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy 1:1 call invites (app2 → app2).
|
||||||
|
void _onCallInvite(EventUpdate update) {
|
||||||
final senderId = update.content['sender'] as String?;
|
final senderId = update.content['sender'] as String?;
|
||||||
// Ignore our own invites.
|
// Ignore our own invites and stale events replayed on initial sync.
|
||||||
if (senderId == _myUserId) return;
|
if (senderId == _myUserId || _isStale(update)) return;
|
||||||
|
|
||||||
final roomId = update.roomID;
|
final roomId = update.roomID;
|
||||||
final content = update.content['content'] as Map<String, dynamic>?;
|
final content = update.content['content'] as Map<String, dynamic>?;
|
||||||
@@ -93,21 +122,66 @@ class MatrixRtcRepository {
|
|||||||
|
|
||||||
final callId = content['call_id'] as String?;
|
final callId = content['call_id'] as String?;
|
||||||
if (callId == null) return;
|
if (callId == null) return;
|
||||||
|
if (!_seenMembershipKeys.add('$roomId|$senderId|$callId')) return;
|
||||||
|
|
||||||
final room = _client.getRoomById(roomId);
|
_emitIncomingCall(
|
||||||
final senderProfile = room?.unsafeGetUserFromMemoryOrFallback(
|
roomId: roomId,
|
||||||
senderId ?? '',
|
senderId: senderId ?? '',
|
||||||
|
callId: callId,
|
||||||
|
isVideo: content['offer'] != null,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MSC3401 group-call membership: someone (e.g. Element X) joined a call.
|
||||||
|
void _onCallMember(EventUpdate update) {
|
||||||
|
final senderId = update.content['sender'] as String?;
|
||||||
|
if (senderId == null || senderId == _myUserId || _isStale(update)) return;
|
||||||
|
|
||||||
|
final content = update.content['content'] as Map<String, dynamic>?;
|
||||||
|
final memberships = content?['memberships'] as List<dynamic>?;
|
||||||
|
// Empty/cleared memberships means the caller hung up — nothing to show.
|
||||||
|
if (memberships == null || memberships.isEmpty) return;
|
||||||
|
|
||||||
|
final membership = memberships.first as Map<String, dynamic>;
|
||||||
|
if (membership['application'] != 'm.call') return;
|
||||||
|
|
||||||
|
final expiresTs = membership['expires_ts'] as int?;
|
||||||
|
if (expiresTs != null &&
|
||||||
|
expiresTs < DateTime.now().millisecondsSinceEpoch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final membershipId =
|
||||||
|
(membership['membershipID'] ?? membership['call_id'] ?? '').toString();
|
||||||
|
final roomId = update.roomID;
|
||||||
|
if (!_seenMembershipKeys.add('$roomId|$senderId|$membershipId')) return;
|
||||||
|
|
||||||
|
_emitIncomingCall(
|
||||||
|
roomId: roomId,
|
||||||
|
senderId: senderId,
|
||||||
|
callId: membershipId.isEmpty ? 'matrixrtc' : membershipId,
|
||||||
|
isVideo: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _emitIncomingCall({
|
||||||
|
required String roomId,
|
||||||
|
required String senderId,
|
||||||
|
required String callId,
|
||||||
|
required bool isVideo,
|
||||||
|
}) {
|
||||||
|
final room = _client.getRoomById(roomId);
|
||||||
|
final senderProfile = room?.unsafeGetUserFromMemoryOrFallback(senderId);
|
||||||
|
|
||||||
_incomingCallController.add(
|
_incomingCallController.add(
|
||||||
IncomingCall(
|
IncomingCall(
|
||||||
callId: callId,
|
callId: callId,
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
callerId: senderId ?? '',
|
callerId: senderId,
|
||||||
callerDisplayName:
|
callerDisplayName:
|
||||||
senderProfile?.displayName ?? senderId?.matrixLocalpart ?? '',
|
senderProfile?.displayName ?? senderId.matrixLocalpart,
|
||||||
callerAvatarUrl: resolveMxcUrl(_client, senderProfile?.avatarUrl),
|
callerAvatarUrl: resolveMxcUrl(_client, senderProfile?.avatarUrl),
|
||||||
isVideo: (content['offer'] != null),
|
isVideo: isVideo,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02
|
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||||
// IncomingCallOverlay — full-screen overlay shown when an m.call.invite
|
// IncomingCallOverlay — full-screen overlay shown when an m.call.invite
|
||||||
// arrives. Displays caller name/avatar, and Accept / Decline buttons.
|
// arrives. Displays caller name/avatar, and Accept / Decline buttons.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import '../../../app/router.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
import '../../../shared/widgets/matrix_avatar.dart';
|
import '../../../shared/widgets/matrix_avatar.dart';
|
||||||
@@ -58,20 +58,27 @@ class IncomingCallOverlayHost extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IncomingCallOverlay extends StatefulWidget {
|
class _IncomingCallOverlay extends ConsumerStatefulWidget {
|
||||||
const _IncomingCallOverlay({required this.call});
|
const _IncomingCallOverlay({required this.call});
|
||||||
|
|
||||||
final IncomingCall call;
|
final IncomingCall call;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_IncomingCallOverlay> createState() => _IncomingCallOverlayState();
|
ConsumerState<_IncomingCallOverlay> createState() =>
|
||||||
|
_IncomingCallOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IncomingCallOverlayState extends State<_IncomingCallOverlay> {
|
class _IncomingCallOverlayState extends ConsumerState<_IncomingCallOverlay> {
|
||||||
bool _dismissed = false;
|
bool _dismissed = false;
|
||||||
|
String? _dismissedForCallId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// A new call resets the dismissal so the overlay can show again.
|
||||||
|
if (_dismissedForCallId != widget.call.callId) {
|
||||||
|
_dismissed = false;
|
||||||
|
_dismissedForCallId = widget.call.callId;
|
||||||
|
}
|
||||||
if (_dismissed) return const SizedBox.shrink();
|
if (_dismissed) return const SizedBox.shrink();
|
||||||
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -129,9 +136,13 @@ class _IncomingCallOverlayState extends State<_IncomingCallOverlay> {
|
|||||||
label: 'Accept',
|
label: 'Accept',
|
||||||
colour: Colors.green,
|
colour: Colors.green,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.push(
|
// The overlay lives above the router's navigator, so
|
||||||
'/calls/${Uri.encodeComponent(call.roomId)}',
|
// context.push would not find GoRouter — use the
|
||||||
);
|
// router instance directly, then dismiss the overlay.
|
||||||
|
setState(() => _dismissed = true);
|
||||||
|
ref
|
||||||
|
.read(routerProvider)
|
||||||
|
.push('/calls/${Uri.encodeComponent(call.roomId)}');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-10
|
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||||
// Chat repository — bridges Matrix SDK timeline to app domain models.
|
// Chat repository — bridges Matrix SDK timeline to app domain models.
|
||||||
// Phase 2 additions: sendFile, sendReaction, redactEvent, reply support.
|
// Phase 2 additions: sendFile, sendReaction, redactEvent, reply support.
|
||||||
|
|
||||||
@@ -73,6 +73,17 @@ class ChatRepository {
|
|||||||
await room.sendFileEvent(file);
|
await room.sendFileEvent(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces the body of an existing text message (m.replace edit).
|
||||||
|
Future<void> editMessage(
|
||||||
|
String roomId,
|
||||||
|
String eventId,
|
||||||
|
String newText,
|
||||||
|
) async {
|
||||||
|
final room = _getRoom(roomId);
|
||||||
|
if (room == null) return;
|
||||||
|
await room.sendTextEvent(newText, editEventId: eventId);
|
||||||
|
}
|
||||||
|
|
||||||
/// Sends an emoji reaction to [eventId].
|
/// Sends an emoji reaction to [eventId].
|
||||||
Future<void> sendReaction(String roomId, String eventId, String emoji) async {
|
Future<void> sendReaction(String roomId, String eventId, String emoji) async {
|
||||||
final room = _getRoom(roomId);
|
final room = _getRoom(roomId);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Version: 1.1.0 | Created: 2026-04-01
|
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||||
// Riverpod providers for chat timeline, send, upload, react, reply.
|
// Riverpod providers for chat timeline, send, upload, react, reply.
|
||||||
|
|
||||||
import 'package:matrix/matrix.dart' show MatrixFile;
|
import 'package:matrix/matrix.dart' show MatrixFile;
|
||||||
@@ -84,6 +84,27 @@ class SendReaction extends _$SendReaction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Edits an existing text message.
|
||||||
|
@riverpod
|
||||||
|
class EditMessage extends _$EditMessage {
|
||||||
|
@override
|
||||||
|
bool build() => false;
|
||||||
|
|
||||||
|
Future<String?> edit(String roomId, String eventId, String newText) async {
|
||||||
|
state = true;
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(chatRepositoryProvider)
|
||||||
|
.editMessage(roomId, eventId, newText);
|
||||||
|
return null;
|
||||||
|
} on Exception catch (e) {
|
||||||
|
return e.toString();
|
||||||
|
} finally {
|
||||||
|
state = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Deletes (redacts) a message by eventId.
|
/// Deletes (redacts) a message by eventId.
|
||||||
@riverpod
|
@riverpod
|
||||||
class DeleteMessage extends _$DeleteMessage {
|
class DeleteMessage extends _$DeleteMessage {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Version: 1.3.1 | Created: 2026-04-01 | Updated: 2026-07-03
|
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||||
// Full chat screen — timeline + input + typing indicators + read receipts
|
// Full chat screen — timeline + input + typing indicators + read receipts
|
||||||
// + long-press context menu (reply, react, copy, delete) + room options sheet.
|
// + long-press context menu (reply, react, copy, delete) + room options sheet.
|
||||||
|
|
||||||
@@ -217,6 +217,12 @@ class _MessageWithGestures extends ConsumerWidget {
|
|||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('Message copied.')));
|
).showSnackBar(const SnackBar(content: Text('Message copied.')));
|
||||||
},
|
},
|
||||||
|
onEdit: message.isMine && message.body != null
|
||||||
|
? () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showEditDialog(context, ref);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
onDelete: message.isMine
|
onDelete: message.isMine
|
||||||
? () async {
|
? () async {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
@@ -229,6 +235,44 @@ class _MessageWithGestures extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showEditDialog(BuildContext context, WidgetRef ref) {
|
||||||
|
final controller = TextEditingController(text: message.body ?? '');
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Edit message'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
maxLines: null,
|
||||||
|
decoration: const InputDecoration(border: OutlineInputBorder()),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final newText = controller.text.trim();
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
if (newText.isEmpty || newText == message.body) return;
|
||||||
|
final error = await ref
|
||||||
|
.read(editMessageProvider.notifier)
|
||||||
|
.edit(roomId, message.eventId, newText);
|
||||||
|
if (error != null && context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Edit failed: $error')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showEmojiPicker(BuildContext context, WidgetRef ref) {
|
void _showEmojiPicker(BuildContext context, WidgetRef ref) {
|
||||||
showModalBottomSheet<void>(
|
showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -437,6 +481,7 @@ class _MessageContextMenu extends StatelessWidget {
|
|||||||
required this.onReply,
|
required this.onReply,
|
||||||
required this.onReact,
|
required this.onReact,
|
||||||
required this.onCopy,
|
required this.onCopy,
|
||||||
|
this.onEdit,
|
||||||
this.onDelete,
|
this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -445,6 +490,7 @@ class _MessageContextMenu extends StatelessWidget {
|
|||||||
final VoidCallback onReply;
|
final VoidCallback onReply;
|
||||||
final VoidCallback onReact;
|
final VoidCallback onReact;
|
||||||
final VoidCallback onCopy;
|
final VoidCallback onCopy;
|
||||||
|
final VoidCallback? onEdit;
|
||||||
final VoidCallback? onDelete;
|
final VoidCallback? onDelete;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -469,6 +515,12 @@ class _MessageContextMenu extends StatelessWidget {
|
|||||||
title: const Text('Copy text'),
|
title: const Text('Copy text'),
|
||||||
onTap: onCopy,
|
onTap: onCopy,
|
||||||
),
|
),
|
||||||
|
if (onEdit != null)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit_outlined),
|
||||||
|
title: const Text('Edit'),
|
||||||
|
onTap: onEdit,
|
||||||
|
),
|
||||||
if (onDelete != null)
|
if (onDelete != null)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||||
|
|||||||
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
lib/features/profile/presentation/key_restore_prompt.dart
Normal file
80
lib/features/profile/presentation/key_restore_prompt.dart
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Version: 1.0.0 | Created: 2026-07-04
|
||||||
|
// Post-login prompt: if the account has secret storage (key backup) and this
|
||||||
|
// fresh device cannot read encrypted history yet, offer the recovery-key
|
||||||
|
// restore straight away instead of leaving it buried in Profile > Security.
|
||||||
|
//
|
||||||
|
// Shown at most once per login session. "Later" keeps the old behaviour
|
||||||
|
// (restore manually from the Profile tab).
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:matrix/matrix.dart';
|
||||||
|
|
||||||
|
import 'key_restore_dialog.dart';
|
||||||
|
|
||||||
|
bool _promptedThisSession = false;
|
||||||
|
|
||||||
|
/// Reset when a new login happens (called from the login flow if needed).
|
||||||
|
void resetKeyRestorePrompt() => _promptedThisSession = false;
|
||||||
|
|
||||||
|
/// Checks whether the freshly logged-in session needs a key restore and,
|
||||||
|
/// if so, asks the user. Safe to call multiple times; only fires once.
|
||||||
|
Future<void> maybePromptKeyRestore(BuildContext context, Client client) async {
|
||||||
|
if (_promptedThisSession) return;
|
||||||
|
_promptedThisSession = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Wait for the first sync so account data (secret storage config) exists.
|
||||||
|
if (client.prevBatch == null) {
|
||||||
|
await client.onSync.stream.first.timeout(const Duration(seconds: 30));
|
||||||
|
}
|
||||||
|
} on TimeoutException {
|
||||||
|
debugPrint('[KeyRestorePrompt] first sync timed out — skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final enc = client.encryption;
|
||||||
|
if (enc == null) return;
|
||||||
|
|
||||||
|
// No secret storage on the account → nothing to restore from.
|
||||||
|
final hasSecretStorage =
|
||||||
|
client.accountData.containsKey('m.secret_storage.default_key');
|
||||||
|
if (!hasSecretStorage) return;
|
||||||
|
|
||||||
|
// Keys already cached (or session verified) → nothing to do.
|
||||||
|
var cached = false;
|
||||||
|
try {
|
||||||
|
cached = await enc.keyManager.isCached();
|
||||||
|
} on Exception catch (e) {
|
||||||
|
debugPrint('[KeyRestorePrompt] isCached failed: $e');
|
||||||
|
}
|
||||||
|
if (cached && !client.isUnknownSession) return;
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
final wantsRestore = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Restore encrypted messages?'),
|
||||||
|
content: const Text(
|
||||||
|
'This device cannot read your older encrypted messages yet. '
|
||||||
|
'Enter your recovery key now to unlock them, or do it later '
|
||||||
|
'from Profile > Security & Privacy.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Later'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text('Enter recovery key'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wantsRestore != true || !context.mounted) return;
|
||||||
|
await showKeyRestoreDialog(context, client);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Version: 1.4.1 | Created: 2026-04-01 | Updated: 2026-07-03
|
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||||
// Profile screen. Shows current user info and logout button.
|
// Profile screen. Shows current user info and logout button.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -10,6 +10,8 @@ import '../../../core/config/app_config.dart';
|
|||||||
import '../../../core/network/matrix_client.dart';
|
import '../../../core/network/matrix_client.dart';
|
||||||
import '../../../shared/utils/matrix_id.dart';
|
import '../../../shared/utils/matrix_id.dart';
|
||||||
import '../../../shared/widgets/matrix_avatar.dart';
|
import '../../../shared/widgets/matrix_avatar.dart';
|
||||||
|
import '../../panic/data/panic_repository.dart';
|
||||||
|
import '../../panic/presentation/panic_settings_dialog.dart';
|
||||||
import 'key_restore_dialog.dart';
|
import 'key_restore_dialog.dart';
|
||||||
import 'security_setup_dialog.dart';
|
import 'security_setup_dialog.dart';
|
||||||
|
|
||||||
@@ -147,6 +149,16 @@ class ProfileScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.emergency_share, color: Colors.red),
|
||||||
|
title: const Text('Emergency panic button'),
|
||||||
|
subtitle: Text(
|
||||||
|
ref.watch(panicRepositoryProvider).config != null
|
||||||
|
? 'Armed. Hold the alert icon on the rooms screen to trigger.'
|
||||||
|
: 'Send your location and an alert to a room of your choice',
|
||||||
|
),
|
||||||
|
onTap: () => showPanicSettingsDialog(context, ref),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-04-27
|
// Version: 1.6.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||||
// Main rooms list screen with bottom navigation.
|
// Main rooms list screen with bottom navigation.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import '../../../core/network/matrix_client.dart';
|
||||||
import '../../help/presentation/help_tab.dart';
|
import '../../help/presentation/help_tab.dart';
|
||||||
|
import '../../panic/presentation/panic_button.dart';
|
||||||
import '../../jitsi/presentation/conference_tab.dart';
|
import '../../jitsi/presentation/conference_tab.dart';
|
||||||
|
import '../../profile/presentation/key_restore_prompt.dart';
|
||||||
import '../../profile/presentation/profile_screen.dart';
|
import '../../profile/presentation/profile_screen.dart';
|
||||||
import '../../spaces/presentation/spaces_screen.dart';
|
import '../../spaces/presentation/spaces_screen.dart';
|
||||||
import 'public_rooms_dialog.dart';
|
import 'public_rooms_dialog.dart';
|
||||||
@@ -27,6 +32,17 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
|
|||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Offer encrypted-history restore right after login (fires once).
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final client = ref.read(matrixClientProvider);
|
||||||
|
unawaited(maybePromptKeyRestore(context, client));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
@@ -99,6 +115,7 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
|
|||||||
)
|
)
|
||||||
: const Text('M8Chat'),
|
: const Text('M8Chat'),
|
||||||
actions: [
|
actions: [
|
||||||
|
const PanicButton(),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(_searchActive ? Icons.close : Icons.search),
|
icon: Icon(_searchActive ? Icons.close : Icons.search),
|
||||||
tooltip: _searchActive ? 'Cancel search' : 'Search rooms',
|
tooltip: _searchActive ? 'Cancel search' : 'Search rooms',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: m8chat_app
|
name: m8chat_app
|
||||||
description: "M8Chat — Matrix chat client for Android, iOS, and Web."
|
description: "M8Chat — Matrix chat client for Android, iOS, and Web."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.6.2+10
|
version: 1.7.0+11
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.11.0 <4.0.0'
|
sdk: '>=3.11.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user