Compare commits
14 Commits
255e7a29f6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e4aa5277d7 | |||
| f9c21fddc4 | |||
| f55f8dc698 | |||
| d2a2a1ab07 | |||
| a36df1c961 | |||
| 923c0ad878 | |||
| 3568b17f0f | |||
| 3865391829 | |||
| 0672f6b28b | |||
| 1cffe09faf | |||
| 542a5e5221 | |||
| c1b5b31d48 | |||
| 7e16b825c5 | |||
| b95471b0b4 |
@@ -1,24 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Version: 1.1.0 | Updated: 2026-07-03 -->
|
||||
<!-- Fills inlined as attributes: flutter_svg does not parse <style> blocks,
|
||||
which made the whole logo render black in the app. -->
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 429 429">
|
||||
<!-- Generator: Adobe Illustrator 29.3.0, SVG Export Plug-In . SVG Version: 2.1.0 Build 100) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #f8fbfe;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #1466eb;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="st1" d="M70.64,352.36c-3.51-2.16,1.51-50.04-.33-57.17-.72-2.41-16.56-9.68-20.12-11.88-54.87-34.03-34.77-110.33,1.79-150.82,55.72-61.71,140.84-65.99,217.72-49.69,48.91,10.37,149.12,46.91,145.84,109.25-2.02,38.32-56.82,85.23-88.37,102.62-66.49,36.67-140.36,36.21-212.2,16.21-2.19.3-16.47,21.54-19.5,25.09-3.72,4.36-18.49,20.3-24.83,16.39ZM314.5,196.52c15.07-5.73,19.34-26.28,12.4-39.91-14.75-28.96-81.5-25.08-84.39,11.41-.63,8.02,3.12,19.38,8.95,25.02,2.58,2.49,7.86,3.06,4.98,5.91-4.11,4.07-8.05,4.76-11.77,11.23-23.76,41.34,43.05,66.64,75.74,40.74,14.99-11.88,16.47-35.84,1.56-48.38-2.18-1.83-5.36-2.8-6.85-3.96-.97-.76-.79-.82-.62-2.05ZM118.5,139.5h-36v120h23v-88l31.16,86.34.79,1.21,23.9.32,31.14-86.86v85.5c0,.11,1.39,1.5,1.5,1.5h21.5v-120h-34.5c-11.09,24.47-16.9,52.95-27.47,77.53-1.08,2.52-3.04,6.59-5.02,8.47l-30.01-86Z"/>
|
||||
<path fill="#1466eb" d="M70.64,352.36c-3.51-2.16,1.51-50.04-.33-57.17-.72-2.41-16.56-9.68-20.12-11.88-54.87-34.03-34.77-110.33,1.79-150.82,55.72-61.71,140.84-65.99,217.72-49.69,48.91,10.37,149.12,46.91,145.84,109.25-2.02,38.32-56.82,85.23-88.37,102.62-66.49,36.67-140.36,36.21-212.2,16.21-2.19.3-16.47,21.54-19.5,25.09-3.72,4.36-18.49,20.3-24.83,16.39ZM314.5,196.52c15.07-5.73,19.34-26.28,12.4-39.91-14.75-28.96-81.5-25.08-84.39,11.41-.63,8.02,3.12,19.38,8.95,25.02,2.58,2.49,7.86,3.06,4.98,5.91-4.11,4.07-8.05,4.76-11.77,11.23-23.76,41.34,43.05,66.64,75.74,40.74,14.99-11.88,16.47-35.84,1.56-48.38-2.18-1.83-5.36-2.8-6.85-3.96-.97-.76-.79-.82-.62-2.05ZM118.5,139.5h-36v120h23v-88l31.16,86.34.79,1.21,23.9.32,31.14-86.86v85.5c0,.11,1.39,1.5,1.5,1.5h21.5v-120h-34.5c-11.09,24.47-16.9,52.95-27.47,77.53-1.08,2.52-3.04,6.59-5.02,8.47l-30.01-86Z"/>
|
||||
<g>
|
||||
<path class="st0" d="M118.5,139.5l30.01,86c1.98-1.88,3.94-5.94,5.02-8.47,10.56-24.59,16.37-53.06,27.47-77.53h34.5v120h-21.5c-.11,0-1.5-1.39-1.5-1.5v-85.5l-31.14,86.86-23.9-.32-.79-1.21-31.16-86.34v88h-23v-120h36Z"/>
|
||||
<path fill="#f8fbfe" d="M118.5,139.5l30.01,86c1.98-1.88,3.94-5.94,5.02-8.47,10.56-24.59,16.37-53.06,27.47-77.53h34.5v120h-21.5c-.11,0-1.5-1.39-1.5-1.5v-85.5l-31.14,86.86-23.9-.32-.79-1.21-31.16-86.34v88h-23v-120h36Z"/>
|
||||
<g>
|
||||
<path class="st0" d="M314.5,196.52c-.17,1.23-.35,1.29.62,2.05,1.48,1.17,4.67,2.13,6.85,3.96,14.91,12.55,13.43,36.51-1.56,48.38-32.68,25.9-99.5.6-75.74-40.74,3.72-6.47,7.66-7.16,11.77-11.23,2.88-2.86-2.4-3.42-4.98-5.91-5.84-5.64-9.59-17-8.95-25.02,2.88-36.49,69.63-40.37,84.39-11.41,6.95,13.63,2.68,34.19-12.4,39.91ZM281.26,156.76c-20.8,2.86-20.27,28.16-1.17,30.66,10.68,1.4,26.97-.71,27.32-14.51.36-14.27-14.74-17.71-26.15-16.15ZM279.26,207.76c-8.99,1.29-15.54,7.92-15.55,17.24-.02,22.72,41.87,22.15,44.72,3.94,3.04-19.37-13.32-23.45-29.17-21.18Z"/>
|
||||
<path class="st1" d="M279.26,207.76c15.85-2.28,32.21,1.81,29.17,21.18-2.86,18.21-44.75,18.78-44.72-3.94,0-9.32,6.57-15.95,15.55-17.24Z"/>
|
||||
<path class="st1" d="M281.26,156.76c11.41-1.57,26.51,1.88,26.15,16.15-.35,13.8-16.64,15.91-27.32,14.51-19.1-2.5-19.63-27.8,1.17-30.66Z"/>
|
||||
<path fill="#f8fbfe" d="M314.5,196.52c-.17,1.23-.35,1.29.62,2.05,1.48,1.17,4.67,2.13,6.85,3.96,14.91,12.55,13.43,36.51-1.56,48.38-32.68,25.9-99.5.6-75.74-40.74,3.72-6.47,7.66-7.16,11.77-11.23,2.88-2.86-2.4-3.42-4.98-5.91-5.84-5.64-9.59-17-8.95-25.02,2.88-36.49,69.63-40.37,84.39-11.41,6.95,13.63,2.68,34.19-12.4,39.91ZM281.26,156.76c-20.8,2.86-20.27,28.16-1.17,30.66,10.68,1.4,26.97-.71,27.32-14.51.36-14.27-14.74-17.71-26.15-16.15ZM279.26,207.76c-8.99,1.29-15.54,7.92-15.55,17.24-.02,22.72,41.87,22.15,44.72,3.94,3.04-19.37-13.32-23.45-29.17-21.18Z"/>
|
||||
<path fill="#1466eb" d="M279.26,207.76c15.85-2.28,32.21,1.81,29.17,21.18-2.86,18.21-44.75,18.78-44.72-3.94,0-9.32,6.57-15.95,15.55-17.24Z"/>
|
||||
<path fill="#1466eb" d="M281.26,156.76c11.41-1.57,26.51,1.88,26.15,16.15-.35,13.8-16.64,15.91-27.32,14.51-19.1-2.5-19.63-27.8,1.17-30.66Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.2 KiB |
@@ -69,3 +69,67 @@
|
||||
- **Rationale:** Element X sends {} when ending calls (verified from DB). Our {'memberships': []} wasn't being recognised.
|
||||
- **Files affected:** livekit_service.dart
|
||||
- **Status:** Deployed. NOT YET TESTED.
|
||||
### 2026-07-03 05:20 — Functionality audit + brand retheme (v1.6.0+8)
|
||||
- **Decision:** Retheme app from purple (#5C35C9) to m8chat.au brand blues taken from the site's Elementor kit: brandBlue #1265ED, accentBlue #3B8BFF, navy darks derived from Ancop Blue #010B46.
|
||||
- **Rationale:** Full audit (2026-07-03) found look and feel did not match m8chat.au. Logo rendered black because flutter_svg does not parse SVG <style> blocks; fills now inlined as attributes in m8logo.svg. Profile screen showed stale hardcoded appVersion 1.3.0. UI strings contained em-dashes (global style rule violation).
|
||||
- **Audit results:** Backend all healthy (client API, livekit JWT, Jitsi external_api.js, push gateway, TURN 3478/5349). Full E2E pass in headless Chromium: login, rooms, spaces, profile, conference, help tabs, room open, message send verified on Synapse. Disposable audit users created and erased via admin API.
|
||||
- **Files affected:** assets/images/m8logo.svg, lib/app/theme.dart (2.0.0), lib/core/config/app_config.dart (2.1.0), web/manifest.json, pubspec.yaml (1.6.0+8), em-dash fixes in 8 presentation/util files, chat_screen.dart lint fix (_leaveRoom context param).
|
||||
- **Status:** Done — built, deployed to app2.m8chat.au (backup public_html.bak.20260703_pre160.tar.gz), visually verified light+dark.
|
||||
|
||||
### 2026-07-04 07:15 — Jitsi conference join fixed (v1.6.2+10)
|
||||
- **Decision:** Root cause of "blank page on join" was the JS interop binding calling JitsiMeetExternalAPI (an ES6 class) as a plain function, i.e. without `new`. Browsers throw "Class constructor cannot be invoked without 'new'" and the meeting screen stayed blank. Feature had never worked in the web release build.
|
||||
- **Fix:** `_JitsiApi` extension type with an external constructor (compiles to `new`), container-div wait loop in joinMeeting (post-frame callback could fire before the platform view div existed), JS options built via setProperty instead of Map.jsify (DOM node in a jsify map is unsupported), joinMeeting returns bool, jitsi_screen shows an error screen instead of blank on failure.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### 2026-07-04 07:50 — Mobile UI v2 design pass (v1.8.0+12)
|
||||
- **Process:** ran a 7-agent design workflow (3 lens designers: information-density, reachability-flow, polish-delight; 3 judges; 1 synthesiser). All proposals scored 8-9/10. Output: prioritised spec (A high-impact/low-risk, B medium, C defer). Full spec archived in session.
|
||||
- **Implemented the whole A tier + safe B items:**
|
||||
- A1/A2 message bubble contrast: self #1265ED (selfBubble token, WCAG AA 5.11:1 vs failing 3.32:1), incoming #1C2452 + hairline border.
|
||||
- A3 replaced failing low-alpha read text with subtleText token (rooms empty state, room tile).
|
||||
- A4 deterministic per-user avatar colour (8-hue brand palette, name.hashCode) in matrix_avatar.dart — every disc now distinct.
|
||||
- A5/A6 room tile: unread preview brightened+w500, compact density, timestamp aligned to title, unread dot spacing.
|
||||
- A7 fixed stale "pencil icon" empty-state copy.
|
||||
- A9 FAB now "New message" (compose) in thumb zone; explore moved to AppBar compass. Straight onPressed swap.
|
||||
- A8 login + welcome brand gradient (#0A0F2E→#131A42), card hairline outlines.
|
||||
- B7 message input capped minLines 1 / maxLines 6. B8 bubble maxWidth = viewport*0.78 clamped 560. B10 AppBar scrolledUnderElevation + hairline.
|
||||
- **Deferred (documented in spec):** B1 message grouping, B2 day separators, B4 quick-reaction row, B5 panic hold-ring, B6 scroll-to-latest FAB, C1 swipe-to-reply (iOS PWA back-gesture risk), C2 group sender prefix (needs RoomModel field), C3 demote Help nav.
|
||||
- **Verified:** headless Chromium at 390x844 on production build — gradient login, distinct avatar colours, compose FAB, AA bubble contrast all confirmed.
|
||||
- **Status:** built + deployed app2.m8chat.au v1.8.0+12 (backup pre180 tarball). Analyze clean, tests pass.
|
||||
|
||||
### 2026-07-04 08:10 — Deferred UI items built (v1.9.0+13)
|
||||
- **B1 message grouping:** MessageBubble gains optional isFirstInGroup/isLastInGroup (defaulted true so callers keep compiling). _Timeline computes them from reverse-index neighbours (older=index+1, newer=index-1) + 5-min gap. Sender name only on first-of-run, avatar only on last-of-run (32px gutter holds space otherwise), tail corner only on last-of-run, tight 1px padding mid-run.
|
||||
- **B2 day separators:** _DayChip (Today/Yesterday/d MMM, toLocal AEST) rendered in a Column above the first message of each local day. Column wraps the gesture widget so long-press stays intact.
|
||||
- **B4 quick-reaction row:** _MessageContextMenu gains a top row of six presets (👍❤️😂😮😢🙏, 44dp each) + "+" opening the existing EmojiPicker, above Reply. Uses existing sendReaction provider.
|
||||
- **Verified in headless Chromium on production build:** grouping renders (Sam's 3-run collapsed to tight blue bubbles; Alex's 2-run shows name once + avatar on last bubble only, amber deterministic disc), "Today" chip renders, quick-react row renders with all six presets + picker button, tapping ❤️ sent a real m.reaction (confirmed via server /messages). Long-press context menu still fires (all of Reply/React/Copy/Edit/Delete intact).
|
||||
- **Note:** cross-day separators are logic-only verified (can't backdate events without an appservice); the same-day "Today" chip confirms the render path.
|
||||
- **Status:** built + deployed app2.m8chat.au v1.9.0+13 (backup pre190 tarball). Analyze clean, format clean, tests pass.
|
||||
|
||||
### 2026-07-04 08:30 — Scroll-to-latest + panic hold-ring (v1.10.0+14)
|
||||
- **B6 scroll-to-latest:** _Timeline converted ConsumerWidget → ConsumerStatefulWidget with a ScrollController. When offset > 400 (reverse list, newest at 0), an AnimatedScale FloatingActionButton.small(arrow_downward) fades in at Positioned(right:12,bottom:12) inside a Stack around the ListView (NOT the Scaffold FAB slot). Tap → animateTo(0, 200ms, easeOut).
|
||||
- **B5 panic hold-ring:** panic_button.dart → onLongPressDown starts a 700ms AnimationController; a red CircularProgressIndicator(value: controller.value) fills around the emergency_share icon; on completion HapticFeedback.heavyImpact + fire; early release resets silently. onTap still shows the hint; configured guard + _sending state preserved.
|
||||
- **Verified:**
|
||||
- Panic ring: headless Chromium screenshot mid-hold shows the red arc ~half-filled; completing the hold fired the alert (🚨 text landed in the SES Emergency room; location correctly skipped when geolocation not granted, proving the alert-before-location safeguard).
|
||||
- Scroll-to-latest: the Flutter CanvasKit surface would NOT accept synthetic scroll input from Playwright (mouse wheel and drag both no-op on the canvas; input field steals focus). Verified instead with a deterministic widget test (test/scroll_to_latest_test.dart): 40 messages, starts at offset 0, drag up → offset > 400 + button appears, tap → offset back to 0. Passes.
|
||||
- **Note for future:** getTimeline() only yields the ~10-event sync window and there is no scroll-back history pagination, so on a normal phone the timeline rarely exceeds ~1 screen and the jump button seldom shows in practice. Wiring requestHistory() on near-top scroll would make both the button and long-scrollback genuinely useful. Flagged, not built.
|
||||
- **Status:** built + deployed app2.m8chat.au v1.10.0+14 (backup pre1100 tarball). Analyze clean, 4 tests pass.
|
||||
|
||||
### 2026-07-04 08:50 — Scroll-back history pagination (v1.11.0+15)
|
||||
- **What:** wired requestHistory() so older messages load as you scroll toward the top. Completes the scroll-to-latest feature (previously the timeline only ever held the ~10-event sync window).
|
||||
- **Repository (chat_repository.dart v1.5.0):** now keeps one live Timeline per room in `_timelines` map. watchTimeline creates it with an onUpdate callback that drives a StreamController signal, re-yielding the mapped list on BOTH live events and history pagination (the old code only re-yielded on onSync, which does NOT fire for requestHistory). loadMoreMessages paginates that stored instance (old code called requestHistory on a throwaway getTimeline() — wrong instance). Added canLoadMore(roomId) → timeline.canRequestHistory. Timeline cancelled + removed in the stream's finally.
|
||||
- **UI (chat_screen.dart v1.7.0):** _TimelineState._onScroll detects near-top (reverse list: pixels >= maxScrollExtent - 400) and calls _loadOlder, guarded by _loadingMore + canLoadMore. Top-centre spinner while loading. Prepended older content grows maxScrollExtent while pixels stays put, so the viewport stays anchored and you move out of the trigger band (no infinite loop).
|
||||
- **Verified:**
|
||||
- Widget test test/history_pagination_test.dart: 40 msgs, fake repo, drag to top → loadMoreMessages called (with offset/override assertions). Passes. 5 tests total green.
|
||||
- Live smoke test on production: room displays the synced window correctly after the refactor, and a newly-sent message appears at the bottom — proving the onUpdate→remap→display path (which history reuses) works.
|
||||
- NOT screenshot-verified: older messages (1-5) materialising on scroll-to-top, because the CanvasKit surface rejects synthetic scroll in headless Chromium. Trigger (widget test) + delivery path (live message) are both proven; the SDK calls onUpdate after requestHistory (timeline.dart:115), same path.
|
||||
- **Status:** built + deployed app2.m8chat.au v1.11.0+15 (backup pre1110 tarball). Analyze clean, 5 tests pass.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Root MaterialApp widget. Wires together theme + router.
|
||||
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||
// 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_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/calls/presentation/incoming_call_overlay.dart';
|
||||
import 'router.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
@@ -26,6 +29,8 @@ class M8ChatApp extends ConsumerWidget {
|
||||
themeMode: ThemeMode.system,
|
||||
routerConfig: router,
|
||||
debugShowCheckedModeBanner: false,
|
||||
builder: (context, child) =>
|
||||
IncomingCallOverlayHost(child: child ?? const SizedBox.shrink()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Version: 1.0.2 | Created: 2026-04-01
|
||||
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-05
|
||||
// All route definitions in one place.
|
||||
// GoRouter is created ONCE (keepAlive) and re-evaluates redirects via
|
||||
// refreshListenable — avoids the GoRouter recreation bug from ref.watch.
|
||||
@@ -12,6 +12,8 @@ import '../core/auth/auth_state.dart';
|
||||
import '../features/auth/presentation/login_screen.dart';
|
||||
import '../features/calls/presentation/call_screen.dart';
|
||||
import '../features/chat/presentation/chat_screen.dart';
|
||||
import '../features/jitsi/presentation/jitsi_screen.dart';
|
||||
import '../features/jitsi/presentation/welcome_screen.dart';
|
||||
import '../features/profile/presentation/profile_screen.dart';
|
||||
import '../features/rooms/presentation/rooms_screen.dart';
|
||||
import '../features/spaces/presentation/spaces_screen.dart';
|
||||
@@ -21,12 +23,14 @@ part 'router.g.dart';
|
||||
/// Route path constants — use these instead of raw strings.
|
||||
abstract final class AppRoutes {
|
||||
static const String root = '/';
|
||||
static const String welcome = '/welcome';
|
||||
static const String login = '/login';
|
||||
static const String rooms = '/rooms';
|
||||
static const String chat = '/rooms/:roomId';
|
||||
static const String call = '/calls/:roomId';
|
||||
static const String profile = '/profile';
|
||||
static const String spaces = '/spaces';
|
||||
static const String jitsi = '/jitsi';
|
||||
}
|
||||
|
||||
/// ChangeNotifier that GoRouter listens to for redirect re-evaluation.
|
||||
@@ -35,6 +39,13 @@ class _AuthRefreshNotifier extends ChangeNotifier {
|
||||
void notify() => notifyListeners();
|
||||
}
|
||||
|
||||
/// Routes that are accessible without authentication.
|
||||
const _publicRoutes = {
|
||||
AppRoutes.welcome,
|
||||
AppRoutes.login,
|
||||
AppRoutes.jitsi,
|
||||
};
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
GoRouter router(Ref ref) {
|
||||
// Create once — notifier triggers re-redirect without recreating the router.
|
||||
@@ -50,16 +61,22 @@ GoRouter router(Ref ref) {
|
||||
// Read (not watch) so redirect does not itself trigger provider changes.
|
||||
final authState = ref.read(authProvider);
|
||||
final isLoggedIn = authState is AuthAuthenticated;
|
||||
final isOnLogin = state.matchedLocation == AppRoutes.login;
|
||||
final currentPath = state.matchedLocation;
|
||||
final isPublicRoute = _publicRoutes.contains(currentPath);
|
||||
|
||||
// While restoring session, stay on splash.
|
||||
if (authState is AuthInitial || authState is AuthLoading) return null;
|
||||
|
||||
// Unauthenticated users must go to login.
|
||||
if (!isLoggedIn && !isOnLogin) return AppRoutes.login;
|
||||
// Unauthenticated users hitting a protected route → welcome screen.
|
||||
if (!isLoggedIn && !isPublicRoute) return AppRoutes.welcome;
|
||||
|
||||
// Authenticated users should not see the login screen.
|
||||
if (isLoggedIn && isOnLogin) return AppRoutes.rooms;
|
||||
// Authenticated users on welcome or login → go straight to rooms.
|
||||
if (isLoggedIn &&
|
||||
(currentPath == AppRoutes.welcome ||
|
||||
currentPath == AppRoutes.login ||
|
||||
currentPath == AppRoutes.root)) {
|
||||
return AppRoutes.rooms;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
@@ -68,6 +85,10 @@ GoRouter router(Ref ref) {
|
||||
path: AppRoutes.root,
|
||||
builder: (context, state) => const _SplashRedirectPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.welcome,
|
||||
builder: (context, state) => const WelcomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.login,
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
@@ -100,6 +121,13 @@ GoRouter router(Ref ref) {
|
||||
path: AppRoutes.spaces,
|
||||
builder: (context, state) => const SpacesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.jitsi,
|
||||
builder: (context, state) {
|
||||
final meetingUrl = state.extra as String? ?? '';
|
||||
return JitsiScreen(meetingUrl: meetingUrl);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-07-03
|
||||
// M8Chat brand theme — dark and light variants.
|
||||
// Primary brand colour: #5C35C9 (deep purple). Accent: #7B5CF6.
|
||||
// Palette from the m8chat.au Elementor kit (kit post 8):
|
||||
// Accent #1 "#1265ED", Blue Element "#3B8BFF", Ancop Blue "#010B46".
|
||||
// v2.0.0 replaces the old purple scheme, which did not match the website.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// M8Chat brand colours.
|
||||
abstract final class M8Colours {
|
||||
static const Color brandPurple = Color(0xFF5C35C9);
|
||||
static const Color accentPurple = Color(0xFF7B5CF6);
|
||||
static const Color darkBackground = Color(0xFF0F0F1A);
|
||||
static const Color darkSurface = Color(0xFF1A1A2E);
|
||||
static const Color darkSurfaceVariant = Color(0xFF22223A);
|
||||
static const Color onDarkSurface = Color(0xFFE8E8F0);
|
||||
static const Color subtleText = Color(0xFF9898B0);
|
||||
static const Color brandBlue = Color(0xFF1265ED);
|
||||
static const Color accentBlue = Color(0xFF3B8BFF);
|
||||
// Self message bubble — deeper than accentBlue so white text passes WCAG AA
|
||||
// (white on #1265ED = 5.11:1; on #3B8BFF = 3.32:1, fails).
|
||||
static const Color selfBubble = Color(0xFF1265ED);
|
||||
static const Color darkBackground = Color(0xFF0A0F2E);
|
||||
static const Color darkSurface = Color(0xFF131A42);
|
||||
static const Color darkSurfaceVariant = Color(0xFF1C2452);
|
||||
static const Color onDarkSurface = Color(0xFFE8ECF5);
|
||||
static const Color subtleText = Color(0xFF96A0C2);
|
||||
static const Color errorRed = Color(0xFFCF6679);
|
||||
static const Color unreadGreen = Color(0xFF4CAF50);
|
||||
}
|
||||
|
||||
/// Dark theme — default for M8Chat.
|
||||
ThemeData buildDarkTheme() {
|
||||
const seed = M8Colours.brandPurple;
|
||||
const seed = M8Colours.brandBlue;
|
||||
|
||||
final scheme =
|
||||
ColorScheme.fromSeed(
|
||||
@@ -28,7 +33,7 @@ ThemeData buildDarkTheme() {
|
||||
).copyWith(
|
||||
surface: M8Colours.darkBackground,
|
||||
surfaceContainerHighest: M8Colours.darkSurface,
|
||||
primary: M8Colours.accentPurple,
|
||||
primary: M8Colours.accentBlue,
|
||||
onSurface: M8Colours.onDarkSurface,
|
||||
error: M8Colours.errorRed,
|
||||
);
|
||||
@@ -41,11 +46,15 @@ ThemeData buildDarkTheme() {
|
||||
backgroundColor: M8Colours.darkSurface,
|
||||
foregroundColor: M8Colours.onDarkSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 2,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: const Border(
|
||||
bottom: BorderSide(color: M8Colours.darkSurfaceVariant),
|
||||
),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: M8Colours.darkSurface,
|
||||
indicatorColor: M8Colours.accentPurple.withAlpha(51),
|
||||
indicatorColor: M8Colours.accentBlue.withAlpha(51),
|
||||
labelTextStyle: WidgetStateProperty.all(
|
||||
const TextStyle(fontSize: 12, color: M8Colours.onDarkSurface),
|
||||
),
|
||||
@@ -59,14 +68,14 @@ ThemeData buildDarkTheme() {
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: M8Colours.accentPurple, width: 2),
|
||||
borderSide: const BorderSide(color: M8Colours.accentBlue, width: 2),
|
||||
),
|
||||
labelStyle: const TextStyle(color: M8Colours.subtleText),
|
||||
hintStyle: const TextStyle(color: M8Colours.subtleText),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: M8Colours.accentPurple,
|
||||
backgroundColor: M8Colours.accentBlue,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
@@ -97,7 +106,7 @@ ThemeData buildDarkTheme() {
|
||||
|
||||
/// Light theme — follows system preference if user selects it.
|
||||
ThemeData buildLightTheme() {
|
||||
const seed = M8Colours.brandPurple;
|
||||
const seed = M8Colours.brandBlue;
|
||||
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
|
||||
@@ -1,88 +1,32 @@
|
||||
// Version: 1.1.1 | Created: 2026-04-01
|
||||
// Version: 2.1.0 | Created: 2026-04-01 | Updated: 2026-04-27
|
||||
// Riverpod notifier that owns the auth state machine.
|
||||
// All login/logout/session-restore transitions go through here.
|
||||
//
|
||||
// WEB SECURITY MODEL: Every visit requires a fresh login. No session
|
||||
// persistence. Only the Matrix device ID is saved across sessions so
|
||||
// encryption keys accumulate on one device instead of creating ghosts.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../features/auth/data/auth_repository.dart';
|
||||
import '../../features/auth/domain/auth_failure.dart';
|
||||
import '../network/matrix_client.dart';
|
||||
import '../push/web_push_service.dart';
|
||||
import '../storage/sync_persistence_service.dart';
|
||||
import 'auth_state.dart';
|
||||
import 'secure_storage.dart';
|
||||
|
||||
part 'auth_notifier.g.dart';
|
||||
|
||||
/// The single source of truth for authentication state.
|
||||
///
|
||||
/// keepAlive: true — auth state must persist for the entire app lifetime.
|
||||
/// GoRouter watches this provider to decide which route to show.
|
||||
@Riverpod(keepAlive: true)
|
||||
class AuthNotifier extends _$AuthNotifier {
|
||||
@override
|
||||
AuthState build() {
|
||||
// Kick off session restore immediately; start in [AuthInitial].
|
||||
_restoreSession();
|
||||
return const AuthState.initial();
|
||||
// No session restore on web — always require fresh login.
|
||||
return const AuthState.unauthenticated();
|
||||
}
|
||||
|
||||
/// Tries to restore a previous session from secure storage.
|
||||
Future<void> _restoreSession() async {
|
||||
state = const AuthState.loading();
|
||||
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
final credentials = await storage.loadCredentials();
|
||||
|
||||
if (credentials == null) {
|
||||
state = const AuthState.unauthenticated();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
|
||||
// Timeout: client.init() starts the Matrix sync loop and can hang
|
||||
// indefinitely if the token is expired or the server is unreachable.
|
||||
// After 12 seconds we give up and send the user to the login screen.
|
||||
await repo
|
||||
.restoreSession(
|
||||
accessToken: credentials.accessToken,
|
||||
userId: credentials.userId,
|
||||
deviceId: credentials.deviceId,
|
||||
)
|
||||
.timeout(
|
||||
const Duration(seconds: 12),
|
||||
onTimeout: () => throw Exception('Session restore timed out'),
|
||||
);
|
||||
|
||||
state = AuthState.authenticated(
|
||||
userId: credentials.userId,
|
||||
accessToken: credentials.accessToken,
|
||||
deviceId: credentials.deviceId,
|
||||
);
|
||||
|
||||
// Resume background persistence — fire-and-forget so it never blocks auth.
|
||||
try {
|
||||
ref.read(syncPersistenceServiceProvider).start();
|
||||
} catch (_) {
|
||||
// Persistence failure is non-fatal; the app works without it.
|
||||
}
|
||||
} on AuthFailure {
|
||||
// Stored credentials are invalid; force re-login.
|
||||
await storage.clearCredentials();
|
||||
state = const AuthState.unauthenticated();
|
||||
} on Exception {
|
||||
// Covers: timeout, network offline, Olm init failure.
|
||||
// Clear stale credentials so the login screen appears cleanly.
|
||||
await storage.clearCredentials();
|
||||
state = const AuthState.unauthenticated();
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to log in with [username] and [password].
|
||||
///
|
||||
/// Transitions: loading → authenticated | unauthenticated(failure).
|
||||
Future<void> login({
|
||||
required String username,
|
||||
required String password,
|
||||
@@ -91,38 +35,42 @@ class AuthNotifier extends _$AuthNotifier {
|
||||
|
||||
try {
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
final response = await repo.login(username: username, password: password);
|
||||
debugPrint('[Auth] Logging in...');
|
||||
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
await storage.saveCredentials(
|
||||
accessToken: response.accessToken,
|
||||
userId: response.userId,
|
||||
deviceId: response.deviceId,
|
||||
final response = await repo.login(
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
debugPrint('[Auth] Login OK as ${response.userId} '
|
||||
'device=${response.deviceId}');
|
||||
|
||||
state = AuthState.authenticated(
|
||||
userId: response.userId,
|
||||
accessToken: response.accessToken,
|
||||
deviceId: response.deviceId,
|
||||
);
|
||||
|
||||
// Start background sync-to-database persistence now that we are logged in.
|
||||
// Start background sync-to-database persistence.
|
||||
try {
|
||||
ref.read(syncPersistenceServiceProvider).start();
|
||||
} catch (_) {}
|
||||
|
||||
// Register Web Push subscription (fire-and-forget, non-fatal).
|
||||
final client = ref.read(matrixClientProvider);
|
||||
unawaited(setupWebPush(client));
|
||||
} on AuthFailure catch (failure) {
|
||||
state = AuthState.unauthenticated(failure: failure.userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs out the current user, clears storage, and resets to unauthenticated.
|
||||
Future<void> logout() async {
|
||||
state = const AuthState.loading();
|
||||
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
await repo.logout();
|
||||
|
||||
final storage = ref.read(secureStorageProvider);
|
||||
await storage.clearCredentials();
|
||||
|
||||
// Keep the device ID in storage so the next login reuses it.
|
||||
state = const AuthState.unauthenticated();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +1,30 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Typed wrapper around flutter_secure_storage.
|
||||
// All token read/write operations go through this class — never call
|
||||
// flutter_secure_storage directly from feature code.
|
||||
// Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-04-11
|
||||
// Minimal storage: only the Matrix device ID is persisted so that
|
||||
// encryption keys accumulate on one device across logins.
|
||||
// Access tokens are NOT stored — every browser visit requires login.
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
part 'secure_storage.g.dart';
|
||||
|
||||
/// Provides a configured [SecureStorage] instance.
|
||||
@Riverpod(keepAlive: true)
|
||||
SecureStorage secureStorage(Ref ref) => const SecureStorage();
|
||||
|
||||
/// Typed wrapper around [FlutterSecureStorage].
|
||||
///
|
||||
/// Uses AES encryption on Android and Keychain on iOS.
|
||||
/// On Web, data is stored in localStorage with encryption — acceptable for
|
||||
/// access tokens but NOT for E2EE private keys (Phase 2 concern).
|
||||
class SecureStorage {
|
||||
const SecureStorage();
|
||||
|
||||
static const _kDeviceId = 'm8chat_device_id';
|
||||
|
||||
static const FlutterSecureStorage _storage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
Future<void> saveCredentials({
|
||||
required String accessToken,
|
||||
required String userId,
|
||||
required String deviceId,
|
||||
}) async {
|
||||
await _storage.write(
|
||||
key: AppConfig.storageKeyAccessToken,
|
||||
value: accessToken,
|
||||
);
|
||||
await _storage.write(key: AppConfig.storageKeyUserId, value: userId);
|
||||
await _storage.write(key: AppConfig.storageKeyDeviceId, value: deviceId);
|
||||
await _storage.write(
|
||||
key: AppConfig.storageKeyHomeserver,
|
||||
value: AppConfig.matrixBaseUrl,
|
||||
);
|
||||
Future<void> saveDeviceId(String deviceId) async {
|
||||
await _storage.write(key: _kDeviceId, value: deviceId);
|
||||
}
|
||||
|
||||
Future<StoredCredentials?> loadCredentials() async {
|
||||
final accessToken = await _storage.read(
|
||||
key: AppConfig.storageKeyAccessToken,
|
||||
);
|
||||
final userId = await _storage.read(key: AppConfig.storageKeyUserId);
|
||||
final deviceId = await _storage.read(key: AppConfig.storageKeyDeviceId);
|
||||
|
||||
if (accessToken == null || userId == null || deviceId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return StoredCredentials(
|
||||
accessToken: accessToken,
|
||||
userId: userId,
|
||||
deviceId: deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearCredentials() async {
|
||||
await _storage.delete(key: AppConfig.storageKeyAccessToken);
|
||||
await _storage.delete(key: AppConfig.storageKeyUserId);
|
||||
await _storage.delete(key: AppConfig.storageKeyDeviceId);
|
||||
await _storage.delete(key: AppConfig.storageKeyHomeserver);
|
||||
Future<String?> loadDeviceId() async {
|
||||
return _storage.read(key: _kDeviceId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the credentials retrieved from secure storage.
|
||||
class StoredCredentials {
|
||||
const StoredCredentials({
|
||||
required this.accessToken,
|
||||
required this.userId,
|
||||
required this.deviceId,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String userId;
|
||||
final String deviceId;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Version: 2.1.0 | Created: 2026-04-01 | Updated: 2026-07-03
|
||||
// App-wide constants. Change matrixBaseUrl for different environments.
|
||||
|
||||
/// Central configuration for the M8Chat application.
|
||||
/// All server URLs and app-level constants live here — never scatter
|
||||
/// magic strings through feature code.
|
||||
abstract final class AppConfig {
|
||||
static const String matrixBaseUrl = 'https://matrix.m8chat.au';
|
||||
static const String matrixServerName = 'matrix.m8chat.au';
|
||||
@@ -14,11 +11,8 @@ abstract final class AppConfig {
|
||||
'turns:matrix.m8chat.au:5349',
|
||||
];
|
||||
static const String appName = 'M8Chat';
|
||||
static const String appVersion = '1.0.0';
|
||||
static const String appVersion = '1.11.0';
|
||||
|
||||
// Secure storage key names
|
||||
static const String storageKeyAccessToken = 'access_token';
|
||||
static const String storageKeyUserId = 'user_id';
|
||||
static const String storageKeyDeviceId = 'device_id';
|
||||
static const String storageKeyHomeserver = 'homeserver';
|
||||
// Jitsi conferencing
|
||||
static const String jitsiDomain = 'conf.m8chat.au';
|
||||
}
|
||||
|
||||
178
lib/core/push/web_push_service.dart
Normal file
178
lib/core/push/web_push_service.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-27
|
||||
// Web Push subscription and Matrix pusher registration.
|
||||
// Only active on web — no-ops on other platforms.
|
||||
//
|
||||
// Flow:
|
||||
// 1. registerPushServiceWorker() — register push_sw.js (called at startup)
|
||||
// 2. setupWebPush(client) — called after login; requests permission,
|
||||
// subscribes to Web Push, registers the subscription as a Matrix pusher.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:js_interop';
|
||||
import 'dart:js_interop_unsafe';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
const _kVapidPublicKey =
|
||||
'BPP0AqC7qTxspQn1i0x2sPbPj3owJTTkhV256A67VUK9748NjaBfbMrQBJtI58foVSVpbk2n390NIq_w2glNjs8';
|
||||
|
||||
const _kAppId = 'au.m8chat.web.push';
|
||||
const _kPushGatewayUrl = 'https://matrix.m8chat.au/_matrix/push/v1/notify';
|
||||
|
||||
/// Registers push_sw.js at startup so the browser knows the service worker
|
||||
/// before the user logs in. Idempotent. No-op on non-web platforms.
|
||||
Future<void> registerPushServiceWorker() async {
|
||||
if (!kIsWeb) return;
|
||||
try {
|
||||
await _registerServiceWorker();
|
||||
} catch (e) {
|
||||
debugPrint('[Push] SW pre-registration failed (non-fatal): $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Called after login. Requests notification permission, subscribes to Web
|
||||
/// Push using the VAPID public key, and registers the subscription as a
|
||||
/// Matrix pusher so Synapse routes notifications through Sygnal → browser.
|
||||
///
|
||||
/// Idempotent — safe to call on every login. No-op on non-web platforms.
|
||||
Future<void> setupWebPush(Client client) async {
|
||||
if (!kIsWeb) return;
|
||||
|
||||
try {
|
||||
final sw = await _registerServiceWorker();
|
||||
if (sw == null) {
|
||||
debugPrint('[Push] SW not available — push skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
final permission = await _requestPermission();
|
||||
if (permission != 'granted') {
|
||||
debugPrint('[Push] Notification permission $permission — skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
final sub = await _subscribe(sw);
|
||||
if (sub == null) {
|
||||
debugPrint('[Push] Could not create push subscription');
|
||||
return;
|
||||
}
|
||||
|
||||
await _registerPusher(client, sub);
|
||||
} catch (e) {
|
||||
debugPrint('[Push] Setup failed (non-fatal): $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<web.ServiceWorkerRegistration?> _registerServiceWorker() async {
|
||||
if (!_pushSupported()) return null;
|
||||
|
||||
try {
|
||||
final reg = await web.window.navigator.serviceWorker
|
||||
.register('/push_sw.js'.toJS, web.RegistrationOptions(scope: '/'))
|
||||
.toDart;
|
||||
debugPrint('[Push] Service worker registered: ${reg.scope}');
|
||||
return reg;
|
||||
} catch (e) {
|
||||
debugPrint('[Push] Service worker error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _requestPermission() async {
|
||||
try {
|
||||
final result = await web.Notification.requestPermission().toDart;
|
||||
return result.toDart;
|
||||
} catch (_) {
|
||||
return 'denied';
|
||||
}
|
||||
}
|
||||
|
||||
Future<web.PushSubscription?> _subscribe(
|
||||
web.ServiceWorkerRegistration reg) async {
|
||||
try {
|
||||
// Re-use existing subscription if the browser already has one.
|
||||
final existing = await reg.pushManager.getSubscription().toDart;
|
||||
if (existing != null) {
|
||||
debugPrint('[Push] Reusing existing push subscription');
|
||||
return existing;
|
||||
}
|
||||
|
||||
final keyBytes = _vapidKeyToBytes(_kVapidPublicKey);
|
||||
final sub = await reg.pushManager
|
||||
.subscribe(web.PushSubscriptionOptionsInit(
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: keyBytes,
|
||||
))
|
||||
.toDart;
|
||||
debugPrint('[Push] New subscription: ${sub.endpoint}');
|
||||
return sub;
|
||||
} catch (e) {
|
||||
debugPrint('[Push] Subscribe error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registerPusher(
|
||||
Client client, web.PushSubscription sub) async {
|
||||
final json = sub.toJSON();
|
||||
final endpoint = json.endpoint;
|
||||
if (endpoint.isEmpty) return;
|
||||
|
||||
// Extract p256dh and auth keys from the subscription.
|
||||
final keysObj = json.keys;
|
||||
final p256dh = _jsObjectGet(keysObj, 'p256dh') ?? '';
|
||||
final auth = _jsObjectGet(keysObj, 'auth') ?? '';
|
||||
|
||||
await client.postPusher(
|
||||
Pusher(
|
||||
pushkey: endpoint,
|
||||
kind: 'http',
|
||||
appId: _kAppId,
|
||||
appDisplayName: 'M8Chat Web',
|
||||
deviceDisplayName: 'Web browser',
|
||||
lang: 'en',
|
||||
data: PusherData(
|
||||
url: Uri.parse(_kPushGatewayUrl),
|
||||
format: 'event_id_only',
|
||||
additionalProperties: {
|
||||
'keys': {'p256dh': p256dh, 'auth': auth},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
debugPrint('[Push] Matrix pusher registered');
|
||||
}
|
||||
|
||||
/// Converts a URL-safe base64 VAPID public key to a JS Uint8Array.
|
||||
JSUint8Array _vapidKeyToBytes(String base64Url) {
|
||||
final padded = base64Url +
|
||||
'=' * ((4 - base64Url.length % 4) % 4);
|
||||
final standard = padded.replaceAll('-', '+').replaceAll('_', '/');
|
||||
final bytes = base64Decode(standard);
|
||||
return bytes.toJS;
|
||||
}
|
||||
|
||||
/// Reads a string property from a JSObject via js_interop.
|
||||
String? _jsObjectGet(JSObject obj, String key) {
|
||||
try {
|
||||
final val = obj.getProperty<JSAny?>(key.toJS);
|
||||
if (val == null || val.isUndefinedOrNull) return null;
|
||||
return (val as JSString).toDart;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
bool _pushSupported() {
|
||||
try {
|
||||
web.window.navigator.serviceWorker;
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02
|
||||
// Version: 2.0.0 | Created: 2026-04-01 | Updated: 2026-04-11
|
||||
// Auth repository: handles all Matrix login/logout API interactions.
|
||||
// Uses the Matrix Dart SDK — no raw HTTP calls for auth.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
@@ -34,15 +35,16 @@ class AuthRepository {
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
// Set homeserver directly — avoids network round-trips and version checks
|
||||
// that checkHomeserver() makes. The setter is public in matrix 0.33.0.
|
||||
_client.homeserver = Uri.parse(AppConfig.matrixBaseUrl);
|
||||
return await _client.login(
|
||||
final response = await _client.login(
|
||||
LoginType.mLoginPassword,
|
||||
identifier: AuthenticationUserIdentifier(user: username),
|
||||
password: password,
|
||||
initialDeviceDisplayName: AppConfig.appName,
|
||||
);
|
||||
debugPrint('[M8Chat] Login complete. encryptionEnabled=${_client.encryptionEnabled}');
|
||||
debugPrint('[M8Chat] encryption=${_client.encryption}');
|
||||
return response;
|
||||
} on MatrixException catch (e) {
|
||||
throw switch (e.errcode) {
|
||||
'M_FORBIDDEN' => const AuthFailure.invalidCredentials(),
|
||||
@@ -77,19 +79,5 @@ class AuthRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores an existing Matrix session using a stored access token.
|
||||
Future<void> restoreSession({
|
||||
required String accessToken,
|
||||
required String userId,
|
||||
required String deviceId,
|
||||
}) async {
|
||||
await _client.init(
|
||||
newToken: accessToken,
|
||||
newUserID: userId,
|
||||
newDeviceID: deviceId,
|
||||
newDeviceName: AppConfig.appName,
|
||||
newHomeserver: Uri.parse(AppConfig.matrixBaseUrl),
|
||||
newOlmAccount: null,
|
||||
);
|
||||
}
|
||||
// restoreSession() removed — web app requires fresh login every visit.
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-03
|
||||
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||
// Login screen. Username + password only. No registration link.
|
||||
// Respects system theme preference.
|
||||
|
||||
@@ -55,7 +55,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
body: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF0A0F2E), Color(0xFF131A42)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
|
||||
@@ -90,6 +98,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
185
lib/features/calls/data/call_e2ee.dart
Normal file
185
lib/features/calls/data/call_e2ee.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-10
|
||||
// E2EE key exchange for LiveKit calls via Matrix to-device events.
|
||||
// Implements the m.rtc.encryption_keys protocol for interop with Element X.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:matrix/matrix.dart' show Client, DeviceKeys, ToDeviceEvent;
|
||||
|
||||
/// Event type used by Element X / Element Call for key exchange.
|
||||
const kEncryptionKeysEventType = 'm.rtc.encryption_keys';
|
||||
|
||||
/// Manages E2EE key generation, distribution, and reception for LiveKit calls.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. [init] creates a per-participant [BaseKeyProvider] and generates a local key.
|
||||
/// 2. After LiveKit connects, call [setLocalKey] with the local participant identity.
|
||||
/// 3. Call [sendKeyToParticipants] to share our key via encrypted to-device events.
|
||||
/// 4. Incoming keys from Element X are received via [Client.onToDeviceEvent] and
|
||||
/// set on the [BaseKeyProvider] automatically.
|
||||
class CallE2EEManager {
|
||||
CallE2EEManager({
|
||||
required Client client,
|
||||
required String matrixRoomId,
|
||||
}) : _client = client,
|
||||
_matrixRoomId = matrixRoomId;
|
||||
|
||||
final Client _client;
|
||||
final String _matrixRoomId;
|
||||
BaseKeyProvider? _keyProvider;
|
||||
StreamSubscription<ToDeviceEvent>? _toDeviceSub;
|
||||
Uint8List? _localKey;
|
||||
final int _localKeyIndex = 0;
|
||||
|
||||
BaseKeyProvider? get keyProvider => _keyProvider;
|
||||
|
||||
/// Create the key provider and start listening for incoming keys.
|
||||
Future<BaseKeyProvider> init() async {
|
||||
_keyProvider = await BaseKeyProvider.create(sharedKey: false);
|
||||
|
||||
// Generate a random 32-byte encryption key for our media tracks.
|
||||
_localKey = _secureRandomBytes(32);
|
||||
|
||||
// Listen for incoming encryption keys from other call participants.
|
||||
_toDeviceSub = _client.onToDeviceEvent.stream.listen(_onToDeviceEvent);
|
||||
|
||||
debugPrint('[E2EE] Key provider created, local key generated '
|
||||
'(${base64Encode(_localKey!).substring(0, 8)}…)');
|
||||
return _keyProvider!;
|
||||
}
|
||||
|
||||
/// Set our local key on the provider using the LiveKit participant identity.
|
||||
/// Call this AFTER the LiveKit Room is connected and localParticipant is available.
|
||||
Future<void> setLocalKey(String localIdentity) async {
|
||||
if (_keyProvider == null || _localKey == null) return;
|
||||
await _keyProvider!.setRawKey(
|
||||
_localKey!,
|
||||
participantId: localIdentity,
|
||||
keyIndex: _localKeyIndex,
|
||||
);
|
||||
debugPrint('[E2EE] Local key set for identity=$localIdentity');
|
||||
}
|
||||
|
||||
/// Send our encryption key to all other call members via encrypted to-device.
|
||||
Future<void> sendKeyToParticipants() async {
|
||||
if (_localKey == null) return;
|
||||
|
||||
final room = _client.getRoomById(_matrixRoomId);
|
||||
if (room == null) {
|
||||
debugPrint('[E2EE] Room $_matrixRoomId not found — cannot send keys');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find other participants from the MSC3401 call.member state events.
|
||||
final memberEvents =
|
||||
room.states['org.matrix.msc3401.call.member'] ?? {};
|
||||
|
||||
final content = <String, Object>{
|
||||
'keys': [
|
||||
{'index': _localKeyIndex, 'key': base64Encode(_localKey!)},
|
||||
],
|
||||
'call_id': '',
|
||||
'device_id': _client.deviceID ?? '',
|
||||
'room_id': _matrixRoomId,
|
||||
};
|
||||
|
||||
for (final entry in memberEvents.entries) {
|
||||
final userId = entry.key;
|
||||
if (userId == _client.userID) continue;
|
||||
|
||||
// Parse device_id from the call.member event to target the correct device.
|
||||
final memberships =
|
||||
entry.value.content['memberships'] as List<dynamic>? ?? [];
|
||||
if (memberships.isEmpty) continue;
|
||||
|
||||
await _client.userDeviceKeysLoading;
|
||||
final deviceKeyMap = _client.userDeviceKeys[userId]?.deviceKeys;
|
||||
if (deviceKeyMap == null || deviceKeyMap.isEmpty) {
|
||||
debugPrint('[E2EE] No device keys for $userId — skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send to all devices of this user that are in the call.
|
||||
final List<DeviceKeys> targets = [];
|
||||
for (final m in memberships) {
|
||||
final deviceId = m['device_id'] as String?;
|
||||
if (deviceId != null && deviceKeyMap.containsKey(deviceId)) {
|
||||
targets.add(deviceKeyMap[deviceId]!);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no specific device matched, send to all devices.
|
||||
if (targets.isEmpty) {
|
||||
targets.addAll(deviceKeyMap.values);
|
||||
}
|
||||
|
||||
try {
|
||||
await _client.sendToDeviceEncrypted(
|
||||
targets,
|
||||
kEncryptionKeysEventType,
|
||||
content,
|
||||
);
|
||||
debugPrint(
|
||||
'[E2EE] Sent key to $userId (${targets.length} device(s))');
|
||||
} catch (e) {
|
||||
debugPrint('[E2EE] Failed to send key to $userId: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle incoming to-device encryption key events from other participants.
|
||||
void _onToDeviceEvent(ToDeviceEvent event) {
|
||||
if (event.type != kEncryptionKeysEventType) return;
|
||||
|
||||
// Only process keys for our active call room.
|
||||
final eventRoomId = event.content['room_id'] as String?;
|
||||
if (eventRoomId != null && eventRoomId != _matrixRoomId) return;
|
||||
|
||||
final keys = event.content['keys'] as List<dynamic>?;
|
||||
if (keys == null || keys.isEmpty) {
|
||||
debugPrint(
|
||||
'[E2EE] Received $kEncryptionKeysEventType with empty keys '
|
||||
'from ${event.sender}');
|
||||
return;
|
||||
}
|
||||
|
||||
final senderId = event.sender;
|
||||
debugPrint('[E2EE] Received encryption key(s) from $senderId');
|
||||
|
||||
for (final keyEntry in keys) {
|
||||
final index = (keyEntry['index'] as num?)?.toInt() ?? 0;
|
||||
final keyB64 = keyEntry['key'] as String?;
|
||||
if (keyB64 == null) continue;
|
||||
|
||||
final keyBytes = base64Decode(keyB64);
|
||||
|
||||
// The participant identity in LiveKit should match the Matrix user ID
|
||||
// (set by lk-jwt-service). Log both for debugging.
|
||||
_keyProvider?.setRawKey(
|
||||
keyBytes,
|
||||
participantId: senderId,
|
||||
keyIndex: index,
|
||||
);
|
||||
debugPrint(
|
||||
'[E2EE] Key set for participant=$senderId index=$index '
|
||||
'(${keyB64.substring(0, 8)}…)');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _toDeviceSub?.cancel();
|
||||
_toDeviceSub = null;
|
||||
_localKey = null;
|
||||
_keyProvider = null;
|
||||
debugPrint('[E2EE] Disposed');
|
||||
}
|
||||
|
||||
static Uint8List _secureRandomBytes(int length) {
|
||||
final rng = Random.secure();
|
||||
return Uint8List.fromList(
|
||||
List<int>.generate(length, (_) => rng.nextInt(256)));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-03
|
||||
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-04-10
|
||||
// LiveKitService — fetches a JWT from the Matrix server's /_matrix/livekit/jwt
|
||||
// endpoint, then connects a LiveKit Room using that token.
|
||||
//
|
||||
// The JWT endpoint is defined in AppConfig.livekitJwtUrl and uses the Matrix
|
||||
// access token as Bearer auth. The LiveKit server URL is the same host as the
|
||||
// Matrix server (as configured on chat.m8chat.au).
|
||||
// E2EE: Frame encryption is enabled via livekit_client's E2EEOptions.
|
||||
// Encryption keys are exchanged with Element X via m.rtc.encryption_keys
|
||||
// to-device events (see call_e2ee.dart).
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
@@ -19,6 +19,7 @@ import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/auth/auth_state.dart';
|
||||
import '../../../core/config/app_config.dart';
|
||||
import '../../../core/network/matrix_client.dart';
|
||||
import 'call_e2ee.dart';
|
||||
|
||||
part 'livekit_service.g.dart';
|
||||
|
||||
@@ -67,6 +68,7 @@ class LiveKitService {
|
||||
Room? _activeRoom;
|
||||
String? _activeMatrixRoomId;
|
||||
String? _membershipId;
|
||||
CallE2EEManager? _e2eeManager;
|
||||
|
||||
Room? get activeRoom => _activeRoom;
|
||||
|
||||
@@ -111,14 +113,37 @@ class LiveKitService {
|
||||
_activeMatrixRoomId = matrixRoomId;
|
||||
debugPrint('[LiveKit] Call member state event sent');
|
||||
|
||||
// Step 3 — connect to LiveKit
|
||||
final room = Room();
|
||||
// Step 3 — initialise E2EE key exchange
|
||||
_e2eeManager = CallE2EEManager(
|
||||
client: client,
|
||||
matrixRoomId: matrixRoomId,
|
||||
);
|
||||
final keyProvider = await _e2eeManager!.init();
|
||||
|
||||
// Step 4 — connect to LiveKit with frame encryption
|
||||
// COOP/COEP headers on the server enable SharedArrayBuffer for the
|
||||
// E2EE web worker. Without them the worker crashes.
|
||||
final room = Room(
|
||||
roomOptions: RoomOptions(
|
||||
e2eeOptions: E2EEOptions(keyProvider: keyProvider),
|
||||
),
|
||||
);
|
||||
try {
|
||||
await room.connect(livekitUrl, jwt);
|
||||
_activeRoom = room;
|
||||
|
||||
// Step 5 — set our local key and share with other participants
|
||||
final localIdentity = room.localParticipant?.identity;
|
||||
debugPrint('[LiveKit] Connected, local identity: $localIdentity');
|
||||
if (localIdentity != null) {
|
||||
await _e2eeManager!.setLocalKey(localIdentity);
|
||||
}
|
||||
await _e2eeManager!.sendKeyToParticipants();
|
||||
|
||||
return LiveKitConnected(room: room);
|
||||
} on Exception catch (e) {
|
||||
// Clean up the state event on failure
|
||||
await _e2eeManager?.dispose();
|
||||
_e2eeManager = null;
|
||||
await _clearCallMemberEvent(client, matrixRoomId, userId);
|
||||
await room.disconnect();
|
||||
await room.dispose();
|
||||
@@ -136,6 +161,10 @@ class LiveKitService {
|
||||
_activeMatrixRoomId = null;
|
||||
_membershipId = null;
|
||||
|
||||
// Clean up E2EE key exchange
|
||||
await _e2eeManager?.dispose();
|
||||
_e2eeManager = null;
|
||||
|
||||
// Step 1: Leave LiveKit — Element X will see us disappear from the SFU
|
||||
if (room != null) {
|
||||
await room.disconnect();
|
||||
@@ -255,6 +284,7 @@ class LiveKitService {
|
||||
}
|
||||
|
||||
/// Clear the call.member state event (remove our membership).
|
||||
/// Element X expects empty content `{}` — not `{'memberships': []}`.
|
||||
Future<void> _clearCallMemberEvent(
|
||||
Client client,
|
||||
String matrixRoomId,
|
||||
@@ -265,8 +295,9 @@ class LiveKitService {
|
||||
matrixRoomId,
|
||||
_kCallMemberEventType,
|
||||
userId,
|
||||
{'memberships': []},
|
||||
{},
|
||||
);
|
||||
debugPrint('[LiveKit] Call member event cleared for $userId');
|
||||
} catch (e) {
|
||||
debugPrint('[LiveKit] Failed to clear call member event: $e');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// IncomingCallOverlay can react to them.
|
||||
@@ -79,13 +82,39 @@ class MatrixRtcRepository {
|
||||
}, type: EventTypes.CallInvite);
|
||||
}
|
||||
|
||||
void _onEvent(EventUpdate update) {
|
||||
if (update.type != EventUpdateType.timeline) return;
|
||||
if (update.content['type'] != EventTypes.CallInvite) return;
|
||||
/// MSC3401 MatrixRTC call membership state event (what Element X sends).
|
||||
static const _kCallMemberEventType = 'org.matrix.msc3401.call.member';
|
||||
|
||||
/// 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?;
|
||||
// Ignore our own invites.
|
||||
if (senderId == _myUserId) return;
|
||||
// Ignore our own invites and stale events replayed on initial sync.
|
||||
if (senderId == _myUserId || _isStale(update)) return;
|
||||
|
||||
final roomId = update.roomID;
|
||||
final content = update.content['content'] as Map<String, dynamic>?;
|
||||
@@ -93,21 +122,66 @@ class MatrixRtcRepository {
|
||||
|
||||
final callId = content['call_id'] as String?;
|
||||
if (callId == null) return;
|
||||
if (!_seenMembershipKeys.add('$roomId|$senderId|$callId')) return;
|
||||
|
||||
final room = _client.getRoomById(roomId);
|
||||
final senderProfile = room?.unsafeGetUserFromMemoryOrFallback(
|
||||
senderId ?? '',
|
||||
_emitIncomingCall(
|
||||
roomId: roomId,
|
||||
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(
|
||||
IncomingCall(
|
||||
callId: callId,
|
||||
roomId: roomId,
|
||||
callerId: senderId ?? '',
|
||||
callerId: senderId,
|
||||
callerDisplayName:
|
||||
senderProfile?.displayName ?? senderId?.matrixLocalpart ?? '',
|
||||
senderProfile?.displayName ?? senderId.matrixLocalpart,
|
||||
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
|
||||
// arrives. Displays caller name/avatar, and Accept / Decline buttons.
|
||||
|
||||
import 'package:flutter/material.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 '../../../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});
|
||||
|
||||
final IncomingCall call;
|
||||
|
||||
@override
|
||||
State<_IncomingCallOverlay> createState() => _IncomingCallOverlayState();
|
||||
ConsumerState<_IncomingCallOverlay> createState() =>
|
||||
_IncomingCallOverlayState();
|
||||
}
|
||||
|
||||
class _IncomingCallOverlayState extends State<_IncomingCallOverlay> {
|
||||
class _IncomingCallOverlayState extends ConsumerState<_IncomingCallOverlay> {
|
||||
bool _dismissed = false;
|
||||
String? _dismissedForCallId;
|
||||
|
||||
@override
|
||||
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();
|
||||
|
||||
final theme = Theme.of(context);
|
||||
@@ -129,9 +136,13 @@ class _IncomingCallOverlayState extends State<_IncomingCallOverlay> {
|
||||
label: 'Accept',
|
||||
colour: Colors.green,
|
||||
onTap: () {
|
||||
context.push(
|
||||
'/calls/${Uri.encodeComponent(call.roomId)}',
|
||||
);
|
||||
// The overlay lives above the router's navigator, so
|
||||
// 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,6 +1,12 @@
|
||||
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02
|
||||
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||
// Chat repository — bridges Matrix SDK timeline to app domain models.
|
||||
// Phase 2 additions: sendFile, sendReaction, redactEvent, reply support.
|
||||
// v1.5.0: keep one live Timeline per room so scroll-back history pagination
|
||||
// (requestHistory) acts on the same instance the stream is watching, and
|
||||
// re-yield on the timeline's own onUpdate (fires for both live events and
|
||||
// history) rather than only on onSync (which does not fire for pagination).
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
@@ -22,6 +28,10 @@ class ChatRepository {
|
||||
|
||||
final Client _client;
|
||||
|
||||
/// Live timelines keyed by room id, so [loadMoreMessages] paginates the
|
||||
/// same instance the active [watchTimeline] stream is mapping.
|
||||
final Map<String, Timeline> _timelines = {};
|
||||
|
||||
Room? _getRoom(String roomId) => _client.getRoomById(roomId);
|
||||
|
||||
/// Returns a stream of message lists for [roomId].
|
||||
@@ -29,19 +39,31 @@ class ChatRepository {
|
||||
final room = _getRoom(roomId);
|
||||
if (room == null) return;
|
||||
|
||||
final timeline = await room.getTimeline();
|
||||
final signal = StreamController<void>();
|
||||
final timeline = await room.getTimeline(
|
||||
onUpdate: () {
|
||||
if (!signal.isClosed) signal.add(null);
|
||||
},
|
||||
);
|
||||
_timelines[roomId] = timeline;
|
||||
|
||||
try {
|
||||
yield await _mapTimeline(timeline, room);
|
||||
|
||||
await for (final update in _client.onSync.stream) {
|
||||
final updatesThisRoom = update.rooms?.join?.containsKey(roomId) ?? false;
|
||||
if (updatesThisRoom) {
|
||||
await for (final _ in signal.stream) {
|
||||
yield await _mapTimeline(timeline, room);
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
timeline.cancelSubscriptions();
|
||||
if (identical(_timelines[roomId], timeline)) {
|
||||
_timelines.remove(roomId);
|
||||
}
|
||||
await signal.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [roomId] has older messages that can still be paginated in.
|
||||
bool canLoadMore(String roomId) =>
|
||||
_timelines[roomId]?.canRequestHistory ?? false;
|
||||
|
||||
/// Sends a plain text message. Supports replies via [inReplyToEventId].
|
||||
Future<void> sendTextMessage(
|
||||
@@ -73,6 +95,17 @@ class ChatRepository {
|
||||
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].
|
||||
Future<void> sendReaction(String roomId, String eventId, String emoji) async {
|
||||
final room = _getRoom(roomId);
|
||||
@@ -96,11 +129,12 @@ class ChatRepository {
|
||||
await room.setReadMarker(lastEventId, mRead: lastEventId);
|
||||
}
|
||||
|
||||
/// Requests older messages (pagination).
|
||||
/// Requests older messages (pagination) on the active timeline for [roomId].
|
||||
/// No-op if there is no live timeline or no more history to fetch.
|
||||
Future<void> loadMoreMessages(String roomId) async {
|
||||
final room = _getRoom(roomId);
|
||||
if (room == null) return;
|
||||
await room.requestHistory();
|
||||
final timeline = _timelines[roomId];
|
||||
if (timeline == null || !timeline.canRequestHistory) return;
|
||||
await timeline.requestHistory();
|
||||
}
|
||||
|
||||
/// Event types that should never appear in the chat timeline.
|
||||
@@ -191,6 +225,7 @@ class ChatRepository {
|
||||
|
||||
MessageType _messageType(Event event) {
|
||||
if (event.redacted) return MessageType.redacted;
|
||||
if (event.type == EventTypes.Encrypted) return MessageType.encrypted;
|
||||
|
||||
return switch (event.messageType) {
|
||||
MessageTypes.Text => MessageType.text,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-10
|
||||
// Immutable message model for the chat timeline.
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
@@ -14,6 +14,7 @@ enum MessageType {
|
||||
video,
|
||||
sticker,
|
||||
redacted,
|
||||
encrypted,
|
||||
unsupported,
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
@riverpod
|
||||
class DeleteMessage extends _$DeleteMessage {
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02
|
||||
// Version: 1.7.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||
// Full chat screen — timeline + input + typing indicators + read receipts
|
||||
// + long-press context menu (reply, react, copy, delete).
|
||||
// + long-press context menu (quick reactions, reply, react, copy, edit,
|
||||
// delete) + day separators + same-sender message grouping + room options.
|
||||
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:matrix/matrix.dart' show MatrixFile;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:matrix/matrix.dart' show MatrixFile, Room;
|
||||
|
||||
import '../../../app/theme.dart';
|
||||
import '../../../core/network/matrix_client.dart';
|
||||
import '../../../shared/utils/matrix_id.dart';
|
||||
import '../../../shared/utils/mxc_url.dart';
|
||||
import '../../../shared/widgets/matrix_avatar.dart';
|
||||
import '../../rooms/presentation/user_search_dialog.dart';
|
||||
import '../data/chat_repository.dart';
|
||||
import '../domain/message_model.dart';
|
||||
import 'chat_controller.dart';
|
||||
import 'message_bubble.dart';
|
||||
@@ -34,6 +39,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
|
||||
String get _decodedRoomId => Uri.decodeComponent(widget.roomId);
|
||||
|
||||
void _showRoomOptions(BuildContext context, Room room) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _RoomOptionsSheet(room: room),
|
||||
);
|
||||
}
|
||||
|
||||
void _setReply(MessageModel message) {
|
||||
setState(() {
|
||||
_replyToEventId = message.eventId;
|
||||
@@ -87,7 +100,9 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
tooltip: 'Room options',
|
||||
onPressed: () {},
|
||||
onPressed: room != null
|
||||
? () => _showRoomOptions(context, room)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -114,15 +129,82 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
// Timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _Timeline extends ConsumerWidget {
|
||||
class _Timeline extends ConsumerStatefulWidget {
|
||||
const _Timeline({required this.roomId, required this.onReply});
|
||||
|
||||
final String roomId;
|
||||
final void Function(MessageModel) onReply;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final timelineAsync = ref.watch(chatTimelineProvider(roomId));
|
||||
ConsumerState<_Timeline> createState() => _TimelineState();
|
||||
}
|
||||
|
||||
class _TimelineState extends ConsumerState<_Timeline> {
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
/// The list is reverse:true, so the newest message sits at offset 0.
|
||||
/// Show the jump-to-latest button once the user scrolls up past this.
|
||||
static const _showButtonThreshold = 400.0;
|
||||
|
||||
/// How close to the oldest (top of a reverse list) before we prefetch more.
|
||||
static const _loadMoreThreshold = 400.0;
|
||||
|
||||
bool _showScrollToLatest = false;
|
||||
bool _loadingMore = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final pos = _scrollController.position;
|
||||
|
||||
final show = pos.pixels > _showButtonThreshold;
|
||||
if (show != _showScrollToLatest) {
|
||||
setState(() => _showScrollToLatest = show);
|
||||
}
|
||||
|
||||
// Near the oldest end (top of a reverse list) → load older history.
|
||||
if (pos.hasContentDimensions &&
|
||||
pos.pixels >= pos.maxScrollExtent - _loadMoreThreshold) {
|
||||
_loadOlder();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadOlder() async {
|
||||
if (_loadingMore) return;
|
||||
final repo = ref.read(chatRepositoryProvider);
|
||||
if (!repo.canLoadMore(widget.roomId)) return;
|
||||
setState(() => _loadingMore = true);
|
||||
try {
|
||||
await repo.loadMoreMessages(widget.roomId);
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingMore = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _scrollToLatest() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
_scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final timelineAsync = ref.watch(chatTimelineProvider(widget.roomId));
|
||||
|
||||
return timelineAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
@@ -144,20 +226,134 @@ class _Timeline extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
return Stack(
|
||||
children: [
|
||||
ListView.builder(
|
||||
controller: _scrollController,
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
return _MessageWithGestures(
|
||||
// List is reverse:true, so the visually-older neighbour is at
|
||||
// index+1 and the visually-newer neighbour is at index-1.
|
||||
final older = index < messages.length - 1
|
||||
? messages[index + 1]
|
||||
: null;
|
||||
final newer = index > 0 ? messages[index - 1] : null;
|
||||
|
||||
const groupGap = Duration(minutes: 5);
|
||||
final isFirstInGroup =
|
||||
older == null ||
|
||||
older.senderId != message.senderId ||
|
||||
message.timestamp.difference(older.timestamp) > groupGap;
|
||||
final isLastInGroup =
|
||||
newer == null ||
|
||||
newer.senderId != message.senderId ||
|
||||
newer.timestamp.difference(message.timestamp) > groupGap;
|
||||
|
||||
// A day chip sits above the first message of each local day.
|
||||
final showDay =
|
||||
older == null ||
|
||||
!_sameLocalDay(message.timestamp, older.timestamp);
|
||||
|
||||
final bubble = _MessageWithGestures(
|
||||
message: message,
|
||||
roomId: roomId,
|
||||
onReply: () => onReply(message),
|
||||
roomId: widget.roomId,
|
||||
onReply: () => widget.onReply(message),
|
||||
isFirstInGroup: isFirstInGroup,
|
||||
isLastInGroup: isLastInGroup,
|
||||
);
|
||||
|
||||
if (!showDay) return bubble;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_DayChip(timestamp: message.timestamp),
|
||||
bubble,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
// Loading older history (top of a reverse list).
|
||||
if (_loadingMore)
|
||||
const Positioned(
|
||||
top: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 12,
|
||||
bottom: 12,
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
scale: _showScrollToLatest ? 1 : 0,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'scrollToLatest',
|
||||
onPressed: _scrollToLatest,
|
||||
tooltip: 'Jump to latest',
|
||||
child: const Icon(Icons.arrow_downward),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// True when two timestamps fall on the same day in the local timezone.
|
||||
bool _sameLocalDay(DateTime a, DateTime b) {
|
||||
final la = a.toLocal();
|
||||
final lb = b.toLocal();
|
||||
return la.year == lb.year && la.month == lb.month && la.day == lb.day;
|
||||
}
|
||||
|
||||
/// Centred date pill shown above the first message of each local day.
|
||||
class _DayChip extends StatelessWidget {
|
||||
const _DayChip({required this.timestamp});
|
||||
|
||||
final DateTime timestamp;
|
||||
|
||||
String _label() {
|
||||
final now = DateTime.now().toLocal();
|
||||
final local = timestamp.toLocal();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final that = DateTime(local.year, local.month, local.day);
|
||||
final diff = today.difference(that).inDays;
|
||||
if (diff == 0) return 'Today';
|
||||
if (diff == 1) return 'Yesterday';
|
||||
if (local.year == now.year) return DateFormat('d MMM').format(local);
|
||||
return DateFormat('d MMM yyyy').format(local);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_label(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: M8Colours.subtleText,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -171,17 +367,25 @@ class _MessageWithGestures extends ConsumerWidget {
|
||||
required this.message,
|
||||
required this.roomId,
|
||||
required this.onReply,
|
||||
this.isFirstInGroup = true,
|
||||
this.isLastInGroup = true,
|
||||
});
|
||||
|
||||
final MessageModel message;
|
||||
final String roomId;
|
||||
final VoidCallback onReply;
|
||||
final bool isFirstInGroup;
|
||||
final bool isLastInGroup;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return GestureDetector(
|
||||
onLongPress: () => _showContextMenu(context, ref),
|
||||
child: MessageBubble(message: message),
|
||||
child: MessageBubble(
|
||||
message: message,
|
||||
isFirstInGroup: isFirstInGroup,
|
||||
isLastInGroup: isLastInGroup,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,6 +395,12 @@ class _MessageWithGestures extends ConsumerWidget {
|
||||
builder: (_) => _MessageContextMenu(
|
||||
message: message,
|
||||
roomId: roomId,
|
||||
onQuickReact: (emoji) {
|
||||
Navigator.pop(context);
|
||||
ref
|
||||
.read(sendReactionProvider.notifier)
|
||||
.react(roomId, message.eventId, emoji);
|
||||
},
|
||||
onReply: () {
|
||||
Navigator.pop(context);
|
||||
onReply();
|
||||
@@ -206,6 +416,12 @@ class _MessageWithGestures extends ConsumerWidget {
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('Message copied.')));
|
||||
},
|
||||
onEdit: message.isMine && message.body != null
|
||||
? () {
|
||||
Navigator.pop(context);
|
||||
_showEditDialog(context, ref);
|
||||
}
|
||||
: null,
|
||||
onDelete: message.isMine
|
||||
? () async {
|
||||
Navigator.pop(context);
|
||||
@@ -218,6 +434,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) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -239,21 +493,203 @@ class _MessageWithGestures extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Room options sheet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _RoomOptionsSheet extends ConsumerStatefulWidget {
|
||||
const _RoomOptionsSheet({required this.room});
|
||||
final Room room;
|
||||
|
||||
@override
|
||||
ConsumerState<_RoomOptionsSheet> createState() => _RoomOptionsSheetState();
|
||||
}
|
||||
|
||||
class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
|
||||
bool _leaving = false;
|
||||
|
||||
Future<void> _leaveRoom() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('Leave room?'),
|
||||
content: Text(
|
||||
'You will leave "${widget.room.getLocalizedDisplayname()}". '
|
||||
'You can rejoin if you have an invite.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: const Text('Leave'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
setState(() => _leaving = true);
|
||||
try {
|
||||
await widget.room.leave();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // close sheet
|
||||
GoRouter.of(context).go('/rooms');
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _leaving = false);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Could not leave room: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final client = ref.watch(matrixClientProvider);
|
||||
final theme = Theme.of(context);
|
||||
final room = widget.room;
|
||||
final members = room.getParticipants();
|
||||
final canInvite = room.canInvite;
|
||||
|
||||
return SafeArea(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.75,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
room.getLocalizedDisplayname(),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Members section
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||
child: Text(
|
||||
'Members (${members.length})',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: members.length,
|
||||
itemBuilder: (context, index) {
|
||||
final member = members[index];
|
||||
final name = member.displayName ?? member.id.matrixLocalpart;
|
||||
final avatarUrl = resolveMxcUrl(client, member.avatarUrl);
|
||||
final isMe = member.id == client.userID;
|
||||
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: MatrixAvatar(
|
||||
name: name,
|
||||
avatarUrl: avatarUrl,
|
||||
radius: 16,
|
||||
),
|
||||
title: Text(
|
||||
isMe ? '$name (you)' : name,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
subtitle: Text(
|
||||
member.id,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(120),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// Actions
|
||||
if (canInvite)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_add_outlined),
|
||||
title: const Text('Invite someone'),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
showUserSearchDialog(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: _leaving
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(Icons.exit_to_app, color: theme.colorScheme.error),
|
||||
title: Text(
|
||||
'Leave room',
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
enabled: !_leaving,
|
||||
onTap: _leaveRoom,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageContextMenu extends StatelessWidget {
|
||||
const _MessageContextMenu({
|
||||
required this.message,
|
||||
required this.roomId,
|
||||
required this.onQuickReact,
|
||||
required this.onReply,
|
||||
required this.onReact,
|
||||
required this.onCopy,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
});
|
||||
|
||||
/// Six one-tap presets shown across the top of the menu.
|
||||
static const _quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
|
||||
|
||||
final MessageModel message;
|
||||
final String roomId;
|
||||
final void Function(String emoji) onQuickReact;
|
||||
final VoidCallback onReply;
|
||||
final VoidCallback onReact;
|
||||
final VoidCallback onCopy;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
@override
|
||||
@@ -262,6 +698,43 @@ class _MessageContextMenu extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Quick-reaction row: one-tap presets + open the full picker.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
for (final emoji in _quickReactions)
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: () => onQuickReact(emoji),
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Center(
|
||||
child: Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
onTap: onReact,
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.reply),
|
||||
title: const Text('Reply'),
|
||||
@@ -278,6 +751,12 @@ class _MessageContextMenu extends StatelessWidget {
|
||||
title: const Text('Copy text'),
|
||||
onTap: onCopy,
|
||||
),
|
||||
if (onEdit != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined),
|
||||
title: const Text('Edit'),
|
||||
onTap: onEdit,
|
||||
),
|
||||
if (onDelete != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02
|
||||
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||
// Message bubble widget. Handles text, images, files, redacted, replies.
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../app/theme.dart';
|
||||
import '../../../shared/widgets/matrix_avatar.dart';
|
||||
import '../domain/message_model.dart';
|
||||
|
||||
class MessageBubble extends StatelessWidget {
|
||||
const MessageBubble({super.key, required this.message});
|
||||
const MessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.isFirstInGroup = true,
|
||||
this.isLastInGroup = true,
|
||||
});
|
||||
|
||||
final MessageModel message;
|
||||
|
||||
/// First message of a same-sender run: show the sender name, add top space.
|
||||
final bool isFirstInGroup;
|
||||
|
||||
/// Last message of a same-sender run: show the avatar, keep the tail corner.
|
||||
final bool isLastInGroup;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -20,8 +32,8 @@ class MessageBubble extends StatelessWidget {
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
top: isFirstInGroup ? 8 : 1,
|
||||
bottom: isLastInGroup ? 2 : 1,
|
||||
left: isMine ? 48 : 8,
|
||||
right: isMine ? 8 : 48,
|
||||
),
|
||||
@@ -32,11 +44,16 @@ class MessageBubble extends StatelessWidget {
|
||||
: MainAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMine) ...[
|
||||
// Only the last message of a run carries the avatar; earlier ones
|
||||
// hold the gutter so the column stays aligned.
|
||||
if (isLastInGroup)
|
||||
MatrixAvatar(
|
||||
name: message.senderDisplayName,
|
||||
avatarUrl: message.senderAvatarUrl,
|
||||
radius: 16,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 32),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Flexible(
|
||||
@@ -45,7 +62,7 @@ class MessageBubble extends StatelessWidget {
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMine)
|
||||
if (!isMine && isFirstInGroup)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 2),
|
||||
child: Text(
|
||||
@@ -56,7 +73,11 @@ class MessageBubble extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
_BubbleContent(message: message, isMine: isMine),
|
||||
_BubbleContent(
|
||||
message: message,
|
||||
isMine: isMine,
|
||||
isLastInGroup: isLastInGroup,
|
||||
),
|
||||
if (message.reactions.isNotEmpty)
|
||||
_ReactionsRow(reactions: message.reactions),
|
||||
],
|
||||
@@ -69,29 +90,46 @@ class MessageBubble extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _BubbleContent extends StatelessWidget {
|
||||
const _BubbleContent({required this.message, required this.isMine});
|
||||
const _BubbleContent({
|
||||
required this.message,
|
||||
required this.isMine,
|
||||
this.isLastInGroup = true,
|
||||
});
|
||||
|
||||
final MessageModel message;
|
||||
final bool isMine;
|
||||
final bool isLastInGroup;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final bgColour = isMine
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.surfaceContainerHighest;
|
||||
? M8Colours.selfBubble
|
||||
: M8Colours.darkSurfaceVariant;
|
||||
final textColour = isMine ? Colors.white : theme.colorScheme.onSurface;
|
||||
final maxBubbleWidth =
|
||||
(MediaQuery.sizeOf(context).width.clamp(0, 560) * 0.78).toDouble();
|
||||
// Only the last bubble of a same-sender run gets the pointed tail corner;
|
||||
// mid-run bubbles stay fully rounded so the run reads as one block.
|
||||
final tail = isLastInGroup ? const Radius.circular(4) : null;
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColour,
|
||||
border: isMine
|
||||
? null
|
||||
: Border.all(color: theme.colorScheme.outline.withAlpha(31)),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isMine ? 16 : 4),
|
||||
bottomRight: Radius.circular(isMine ? 4 : 16),
|
||||
bottomLeft: isMine
|
||||
? const Radius.circular(16)
|
||||
: (tail ?? const Radius.circular(16)),
|
||||
bottomRight: isMine
|
||||
? (tail ?? const Radius.circular(16))
|
||||
: const Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
@@ -138,6 +176,22 @@ class _MessageContentBody extends StatelessWidget {
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
MessageType.encrypted => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.lock_outline, size: 14, color: textColour.withAlpha(153)),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Encrypted. Use Element to read',
|
||||
style: TextStyle(
|
||||
color: textColour.withAlpha(153),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_ => Text(
|
||||
message.body ?? 'Unsupported message type',
|
||||
style: TextStyle(
|
||||
@@ -156,11 +210,15 @@ class _ImageContent extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (message.mediaUrl != null) {
|
||||
return ClipRRect(
|
||||
final url = message.mediaUrl;
|
||||
if (url == null) return const Icon(Icons.image_not_supported);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _openFullscreen(context, url, message.body),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: message.mediaUrl!,
|
||||
imageUrl: url,
|
||||
width: 240,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => const SizedBox(
|
||||
@@ -172,9 +230,56 @@ class _ImageContent extends StatelessWidget {
|
||||
child: Center(child: Icon(Icons.broken_image)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Icon(Icons.image_not_supported);
|
||||
|
||||
void _openFullscreen(BuildContext context, String url, String? caption) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => _ImageViewer(url: url, caption: caption),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageViewer extends StatelessWidget {
|
||||
const _ImageViewer({required this.url, this.caption});
|
||||
|
||||
final String url;
|
||||
final String? caption;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
title: caption != null
|
||||
? Text(
|
||||
caption!,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: Center(
|
||||
child: InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 4,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (_, __) =>
|
||||
const CircularProgressIndicator(color: Colors.white),
|
||||
errorWidget: (_, __, ___) =>
|
||||
const Icon(Icons.broken_image, color: Colors.white, size: 64),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Version: 1.1.0 | Created: 2026-04-01
|
||||
// Version: 1.1.1 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||
// Message input bar — text, send, attach file, reply quote.
|
||||
// File picker enabled in Phase 2 via file_picker ^8.0.0.
|
||||
|
||||
@@ -195,7 +195,8 @@ class _MessageInputState extends State<MessageInput> {
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _submit(),
|
||||
maxLines: null,
|
||||
minLines: 1,
|
||||
maxLines: 6,
|
||||
keyboardType: TextInputType.multiline,
|
||||
),
|
||||
),
|
||||
|
||||
332
lib/features/help/presentation/help_tab.dart
Normal file
332
lib/features/help/presentation/help_tab.dart
Normal file
@@ -0,0 +1,332 @@
|
||||
// Version: 1.1.1 | Created: 2026-04-11 | Updated: 2026-07-03
|
||||
// Help tab with in-app guidance for common tasks and troubleshooting.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
import '../../../core/network/matrix_client.dart';
|
||||
import '../../profile/presentation/security_setup_dialog.dart';
|
||||
|
||||
void _launchUrl(BuildContext context, String url) {
|
||||
web.window.open(url, '_blank');
|
||||
}
|
||||
|
||||
class HelpTab extends ConsumerWidget {
|
||||
const HelpTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final client = ref.watch(matrixClientProvider);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'Help & Troubleshooting',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// --- Encrypted messages ---
|
||||
_HelpCard(
|
||||
icon: Icons.lock_outline,
|
||||
title: 'I see "Encrypted. Use Element to read"',
|
||||
children: [
|
||||
const _HelpParagraph(
|
||||
'This means your device does not have the keys needed to '
|
||||
'read those messages. This happens when:',
|
||||
),
|
||||
const _HelpBullet(
|
||||
'You are using M8Chat on a new device or browser.'),
|
||||
const _HelpBullet(
|
||||
'The messages were sent before you set up security.'),
|
||||
const _HelpBullet(
|
||||
'Your browser data was cleared.'),
|
||||
const SizedBox(height: 12),
|
||||
const _HelpSubheading('How to fix it'),
|
||||
const _HelpParagraph(
|
||||
'1. Go to the Profile tab.\n'
|
||||
'2. Tap Security & Privacy to expand it.\n'
|
||||
'3. Tap Security setup.\n'
|
||||
'4. If you have set up security before, enter your '
|
||||
'recovery key. If not, a new one will be created for you.\n'
|
||||
'5. Save the recovery key somewhere safe.\n'
|
||||
'6. After setup, go back to your rooms. Messages should '
|
||||
'now be readable.',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const _HelpSubheading('What is a recovery key?'),
|
||||
const _HelpParagraph(
|
||||
'A recovery key is a long code starting with "EsTc" that '
|
||||
'unlocks your encrypted messages. It was shown to you when '
|
||||
'you first set up security in Element or M8Chat. If you '
|
||||
'saved it, you can use it to restore your messages on any '
|
||||
'device.',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Builder(
|
||||
builder: (context) => OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
showSecuritySetupDialog(context, client),
|
||||
icon: const Icon(Icons.shield_outlined),
|
||||
label: const Text('Open Security Setup'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// --- New to M8Chat ---
|
||||
const _HelpCard(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
title: 'Getting started with M8Chat',
|
||||
children: [
|
||||
_HelpParagraph(
|
||||
'M8Chat is a secure messaging app built on the Matrix '
|
||||
'protocol. All your messages are end-to-end encrypted, '
|
||||
'meaning only you and the people you chat with can read '
|
||||
'them.',
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
_HelpSubheading('First steps'),
|
||||
_HelpParagraph(
|
||||
'1. Set up security: go to Profile > Security & Privacy '
|
||||
'> Security setup. This creates your encryption keys and '
|
||||
'backs them up.\n'
|
||||
'2. Save your recovery key. You will need it if you ever '
|
||||
'log in on a new device.\n'
|
||||
'3. Start chatting. Tap the pencil icon at the top right '
|
||||
'to search for users and start a conversation.',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// --- Joining a conference ---
|
||||
const _HelpCard(
|
||||
icon: Icons.videocam_outlined,
|
||||
title: 'Joining a video conference',
|
||||
children: [
|
||||
_HelpParagraph(
|
||||
'M8Chat supports Jitsi video conferences. To join one:',
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
_HelpParagraph(
|
||||
'1. Tap the Conference tab at the bottom.\n'
|
||||
'2. Paste the meeting link (e.g. conf.m8chat.au/MyMeeting).\n'
|
||||
'3. Tap Join Meeting.\n\n'
|
||||
'You can also make voice and video calls directly from any '
|
||||
'chat room using the phone and camera icons at the top of '
|
||||
'the conversation.',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// --- Video call issues ---
|
||||
const _HelpCard(
|
||||
icon: Icons.videocam_off_outlined,
|
||||
title: 'Video call problems',
|
||||
children: [
|
||||
_HelpSubheading('Scrambled or black video'),
|
||||
_HelpParagraph(
|
||||
'If video appears scrambled when calling someone on Element X, '
|
||||
'this is an encryption compatibility issue that is being '
|
||||
'worked on. Try calling from the same app on both ends.',
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
_HelpSubheading('No audio or robotic sound'),
|
||||
_HelpParagraph(
|
||||
'This is usually caused by browser permissions. Make sure '
|
||||
'you have allowed microphone and camera access when '
|
||||
'prompted. On some devices (especially Sailfish OS), the '
|
||||
'audio codec support may be limited.',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// --- Account & privacy ---
|
||||
const _HelpCard(
|
||||
icon: Icons.security_outlined,
|
||||
title: 'Account & privacy',
|
||||
children: [
|
||||
_HelpSubheading('Who can read my messages?'),
|
||||
_HelpParagraph(
|
||||
'Only you and the people in the conversation. M8Chat uses '
|
||||
'end-to-end encryption. Not even the server operator can '
|
||||
'read your messages.',
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
_HelpSubheading('Can I use multiple devices?'),
|
||||
_HelpParagraph(
|
||||
'Yes. Set up security on each device using the same '
|
||||
'recovery key. Your messages will be available on all '
|
||||
'devices where security is set up.',
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
_HelpSubheading('What happens if I lose my recovery key?'),
|
||||
_HelpParagraph(
|
||||
'You will not be able to read old messages on new devices. '
|
||||
'New messages will still work. You can create a new '
|
||||
'recovery key from the Security setup, but old messages '
|
||||
'encrypted with the previous key will remain unreadable.',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// --- Account management ---
|
||||
_HelpCard(
|
||||
icon: Icons.manage_accounts_outlined,
|
||||
title: 'Account management',
|
||||
children: [
|
||||
const _HelpParagraph(
|
||||
'All account changes need to be made through the M8Chat '
|
||||
'website. This includes:',
|
||||
),
|
||||
const _HelpBullet('Changing your password'),
|
||||
const _HelpBullet('Membership settings and billing'),
|
||||
const _HelpBullet('Updating your profile or email'),
|
||||
const _HelpBullet('Deleting your account'),
|
||||
const SizedBox(height: 12),
|
||||
Builder(
|
||||
builder: (context) => OutlinedButton.icon(
|
||||
onPressed: () => _launchUrl(context, 'https://m8chat.au'),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('Go to m8chat.au'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const _HelpSubheading('Privacy'),
|
||||
const _HelpParagraph(
|
||||
'No account details or other privacy or user identifiers '
|
||||
'are saved or stored on this application. All data stays '
|
||||
'on the M8Chat server and is protected by end-to-end '
|
||||
'encryption.',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const _HelpParagraph(
|
||||
'For more information about what happens if things go '
|
||||
'wrong, including data recovery and account issues:',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Builder(
|
||||
builder: (context) => OutlinedButton.icon(
|
||||
onPressed: () => _launchUrl(
|
||||
context, 'https://m8chat.au/when-it-all-goes-wrong/'),
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('When it all goes wrong'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
Center(
|
||||
child: Text(
|
||||
'Need more help? Visit m8chat.au or contact the M8Chat team.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(102),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reusable help widgets ---
|
||||
|
||||
class _HelpCard extends StatelessWidget {
|
||||
const _HelpCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: ExpansionTile(
|
||||
leading: Icon(icon, color: theme.colorScheme.primary),
|
||||
title: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
childrenPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HelpParagraph extends StatelessWidget {
|
||||
const _HelpParagraph(this.text);
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.5),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HelpSubheading extends StatelessWidget {
|
||||
const _HelpSubheading(this.text);
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HelpBullet extends StatelessWidget {
|
||||
const _HelpBullet(this.text);
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8, top: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('\u2022 '),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(height: 1.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
198
lib/features/jitsi/data/jitsi_web_service.dart
Normal file
198
lib/features/jitsi/data/jitsi_web_service.dart
Normal file
@@ -0,0 +1,198 @@
|
||||
// Version: 1.2.0 | Created: 2026-04-05 | Updated: 2026-07-04
|
||||
// Web implementation: uses JitsiMeetExternalAPI via dart:js_interop.
|
||||
// This file is web-only — guarded by the Flutter web build.
|
||||
// The external_api.js script is lazy-loaded on first use — not in index.html.
|
||||
// This prevents privacy browsers (Brave, Mullvad) from blocking the app
|
||||
// at startup due to a cross-origin script from conf.m8chat.au.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:js_interop';
|
||||
import 'dart:js_interop_unsafe';
|
||||
import 'dart:ui_web' as ui_web;
|
||||
|
||||
import 'package:web/web.dart' as web;
|
||||
|
||||
import '../../../core/config/app_config.dart';
|
||||
|
||||
/// Manages a single Jitsi iframe session via the JitsiMeetExternalAPI.
|
||||
class JitsiWebService {
|
||||
JitsiWebService._();
|
||||
static final instance = JitsiWebService._();
|
||||
|
||||
JSObject? _api;
|
||||
bool _viewRegistered = false;
|
||||
bool _scriptLoaded = false;
|
||||
bool _scriptLoading = false;
|
||||
|
||||
/// Unique view type for HtmlElementView.
|
||||
static const String viewType = 'jitsi-meet-container';
|
||||
|
||||
/// Lazy-loads the JitsiMeetExternalAPI script from conf.m8chat.au.
|
||||
/// Returns true if the script loaded successfully, false otherwise.
|
||||
Future<bool> _ensureScriptLoaded() async {
|
||||
if (_scriptLoaded) return true;
|
||||
if (_scriptLoading) {
|
||||
// Wait for in-flight load to finish.
|
||||
for (var i = 0; i < 50; i++) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
if (_scriptLoaded) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
_scriptLoading = true;
|
||||
|
||||
final completer = Completer<bool>();
|
||||
final script =
|
||||
web.document.createElement('script') as web.HTMLScriptElement;
|
||||
script.src = 'https://${AppConfig.jitsiDomain}/external_api.js';
|
||||
script.async = true;
|
||||
script.onload = (web.Event event) {
|
||||
_scriptLoaded = true;
|
||||
_scriptLoading = false;
|
||||
completer.complete(true);
|
||||
}.toJS;
|
||||
script.onerror = (web.Event event) {
|
||||
_scriptLoading = false;
|
||||
completer.complete(false);
|
||||
}.toJS;
|
||||
web.document.head?.append(script);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Registers the platform view factory (once).
|
||||
void ensureViewRegistered() {
|
||||
if (_viewRegistered) return;
|
||||
ui_web.platformViewRegistry.registerViewFactory(
|
||||
viewType,
|
||||
(int viewId, {Object? params}) {
|
||||
final div = web.document.createElement('div') as web.HTMLDivElement;
|
||||
div.id = 'jitsi-container-$viewId';
|
||||
div.style
|
||||
..width = '100%'
|
||||
..height = '100%';
|
||||
return div;
|
||||
},
|
||||
);
|
||||
_viewRegistered = true;
|
||||
}
|
||||
|
||||
/// Starts a Jitsi meeting inside the container div created by the platform view.
|
||||
/// Call this AFTER the HtmlElementView has been scheduled for mounting
|
||||
/// (e.g. in a post-frame callback). Lazy-loads the Jitsi script on first
|
||||
/// call and waits for the platform-view container div to appear — the
|
||||
/// post-frame callback can run before the HtmlElementView factory has
|
||||
/// inserted the div, which previously made this method bail out silently
|
||||
/// and leave the meeting screen blank.
|
||||
/// Returns true if the meeting iframe was created.
|
||||
Future<bool> joinMeeting({
|
||||
required String roomName,
|
||||
String? jwt,
|
||||
String? displayName,
|
||||
String? avatarUrl,
|
||||
void Function()? onReadyToClose,
|
||||
}) async {
|
||||
dispose(); // tear down any previous meeting
|
||||
|
||||
// Lazy-load the external_api.js script if not already present.
|
||||
final loaded = await _ensureScriptLoaded();
|
||||
if (!loaded) {
|
||||
web.console.warn('[Jitsi] external_api.js failed to load'.toJS);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait for the container div — the platform view may not be in the DOM
|
||||
// yet when the post-frame callback fires.
|
||||
web.Node? parentNode;
|
||||
for (var i = 0; i < 50; i++) {
|
||||
final containers =
|
||||
web.document.querySelectorAll('[id^="jitsi-container-"]');
|
||||
if (containers.length > 0) {
|
||||
parentNode = containers.item(containers.length - 1);
|
||||
break;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
if (parentNode == null) {
|
||||
web.console.warn('[Jitsi] container div never appeared'.toJS);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build the options as a real JS object. Passing a DOM node through
|
||||
// Map.jsify() is not supported and threw at runtime in the dart2js build.
|
||||
final configOverwrite = JSObject()
|
||||
..setProperty('startAudioMuted'.toJS, 0.toJS)
|
||||
..setProperty('startVideoMuted'.toJS, 0.toJS)
|
||||
..setProperty('disableDeepLinking'.toJS, true.toJS)
|
||||
..setProperty('prejoinPageEnabled'.toJS, true.toJS);
|
||||
|
||||
final interfaceConfigOverwrite = JSObject()
|
||||
..setProperty('SHOW_CHROME_EXTENSION_BANNER'.toJS, false.toJS);
|
||||
|
||||
final options = JSObject()
|
||||
..setProperty('roomName'.toJS, roomName.toJS)
|
||||
..setProperty('parentNode'.toJS, parentNode as JSAny)
|
||||
..setProperty('width'.toJS, '100%'.toJS)
|
||||
..setProperty('height'.toJS, '100%'.toJS)
|
||||
..setProperty('configOverwrite'.toJS, configOverwrite)
|
||||
..setProperty('interfaceConfigOverwrite'.toJS, interfaceConfigOverwrite);
|
||||
|
||||
if (jwt != null && jwt.isNotEmpty) {
|
||||
options.setProperty('jwt'.toJS, jwt.toJS);
|
||||
}
|
||||
|
||||
if (displayName != null && displayName.isNotEmpty) {
|
||||
final userInfo = JSObject()
|
||||
..setProperty('displayName'.toJS, displayName.toJS);
|
||||
if (avatarUrl != null) {
|
||||
userInfo.setProperty('avatarUrl'.toJS, avatarUrl.toJS);
|
||||
}
|
||||
options.setProperty('userInfo'.toJS, userInfo);
|
||||
}
|
||||
|
||||
try {
|
||||
_api = _createJitsiApi(AppConfig.jitsiDomain.toJS, options);
|
||||
} catch (e) {
|
||||
web.console.error('[Jitsi] JitsiMeetExternalAPI threw: $e'.toJS);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (onReadyToClose != null && _api != null) {
|
||||
_addEventListener(_api!, 'readyToClose'.toJS, () {
|
||||
onReadyToClose();
|
||||
}.toJS);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Cleans up the Jitsi iframe.
|
||||
void dispose() {
|
||||
if (_api != null) {
|
||||
_disposeApi(_api!);
|
||||
_api = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Binding for the JitsiMeetExternalAPI ES6 class.
|
||||
/// The external constructor compiles to `new JitsiMeetExternalAPI(...)`.
|
||||
/// (A plain external function binding calls it WITHOUT `new`, which throws
|
||||
/// "Class constructor cannot be invoked without 'new'" — the original cause
|
||||
/// of the blank meeting screen.)
|
||||
@JS('JitsiMeetExternalAPI')
|
||||
extension type _JitsiApi._(JSObject _) implements JSObject {
|
||||
external _JitsiApi(JSString domain, JSObject options);
|
||||
external void addEventListener(JSString event, JSFunction callback);
|
||||
external void dispose();
|
||||
}
|
||||
|
||||
JSObject _createJitsiApi(JSString domain, JSObject options) =>
|
||||
_JitsiApi(domain, options);
|
||||
|
||||
void _addEventListener(JSObject api, JSString event, JSFunction callback) {
|
||||
(api as _JitsiApi).addEventListener(event, callback);
|
||||
}
|
||||
|
||||
void _disposeApi(JSObject api) {
|
||||
(api as _JitsiApi).dispose();
|
||||
}
|
||||
43
lib/features/jitsi/domain/jitsi_link.dart
Normal file
43
lib/features/jitsi/domain/jitsi_link.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-05
|
||||
// Parses a Jitsi meeting link into room name and optional JWT.
|
||||
|
||||
/// Parsed Jitsi meeting link.
|
||||
class JitsiLink {
|
||||
const JitsiLink({required this.roomName, this.jwt});
|
||||
|
||||
final String roomName;
|
||||
final String? jwt;
|
||||
|
||||
/// Parses a Jitsi link in common formats:
|
||||
/// - `https://conf.m8chat.au/RoomName`
|
||||
/// - `https://conf.m8chat.au/RoomName?jwt=abc123`
|
||||
/// - `conf.m8chat.au/RoomName?jwt=abc123`
|
||||
/// - `RoomName` (bare room name)
|
||||
static JitsiLink? tryParse(String input) {
|
||||
final trimmed = input.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
|
||||
// Try as a URL first.
|
||||
var uriString = trimmed;
|
||||
if (!uriString.contains('://') && uriString.contains('/')) {
|
||||
uriString = 'https://$uriString';
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(uriString);
|
||||
if (uri != null && uri.pathSegments.isNotEmpty && uri.host.isNotEmpty) {
|
||||
final roomName = uri.pathSegments
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join('/');
|
||||
if (roomName.isEmpty) return null;
|
||||
final jwt = uri.queryParameters['jwt'];
|
||||
return JitsiLink(roomName: roomName, jwt: jwt);
|
||||
}
|
||||
|
||||
// Bare room name — no slashes, no protocol.
|
||||
if (!trimmed.contains('/') && !trimmed.contains(' ')) {
|
||||
return JitsiLink(roomName: trimmed);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
96
lib/features/jitsi/presentation/conference_tab.dart
Normal file
96
lib/features/jitsi/presentation/conference_tab.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-10
|
||||
// Embedded conference join widget for the bottom nav tab.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Inline widget shown inside the RoomsScreen bottom nav.
|
||||
/// Lets authenticated users paste a Jitsi meeting link and join.
|
||||
class ConferenceTab extends StatefulWidget {
|
||||
const ConferenceTab({super.key});
|
||||
|
||||
@override
|
||||
State<ConferenceTab> createState() => _ConferenceTabState();
|
||||
}
|
||||
|
||||
class _ConferenceTabState extends State<ConferenceTab> {
|
||||
final _linkController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_linkController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _joinConference() {
|
||||
final link = _linkController.text.trim();
|
||||
if (link.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please paste a meeting link.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.push('/jitsi', extra: link);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.videocam_outlined,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary.withAlpha(180),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Join a Conference',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Paste your meeting link below to join.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _linkController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'conf.m8chat.au/YourMeeting',
|
||||
prefixIcon: Icon(Icons.link),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.go,
|
||||
onSubmitted: (_) => _joinConference(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _joinConference,
|
||||
icon: const Icon(Icons.videocam),
|
||||
label: const Text('Join Meeting'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
154
lib/features/jitsi/presentation/jitsi_screen.dart
Normal file
154
lib/features/jitsi/presentation/jitsi_screen.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
// Version: 1.3.0 | Created: 2026-04-05 | Updated: 2026-07-04
|
||||
// Full-screen Jitsi meeting embedded via HtmlElementView (web platform view).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../data/jitsi_web_service.dart';
|
||||
import '../domain/jitsi_link.dart';
|
||||
|
||||
class JitsiScreen extends StatefulWidget {
|
||||
const JitsiScreen({super.key, required this.meetingUrl});
|
||||
|
||||
/// The raw meeting link or room name provided by the user.
|
||||
final String meetingUrl;
|
||||
|
||||
@override
|
||||
State<JitsiScreen> createState() => _JitsiScreenState();
|
||||
}
|
||||
|
||||
class _JitsiScreenState extends State<JitsiScreen> {
|
||||
late final JitsiLink? _link;
|
||||
bool _meetingStarted = false;
|
||||
bool _meetingEnded = false;
|
||||
bool _meetingFailed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_link = JitsiLink.tryParse(widget.meetingUrl);
|
||||
if (_link != null) {
|
||||
JitsiWebService.instance.ensureViewRegistered();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
JitsiWebService.instance.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startMeeting() {
|
||||
if (_link == null || _meetingStarted) return;
|
||||
_meetingStarted = true;
|
||||
|
||||
// Post-frame so the HtmlElementView div is mounted in the DOM.
|
||||
// joinMeeting itself waits for the container div to appear.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final ok = await JitsiWebService.instance.joinMeeting(
|
||||
roomName: _link.roomName,
|
||||
jwt: _link.jwt,
|
||||
onReadyToClose: () {
|
||||
if (mounted) {
|
||||
setState(() => _meetingEnded = true);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!ok && mounted) {
|
||||
setState(() => _meetingFailed = true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_link == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Conference')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.link_off, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Could not parse the meeting link.'),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/rooms'),
|
||||
child: const Text('Back'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_meetingEnded) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.videocam_off_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Meeting ended',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/rooms'),
|
||||
child: const Text('Back to M8Chat'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_meetingFailed) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Conference')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.videocam_off_outlined, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Could not start the meeting. '
|
||||
'Check your connection and try again.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/rooms'),
|
||||
child: const Text('Back'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Render the Jitsi iframe and kick off the meeting once mounted.
|
||||
_startMeeting();
|
||||
|
||||
return Scaffold(
|
||||
body: const HtmlElementView(viewType: JitsiWebService.viewType),
|
||||
);
|
||||
}
|
||||
}
|
||||
227
lib/features/jitsi/presentation/welcome_screen.dart
Normal file
227
lib/features/jitsi/presentation/welcome_screen.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
// Version: 1.1.0 | Created: 2026-04-05 | Updated: 2026-07-04
|
||||
// Welcome/landing screen — first thing unauthenticated users see.
|
||||
// Two paths: join a Jitsi conference (no login) or sign in for Matrix chat.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class WelcomeScreen extends StatefulWidget {
|
||||
const WelcomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<WelcomeScreen> createState() => _WelcomeScreenState();
|
||||
}
|
||||
|
||||
class _WelcomeScreenState extends State<WelcomeScreen> {
|
||||
final _linkController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_linkController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _joinConference() {
|
||||
final link = _linkController.text.trim();
|
||||
if (link.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please paste a meeting link.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.go('/jitsi', extra: link);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
body: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF0A0F2E), Color(0xFF131A42)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// --- Logo & tagline ---
|
||||
Column(
|
||||
children: [
|
||||
SvgPicture.asset(
|
||||
'assets/images/m8logo.svg',
|
||||
width: 80,
|
||||
height: 80,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'M8Chat',
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Chat and conference, all in one place',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// --- Conference section ---
|
||||
_SectionCard(
|
||||
theme: theme,
|
||||
isDark: isDark,
|
||||
icon: Icons.videocam_outlined,
|
||||
title: 'Join a Conference',
|
||||
subtitle: 'Paste your meeting link to join instantly. No account needed.',
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _linkController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'conf.m8chat.au/YourMeeting',
|
||||
prefixIcon: Icon(Icons.link),
|
||||
),
|
||||
textInputAction: TextInputAction.go,
|
||||
onSubmitted: (_) => _joinConference(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _joinConference,
|
||||
icon: const Icon(Icons.videocam),
|
||||
label: const Text('Join Meeting'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// --- Divider ---
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Divider()),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'or',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(102),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Expanded(child: Divider()),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// --- Sign in section ---
|
||||
_SectionCard(
|
||||
theme: theme,
|
||||
isDark: isDark,
|
||||
icon: Icons.chat_bubble_outline,
|
||||
title: 'Sign In to Chat',
|
||||
subtitle: 'Access your rooms, messages and video calls.',
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => context.go('/login'),
|
||||
icon: const Icon(Icons.login),
|
||||
label: const Text('Sign In'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
side: BorderSide(color: theme.colorScheme.primary),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Styled card for each welcome section.
|
||||
class _SectionCard extends StatelessWidget {
|
||||
const _SectionCard({
|
||||
required this.theme,
|
||||
required this.isDark,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final ThemeData theme;
|
||||
final bool isDark;
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: theme.colorScheme.primary, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
147
lib/features/panic/presentation/panic_button.dart
Normal file
147
lib/features/panic/presentation/panic_button.dart
Normal file
@@ -0,0 +1,147 @@
|
||||
// Version: 1.1.0 | Created: 2026-07-04 | Updated: 2026-07-04
|
||||
// Panic trigger button for the rooms AppBar. Push-to-talk style: HOLD for
|
||||
// 700ms to fire the alert, with a filling ring showing progress so a brush
|
||||
// cannot fire it and the user sees exactly when it sends. A plain tap only
|
||||
// shows the hint. Only rendered when a panic configuration exists.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _holdDuration = Duration(milliseconds: 700);
|
||||
|
||||
late final AnimationController _holdController;
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_holdController = AnimationController(vsync: this, duration: _holdDuration)
|
||||
..addStatusListener(_onHoldStatus);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_holdController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onHoldStatus(AnimationStatus status) {
|
||||
// The ring filled all the way: the hold completed, fire the alert.
|
||||
if (status == AnimationStatus.completed) {
|
||||
HapticFeedback.heavyImpact();
|
||||
_trigger();
|
||||
_holdController.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void _onHoldStart() {
|
||||
if (_sending) return;
|
||||
_holdController.forward(from: 0);
|
||||
}
|
||||
|
||||
void _onHoldCancelled() {
|
||||
// Released before the ring filled: cancel silently.
|
||||
if (_holdController.status == AnimationStatus.forward) {
|
||||
_holdController.reset();
|
||||
}
|
||||
}
|
||||
|
||||
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.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showHint() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Hold the button to send the emergency alert.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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(
|
||||
onTap: _showHint,
|
||||
onLongPressDown: (_) => _onHoldStart(),
|
||||
onLongPressUp: _onHoldCancelled,
|
||||
onLongPressCancel: _onHoldCancelled,
|
||||
child: Tooltip(
|
||||
message: 'Hold to send emergency alert',
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Progress ring, only painted while the hold is in progress.
|
||||
AnimatedBuilder(
|
||||
animation: _holdController,
|
||||
builder: (context, _) {
|
||||
if (_holdController.value == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: CircularProgressIndicator(
|
||||
value: _holdController.value,
|
||||
strokeWidth: 3,
|
||||
color: Colors.red,
|
||||
backgroundColor: Colors.red.withAlpha(40),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_sending)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
const Icon(Icons.emergency_share, color: Colors.red),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
213
lib/features/profile/presentation/key_restore_dialog.dart
Normal file
213
lib/features/profile/presentation/key_restore_dialog.dart
Normal file
@@ -0,0 +1,213 @@
|
||||
// Version: 1.0.1 | Created: 2026-04-11 | Updated: 2026-07-03
|
||||
// Dialog for entering a recovery key to restore encrypted message keys
|
||||
// from the server-side key backup (SSSS / Secure Secret Storage).
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:matrix/encryption/ssss.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
/// Shows a dialog that asks the user for their recovery key, unlocks SSSS,
|
||||
/// and restores all Megolm session keys from the server backup.
|
||||
///
|
||||
/// Returns `true` if keys were restored successfully, `false` otherwise.
|
||||
Future<bool> showKeyRestoreDialog(BuildContext context, Client client) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _KeyRestoreDialog(client: client),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
class _KeyRestoreDialog extends StatefulWidget {
|
||||
const _KeyRestoreDialog({required this.client});
|
||||
final Client client;
|
||||
|
||||
@override
|
||||
State<_KeyRestoreDialog> createState() => _KeyRestoreDialogState();
|
||||
}
|
||||
|
||||
enum _RestorePhase { input, unlocking, downloading, done, error }
|
||||
|
||||
class _KeyRestoreDialogState extends State<_KeyRestoreDialog> {
|
||||
final _controller = TextEditingController();
|
||||
_RestorePhase _phase = _RestorePhase.input;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _restore() async {
|
||||
final key = _controller.text.trim();
|
||||
if (key.isEmpty) return;
|
||||
|
||||
final enc = widget.client.encryption;
|
||||
if (enc == null) {
|
||||
setState(() {
|
||||
_phase = _RestorePhase.error;
|
||||
_error = 'Encryption is not available.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 1: Unlock SSSS with the recovery key
|
||||
setState(() => _phase = _RestorePhase.unlocking);
|
||||
try {
|
||||
final ssssKey = enc.ssss.open();
|
||||
await ssssKey.unlock(keyOrPassphrase: key);
|
||||
debugPrint('[KeyRestore] SSSS unlocked successfully');
|
||||
} on InvalidPassphraseException {
|
||||
setState(() {
|
||||
_phase = _RestorePhase.error;
|
||||
_error = 'Invalid recovery key. Please check and try again.';
|
||||
});
|
||||
return;
|
||||
} on Exception catch (e) {
|
||||
setState(() {
|
||||
_phase = _RestorePhase.error;
|
||||
_error = 'Could not unlock: ${e.toString().split('\n').first}';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 2: Download and decrypt all keys from backup
|
||||
setState(() => _phase = _RestorePhase.downloading);
|
||||
try {
|
||||
await enc.keyManager.loadAllKeys();
|
||||
debugPrint('[KeyRestore] All keys loaded from backup');
|
||||
setState(() => _phase = _RestorePhase.done);
|
||||
} on MatrixException catch (e) {
|
||||
if (e.errcode == 'M_NOT_FOUND') {
|
||||
setState(() {
|
||||
_phase = _RestorePhase.error;
|
||||
_error = 'No key backup found on the server for this account.';
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_phase = _RestorePhase.error;
|
||||
_error = 'Server error: ${e.errorMessage}';
|
||||
});
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
setState(() {
|
||||
_phase = _RestorePhase.error;
|
||||
_error = 'Failed to download keys: ${e.toString().split('\n').first}';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(switch (_phase) {
|
||||
_RestorePhase.input => 'Restore Message Keys',
|
||||
_RestorePhase.unlocking => 'Unlocking...',
|
||||
_RestorePhase.downloading => 'Downloading Keys...',
|
||||
_RestorePhase.done => 'Keys Restored',
|
||||
_RestorePhase.error => 'Restore Failed',
|
||||
}),
|
||||
content: switch (_phase) {
|
||||
_RestorePhase.input => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Enter your recovery key to decrypt old messages. '
|
||||
'This is the key you were given when you set up '
|
||||
'encryption in Element.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EsTc oRgW rqHN...',
|
||||
labelText: 'Recovery key',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _restore(),
|
||||
),
|
||||
],
|
||||
),
|
||||
_RestorePhase.unlocking => const _ProgressContent(
|
||||
message: 'Verifying recovery key...',
|
||||
),
|
||||
_RestorePhase.downloading => const _ProgressContent(
|
||||
message: 'Downloading and decrypting message keys...\n'
|
||||
'This may take a moment.',
|
||||
),
|
||||
_RestorePhase.done => const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: Colors.green, size: 48),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Message keys have been restored. '
|
||||
'Go back to your rooms. Previously encrypted '
|
||||
'messages should now be readable.',
|
||||
),
|
||||
],
|
||||
),
|
||||
_RestorePhase.error => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error ?? 'An unknown error occurred.'),
|
||||
],
|
||||
),
|
||||
},
|
||||
actions: switch (_phase) {
|
||||
_RestorePhase.input => [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _restore,
|
||||
child: const Text('Restore'),
|
||||
),
|
||||
],
|
||||
_RestorePhase.unlocking || _RestorePhase.downloading => [],
|
||||
_RestorePhase.done => [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
_RestorePhase.error => [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
setState(() => _phase = _RestorePhase.input),
|
||||
child: const Text('Try again'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProgressContent extends StatelessWidget {
|
||||
const _ProgressContent({required this.message});
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
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.1.0 | Created: 2026-04-01 | Updated: 2026-04-02
|
||||
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||
// Profile screen. Shows current user info and logout button.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -10,6 +10,10 @@ import '../../../core/config/app_config.dart';
|
||||
import '../../../core/network/matrix_client.dart';
|
||||
import '../../../shared/utils/matrix_id.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 'security_setup_dialog.dart';
|
||||
|
||||
class ProfileScreen extends ConsumerWidget {
|
||||
const ProfileScreen({super.key, this.embedded = false});
|
||||
@@ -85,14 +89,57 @@ class ProfileScreen extends ConsumerWidget {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.lock_outline),
|
||||
title: const Text('End-to-end encryption'),
|
||||
subtitle: const Text('Setup coming in Phase 3'),
|
||||
subtitle: Text(
|
||||
client.encryptionEnabled
|
||||
? 'Active. Messages are encrypted'
|
||||
: 'Not available in this browser',
|
||||
),
|
||||
enabled: false,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.verified_user_outlined),
|
||||
title: const Text('Verify devices'),
|
||||
subtitle: const Text('Cross-signing setup — coming in Phase 2'),
|
||||
enabled: false,
|
||||
leading: const Icon(Icons.key_outlined),
|
||||
title: const Text('Restore message keys'),
|
||||
subtitle: const Text(
|
||||
'Enter your recovery key to decrypt old messages',
|
||||
),
|
||||
enabled: client.encryptionEnabled,
|
||||
onTap: client.encryptionEnabled
|
||||
? () async {
|
||||
final restored =
|
||||
await showKeyRestoreDialog(context, client);
|
||||
if (restored && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Keys restored. Reopen rooms '
|
||||
'to see decrypted messages.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.shield_outlined),
|
||||
title: const Text('Security setup'),
|
||||
subtitle: Text(
|
||||
client.encryption?.crossSigning.enabled == true
|
||||
? 'Cross-signing active. Device verified'
|
||||
: 'Set up cross-signing and key backup',
|
||||
),
|
||||
enabled: client.encryptionEnabled,
|
||||
onTap: client.encryptionEnabled
|
||||
? () async {
|
||||
final completed =
|
||||
await showSecuritySetupDialog(context, client);
|
||||
if (completed && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Security setup complete.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.password_outlined),
|
||||
@@ -102,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 Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
416
lib/features/profile/presentation/security_setup_dialog.dart
Normal file
416
lib/features/profile/presentation/security_setup_dialog.dart
Normal file
@@ -0,0 +1,416 @@
|
||||
// Version: 1.0.1 | Created: 2026-04-11 | Updated: 2026-07-03
|
||||
// Security setup dialog: drives the Matrix SDK Bootstrap to set up
|
||||
// SSSS, cross-signing, and online key backup in one flow.
|
||||
// For new users: creates everything, shows recovery key.
|
||||
// For existing users: unlocks with recovery key, restores message keys.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:matrix/encryption/ssss.dart';
|
||||
import 'package:matrix/encryption/utils/bootstrap.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
/// Shows the security setup dialog. Returns true if setup completed.
|
||||
Future<bool> showSecuritySetupDialog(
|
||||
BuildContext context, Client client) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => _SecuritySetupDialog(client: client),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
class _SecuritySetupDialog extends StatefulWidget {
|
||||
const _SecuritySetupDialog({required this.client});
|
||||
final Client client;
|
||||
|
||||
@override
|
||||
State<_SecuritySetupDialog> createState() => _SecuritySetupDialogState();
|
||||
}
|
||||
|
||||
enum _Phase {
|
||||
checking,
|
||||
needsRecoveryKey,
|
||||
working,
|
||||
showRecoveryKey,
|
||||
restoring,
|
||||
done,
|
||||
error,
|
||||
}
|
||||
|
||||
class _SecuritySetupDialogState extends State<_SecuritySetupDialog> {
|
||||
final _keyController = TextEditingController();
|
||||
_Phase _phase = _Phase.checking;
|
||||
String? _error;
|
||||
String? _recoveryKey;
|
||||
String _statusMessage = 'Checking security setup...';
|
||||
late Bootstrap _bootstrap;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_start();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
final enc = widget.client.encryption;
|
||||
if (enc == null) {
|
||||
_setError('Encryption is not available in this browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
_bootstrap = enc.bootstrap(onUpdate: (_) {});
|
||||
|
||||
try {
|
||||
await _driveBootstrap();
|
||||
} catch (e) {
|
||||
_setError(e.toString().split('\n').first);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _driveBootstrap() async {
|
||||
while (_bootstrap.state != BootstrapState.done &&
|
||||
_bootstrap.state != BootstrapState.error) {
|
||||
debugPrint('[Security] Bootstrap state: ${_bootstrap.state}');
|
||||
|
||||
switch (_bootstrap.state) {
|
||||
case BootstrapState.loading:
|
||||
setState(() {
|
||||
_phase = _Phase.working;
|
||||
_statusMessage = 'Setting up...';
|
||||
});
|
||||
// Wait for the bootstrap to advance via onUpdate
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
|
||||
case BootstrapState.askWipeSsss:
|
||||
// Existing SSSS found — don't wipe, use it
|
||||
setState(() {
|
||||
_phase = _Phase.working;
|
||||
_statusMessage = 'Found existing security setup...';
|
||||
});
|
||||
_bootstrap.wipeSsss(false);
|
||||
|
||||
case BootstrapState.askUseExistingSsss:
|
||||
// Use the existing SSSS
|
||||
_bootstrap.useExistingSsss(true);
|
||||
|
||||
case BootstrapState.openExistingSsss:
|
||||
// Need the recovery key from the user
|
||||
if (!_bootstrap.newSsssKey!.isUnlocked) {
|
||||
setState(() => _phase = _Phase.needsRecoveryKey);
|
||||
return; // Wait for user input
|
||||
}
|
||||
// Already unlocked, continue
|
||||
setState(() {
|
||||
_phase = _Phase.working;
|
||||
_statusMessage = 'Verifying security keys...';
|
||||
});
|
||||
await _bootstrap.openExistingSsss();
|
||||
|
||||
case BootstrapState.askBadSsss:
|
||||
// Ignore bad secrets and continue
|
||||
_bootstrap.ignoreBadSecrets(true);
|
||||
|
||||
case BootstrapState.askUnlockSsss:
|
||||
// Old keys need unlocking — this is a migration scenario
|
||||
if (_bootstrap.oldSsssKeys != null) {
|
||||
for (final key in _bootstrap.oldSsssKeys!.values) {
|
||||
if (!key.isUnlocked) {
|
||||
setState(() => _phase = _Phase.needsRecoveryKey);
|
||||
return; // Wait for user input
|
||||
}
|
||||
}
|
||||
}
|
||||
_bootstrap.unlockedSsss();
|
||||
|
||||
case BootstrapState.askNewSsss:
|
||||
// Create new SSSS (no passphrase — recovery key only)
|
||||
setState(() {
|
||||
_phase = _Phase.working;
|
||||
_statusMessage = 'Creating security keys...';
|
||||
});
|
||||
await _bootstrap.newSsss();
|
||||
|
||||
case BootstrapState.askSetupCrossSigning:
|
||||
setState(() => _statusMessage = 'Setting up cross-signing...');
|
||||
await _bootstrap.askSetupCrossSigning(
|
||||
setupMasterKey: true,
|
||||
setupSelfSigningKey: true,
|
||||
setupUserSigningKey: true,
|
||||
);
|
||||
|
||||
case BootstrapState.askWipeCrossSigning:
|
||||
// Don't wipe existing cross-signing
|
||||
await _bootstrap.wipeCrossSigning(false);
|
||||
|
||||
case BootstrapState.askSetupOnlineKeyBackup:
|
||||
setState(() => _statusMessage = 'Creating key backup...');
|
||||
await _bootstrap.askSetupOnlineKeyBackup(true);
|
||||
|
||||
case BootstrapState.askWipeOnlineKeyBackup:
|
||||
// Don't wipe existing backup
|
||||
_bootstrap.wipeOnlineKeyBackup(false);
|
||||
|
||||
case BootstrapState.done:
|
||||
case BootstrapState.error:
|
||||
break; // Exit loop
|
||||
}
|
||||
}
|
||||
|
||||
if (_bootstrap.state == BootstrapState.error) {
|
||||
_setError('Setup failed. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Success — show recovery key if we created new SSSS
|
||||
_recoveryKey = _bootstrap.newSsssKey?.recoveryKey;
|
||||
|
||||
if (_recoveryKey != null && _phase != _Phase.needsRecoveryKey) {
|
||||
// New setup — show the recovery key
|
||||
setState(() => _phase = _Phase.showRecoveryKey);
|
||||
} else {
|
||||
// Existing setup — restore keys from backup
|
||||
await _restoreKeys();
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the user enters their recovery key for existing SSSS.
|
||||
Future<void> _unlockWithRecoveryKey() async {
|
||||
final key = _keyController.text.trim();
|
||||
if (key.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_phase = _Phase.working;
|
||||
_statusMessage = 'Verifying recovery key...';
|
||||
});
|
||||
|
||||
try {
|
||||
// Unlock whichever SSSS key is waiting
|
||||
if (_bootstrap.newSsssKey != null && !_bootstrap.newSsssKey!.isUnlocked) {
|
||||
await _bootstrap.newSsssKey!.unlock(keyOrPassphrase: key);
|
||||
}
|
||||
if (_bootstrap.oldSsssKeys != null) {
|
||||
for (final ssssKey in _bootstrap.oldSsssKeys!.values) {
|
||||
if (!ssssKey.isUnlocked) {
|
||||
await ssssKey.unlock(keyOrPassphrase: key);
|
||||
}
|
||||
}
|
||||
}
|
||||
} on InvalidPassphraseException {
|
||||
_setError('Invalid recovery key. Please check and try again.');
|
||||
return;
|
||||
} on Exception catch (e) {
|
||||
_setError('Could not unlock: ${e.toString().split('\n').first}');
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue the bootstrap
|
||||
try {
|
||||
await _driveBootstrap();
|
||||
} catch (e) {
|
||||
_setError(e.toString().split('\n').first);
|
||||
}
|
||||
}
|
||||
|
||||
/// Download all keys from the online key backup after SSSS is unlocked.
|
||||
Future<void> _restoreKeys() async {
|
||||
setState(() {
|
||||
_phase = _Phase.restoring;
|
||||
_statusMessage = 'Downloading message keys from backup...';
|
||||
});
|
||||
|
||||
try {
|
||||
await widget.client.encryption?.keyManager.loadAllKeys();
|
||||
debugPrint('[Security] All keys loaded from backup');
|
||||
} on MatrixException catch (e) {
|
||||
if (e.errcode != 'M_NOT_FOUND') {
|
||||
debugPrint('[Security] Key restore error: ${e.errorMessage}');
|
||||
}
|
||||
// M_NOT_FOUND just means no backup exists yet — not an error
|
||||
} on Exception catch (e) {
|
||||
debugPrint('[Security] Key restore error: $e');
|
||||
}
|
||||
|
||||
setState(() => _phase = _Phase.done);
|
||||
}
|
||||
|
||||
void _setError(String message) {
|
||||
setState(() {
|
||||
_phase = _Phase.error;
|
||||
_error = message;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(switch (_phase) {
|
||||
_Phase.checking => 'Security Setup',
|
||||
_Phase.needsRecoveryKey => 'Enter Recovery Key',
|
||||
_Phase.working || _Phase.restoring => 'Setting Up...',
|
||||
_Phase.showRecoveryKey => 'Save Your Recovery Key',
|
||||
_Phase.done => 'Security Setup Complete',
|
||||
_Phase.error => 'Setup Error',
|
||||
}),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: switch (_phase) {
|
||||
_Phase.checking || _Phase.working || _Phase.restoring => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(_statusMessage, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
_Phase.needsRecoveryKey => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Enter your recovery key to unlock your encrypted '
|
||||
'messages. This is the key shown when you first set '
|
||||
'up encryption in Element.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _keyController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'EsTc oRgW rqHN...',
|
||||
labelText: 'Recovery key or passphrase',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
onSubmitted: (_) => _unlockWithRecoveryKey(),
|
||||
),
|
||||
],
|
||||
),
|
||||
_Phase.showRecoveryKey => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Your recovery key has been created. Save it somewhere '
|
||||
'safe. You will need it to restore your messages on '
|
||||
'new devices.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
child: SelectableText(
|
||||
_recoveryKey ?? '',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'If you lose this key, you will not be able to '
|
||||
'read your encrypted messages on new devices.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
_Phase.done => const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.verified, color: Colors.green, size: 48),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Security is set up. Cross-signing is active and '
|
||||
'your message keys are backed up. Previously '
|
||||
'encrypted messages should now be readable.',
|
||||
),
|
||||
],
|
||||
),
|
||||
_Phase.error => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error ?? 'An unknown error occurred.'),
|
||||
],
|
||||
),
|
||||
},
|
||||
),
|
||||
actions: switch (_phase) {
|
||||
_Phase.checking || _Phase.working || _Phase.restoring => [],
|
||||
_Phase.needsRecoveryKey => [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _unlockWithRecoveryKey,
|
||||
child: const Text('Unlock'),
|
||||
),
|
||||
],
|
||||
_Phase.showRecoveryKey => [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: _recoveryKey ?? ''));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Recovery key copied to clipboard')),
|
||||
);
|
||||
},
|
||||
child: const Text('Copy'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await _restoreKeys();
|
||||
},
|
||||
child: const Text('I saved it, continue'),
|
||||
),
|
||||
],
|
||||
_Phase.done => [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
_Phase.error => [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _phase = _Phase.needsRecoveryKey);
|
||||
},
|
||||
child: const Text('Try again'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Close'),
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02
|
||||
// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-27
|
||||
// Rooms repository. Reads room list from the Matrix SDK client.
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
225
lib/features/rooms/presentation/public_rooms_dialog.dart
Normal file
225
lib/features/rooms/presentation/public_rooms_dialog.dart
Normal file
@@ -0,0 +1,225 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-27
|
||||
// Public room browser — search Matrix room directory and join rooms.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:matrix/matrix_api_lite.dart'
|
||||
show PublicRoomQueryFilter, PublicRoomsChunk;
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../core/network/matrix_client.dart';
|
||||
import '../../../shared/widgets/matrix_avatar.dart';
|
||||
|
||||
part 'public_rooms_dialog.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<PublicRoomsChunk>> publicRooms(
|
||||
Ref ref, String searchTerm) async {
|
||||
final client = ref.watch(matrixClientProvider);
|
||||
final filter = searchTerm.trim().isEmpty
|
||||
? null
|
||||
: PublicRoomQueryFilter(genericSearchTerm: searchTerm.trim());
|
||||
|
||||
final response = await client.queryPublicRooms(
|
||||
filter: filter,
|
||||
limit: 30,
|
||||
);
|
||||
return response.chunk;
|
||||
}
|
||||
|
||||
Future<void> showPublicRoomsDialog(BuildContext context) {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const _PublicRoomsDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
class _PublicRoomsDialog extends ConsumerStatefulWidget {
|
||||
const _PublicRoomsDialog();
|
||||
|
||||
@override
|
||||
ConsumerState<_PublicRoomsDialog> createState() =>
|
||||
_PublicRoomsDialogState();
|
||||
}
|
||||
|
||||
class _PublicRoomsDialogState extends ConsumerState<_PublicRoomsDialog> {
|
||||
final _controller = TextEditingController();
|
||||
String _searchTerm = '';
|
||||
Timer? _debounce;
|
||||
String? _joiningRoomId;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSearchChanged(String val) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 450), () {
|
||||
if (mounted) setState(() => _searchTerm = val);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _join(PublicRoomsChunk room) async {
|
||||
if (_joiningRoomId != null) return;
|
||||
setState(() => _joiningRoomId = room.roomId);
|
||||
|
||||
try {
|
||||
final client = ref.read(matrixClientProvider);
|
||||
await client.joinRoom(room.canonicalAlias ?? room.roomId);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
context.push('/rooms/${Uri.encodeComponent(room.roomId)}');
|
||||
} on Exception catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _joiningRoomId = null);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Could not join room: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final roomsAsync = ref.watch(publicRoomsProvider(_searchTerm));
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 600),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 4, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Find rooms',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Search
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search public rooms...',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
),
|
||||
|
||||
// Results
|
||||
Flexible(
|
||||
child: roomsAsync.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Search failed: $err'),
|
||||
),
|
||||
data: (rooms) {
|
||||
if (rooms.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
_searchTerm.isEmpty
|
||||
? 'No public rooms found.'
|
||||
: 'No rooms match "$_searchTerm".',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(102),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: rooms.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const Divider(height: 1, indent: 72),
|
||||
itemBuilder: (context, index) {
|
||||
final room = rooms[index];
|
||||
final name = room.name ??
|
||||
room.canonicalAlias ??
|
||||
room.roomId;
|
||||
final isJoining = _joiningRoomId == room.roomId;
|
||||
|
||||
return ListTile(
|
||||
leading: MatrixAvatar(
|
||||
name: name,
|
||||
avatarUrl: room.avatarUrl?.toString(),
|
||||
radius: 20,
|
||||
),
|
||||
title: Text(
|
||||
name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (room.topic != null)
|
||||
Text(
|
||||
room.topic!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
'${room.numJoinedMembers} member'
|
||||
'${room.numJoinedMembers == 1 ? '' : 's'}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface
|
||||
.withAlpha(120),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
isThreeLine: room.topic != null,
|
||||
trailing: isJoining
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2),
|
||||
)
|
||||
: OutlinedButton(
|
||||
onPressed: _joiningRoomId != null
|
||||
? null
|
||||
: () => _join(room),
|
||||
child: const Text('Join'),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// Version: 1.1.0 | Created: 2026-04-01 | Updated: 2026-04-02
|
||||
// Individual room list tile. Kept under 100 lines.
|
||||
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||
// Individual room list tile. Kept under 120 lines.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:timeago/timeago.dart' as timeago;
|
||||
|
||||
import '../../../app/theme.dart';
|
||||
import '../../../shared/widgets/matrix_avatar.dart';
|
||||
import '../domain/room_model.dart';
|
||||
|
||||
@@ -19,6 +20,8 @@ class RoomTile extends StatelessWidget {
|
||||
final hasUnread = room.unreadCount > 0;
|
||||
|
||||
return ListTile(
|
||||
visualDensity: VisualDensity.compact,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
leading: MatrixAvatar(
|
||||
name: room.displayName,
|
||||
avatarUrl: room.avatarUrl,
|
||||
@@ -38,12 +41,15 @@ class RoomTile extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
color: hasUnread
|
||||
? theme.colorScheme.onSurface.withAlpha(230)
|
||||
: M8Colours.subtleText,
|
||||
fontWeight: hasUnread ? FontWeight.w500 : FontWeight.normal,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (room.lastActivityAt != null)
|
||||
@@ -52,13 +58,14 @@ class RoomTile extends StatelessWidget {
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: hasUnread
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface.withAlpha(102),
|
||||
: M8Colours.subtleText,
|
||||
),
|
||||
),
|
||||
if (hasUnread) ...[
|
||||
const SizedBox(height: 4),
|
||||
_UnreadBadge(count: room.unreadCount),
|
||||
],
|
||||
] else
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
onTap: onTap,
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
// Version: 1.1.0 | Created: 2026-04-01
|
||||
// Version: 1.7.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||
// Main rooms list screen with bottom navigation.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import '../../../app/theme.dart';
|
||||
import '../../../core/network/matrix_client.dart';
|
||||
import '../../help/presentation/help_tab.dart';
|
||||
import '../../panic/presentation/panic_button.dart';
|
||||
import '../../jitsi/presentation/conference_tab.dart';
|
||||
import '../../profile/presentation/key_restore_prompt.dart';
|
||||
import '../../profile/presentation/profile_screen.dart';
|
||||
import '../../spaces/presentation/spaces_screen.dart';
|
||||
import 'public_rooms_dialog.dart';
|
||||
import 'room_tile.dart';
|
||||
import 'rooms_controller.dart';
|
||||
import 'user_search_dialog.dart';
|
||||
@@ -20,6 +29,36 @@ class RoomsScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _RoomsScreenState extends ConsumerState<RoomsScreen> {
|
||||
int _selectedIndex = 0;
|
||||
bool _searchActive = false;
|
||||
String _searchQuery = '';
|
||||
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
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleSearch() {
|
||||
setState(() {
|
||||
_searchActive = !_searchActive;
|
||||
if (!_searchActive) {
|
||||
_searchQuery = '';
|
||||
_searchController.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static const _destinations = [
|
||||
NavigationDestination(
|
||||
@@ -37,13 +76,25 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: 'Profile',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.videocam_outlined),
|
||||
selectedIcon: Icon(Icons.videocam),
|
||||
label: 'Conference',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.help_outline),
|
||||
selectedIcon: Icon(Icons.help),
|
||||
label: 'Help',
|
||||
),
|
||||
];
|
||||
|
||||
Widget _buildBody() {
|
||||
return switch (_selectedIndex) {
|
||||
0 => const _RoomListBody(),
|
||||
0 => _RoomListBody(searchQuery: _searchQuery),
|
||||
1 => const SpacesScreen(embedded: true),
|
||||
2 => const ProfileScreen(embedded: true),
|
||||
3 => const ConferenceTab(),
|
||||
4 => const HelpTab(),
|
||||
_ => const _RoomListBody(),
|
||||
};
|
||||
}
|
||||
@@ -53,24 +104,42 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
|
||||
return Scaffold(
|
||||
appBar: _selectedIndex == 0
|
||||
? AppBar(
|
||||
title: const Text('M8Chat'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
tooltip: 'Search rooms',
|
||||
onPressed: () {
|
||||
// Phase 2: room search
|
||||
},
|
||||
title: _searchActive
|
||||
? TextField(
|
||||
controller: _searchController,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Search rooms...',
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (v) => setState(() => _searchQuery = v),
|
||||
)
|
||||
: const Text('M8Chat'),
|
||||
actions: [
|
||||
const PanicButton(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_square),
|
||||
tooltip: 'New message',
|
||||
onPressed: () => showUserSearchDialog(context),
|
||||
icon: Icon(_searchActive ? Icons.close : Icons.search),
|
||||
tooltip: _searchActive ? 'Cancel search' : 'Search rooms',
|
||||
onPressed: _toggleSearch,
|
||||
),
|
||||
if (!_searchActive)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.explore_outlined),
|
||||
tooltip: 'Find rooms',
|
||||
onPressed: () => showPublicRoomsDialog(context),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
body: _buildBody(),
|
||||
floatingActionButton: _selectedIndex == 0 && !_searchActive
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () => showUserSearchDialog(context),
|
||||
tooltip: 'New message',
|
||||
icon: const Icon(Icons.edit_square),
|
||||
label: const Text('New message'),
|
||||
)
|
||||
: null,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) =>
|
||||
@@ -82,7 +151,9 @@ class _RoomsScreenState extends ConsumerState<RoomsScreen> {
|
||||
}
|
||||
|
||||
class _RoomListBody extends ConsumerWidget {
|
||||
const _RoomListBody();
|
||||
const _RoomListBody({this.searchQuery = ''});
|
||||
|
||||
final String searchQuery;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -118,14 +189,39 @@ class _RoomListBody extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
data: (rooms) {
|
||||
if (rooms.isEmpty) {
|
||||
return const _EmptyRoomsState();
|
||||
final filtered = searchQuery.trim().isEmpty
|
||||
? rooms
|
||||
: rooms
|
||||
.where((r) => r.displayName
|
||||
.toLowerCase()
|
||||
.contains(searchQuery.trim().toLowerCase()))
|
||||
.toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
return searchQuery.isNotEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'No rooms match "$searchQuery".',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withAlpha(153),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const _EmptyRoomsState();
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
itemCount: rooms.length,
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1, indent: 72),
|
||||
itemBuilder: (context, index) {
|
||||
final room = rooms[index];
|
||||
final room = filtered[index];
|
||||
return RoomTile(
|
||||
room: room,
|
||||
onTap: () =>
|
||||
@@ -159,16 +255,24 @@ class _EmptyRoomsState extends StatelessWidget {
|
||||
Text(
|
||||
'No rooms yet',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(153),
|
||||
color: M8Colours.subtleText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Rooms you join will appear here.',
|
||||
'Tap the New message button to message someone, or find '
|
||||
'a public room to join.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withAlpha(102),
|
||||
color: M8Colours.subtleText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => showPublicRoomsDialog(context),
|
||||
icon: const Icon(Icons.explore_outlined),
|
||||
label: const Text('Find rooms'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
// Version: 1.0.1 | Created: 2026-04-01
|
||||
// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-27
|
||||
// Application entry point.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:olm/olm.dart' as olm;
|
||||
|
||||
import 'app/app.dart';
|
||||
import 'core/push/web_push_service.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Check Olm availability — this prints to the browser console.
|
||||
try {
|
||||
await olm.init();
|
||||
debugPrint('[M8Chat] Olm initialized successfully: ${olm.get_library_version()}');
|
||||
} catch (e) {
|
||||
debugPrint('[M8Chat] Olm NOT available: $e');
|
||||
}
|
||||
|
||||
// Register the push service worker at startup so the browser knows about it.
|
||||
// The actual push subscription happens after login (in auth_notifier.dart).
|
||||
if (kIsWeb) {
|
||||
unawaited(registerPushServiceWorker());
|
||||
}
|
||||
|
||||
// Catch Flutter framework errors (widget build, rendering) without crashing.
|
||||
FlutterError.onError = (details) {
|
||||
debugPrint('[M8Chat] Flutter error: ${details.exceptionAsString()}');
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-02
|
||||
// Version: 2.0.0 | Created: 2026-04-02 | Updated: 2026-04-11
|
||||
// Synchronous MXC URI to HTTP URL resolution.
|
||||
// Avatars and thumbnails in the room list / sync service need a resolved HTTP
|
||||
// URL. The Matrix SDK's getDownloadUri() is async (checks authenticated media
|
||||
// support), but for avatar display we need a synchronous result.
|
||||
//
|
||||
// This helper builds the legacy v3 media URL which works on all homeservers.
|
||||
// Synapse 1.120+ requires authenticated media downloads. The old
|
||||
// /_matrix/media/v3/download/ endpoint is frozen and returns 404.
|
||||
// We use /_matrix/client/v1/media/download/ with the access token.
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
/// Resolves an `mxc://` [Uri] to an HTTP download URL using the client's
|
||||
/// homeserver. Returns `null` if the URI is not an mxc scheme or the client
|
||||
/// has no homeserver set.
|
||||
///
|
||||
/// This is synchronous — suitable for use in non-async model mapping.
|
||||
/// Resolves an `mxc://` [Uri] to an authenticated HTTP download URL.
|
||||
/// Returns `null` if the URI is not mxc:// or the client is not connected.
|
||||
String? resolveMxcUrl(Client client, Uri? mxcUri) {
|
||||
if (mxcUri == null || !mxcUri.isScheme('mxc')) return null;
|
||||
final homeserver = client.homeserver;
|
||||
if (homeserver == null) return null;
|
||||
|
||||
// Build the media download path per the Matrix spec.
|
||||
final serverName = mxcUri.host;
|
||||
final port = mxcUri.hasPort ? ':${mxcUri.port}' : '';
|
||||
final mediaId = mxcUri.path; // includes leading /
|
||||
return homeserver
|
||||
.resolve('_matrix/media/v3/download/$serverName$port$mediaId')
|
||||
|
||||
// Use the authenticated media endpoint (MSC3916).
|
||||
final base = homeserver
|
||||
.resolve('_matrix/client/v1/media/download/$serverName$port$mediaId')
|
||||
.toString();
|
||||
|
||||
// Append the access token so CachedNetworkImage / Image.network can
|
||||
// fetch without custom headers. The token is already in the browser's
|
||||
// JS memory so this doesn't expand the attack surface.
|
||||
final token = client.accessToken;
|
||||
if (token == null) return base;
|
||||
return '$base?access_token=$token';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-02
|
||||
// Version: 1.1.1 | Created: 2026-04-02 | Updated: 2026-07-03
|
||||
// Shared utility for generating a last-message preview string from a Room.
|
||||
// Used by both RoomsRepository and SyncPersistenceService to avoid duplication.
|
||||
|
||||
@@ -12,7 +12,7 @@ String? lastMessagePreview(Room room) {
|
||||
|
||||
return switch (lastEvent.type) {
|
||||
EventTypes.Message => lastEvent.body,
|
||||
EventTypes.Encrypted => 'Encrypted message',
|
||||
EventTypes.Encrypted => '\u{1F512} Encrypted. Use Element to read',
|
||||
EventTypes.Sticker => 'Sticker',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-02
|
||||
// Version: 1.1.0 | Created: 2026-04-02 | Updated: 2026-07-04
|
||||
// Shared avatar widget used throughout the app. Displays a cached network
|
||||
// image when an HTTP avatar URL is available, or falls back to a coloured
|
||||
// circle with the first letter of the display name.
|
||||
// image when an HTTP avatar URL is available, or falls back to a circle
|
||||
// coloured deterministically from the display name (so each person gets a
|
||||
// stable, distinct disc) with the first letter of the display name.
|
||||
//
|
||||
// The [avatarUrl] MUST be a resolved HTTP URL — never pass an mxc:// URI.
|
||||
|
||||
@@ -25,6 +26,21 @@ class MatrixAvatar extends StatelessWidget {
|
||||
/// Radius of the [CircleAvatar].
|
||||
final double radius;
|
||||
|
||||
/// Brand-compatible hues for initials avatars. All dark enough for white
|
||||
/// initials to remain legible.
|
||||
static const List<Color> _palette = [
|
||||
Color(0xFF1265ED), // brand blue
|
||||
Color(0xFF2E7DE0), // sky
|
||||
Color(0xFF1E88A8), // teal
|
||||
Color(0xFF3949AB), // indigo
|
||||
Color(0xFF6A4CC0), // violet
|
||||
Color(0xFF00897B), // green-teal
|
||||
Color(0xFF5C6BC0), // periwinkle
|
||||
Color(0xFFB1631F), // warm amber
|
||||
];
|
||||
|
||||
Color get _discColour => _palette[name.hashCode.abs() % _palette.length];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
@@ -40,13 +56,13 @@ class MatrixAvatar extends StatelessWidget {
|
||||
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: theme.colorScheme.primary.withAlpha(51),
|
||||
backgroundColor: _discColour,
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
fontSize: radius * 0.7,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: m8chat_app
|
||||
description: "M8Chat — Matrix chat client for Android, iOS, and Web."
|
||||
publish_to: 'none'
|
||||
version: 1.1.0+2
|
||||
version: 1.11.0+15
|
||||
|
||||
environment:
|
||||
sdk: '>=3.11.0 <4.0.0'
|
||||
@@ -12,6 +12,7 @@ dependencies:
|
||||
|
||||
# Matrix SDK
|
||||
matrix: ^0.33.0
|
||||
olm: ^2.0.4
|
||||
|
||||
# State management
|
||||
flutter_riverpod: ^3.0.0
|
||||
@@ -42,6 +43,9 @@ dependencies:
|
||||
# Emoji reactions
|
||||
emoji_picker_flutter: ^4.0.0
|
||||
|
||||
# Web platform (required for dart:js_interop DOM access)
|
||||
web: ^1.1.0
|
||||
|
||||
# UI
|
||||
cached_network_image: ^3.4.0
|
||||
timeago: ^3.7.0
|
||||
|
||||
104
test/history_pagination_test.dart
Normal file
104
test/history_pagination_test.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
// Version: 1.0.0 | Created: 2026-07-04
|
||||
// Verifies the chat timeline requests older history when scrolled near the
|
||||
// top (oldest end) of the reverse list. Driven through the real widget tree
|
||||
// with a fake repository, since Playwright cannot scroll the CanvasKit surface.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:m8chat_app/core/network/matrix_client.dart';
|
||||
import 'package:m8chat_app/features/chat/data/chat_repository.dart';
|
||||
import 'package:m8chat_app/features/chat/domain/message_model.dart';
|
||||
import 'package:m8chat_app/features/chat/presentation/chat_controller.dart';
|
||||
import 'package:m8chat_app/features/chat/presentation/chat_screen.dart';
|
||||
|
||||
class _FakeChatRepository extends ChatRepository {
|
||||
_FakeChatRepository() : super(client: Client('test-client'));
|
||||
|
||||
int loadMoreCalls = 0;
|
||||
bool _hasMore = true;
|
||||
|
||||
@override
|
||||
bool canLoadMore(String roomId) => _hasMore;
|
||||
|
||||
@override
|
||||
Future<void> loadMoreMessages(String roomId) async {
|
||||
loadMoreCalls++;
|
||||
_hasMore = false; // one page then exhausted
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('scrolling to the top requests older history once', (
|
||||
tester,
|
||||
) async {
|
||||
const roomId = '!paginate:server';
|
||||
final base = DateTime(2026, 7, 4, 8);
|
||||
final messages = [
|
||||
for (var i = 40; i >= 1; i--)
|
||||
MessageModel(
|
||||
eventId: '\$e$i',
|
||||
roomId: roomId,
|
||||
senderId: '@me:server',
|
||||
senderDisplayName: 'Me',
|
||||
timestamp: base.add(Duration(seconds: i)),
|
||||
type: MessageType.text,
|
||||
body: 'Message $i',
|
||||
isMine: true,
|
||||
),
|
||||
];
|
||||
|
||||
final repo = _FakeChatRepository();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
matrixClientProvider.overrideWithValue(Client('test-client')),
|
||||
chatRepositoryProvider.overrideWithValue(repo),
|
||||
chatTimelineProvider(
|
||||
roomId,
|
||||
).overrideWith((ref) => Stream.value(messages)),
|
||||
],
|
||||
child: const MaterialApp(home: ChatScreen(roomId: roomId)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(repo.loadMoreCalls, 0);
|
||||
|
||||
// Confirm the override is actually in effect.
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(ChatScreen)),
|
||||
);
|
||||
expect(identical(container.read(chatRepositoryProvider), repo), isTrue);
|
||||
|
||||
double offset() => tester
|
||||
.state<ScrollableState>(find.byType(Scrollable).first)
|
||||
.position
|
||||
.pixels;
|
||||
double maxExtent() => tester
|
||||
.state<ScrollableState>(find.byType(Scrollable).first)
|
||||
.position
|
||||
.maxScrollExtent;
|
||||
|
||||
// Scroll up toward the oldest end (reverse list: drag content down).
|
||||
await tester.drag(find.byType(ListView), const Offset(0, 3000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
offset(),
|
||||
greaterThan(maxExtent() - 400),
|
||||
reason:
|
||||
'drag should have reached the oldest end (offset=${offset()}, '
|
||||
'max=${maxExtent()})',
|
||||
);
|
||||
|
||||
expect(
|
||||
repo.loadMoreCalls,
|
||||
greaterThan(0),
|
||||
reason: 'reaching the top should request older history',
|
||||
);
|
||||
});
|
||||
}
|
||||
80
test/scroll_to_latest_test.dart
Normal file
80
test/scroll_to_latest_test.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
// Version: 1.0.0 | Created: 2026-07-04
|
||||
// Deterministic test for the chat timeline's scroll-to-latest button.
|
||||
// Playwright cannot drive scrolling on the Flutter CanvasKit surface, so the
|
||||
// jump-to-latest behaviour is verified here against the real widget tree.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:m8chat_app/core/network/matrix_client.dart';
|
||||
import 'package:m8chat_app/features/chat/domain/message_model.dart';
|
||||
import 'package:m8chat_app/features/chat/presentation/chat_controller.dart';
|
||||
import 'package:m8chat_app/features/chat/presentation/chat_screen.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'jump-to-latest button appears when scrolled up and returns to bottom',
|
||||
(tester) async {
|
||||
const roomId = '!scrolltest:server';
|
||||
final base = DateTime(2026, 7, 4, 8);
|
||||
// Index 0 is the newest message (the timeline stream is newest-first).
|
||||
final messages = [
|
||||
for (var i = 40; i >= 1; i--)
|
||||
MessageModel(
|
||||
eventId: '\$e$i',
|
||||
roomId: roomId,
|
||||
senderId: '@me:server',
|
||||
senderDisplayName: 'Me',
|
||||
timestamp: base.add(Duration(seconds: i)),
|
||||
type: MessageType.text,
|
||||
body: 'Message $i',
|
||||
isMine: true,
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
matrixClientProvider.overrideWithValue(Client('test-client')),
|
||||
chatTimelineProvider(
|
||||
roomId,
|
||||
).overrideWith((ref) => Stream.value(messages)),
|
||||
],
|
||||
child: const MaterialApp(home: ChatScreen(roomId: roomId)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final listView = find.byType(ListView);
|
||||
expect(listView, findsOneWidget);
|
||||
|
||||
double offset() =>
|
||||
tester.state<ScrollableState>(find.byType(Scrollable).first)
|
||||
.position
|
||||
.pixels;
|
||||
|
||||
// Starts pinned to the newest message (offset 0 in a reverse list).
|
||||
expect(offset(), 0);
|
||||
|
||||
// Scroll up toward older messages (reverse list: drag content down).
|
||||
await tester.drag(listView, const Offset(0, 900));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
offset(),
|
||||
greaterThan(400),
|
||||
reason: 'should have scrolled well past the button threshold',
|
||||
);
|
||||
|
||||
// The jump-to-latest button is now shown; tap it.
|
||||
final jumpButton = find.byIcon(Icons.arrow_downward);
|
||||
expect(jumpButton, findsOneWidget);
|
||||
await tester.tap(jumpButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Back at the newest message.
|
||||
expect(offset(), 0);
|
||||
},
|
||||
);
|
||||
}
|
||||
15
web/.htaccess
Normal file
15
web/.htaccess
Normal file
@@ -0,0 +1,15 @@
|
||||
# Required for SharedArrayBuffer — needed by LiveKit E2EE frame encryption.
|
||||
# Without COOP+COEP, the LiveKit E2EE web worker cannot be created.
|
||||
<IfModule mod_headers.c>
|
||||
Header set Cross-Origin-Opener-Policy "same-origin"
|
||||
Header set Cross-Origin-Embedder-Policy "require-corp"
|
||||
</IfModule>
|
||||
|
||||
# Flutter HTML5 routing — all unknown paths serve index.html
|
||||
Options -Indexes
|
||||
RewriteEngine On
|
||||
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
|
||||
RewriteRule ^(.*)$ /index.html [L,QSA]
|
||||
@@ -18,12 +18,12 @@
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
<meta name="description" content="M8Chat — secure chat and conferencing.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="m8chat_app">
|
||||
<meta name="apple-mobile-web-app-title" content="M8Chat">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
@@ -49,12 +49,43 @@
|
||||
}
|
||||
</script>
|
||||
<!--
|
||||
Olm (E2EE) disabled for Phase 1 web builds. The Matrix SDK gracefully
|
||||
skips encryption when Olm is not loaded — unencrypted rooms work fine.
|
||||
E2EE requires persistent Olm account storage (IndexedDB) to avoid
|
||||
device key conflicts on /keys/upload (400). Deferred to Phase 3.
|
||||
The olm.js and olm.wasm files remain deployed for future use.
|
||||
Olm (E2EE): load olm.js synchronously, await Olm.init() for WASM, THEN
|
||||
inject flutter_bootstrap.js so the Matrix SDK finds global Olm ready.
|
||||
If Olm fails to load (e.g. WASM blocked by browser), Flutter boots anyway
|
||||
without encryption — unencrypted rooms still work.
|
||||
-->
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
<script src="olm.js"></script>
|
||||
<script>
|
||||
(function() {
|
||||
function bootFlutter() {
|
||||
var s = document.createElement('script');
|
||||
s.src = 'flutter_bootstrap.js';
|
||||
s.async = true;
|
||||
document.body.appendChild(s);
|
||||
}
|
||||
// Ensure Olm is accessible on both window and globalThis.
|
||||
// dart2js may resolve bare 'Olm' via different global references.
|
||||
if (typeof Olm !== 'undefined') {
|
||||
window.Olm = Olm;
|
||||
if (typeof globalThis !== 'undefined') globalThis.Olm = Olm;
|
||||
if (typeof self !== 'undefined') self.Olm = Olm;
|
||||
}
|
||||
if (typeof Olm !== 'undefined' && Olm.init) {
|
||||
console.log('[M8Chat] Olm.js loaded, calling Olm.init()...');
|
||||
Olm.init().then(function() {
|
||||
console.log('[M8Chat] Olm.init() SUCCESS — E2EE available');
|
||||
console.log('[M8Chat] window.Olm =', window.Olm);
|
||||
console.log('[M8Chat] typeof Olm.Account =', typeof Olm.Account);
|
||||
bootFlutter();
|
||||
}).catch(function(e) {
|
||||
console.warn('[M8Chat] Olm init FAILED, booting without E2EE:', e);
|
||||
bootFlutter();
|
||||
});
|
||||
} else {
|
||||
console.warn('[M8Chat] olm.js did NOT load — typeof Olm =', typeof Olm);
|
||||
bootFlutter();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "m8chat_app",
|
||||
"short_name": "m8chat_app",
|
||||
"name": "M8Chat",
|
||||
"short_name": "M8Chat",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"background_color": "#0A0F2E",
|
||||
"theme_color": "#1265ED",
|
||||
"description": "M8Chat — secure chat and conferencing.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
|
||||
64
web/push_sw.js
Normal file
64
web/push_sw.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-27
|
||||
// Web Push service worker for M8Chat.
|
||||
// Receives push events from the browser and shows system notifications.
|
||||
// This script is registered separately from flutter_service_worker.js.
|
||||
|
||||
'use strict';
|
||||
|
||||
self.addEventListener('push', function (event) {
|
||||
if (!event.data) return;
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = event.data.json();
|
||||
} catch (_) {
|
||||
data = { notification: { body: event.data.text() } };
|
||||
}
|
||||
|
||||
// Sygnal delivers: { notification: { content: { body, msgtype, ... }, room_name, sender_display_name, ... } }
|
||||
const notif = data.notification || {};
|
||||
const content = notif.content || {};
|
||||
|
||||
const sender = notif.sender_display_name || notif.sender || 'M8Chat';
|
||||
const room = notif.room_name || notif.room_alias || '';
|
||||
const body = content.body || notif.body || 'New message';
|
||||
|
||||
const title = room ? `${sender} in ${room}` : sender;
|
||||
|
||||
const options = {
|
||||
body: body,
|
||||
icon: '/icons/Icon-192.png',
|
||||
badge: '/icons/Icon-192.png',
|
||||
tag: notif.room_id || 'm8chat',
|
||||
data: {
|
||||
roomId: notif.room_id,
|
||||
url: self.location.origin + (notif.room_id
|
||||
? '/rooms/' + encodeURIComponent(notif.room_id)
|
||||
: '/rooms'),
|
||||
},
|
||||
requireInteraction: false,
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', function (event) {
|
||||
event.notification.close();
|
||||
|
||||
const url = (event.notification.data && event.notification.data.url)
|
||||
? event.notification.data.url
|
||||
: self.location.origin + '/rooms';
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (clientList) {
|
||||
for (const client of clientList) {
|
||||
if (client.url === url && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(url);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user