From 5d772389d619a17a6d9d50fb3e16e99f2fdc819f Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Fri, 3 Jul 2026 10:56:48 +0200 Subject: [PATCH] refactor: Gott-Datei settings_modal.dart in 12 Dateien aufgeteilt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reine Verschiebung per Dart part/part-of (keine Umbenennung, keine Logikänderung) - settings_modal.dart schrumpft von 5541 auf 270 Zeilen. Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze (Baseline unverändert: 1 bekannter Hinweis). Co-Authored-By: Claude Sonnet 5 --- CHANGES.md | 2 + PROGRESS.md | 76 + ROADMAP.md | 8 +- lib/widgets/settings/settings_about.dart | 265 + lib/widgets/settings/settings_account.dart | 364 ++ lib/widgets/settings/settings_appearance.dart | 187 + lib/widgets/settings/settings_encryption.dart | 1465 +++++ lib/widgets/settings/settings_keybinds.dart | 67 + .../settings/settings_notifications.dart | 168 + lib/widgets/settings/settings_privacy.dart | 162 + lib/widgets/settings/settings_profile.dart | 513 ++ lib/widgets/settings/settings_sessions.dart | 468 ++ lib/widgets/settings/settings_shared.dart | 639 ++ .../settings/settings_verification.dart | 482 ++ lib/widgets/settings/settings_voice.dart | 433 ++ lib/widgets/settings_modal.dart | 5295 +---------------- 16 files changed, 5309 insertions(+), 5285 deletions(-) create mode 100644 lib/widgets/settings/settings_about.dart create mode 100644 lib/widgets/settings/settings_account.dart create mode 100644 lib/widgets/settings/settings_appearance.dart create mode 100644 lib/widgets/settings/settings_encryption.dart create mode 100644 lib/widgets/settings/settings_keybinds.dart create mode 100644 lib/widgets/settings/settings_notifications.dart create mode 100644 lib/widgets/settings/settings_privacy.dart create mode 100644 lib/widgets/settings/settings_profile.dart create mode 100644 lib/widgets/settings/settings_sessions.dart create mode 100644 lib/widgets/settings/settings_shared.dart create mode 100644 lib/widgets/settings/settings_verification.dart create mode 100644 lib/widgets/settings/settings_voice.dart diff --git a/CHANGES.md b/CHANGES.md index 19b3a85..627d40b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1159,3 +1159,5 @@ 2026-07-03 10:29 [Edit] /home/steggi/pyramid/lib/features/chat/message_group.dart 2026-07-03 10:29 [Edit] /home/steggi/pyramid/PROGRESS.md 2026-07-03 10:29 [Edit] /home/steggi/pyramid/ROADMAP.md +2026-07-03 10:56 [Edit] /home/steggi/pyramid/ROADMAP.md +2026-07-03 10:56 [Edit] /home/steggi/pyramid/PROGRESS.md diff --git a/PROGRESS.md b/PROGRESS.md index a8a5c7b..31e0f8a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -13,6 +13,82 @@ Schritt (und beim Abbruch mitten im Schritt den Zwischenstand). Format: --- +## 2026-07-03 – M2: Gott-Datei `settings_modal.dart` aufgeteilt (12 Dateien) + +**Erledigt:** `widgets/settings_modal.dart` (5541 Zeilen, 50+ Klassen) war laut +Ist-Analyse die größte „Gott-Datei". „Call-Schicht entwirren" (nächster +ROADMAP-Punkt) bleibt weiter blockiert, da es dabei um Verhaltens-/State- +Management-Änderungen an einem CLAUDE.md-„heiligen" Bereich (Calls) geht, die +laut Arbeitsregeln nach jedem Schritt einen echten App-Test brauchen – +auf dem Pi ohne GUI-Toolchain (kein Android-SDK, kein Linux-Desktop-Build, +siehe `flutter doctor`) unmöglich zu prüfen. Stattdessen den in +`docs/M2_MODULE_SCHNITT.md` als zweiten (risikoärmeren) Schritt vorgeschlagenen +Umbau vorgezogen: reine Datei-Aufteilung ohne Verhaltensänderung, komplett über +`flutter analyze` prüfbar. + +- Technik: Dart `part`/`part of` statt eigenständiger Libraries – dadurch + bleiben alle privaten Klassen (`_ProfileSection`, `_SettingsGroup`, …) ohne + Umbenennung nutzbar, und alle Imports bleiben zentral in `settings_modal.dart` + (Part-Dateien brauchen keine eigenen Imports). Kein Klassenname geändert, + kein Zeichen Logik angefasst. +- 12 neue Dateien unter `lib/widgets/settings/`: `settings_shared.dart` + (gemeinsame Low-Level-Widgets wie `_SettingsGroup`/`_Toggle`/`_Divider`), + `settings_profile.dart`, `settings_notifications.dart`, + `settings_appearance.dart`, `settings_voice.dart`, `settings_keybinds.dart` + (Tastaturkürzel-Anzeige, NICHT zu verwechseln mit Verschlüsselungs-Keys), + `settings_privacy.dart`, `settings_sessions.dart`, `settings_verification.dart` + (SAS/QR-Dialog), `settings_encryption.dart` (inkl. der Megolm-Export/Import- + Kryptofunktionen `_runPbkdf2`/`_aesCtr`/`_encryptMegolmKeys`/`_decryptMegolmKeys`, + die bisher lose im Datei-Kopf lagen), `settings_account.dart` (Passwort ändern/ + Account löschen), `settings_about.dart`. +- `settings_modal.dart` selbst: von 5541 auf 270 Zeilen geschrumpft – enthält + nur noch Imports, die 12 `part`-Direktiven, `SettingsModal` und + `_SettingsModalState` (Navigation/Layout). +- Verifikation (da kein GUI-Test möglich): Skript-gestützte Extraktion per + Klammer-Zählung (kein manuelles Copy-Paste, um Übertragungsfehler + auszuschließen); anschließend automatisiert geprüft, dass alle 61 + ursprünglichen Klassen/Enums wortwörtlich (`in`-Substring-Check) in den neuen + Dateien wieder auftauchen; Anzahl `class`/`enum`-Deklarationen (60) und + `Widget build(`-Methoden (40) vorher/nachher identisch; alle Lücken zwischen + den Klassenblöcken im Original einzeln geprüft – ausschließlich Leerzeilen + und dekorative Trennkommentare, kein Code verloren. +- `flutter analyze`: weiterhin nur der eine bekannte, zurückgestellte Hinweis + (`chat_provider.dart:42`). +- **Stolperfalle vermieden:** `dart format` versuchte 40 neue Lint-Hinweise + einzuführen (`curly_braces_in_flow_control_structures`) – Ursache: die + Originaldatei war selbst nie komplett `dart format`-clean (bestätigt per + `dart format --set-exit-if-changed` auf dem Original-Git-Stand), und Reflow + einzelner langer Einzeiler-`if`s ohne Klammern erzeugte neue Zeilenumbrüche, + die der Linter anders bewertet als die Originalformatierung. Formatierung + komplett verworfen und die Dateien unformatiert aus dem Original übernommen, + um „keine Verhaltensänderung ohne Not" strikt einzuhalten und die + `flutter analyze`-Baseline (1 Hinweis) nicht zu verwässern. + +**Offen/Nächster Schritt:** UNGETESTET (Pi) im Sinne von „nicht in echter UI +gesehen". Nächster Windows-Lauf: Einstellungen öffnen, durch alle Reiter +klicken (Profil, Benachrichtigungen, Erscheinungsbild, Voice, Tastaturkürzel, +Privatsphäre, Sessions, Verschlüsselung inkl. SAS/QR-Verifizierung und +Recovery-Key-Anzeige/-Import, Passwort ändern, Account löschen-Dialog öffnen +(ohne zu bestätigen!), Über). Da rein mechanisch verschoben, ist das Risiko +gering, aber Verschlüsselung/Recovery-Key ist laut CLAUDE.md heilig – bitte +gezielt den Recovery-Key-Anzeige- und Passphrase-Export/Import-Flow einmal +durchklicken. Danach ROADMAP-Häkchen für „Ordnerstruktur vereinheitlichen" +erst setzen, wenn auch die übrigen Gott-Dateien (`message_group.dart`, +`rooms_panel.dart`, `space_admin_dialog.dart`, `chat_view.dart`, +`document_viewer.dart`) angegangen sind – aktuell nur `settings_modal.dart` +erledigt. + +**Stolperfallen:** `dart format` NICHT blind nach einem Refactoring laufen +lassen, wenn die Baseline vorher nicht schon formatter-clean war – kann neue, +unrelated Lint-Hinweise einführen und die vermeintlich reine Verschiebung mit +Formatierungsrauschen vermischen (siehe oben). + +**Fragen an Bernd:** Keine neuen – die offene Frage zum Call-Pilot aus dem +Eintrag „M2: Modul-Schnitt definiert" ist weiterhin unbeantwortet, blockiert +aber nicht (siehe dort). + +--- + ## 2026-07-03 – M2: Toten Code & Duplikate entfernt (Chat-Timeline) **Erledigt:** Vorgriff auf den ROADMAP-Punkt „Toten Code & Duplikate entfernen" diff --git a/ROADMAP.md b/ROADMAP.md index 9b6ba90..4633142 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -77,8 +77,12 @@ lassen, ohne dass andere Module angefasst werden müssen. - [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns) - [ ] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen (angefangen, PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt, - doppelte `_formatTime` in `message_group.dart` zusammengeführt – weitere - Fundstellen aus der Ist-Analyse noch offen, Ordnerstruktur noch nicht angefasst) + doppelte `_formatTime` in `message_group.dart` zusammengeführt; + Gott-Datei `widgets/settings_modal.dart` (5541 Zeilen) in 12 + `part`-Dateien unter `widgets/settings/` aufgeteilt, siehe PROGRESS.md + 2026-07-03 „Settings-Datei aufgeteilt" – restliche Gott-Dateien + `message_group.dart`, `rooms_panel.dart`, `space_admin_dialog.dart`, + `chat_view.dart`, `document_viewer.dart` aus der Ist-Analyse noch offen) - [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit ## M3 – Calls: Logik, UI & Audio (Discord als Referenz) diff --git a/lib/widgets/settings/settings_about.dart b/lib/widgets/settings/settings_about.dart new file mode 100644 index 0000000..00e02cd --- /dev/null +++ b/lib/widgets/settings/settings_about.dart @@ -0,0 +1,265 @@ +part of '../settings_modal.dart'; + +class _AboutSection extends ConsumerWidget { + final PyramidTheme pt; + const _AboutSection({required this.pt}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientAsync = ref.watch(matrixClientProvider); + final client = clientAsync.valueOrNull; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle(title: 'Über Pyramid', subtitle: 'Version & Konto-Infos.', pt: pt), + _SettingsGroup(title: 'Version', pt: pt, children: [ + _SettingsRow( + title: 'Pyramid', + desc: '0.1.0 · Flutter · Matrix SDK', + pt: pt, + control: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: pt.accentSoft, + borderRadius: BorderRadius.circular(6)), + child: Text('Beta', + style: TextStyle( + color: pt.accent, fontSize: 10, fontWeight: FontWeight.w700)), + )), + _Divider(pt: pt), + _SettingsRow( + title: 'Matrix SDK', + desc: 'matrix-dart-sdk ^6.2.0', + pt: pt), + ]), + const SizedBox(height: 20), + _UpdatesSettingsGroup(pt: pt), + const SizedBox(height: 20), + _NotificationsSettingsGroup(pt: pt), + const SizedBox(height: 20), + if (client != null) + _SettingsGroup(title: 'Konto', pt: pt, children: [ + _SettingsRow( + title: 'Matrix-ID', + desc: client.userID ?? '', + pt: pt, + control: GestureDetector( + onTap: () => Clipboard.setData(ClipboardData(text: client.userID ?? '')), + child: Tooltip( + message: 'Kopieren', + child: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), + ), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Homeserver', + desc: client.homeserver?.toString() ?? '', + pt: pt, + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Gerät', + desc: client.deviceID ?? '', + pt: pt, + control: GestureDetector( + onTap: () => + Clipboard.setData(ClipboardData(text: client.deviceID ?? '')), + child: Tooltip( + message: 'Kopieren', + child: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), + ), + ), + ), + ]), + const SizedBox(height: 20), + _SettingsGroup(title: 'Konto-Aktionen', pt: pt, children: [ + _SettingsRow( + title: 'Abmelden', + desc: 'Auf diesem Gerät ausloggen', + pt: pt, + destructive: true, + showArrow: true, + onTap: () => _confirmLogout(context, ref), + ), + ]), + ], + ), + ); + } + + Future _confirmLogout(BuildContext context, WidgetRef ref) async { + final client = await ref.read(matrixClientProvider.future); + final backupMissing = await isKeyBackupMissing(client); + if (!context.mounted) return; + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + title: Text('Abmelden?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text( + backupMissing + ? 'Du wirst auf diesem Gerät abgemeldet. Es ist kein Schlüssel-Backup eingerichtet — ' + 'ältere verschlüsselte Nachrichten sind danach auf keinem Gerät mehr lesbar.' + : 'Du wirst auf diesem Gerät abgemeldet. Deine Nachrichten bleiben erhalten.', + style: TextStyle( + color: backupMissing ? pt.danger : pt.fgMuted, fontSize: 13, height: 1.4), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.danger, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Abmelden'), + ), + ], + ), + ); + if (confirm == true) { + unawaited(AuthLog.write('User-initiated logout (Settings)')); + await client.logout().catchError((_) {}); + } + } +} + +class _UpdatesSettingsGroup extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _UpdatesSettingsGroup({required this.pt}); + + @override + ConsumerState<_UpdatesSettingsGroup> createState() => + _UpdatesSettingsGroupState(); +} + +class _UpdatesSettingsGroupState extends ConsumerState<_UpdatesSettingsGroup> { + bool _checking = false; + + Future _checkNow() async { + setState(() => _checking = true); + await resetUpdateCheckTimer(); + ref.invalidate(updateInfoProvider); + // Give the async provider a moment to resolve before resetting the spinner. + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _checking = false); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final updateAsync = ref.watch(updateInfoProvider); + + return _SettingsGroup(title: 'Updates', pt: pt, children: [ + updateAsync.when( + loading: () => _SettingsRow( + title: 'Nach Updates suchen…', + desc: 'Gitea Releases wird geprüft', + pt: pt, + control: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: pt.accent, + ), + ), + ), + error: (e, _) => _SettingsRow( + title: 'Update-Prüfung fehlgeschlagen', + desc: 'Keine Verbindung oder Gitea nicht erreichbar (nur im Heimnetz).', + pt: pt, + control: _checking + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: pt.accent), + ) + : GestureDetector( + onTap: _checkNow, + child: Tooltip( + message: 'Erneut prüfen', + child: Icon(Icons.refresh_rounded, + size: 16, color: pt.fgDim), + ), + ), + ), + data: (info) { + if (info == null) { + return _SettingsRow( + title: 'Pyramid ist aktuell', + desc: 'Du verwendest die neueste Version.', + pt: pt, + onTap: _checking ? null : _checkNow, + control: _checking + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: pt.accent), + ) + : Icon(Icons.refresh_rounded, size: 16, color: pt.fgDim), + ); + } + // New version available + return Column( + children: [ + _SettingsRow( + title: 'Update verfügbar: ${info.tagName}', + desc: info.releaseName, + pt: pt, + showArrow: true, + onTap: () async { + if (info.canDownload) { + await showUpdateDownloadDialog(context, info); + } else { + final uri = Uri.parse(info.htmlUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + }, + control: Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: pt.accent, + borderRadius: BorderRadius.circular(6)), + child: Text( + 'Neu', + style: TextStyle( + color: pt.accentFg, + fontSize: 10, + fontWeight: FontWeight.w700), + ), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Diese Version überspringen', + desc: '${info.tagName} nicht mehr anzeigen', + pt: pt, + onTap: () async { + await skipUpdateVersion(info.tagName); + ref.invalidate(updateInfoProvider); + }, + ), + ], + ); + }, + ), + ]); + } +} diff --git a/lib/widgets/settings/settings_account.dart b/lib/widgets/settings/settings_account.dart new file mode 100644 index 0000000..ce55f65 --- /dev/null +++ b/lib/widgets/settings/settings_account.dart @@ -0,0 +1,364 @@ +part of '../settings_modal.dart'; + +class _ChangePasswordDialog extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _ChangePasswordDialog({required this.pt}); + + @override + ConsumerState<_ChangePasswordDialog> createState() => _ChangePasswordDialogState(); +} + +class _ChangePasswordDialogState extends ConsumerState<_ChangePasswordDialog> { + final _oldCtrl = TextEditingController(); + final _newCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + bool _oldVisible = false; + bool _newVisible = false; + bool _loading = false; + String? _error; + bool _done = false; + + @override + void dispose() { + _oldCtrl.dispose(); + _newCtrl.dispose(); + _confirmCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + final oldPw = _oldCtrl.text; + final newPw = _newCtrl.text; + final confirm = _confirmCtrl.text; + if (oldPw.isEmpty || newPw.isEmpty) { + setState(() => _error = 'Bitte alle Felder ausfüllen.'); + return; + } + if (newPw != confirm) { + setState(() => _error = 'Die neuen Passwörter stimmen nicht überein.'); + return; + } + if (newPw.length < 8) { + setState(() => _error = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.'); + return; + } + setState(() { _loading = true; _error = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + await client.changePassword(newPw, oldPassword: oldPw); + if (mounted) setState(() { _loading = false; _done = true; }); + } on MatrixException catch (e) { + if (mounted) setState(() { _loading = false; _error = e.errorMessage; }); + } catch (e) { + if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; }); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Dialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420, minWidth: 300), + child: Padding( + padding: const EdgeInsets.all(24), + child: _done ? _buildDone(pt) : _buildForm(pt), + ), + ), + ); + } + + Widget _buildDone(PyramidTheme pt) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52), + const SizedBox(height: 12), + Text('Passwort geändert', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + Text('Dein Passwort wurde erfolgreich aktualisiert.', + textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)), + const SizedBox(height: 20), + SizedBox(width: double.infinity, child: ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Schließen'), + )), + ], + ); + + Widget _buildForm(PyramidTheme pt) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.lock_outline_rounded, size: 18, color: pt.accent), + const SizedBox(width: 8), + Text('Passwort ändern', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), + const Spacer(), + IconButton( + icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + ), + ]), + const SizedBox(height: 20), + _PwField(ctrl: _oldCtrl, label: 'Aktuelles Passwort', pt: pt, + visible: _oldVisible, onToggle: () => setState(() => _oldVisible = !_oldVisible)), + const SizedBox(height: 12), + _PwField(ctrl: _newCtrl, label: 'Neues Passwort', pt: pt, + visible: _newVisible, onToggle: () => setState(() => _newVisible = !_newVisible)), + const SizedBox(height: 12), + _PwField(ctrl: _confirmCtrl, label: 'Neues Passwort bestätigen', pt: pt, + visible: _newVisible, onToggle: null, + onSubmitted: (_) => _submit()), + if (_error != null) ...[ + const SizedBox(height: 10), + _StatusCard(pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!), + ], + const SizedBox(height: 20), + SizedBox(width: double.infinity, child: ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: _loading ? null : _submit, + child: _loading + ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) + : const Text('Passwort ändern'), + )), + ], + ); +} + +class _DeleteAccountDialog extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _DeleteAccountDialog({required this.pt}); + + @override + ConsumerState<_DeleteAccountDialog> createState() => _DeleteAccountDialogState(); +} + +enum _DeleteStep { confirm, auth, deleting, error } + +class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> { + _DeleteStep _step = _DeleteStep.confirm; + final _pwCtrl = TextEditingController(); + bool _pwVisible = false; + bool _eraseData = true; + String? _error; + + @override + void dispose() { _pwCtrl.dispose(); super.dispose(); } + + Future _proceed() async { + setState(() { _step = _DeleteStep.deleting; _error = null; }); + final client = await ref.read(matrixClientProvider.future); + try { + await client.deactivateAccount(erase: _eraseData); + } on MatrixException catch (e) { + if (e.requireAdditionalAuthentication && mounted) { + // UIA required — try with password + try { + await client.deactivateAccount( + erase: _eraseData, + auth: AuthenticationData( + type: AuthenticationTypes.password, + session: e.session, + additionalFields: { + 'identifier': {'type': 'm.id.user', 'user': client.userID}, + 'password': _pwCtrl.text, + }, + ), + ); + } on MatrixException catch (e2) { + if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.errorMessage; }); + return; + } catch (e2) { + if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.toString().split('\n').first; }); + return; + } + } else { + if (mounted) setState(() { _step = _DeleteStep.error; _error = e.errorMessage; }); + return; + } + } catch (e) { + if (mounted) setState(() { _step = _DeleteStep.error; _error = e.toString().split('\n').first; }); + return; + } + // Konto wurde gelöscht — ausloggen + unawaited(AuthLog.write('User-initiated logout (Konto gelöscht)')); + try { await client.logout(); } catch (_) {} + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Dialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440, minWidth: 300), + child: Padding( + padding: const EdgeInsets.all(24), + child: switch (_step) { + _DeleteStep.confirm => _buildConfirm(pt), + _DeleteStep.auth => _buildAuth(pt), + _DeleteStep.deleting => const Center( + child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()), + ), + _DeleteStep.error => _buildError(pt), + }, + ), + ), + ); + } + + Widget _buildConfirm(PyramidTheme pt) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.warning_rounded, size: 20, color: pt.danger), + const SizedBox(width: 8), + Text('Konto löschen', style: TextStyle(color: pt.danger, fontSize: 15, fontWeight: FontWeight.w700)), + const Spacer(), + IconButton( + icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), + onPressed: () => Navigator.of(context).pop(), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + ), + ]), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.danger.withAlpha(20), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: pt.danger.withAlpha(60)), + ), + child: Text( + 'Diese Aktion ist unwiderruflich. Dein Konto, alle Sitzungen und ' + 'deine Mitgliedschaft in Räumen werden dauerhaft gelöscht.', + style: TextStyle(color: pt.fg, fontSize: 13, height: 1.5), + ), + ), + const SizedBox(height: 16), + GestureDetector( + onTap: () => setState(() => _eraseData = !_eraseData), + child: Row(children: [ + SizedBox( + width: 18, height: 18, + child: Checkbox( + value: _eraseData, + onChanged: (v) => setState(() => _eraseData = v ?? true), + activeColor: pt.accent, + side: BorderSide(color: pt.border), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + const SizedBox(width: 8), + Expanded(child: Text('Nachrichten und Daten vom Server löschen', + style: TextStyle(color: pt.fg, fontSize: 13))), + ]), + ), + const SizedBox(height: 20), + Row(children: [ + Expanded(child: OutlinedButton( + style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + )), + const SizedBox(width: 12), + Expanded(child: ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => setState(() => _step = _DeleteStep.auth), + child: const Text('Weiter'), + )), + ]), + ], + ); + + Widget _buildAuth(PyramidTheme pt) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.lock_outline_rounded, size: 18, color: pt.danger), + const SizedBox(width: 8), + Text('Konto löschen bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), + ]), + const SizedBox(height: 16), + Text('Gib dein Passwort ein, um die Löschung zu bestätigen.', + style: TextStyle(color: pt.fgMuted, fontSize: 13)), + const SizedBox(height: 16), + TextField( + controller: _pwCtrl, + autofocus: true, + obscureText: !_pwVisible, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + labelText: 'Passwort', + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + filled: true, fillColor: pt.bg3, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.danger)), + suffixIcon: IconButton( + icon: Icon(_pwVisible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim), + onPressed: () => setState(() => _pwVisible = !_pwVisible), + ), + ), + cursorColor: pt.danger, + onSubmitted: (_) => _proceed(), + ), + const SizedBox(height: 20), + Row(children: [ + Expanded(child: OutlinedButton( + style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => setState(() => _step = _DeleteStep.confirm), + child: const Text('Zurück'), + )), + const SizedBox(width: 12), + Expanded(child: ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: _proceed, + child: const Text('Konto endgültig löschen'), + )), + ]), + ], + ); + + Widget _buildError(PyramidTheme pt) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline_rounded, color: pt.danger, size: 48), + const SizedBox(height: 12), + Text(_error ?? 'Ein Fehler ist aufgetreten.', + textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)), + const SizedBox(height: 20), + SizedBox(width: double.infinity, child: OutlinedButton( + style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Schließen'), + )), + ], + ); +} diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart new file mode 100644 index 0000000..6da9b93 --- /dev/null +++ b/lib/widgets/settings/settings_appearance.dart @@ -0,0 +1,187 @@ +part of '../settings_modal.dart'; + +class _AppearanceSection extends StatelessWidget { + final PyramidTheme pt; + final bool isDark; + final int accentIdx; + final double radius; + final double density; + final double motion; + final WidgetRef ref; + + const _AppearanceSection({ + required this.pt, + required this.isDark, + required this.accentIdx, + required this.radius, + required this.density, + required this.motion, + required this.ref, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Erscheinungsbild', + subtitle: 'Gestalte Pyramid nach deinem Geschmack.', + pt: pt), + _SettingsGroup(title: 'Theme', pt: pt, children: [ + _SettingsRow( + title: 'Farbmodus', + desc: 'Hell oder dunkel', + pt: pt, + control: _SegControl( + options: const ['Dunkel', 'Hell'], + selected: isDark ? 0 : 1, + pt: pt, + onSelect: (i) { + final dark = i == 0; + ref.read(themeModeProvider.notifier).state = dark; + saveThemePrefs(isDark: dark); + }, + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Akzentfarbe', + desc: 'Farbe für Hervorhebungen und Buttons', + pt: pt, + control: Row( + children: accentPresets.asMap().entries.map((e) { + final i = e.key; + final preset = e.value; + final isActive = accentIdx == i; + return GestureDetector( + onTap: () { + ref.read(accentProvider.notifier).state = i; + saveThemePrefs(accentIdx: i); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 22, + height: 22, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: preset.color, + shape: BoxShape.circle, + border: isActive ? Border.all(color: Colors.white, width: 2) : null, + boxShadow: isActive + ? [BoxShadow(color: preset.color.withAlpha(100), blurRadius: 8)] + : null, + ), + ), + ); + }).toList(), + ), + ), + ]), + const SizedBox(height: 20), + _SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [ + _SettingsRow( + title: 'Eckenrundung', + desc: '${radius.round()} px', + pt: pt, + control: SizedBox( + width: 150, + child: SliderTheme( + data: SliderThemeData( + activeTrackColor: pt.accent, + thumbColor: pt.accent, + inactiveTrackColor: pt.border, + trackHeight: 3, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + ), + child: Slider( + value: radius, + min: 4, + max: 20, + onChanged: (v) { + ref.read(radiusProvider.notifier).state = v; + saveThemePrefs(radius: v); + }, + ), + ), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Animationen', + desc: '${(motion * 100).round()}%', + pt: pt, + control: SizedBox( + width: 150, + child: SliderTheme( + data: SliderThemeData( + activeTrackColor: pt.accent, + thumbColor: pt.accent, + inactiveTrackColor: pt.border, + trackHeight: 3, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), + ), + child: Slider( + value: motion, + min: 0, + max: 1, + onChanged: (v) { + ref.read(motionProvider.notifier).state = v; + saveThemePrefs(motion: v); + }, + ), + ), + ), + ), + ]), + const SizedBox(height: 20), + _SettingsGroup(title: 'Vorschau', pt: pt, children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Column( + children: [ + Container( + height: 100, + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + child: const Center(child: PyramidLoader(size: 70, useLottie: true)), + ), + const SizedBox(height: 6), + Text('Lottie', style: TextStyle(color: pt.fgMuted, fontSize: 11)), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + children: [ + Container( + height: 100, + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + child: const Center(child: PyramidLoader(size: 70, useLottie: false)), + ), + const SizedBox(height: 6), + Text('SVG', style: TextStyle(color: pt.fgMuted, fontSize: 11)), + ], + ), + ), + ], + ), + ), + ]), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/settings_encryption.dart b/lib/widgets/settings/settings_encryption.dart new file mode 100644 index 0000000..01b8597 --- /dev/null +++ b/lib/widgets/settings/settings_encryption.dart @@ -0,0 +1,1465 @@ +part of '../settings_modal.dart'; + +// ── Megolm key export / import – top-level so compute() can use them ───────── + +const _kMegolmHeader = '-----BEGIN MEGOLM SESSION DATA-----'; +const _kMegolmFooter = '-----END MEGOLM SESSION DATA-----'; + +// PBKDF2-HMAC-SHA512. args: {pass: Uint8List, salt: Uint8List, iter: int} +Uint8List _runPbkdf2(Map args) { + final pass = args['pass'] as Uint8List; + final salt = args['salt'] as Uint8List; + final iterations = args['iter'] as int; + const blockLen = 64; // SHA-512 digest size + const dkLen = 64; + + final hmac = mcrypto.Hmac(mcrypto.sha512, pass); + final blockCount = (dkLen / blockLen).ceil(); + final dk = Uint8List(blockCount * blockLen); + + for (var i = 1; i <= blockCount; i++) { + final saltI = Uint8List(salt.length + 4); + saltI.setRange(0, salt.length, salt); + ByteData.sublistView(saltI, salt.length).setUint32(0, i); + + var u = Uint8List.fromList(hmac.convert(saltI).bytes); + final block = Uint8List.fromList(u); + + for (var j = 1; j < iterations; j++) { + u = Uint8List.fromList(hmac.convert(u).bytes); + for (var k = 0; k < blockLen; k++) { block[k] ^= u[k]; } + } + dk.setRange((i - 1) * blockLen, i * blockLen, block); + } + return dk.sublist(0, dkLen); +} + +Uint8List _aesCtr(Uint8List key, Uint8List iv, Uint8List data) { + final cipher = pc.SICStreamCipher(pc.AESEngine()) + ..init(true, pc.ParametersWithIV(pc.KeyParameter(key), iv)); + final out = Uint8List(data.length); + cipher.processBytes(data, 0, data.length, out, 0); + return out; +} + +// args: {passphrase: String, sessions: List} → encrypted String +String _encryptMegolmKeys(Map args) { + final pass = args['passphrase'] as String; + final sessions = args['sessions'] as List; + + final rng = Random.secure(); + final salt = Uint8List.fromList(List.generate(16, (_) => rng.nextInt(256))); + // IV: 8 random bytes as nonce, last 8 bytes 0x00 (counter starts at 0) + final iv = Uint8List(16); + for (var i = 0; i < 8; i++) { iv[i] = rng.nextInt(256); } + + const iterations = 100000; + final dk = _runPbkdf2({ + 'pass': Uint8List.fromList(utf8.encode(pass)), + 'salt': salt, + 'iter': iterations, + }); + final aesKey = dk.sublist(0, 32); + final hmacKey = dk.sublist(32, 64); + + final plaintext = Uint8List.fromList(utf8.encode(jsonEncode(sessions))); + final encrypted = _aesCtr(aesKey, iv, plaintext); + + final iterBe = Uint8List(4)..buffer.asByteData().setUint32(0, iterations); + + // header bytes: 0x01 | salt(16) | iv(16) | iterBe(4) | encrypted(n) + final header = Uint8List(1 + 16 + 16 + 4 + encrypted.length); + var off = 0; + header[off++] = 0x01; + header.setRange(off, off += 16, salt); + header.setRange(off, off += 16, iv); + header.setRange(off, off += 4, iterBe); + header.setRange(off, off + encrypted.length, encrypted); + + final mac = Uint8List.fromList( + mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(header).bytes, + ); + + final full = Uint8List(header.length + 32); + full.setRange(0, header.length, header); + full.setRange(header.length, full.length, mac); + + return '$_kMegolmHeader\n${base64.encode(full)}\n$_kMegolmFooter'; +} + +// args: {passphrase: String, data: List} → List sessions +List _decryptMegolmKeys(Map args) { + final pass = args['passphrase'] as String; + final raw = Uint8List.fromList(args['data'] as List); + + if (raw.length < 1 + 16 + 16 + 4 + 32 || raw[0] != 0x01) { + throw Exception('Ungültiges Dateiformat (Version ${raw.isEmpty ? "?" : raw[0]}).'); + } + + final salt = raw.sublist(1, 17); + final iv = raw.sublist(17, 33); + final iterations = raw.buffer.asByteData().getUint32(33); + final encrypted = raw.sublist(37, raw.length - 32); + final storedMac = raw.sublist(raw.length - 32); + + if (iterations < 1 || iterations > 10000000) { + throw Exception('Iterationsanzahl außerhalb des erlaubten Bereichs.'); + } + + final dk = _runPbkdf2({ + 'pass': Uint8List.fromList(utf8.encode(pass)), + 'salt': Uint8List.fromList(salt), + 'iter': iterations, + }); + final aesKey = dk.sublist(0, 32); + final hmacKey = dk.sublist(32, 64); + + // Verify MAC over all bytes except the appended MAC itself + final toMac = raw.sublist(0, raw.length - 32); + final expectedMac = mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(toMac).bytes; + var macOk = true; + for (var i = 0; i < 32; i++) { + if (expectedMac[i] != storedMac[i]) { macOk = false; break; } + } + if (!macOk) throw Exception('Falsches Passwort oder beschädigte Datei.'); + + final decrypted = _aesCtr(aesKey, Uint8List.fromList(iv), Uint8List.fromList(encrypted)); + return jsonDecode(utf8.decode(decrypted)) as List; +} + +// ───────────────────────────────────────────────────────────────────────────── + +enum _EncState { idle, loading, needsKey, unlocking, done, error } + +enum _RecoveryStep { loading, showKey, confirmKey, applying, done, error } + +class _EncryptionSection extends ConsumerStatefulWidget { + final PyramidTheme pt; + final WidgetRef ref; + const _EncryptionSection({required this.pt, required this.ref}); + + @override + ConsumerState<_EncryptionSection> createState() => _EncryptionSectionState(); +} + +class _EncryptionSectionState extends ConsumerState<_EncryptionSection> { + _EncState _state = _EncState.idle; + Bootstrap? _bootstrap; + final _keyCtrl = TextEditingController(); + String? _error; + + // Status loaded from encryption object + bool? _crossSigningEnabled; + bool? _crossSigningCached; + bool? _keyBackupEnabled; + bool _statusLoaded = false; + + // Key file export / import + bool _exportLoading = false; + bool _importLoading = false; + String? _keyFileMsg; + bool _keyFileMsgIsError = false; + bool _diagUploading = false; + String? _diagMsg; + bool _diagMsgIsError = false; + + @override + void initState() { + super.initState(); + _loadStatus(); + } + + @override + void dispose() { + _keyCtrl.dispose(); + super.dispose(); + } + + Future _loadStatus() async { + try { + final client = await ref.read(matrixClientProvider.future); + final enc = client.encryption; + if (enc == null) return; + final cached = await enc.crossSigning.isCached().catchError((_) => false); + if (!mounted) return; + setState(() { + _crossSigningEnabled = enc.crossSigning.enabled; + _crossSigningCached = cached; + _keyBackupEnabled = enc.keyManager.enabled; + _statusLoaded = true; + }); + } catch (_) {} + } + + Future _start() async { + setState(() { _state = _EncState.loading; _error = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + if (client.encryption == null) { + setState(() { _state = _EncState.error; _error = 'Verschlüsselung nicht verfügbar.'; }); + return; + } + final enc = client.encryption!; + final cached = await enc.keyManager.isCached().catchError((_) => false); + if (cached) { + await enc.keyManager.loadAllKeys().catchError((_) {}); + if (mounted) setState(() { _state = _EncState.done; _error = null; }); + await _loadStatus(); + return; + } + + await client.roomsLoading; + await client.accountDataLoading; + await client.userDeviceKeysLoading; + + final completer = Completer(); + _bootstrap = enc.bootstrap(onUpdate: (bs) { + if (!mounted) return; + switch (bs.state) { + case BootstrapState.askWipeSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(false)); + case BootstrapState.askBadSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); + case BootstrapState.askUseExistingSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(true)); + case BootstrapState.openExistingSsss: + if (!completer.isCompleted) completer.complete(true); + case BootstrapState.askNewSsss: + if (!completer.isCompleted) completer.complete(false); + case BootstrapState.error: + if (!completer.isCompleted) completer.complete(false); + if (mounted) setState(() { _state = _EncState.error; _error = 'Bootstrap-Fehler.'; }); + default: + break; + } + }); + + final needsKey = await completer.future + .timeout(const Duration(seconds: 30), onTimeout: () => false); + if (!mounted) return; + setState(() => _state = needsKey ? _EncState.needsKey : _EncState.done); + await _loadStatus(); + } catch (e) { + if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); + } + } + + Future _unlock() async { + final bs = _bootstrap; + if (bs == null || bs.newSsssKey == null) return; + final key = _keyCtrl.text.trim(); + if (key.isEmpty) { setState(() => _error = 'Bitte Wiederherstellungsschlüssel eingeben.'); return; } + setState(() { _state = _EncState.unlocking; _error = null; }); + try { + await bs.newSsssKey!.unlock(keyOrPassphrase: key); + await bs.openExistingSsss(); + final client = await ref.read(matrixClientProvider.future); + if (bs.encryption.crossSigning.enabled) { + await client.encryption!.crossSigning.selfSign(recoveryKey: key); + } + await client.encryption!.keyManager.loadAllKeys(); + if (mounted) setState(() => _state = _EncState.done); + await _loadStatus(); + } on InvalidPassphraseException catch (_) { + if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Falscher Schlüssel.'; }); + } on FormatException catch (_) { + if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Ungültiges Format.'; }); + } catch (e) { + if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); + } + } + + Future _resetSecurity() async { + final confirm = await showDialog( + context: context, + builder: (ctx) { + final pt = widget.pt; + return AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + title: Text('Sicherheit zurücksetzen?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text( + 'Dies löscht deine aktuellen Cross-Signing-Schlüssel und den Online-Schlüssel-Backup. Nicht gesicherte Sitzungen können Nachrichten möglicherweise nicht mehr entschlüsseln.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Zurücksetzen'), + ), + ], + ); + }, + ); + if (confirm != true || !mounted) return; + setState(() { _state = _EncState.loading; _error = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + final enc = client.encryption; + if (enc == null) throw Exception('Verschlüsselung nicht verfügbar'); + await client.roomsLoading; + await client.accountDataLoading; + await client.userDeviceKeysLoading; + final completer = Completer(); + enc.bootstrap(onUpdate: (bs) { + if (!mounted) return; + switch (bs.state) { + case BootstrapState.askWipeSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(true)); + case BootstrapState.askBadSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); + case BootstrapState.askUseExistingSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(false)); + case BootstrapState.askNewSsss: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss()); + case BootstrapState.askWipeCrossSigning: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeCrossSigning(true)); + case BootstrapState.askSetupCrossSigning: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupCrossSigning( + setupMasterKey: true, setupSelfSigningKey: true, setupUserSigningKey: true)); + case BootstrapState.askWipeOnlineKeyBackup: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(true)); + case BootstrapState.askSetupOnlineKeyBackup: + WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true)); + case BootstrapState.done: + if (!completer.isCompleted) completer.complete(); + case BootstrapState.error: + if (!completer.isCompleted) completer.completeError('Bootstrap-Fehler'); + default: + break; + } + }); + await completer.future.timeout(const Duration(seconds: 60)); + if (mounted) setState(() => _state = _EncState.done); + await _loadStatus(); + } catch (e) { + if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); + } + } + + Future _showPassphraseDialog({required bool forExport}) { + return showDialog( + context: context, + builder: (ctx) => _PassphraseDialog(pt: widget.pt, forExport: forExport), + ); + } + + Future _exportKeys() async { + final passphrase = await _showPassphraseDialog(forExport: true); + if (passphrase == null || !mounted) return; + + setState(() { _exportLoading = true; _keyFileMsg = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + final db = client.database; + + final stored = await db.getAllInboundGroupSessions(); + final sessions = >[]; + + for (final s in stored) { + try { + final content = jsonDecode(s.content) as Map; + final sessionKey = content['session_key'] as String?; + if (sessionKey == null || sessionKey.isEmpty) continue; + + final claimedKeys = s.senderClaimedKeys.isNotEmpty + ? (jsonDecode(s.senderClaimedKeys) as Map) + .map((k, v) => MapEntry(k, v.toString())) + : {}; + + sessions.add({ + 'algorithm': 'm.megolm.v1.aes-sha2', + 'forwarding_curve25519_key_chain': [], + 'room_id': s.roomId, + 'sender_key': s.senderKey, + 'sender_claimed_keys': claimedKeys, + 'session_id': s.sessionId, + 'session_key': sessionKey, + }); + } catch (_) { continue; } + } + + if (sessions.isEmpty) throw Exception('Keine exportierbaren Sitzungsschlüssel gefunden.'); + + final encrypted = await compute(_encryptMegolmKeys, { + 'passphrase': passphrase, + 'sessions': sessions, + }); + + final dir = await getApplicationDocumentsDirectory(); + final now = DateTime.now(); + final stamp = '${now.year}' + '${now.month.toString().padLeft(2, '0')}' + '${now.day.toString().padLeft(2, '0')}'; + final file = File('${dir.path}/pyramid_keys_$stamp.txt'); + await file.writeAsString(encrypted); + + if (mounted) { + setState(() { + _exportLoading = false; + _keyFileMsgIsError = false; + _keyFileMsg = '${sessions.length} Schlüssel exportiert → ${file.path}'; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _exportLoading = false; + _keyFileMsgIsError = true; + _keyFileMsg = 'Export fehlgeschlagen: ${e.toString().split('\n').first}'; + }); + } + } + } + + Future _uploadDiagnostics() async { + setState(() { _diagUploading = true; _diagMsg = null; }); + try { + final notifier = ref.read(e2eeDiagnosticsProvider.notifier); + final msg = await notifier.upload( + serverUrl: 'https://dashboard.steggi-matrix.work', + token: 'J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI', + ); + if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = false; _diagMsg = msg; }); + } catch (e) { + if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = true; _diagMsg = e.toString(); }); + } + } + + Future _importKeys() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['txt', 'key', 'keys'], + allowMultiple: false, + ); + if (result == null || result.files.isEmpty || result.files.first.path == null) return; + if (!mounted) return; + + final passphrase = await _showPassphraseDialog(forExport: false); + if (passphrase == null || !mounted) return; + + setState(() { _importLoading = true; _keyFileMsg = null; }); + try { + final fileContent = await File(result.files.first.path!).readAsString(); + final trimmed = fileContent.trim(); + + if (!trimmed.startsWith(_kMegolmHeader) || !trimmed.endsWith(_kMegolmFooter)) { + throw Exception('Ungültiges Dateiformat – kein Megolm-Schlüsseldatei-Header.'); + } + + final lines = trimmed.split('\n'); + // Strip \r so Windows line endings (\r\n) don't corrupt the base64 string. + final b64 = lines.skip(1).take(lines.length - 2).join('').replaceAll(RegExp(r'\s'), ''); + final padded = b64 + '=' * ((4 - b64.length % 4) % 4); + final dataBytes = base64.decode(padded); + + final sessions = await compute(_decryptMegolmKeys, { + 'passphrase': passphrase, + 'data': dataBytes.toList(), + }); + + final client = await ref.read(matrixClientProvider.future); + final km = client.encryption?.keyManager; + if (km == null) throw Exception('Verschlüsselung nicht initialisiert.'); + + var imported = 0; + for (final raw in sessions) { + try { + final session = raw as Map; + final roomId = session['room_id'] as String?; + final sessionId = session['session_id'] as String?; + final senderKey = session['sender_key'] as String?; + if (roomId == null || sessionId == null || senderKey == null) continue; + + final claimedKeys = (session['sender_claimed_keys'] as Map?) + ?.map((k, v) => MapEntry(k.toString(), v.toString())) ?? + {}; + + await km.setInboundGroupSession( + roomId, sessionId, senderKey, + Map.from(session), + forwarded: true, + senderClaimedKeys: claimedKeys, + ); + imported++; + } catch (_) { continue; } + } + + if (mounted) { + setState(() { + _importLoading = false; + _keyFileMsgIsError = false; + _keyFileMsg = '$imported von ${sessions.length} Schlüssel importiert.'; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _importLoading = false; + _keyFileMsgIsError = true; + _keyFileMsg = 'Import fehlgeschlagen: ${e.toString().split('\n').first}'; + }); + } + } + } + + Future _changeRecoveryKey() async { + final changed = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _ChangeRecoveryKeyDialog(pt: widget.pt), + ); + if (changed == true && mounted) { + await _loadStatus(); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Verschlüsselung', + subtitle: 'Ende-zu-Ende-Verschlüsselung & Sicherheit verwalten.', + pt: pt), + + // ── Status overview ─────────────────────────────────────────────── + if (_statusLoaded) ...[ + _SettingsGroup(title: 'Sicherheitsstatus', pt: pt, children: [ + _SettingsRow( + title: 'Cross-Signing', + desc: _crossSigningEnabled == true + ? (_crossSigningCached == true ? 'Eingerichtet & verifiziert' : 'Eingerichtet, Schlüssel nicht im Cache') + : 'Nicht eingerichtet', + pt: pt, + control: Icon( + _crossSigningEnabled == true && _crossSigningCached == true + ? Icons.verified_rounded + : (_crossSigningEnabled == true ? Icons.warning_amber_rounded : Icons.cancel_outlined), + size: 16, + color: _crossSigningEnabled == true && _crossSigningCached == true + ? PyramidColors.success + : (_crossSigningEnabled == true ? pt.away : pt.danger), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Online-Schlüsselbackup', + desc: _keyBackupEnabled == true ? 'Aktiv – Sitzungsschlüssel gesichert' : 'Nicht aktiviert', + pt: pt, + control: Icon( + _keyBackupEnabled == true ? Icons.cloud_done_rounded : Icons.cloud_off_rounded, + size: 16, + color: _keyBackupEnabled == true ? PyramidColors.success : pt.fgDim, + ), + ), + ]), + const SizedBox(height: 20), + _FingerprintGroup(pt: pt), + const SizedBox(height: 20), + ], + + // ── Key recovery / unlock ───────────────────────────────────────── + _SettingsGroup(title: 'Wiederherstellung', pt: pt, children: [ + _SettingsRow( + title: 'Schlüssel wiederherstellen', + desc: 'Nachrichten mit dem Wiederherstellungsschlüssel entschlüsseln', + pt: pt, + showArrow: true, + onTap: _state == _EncState.loading ? null : _start, + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Wiederherstellungsschlüssel ändern', + desc: 'Neuen Schlüssel generieren, notieren und aktivieren', + pt: pt, + showArrow: true, + onTap: _state == _EncState.loading ? null : _changeRecoveryKey, + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Sicherheit neu einrichten', + desc: 'Cross-Signing & Backup zurücksetzen und neu aufsetzen', + pt: pt, + showArrow: true, + destructive: true, + onTap: _state == _EncState.loading ? null : _resetSecurity, + ), + ]), + const SizedBox(height: 20), + + // ── Key file export / import ─────────────────────────────────────── + _SettingsGroup(title: 'Schlüsseldatei', pt: pt, children: [ + _SettingsRow( + title: 'Schlüssel exportieren', + desc: 'Sitzungsschlüssel als verschlüsselte Datei sichern (kompatibel mit Element)', + pt: pt, + showArrow: !_exportLoading, + control: _exportLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : null, + onTap: (_exportLoading || _importLoading) ? null : _exportKeys, + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Schlüssel importieren', + desc: 'Sitzungsschlüssel aus exportierter Datei laden', + pt: pt, + showArrow: !_importLoading, + control: _importLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : null, + onTap: (_exportLoading || _importLoading) ? null : _importKeys, + ), + ]), + + if (_keyFileMsg != null) ...[ + const SizedBox(height: 12), + _StatusCard( + pt: pt, + icon: _keyFileMsgIsError + ? Icons.error_outline_rounded + : Icons.check_circle_outline_rounded, + color: _keyFileMsgIsError ? pt.danger : PyramidColors.success, + text: _keyFileMsg!, + ), + ], + const SizedBox(height: 20), + + // ── E2EE Diagnostics ────────────────────────────────────────────── + Consumer(builder: (context, ref, _) { + final count = ref.watch(e2eeDiagnosticsProvider).length; + return _SettingsGroup(title: 'Diagnose', pt: pt, children: [ + _SettingsRow( + title: 'Entschlüsselungsfehler hochladen', + desc: count == 0 + ? 'Keine Fehler gesammelt' + : '$count Fehler gesammelt – zum Server hochladen', + pt: pt, + showArrow: !_diagUploading && count > 0, + control: _diagUploading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ) + : count > 0 + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: pt.danger.withAlpha(30), + borderRadius: BorderRadius.circular(10), + ), + child: Text('$count', style: TextStyle(color: pt.danger, fontSize: 12)), + ) + : null, + onTap: (_diagUploading || count == 0) ? null : _uploadDiagnostics, + ), + ]); + }), + + if (_diagMsg != null) ...[ + const SizedBox(height: 12), + _StatusCard( + pt: pt, + icon: _diagMsgIsError + ? Icons.error_outline_rounded + : Icons.cloud_done_rounded, + color: _diagMsgIsError ? pt.danger : PyramidColors.success, + text: _diagMsg!, + ), + ], + const SizedBox(height: 20), + + // ── Inline state UI ─────────────────────────────────────────────── + if (_state == _EncState.loading) + const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive())), + + if (_state == _EncState.done) ...[ + _StatusCard( + pt: pt, + icon: Icons.check_circle_outline_rounded, + color: PyramidColors.success, + text: 'Alle Schlüssel importiert. Nachrichten werden entschlüsselt.'), + const SizedBox(height: 12), + ], + + if (_state == _EncState.needsKey || _state == _EncState.unlocking) ...[ + _StatusCard( + pt: pt, + icon: Icons.lock_outline_rounded, + color: pt.accent, + text: 'Wiederherstellungsschlüssel erforderlich.'), + const SizedBox(height: 16), + TextField( + controller: _keyCtrl, + readOnly: _state == _EncState.unlocking, + autofocus: true, + autocorrect: false, + style: TextStyle(color: pt.fg, fontSize: 13, fontFamily: 'monospace'), + decoration: InputDecoration( + hintText: 'EsXX XXXX XXXX XXXX …', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 12), + labelText: 'Wiederherstellungsschlüssel', + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + errorText: _error, + errorMaxLines: 2, + filled: true, + fillColor: pt.bg2, + prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + cursorColor: pt.accent, + onSubmitted: (_) => _unlock(), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: _PrimaryBtn( + label: _state == _EncState.unlocking ? 'Entschlüssele…' : 'Nachrichten entschlüsseln', + icon: Icons.lock_open_outlined, + pt: pt, + loading: _state == _EncState.unlocking, + onPressed: _unlock, + ), + ), + const SizedBox(height: 12), + ], + + if (_state == _EncState.error) ...[ + _StatusCard( + pt: pt, icon: Icons.error_outline_rounded, color: PyramidColors.danger, + text: _error ?? 'Unbekannter Fehler.'), + const SizedBox(height: 12), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: pt.fg, + side: BorderSide(color: pt.border), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _start, + icon: const Icon(Icons.refresh_rounded, size: 16), + label: const Text('Erneut versuchen'), + ), + ], + + if (_state == _EncState.idle && !_statusLoaded) + _StatusCard( + pt: pt, + icon: Icons.shield_outlined, + color: pt.fgMuted, + text: 'Lade Sicherheitsstatus…'), + ], + ), + ); + } +} + +class _ChangeRecoveryKeyDialog extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _ChangeRecoveryKeyDialog({required this.pt}); + + @override + ConsumerState<_ChangeRecoveryKeyDialog> createState() => + _ChangeRecoveryKeyDialogState(); +} + +class _ChangeRecoveryKeyDialogState + extends ConsumerState<_ChangeRecoveryKeyDialog> { + _RecoveryStep _step = _RecoveryStep.loading; + String? _generatedKey; + String? _error; + bool _copied = false; + // Completer signals when bootstrap reaches `done` + final _doneCompleter = Completer(); + + @override + void initState() { + super.initState(); + _startBootstrap(); + } + + Future _startBootstrap() async { + try { + final client = await ref.read(matrixClientProvider.future); + final enc = client.encryption; + if (enc == null) throw Exception('Verschlüsselung nicht verfügbar.'); + await client.roomsLoading; + await client.accountDataLoading; + await client.userDeviceKeysLoading; + enc.bootstrap(onUpdate: (bs) { + if (!mounted) return; + _onBootstrapUpdate(bs); + }); + } catch (e) { + if (mounted) { + setState(() { + _step = _RecoveryStep.error; + _error = e.toString().split('\n').first; + }); + } + } + } + + // After newSsss() calls checkCrossSigning(), the bootstrap immediately + // transitions to askWipeCrossSigning (or askSetupCrossSigning). All post- + // newSsss states must therefore auto-advance unconditionally — guarding on + // _step would leave them stuck. + void _onBootstrapUpdate(Bootstrap bs) { + switch (bs.state) { + case BootstrapState.askWipeSsss: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askWipeSsss) bs.wipeSsss(true); + }); + case BootstrapState.askBadSsss: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askBadSsss) bs.ignoreBadSecrets(true); + }); + case BootstrapState.askUseExistingSsss: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askUseExistingSsss) bs.useExistingSsss(false); + }); + case BootstrapState.askUnlockSsss: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askUnlockSsss) bs.unlockedSsss(); + }); + case BootstrapState.askNewSsss: + // Only generate once + if (_step == _RecoveryStep.loading) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (bs.state != BootstrapState.askNewSsss) return; + try { + await bs.newSsss(); + // newSsss() internally calls checkCrossSigning() which already + // transitions to askWipeCrossSigning; capture key after it returns. + final key = bs.newSsssKey?.recoveryKey; + if (mounted) setState(() { _generatedKey = key; _step = _RecoveryStep.showKey; }); + } catch (e) { + if (mounted) setState(() { _step = _RecoveryStep.error; _error = e.toString().split('\n').first; }); + } + }); + } + case BootstrapState.askWipeCrossSigning: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askWipeCrossSigning) bs.wipeCrossSigning(false); + }); + case BootstrapState.askSetupCrossSigning: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askSetupCrossSigning) { + bs.askSetupCrossSigning( + setupMasterKey: true, + setupSelfSigningKey: true, + setupUserSigningKey: true, + ); + } + }); + case BootstrapState.askWipeOnlineKeyBackup: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askWipeOnlineKeyBackup) bs.wipeOnlineKeyBackup(true); + }); + case BootstrapState.askSetupOnlineKeyBackup: + WidgetsBinding.instance.addPostFrameCallback((_) { + if (bs.state == BootstrapState.askSetupOnlineKeyBackup) bs.askSetupOnlineKeyBackup(true); + }); + case BootstrapState.done: + if (!_doneCompleter.isCompleted) _doneCompleter.complete(); + // Only auto-advance to done if user already clicked through (applying). + // If still on showKey/confirmKey, the user must finish reading first. + if (mounted && _step == _RecoveryStep.applying) { + setState(() => _step = _RecoveryStep.done); + } + case BootstrapState.error: + if (!_doneCompleter.isCompleted) _doneCompleter.completeError('Bootstrap-Fehler'); + if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Fehler bei der Schlüsselgenerierung.'; }); + default: + break; + } + } + + // Called when user taps "Schlüssel gespeichert – weiter" on the showKey screen. + void _onKeySaved() { + setState(() => _step = _RecoveryStep.confirmKey); + } + + // Called when user submits the correct key confirmation. + // Bootstrap may still be running (cross-signing + key backup); wait for done. + Future _onKeyConfirmed() async { + setState(() => _step = _RecoveryStep.applying); + try { + await _doneCompleter.future.timeout(const Duration(seconds: 90)); + if (mounted) setState(() => _step = _RecoveryStep.done); + } catch (e) { + if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Timeout: Bootstrap abgebrochen.'; }); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final canClose = _step != _RecoveryStep.loading && _step != _RecoveryStep.applying && _step != _RecoveryStep.confirmKey; + + return Dialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440, minWidth: 320), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 12, 12), + child: Row( + children: [ + Icon(Icons.key_rounded, size: 18, color: pt.accent), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Wiederherstellungsschlüssel', + style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + if (canClose) + IconButton( + icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), + onPressed: () => Navigator.of(context).pop(_step == _RecoveryStep.done), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + ), + ], + ), + ), + Divider(color: pt.border, height: 1), + Padding( + padding: const EdgeInsets.all(20), + child: _buildBody(pt), + ), + ], + ), + ), + ); + } + + Widget _buildBody(PyramidTheme pt) { + return switch (_step) { + _RecoveryStep.loading => const Center( + child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive()), + ), + _RecoveryStep.showKey => _ShowKeyBody( + pt: pt, + generatedKey: _generatedKey ?? '', + copied: _copied, + onCopy: () { + Clipboard.setData(ClipboardData(text: _generatedKey ?? '')); + setState(() => _copied = true); + }, + onKeySaved: _onKeySaved, + ), + _RecoveryStep.confirmKey => _ConfirmKeyBody( + pt: pt, + expectedKey: _generatedKey ?? '', + onConfirmed: _onKeyConfirmed, + ), + _RecoveryStep.applying => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: 16), + Text( + 'Schlüssel wird aktiviert…\nCross-Signing und Backup werden eingerichtet.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + ], + ), + _RecoveryStep.done => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52), + const SizedBox(height: 12), + Text( + 'Wiederherstellungsschlüssel wurde erfolgreich geändert.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 6), + Text( + 'Bewahre deinen Schlüssel sicher auf – du brauchst ihn bei Geräteverlust.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.4), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Fertig'), + ), + ), + ], + ), + _RecoveryStep.error => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48), + const SizedBox(height: 12), + Text( + _error ?? 'Ein unbekannter Fehler ist aufgetreten.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: pt.fg, side: BorderSide(color: pt.border), + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Schließen'), + ), + ), + ], + ), + }; + } +} + +class _ShowKeyBody extends StatelessWidget { + final PyramidTheme pt; + final String generatedKey; + final bool copied; + final VoidCallback onCopy; + final VoidCallback onKeySaved; + + const _ShowKeyBody({ + required this.pt, + required this.generatedKey, + required this.copied, + required this.onCopy, + required this.onKeySaved, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Dein neuer Wiederherstellungsschlüssel', + style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + 'Speichere diesen Schlüssel sicher – du brauchst ihn, wenn du den Zugang zu allen Geräten verlierst.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: pt.border), + ), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: SelectableText( + generatedKey, + style: TextStyle( + color: pt.accent, fontSize: 13, + fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6, + ), + ), + ), + ), + IconButton( + onPressed: onCopy, + icon: Icon( + copied ? Icons.check_rounded : Icons.copy_rounded, + size: 18, + color: copied ? PyramidColors.success : pt.fgDim, + ), + tooltip: copied ? 'Kopiert!' : 'In Zwischenablage kopieren', + ), + ], + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.orange.withAlpha(28), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.withAlpha(80)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber_rounded, size: 16, color: Colors.orange.shade700), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Schreibe diesen Schlüssel auf oder speichere ihn in einem Passwort-Manager. Er kann nicht wiederhergestellt werden.', + style: TextStyle(color: Colors.orange.shade800, fontSize: 12, height: 1.4), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: onKeySaved, + child: const Text('Schlüssel gespeichert – weiter'), + ), + ), + ], + ); + } +} + +class _ConfirmKeyBody extends StatefulWidget { + final PyramidTheme pt; + final String expectedKey; + final VoidCallback onConfirmed; + + const _ConfirmKeyBody({ + required this.pt, + required this.expectedKey, + required this.onConfirmed, + }); + + @override + State<_ConfirmKeyBody> createState() => _ConfirmKeyBodyState(); +} + +class _ConfirmKeyBodyState extends State<_ConfirmKeyBody> { + final _controller = TextEditingController(); + bool _mismatch = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final entered = _controller.text.trim(); + if (entered == widget.expectedKey) { + widget.onConfirmed(); + } else { + setState(() => _mismatch = true); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Schlüssel bestätigen', + style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + 'Gib deinen neuen Wiederherstellungsschlüssel zur Bestätigung ein.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + const SizedBox(height: 16), + TextField( + controller: _controller, + autofocus: true, + style: TextStyle( + color: pt.fg, fontSize: 13, + fontFamily: 'monospace', letterSpacing: 0.4, + ), + decoration: InputDecoration( + hintText: 'EsTX XXXX XXXX …', + hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), + filled: true, + fillColor: pt.bg3, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: pt.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.accent, width: 1.5), + ), + errorText: _mismatch ? 'Schlüssel stimmt nicht überein.' : null, + ), + onChanged: (_) { if (_mismatch) setState(() => _mismatch = false); }, + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, foregroundColor: pt.accentFg, + elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _submit, + child: const Text('Aktivieren'), + ), + ), + ], + ); + } +} + +class _FingerprintGroup extends ConsumerWidget { + final PyramidTheme pt; + const _FingerprintGroup({required this.pt}); + + static String _fmt(String key) { + final clean = key.replaceAll(RegExp(r'\s'), ''); + final groups = []; + for (var i = 0; i < clean.length; i += 4) { + groups.add(clean.substring(i, (i + 4).clamp(0, clean.length))); + } + return groups.join(' '); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clientAsync = ref.watch(matrixClientProvider); + final client = clientAsync.valueOrNull; + if (client == null) return const SizedBox.shrink(); + + final fingerprint = _fmt(client.fingerprintKey); + final deviceId = client.deviceID ?? ''; + + return _SettingsGroup(title: 'Dieses Gerät', pt: pt, children: [ + _SettingsRow( + title: 'Geräte-ID', + desc: deviceId, + pt: pt, + control: IconButton( + icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), + tooltip: 'Kopieren', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + onPressed: () => Clipboard.setData(ClipboardData(text: deviceId)), + ), + ), + _Divider(pt: pt), + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Ed25519-Fingerabdruck', + style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), + const Spacer(), + IconButton( + icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), + tooltip: 'Fingerabdruck kopieren', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), + onPressed: () => Clipboard.setData(ClipboardData(text: client.fingerprintKey)), + ), + ], + ), + const SizedBox(height: 4), + SelectableText( + fingerprint, + style: TextStyle( + color: pt.fgMuted, fontSize: 11, + fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6, + ), + ), + ], + ), + ), + ]); + } +} + +class _PassphraseDialog extends StatefulWidget { + final PyramidTheme pt; + final bool forExport; + const _PassphraseDialog({required this.pt, required this.forExport}); + + @override + State<_PassphraseDialog> createState() => _PassphraseDialogState(); +} + +class _PassphraseDialogState extends State<_PassphraseDialog> { + final _ctrl1 = TextEditingController(); + final _ctrl2 = TextEditingController(); + bool _obscure = true; + String? _error; + + @override + void dispose() { + _ctrl1.dispose(); + _ctrl2.dispose(); + super.dispose(); + } + + void _submit() { + final pass = _ctrl1.text.trim(); + if (pass.isEmpty) { + setState(() => _error = 'Bitte ein Passwort eingeben.'); + return; + } + if (widget.forExport && pass != _ctrl2.text.trim()) { + setState(() => _error = 'Passwörter stimmen nicht überein.'); + return; + } + Navigator.pop(context, pass); + } + + InputDecoration _inputDeco(String label, PyramidTheme pt) => InputDecoration( + labelText: label, + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + errorText: _error, + filled: true, + fillColor: pt.bg3, + prefixIcon: Icon(Icons.lock_outline_rounded, color: pt.fgDim, size: 18), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), + isDense: true, + errorMaxLines: 2, + suffixIcon: IconButton( + icon: Icon( + _obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined, + size: 18, + color: pt.fgDim, + ), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ); + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border), + ), + title: Text( + widget.forExport ? 'Schlüssel exportieren' : 'Schlüssel importieren', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700), + ), + content: SizedBox( + width: 340, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.forExport + ? 'Wähle ein Passwort zum Verschlüsseln der Schlüsseldatei.' + : 'Gib das Passwort ein, mit dem die Datei verschlüsselt wurde.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + const SizedBox(height: 16), + TextField( + controller: _ctrl1, + obscureText: _obscure, + autofocus: true, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: _inputDeco('Passwort', pt).copyWith( + // Only show error on confirm field if export, else here + errorText: widget.forExport ? null : _error, + ), + cursorColor: pt.accent, + onSubmitted: widget.forExport ? null : (_) => _submit(), + ), + if (widget.forExport) ...[ + const SizedBox(height: 12), + TextField( + controller: _ctrl2, + obscureText: _obscure, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: _inputDeco('Passwort bestätigen', pt), + cursorColor: pt.accent, + onSubmitted: (_) => _submit(), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _submit, + child: Text(widget.forExport ? 'Exportieren' : 'Importieren'), + ), + ], + ); + } +} diff --git a/lib/widgets/settings/settings_keybinds.dart b/lib/widgets/settings/settings_keybinds.dart new file mode 100644 index 0000000..e494fb0 --- /dev/null +++ b/lib/widgets/settings/settings_keybinds.dart @@ -0,0 +1,67 @@ +part of '../settings_modal.dart'; + +class _KeysSection extends StatelessWidget { + final PyramidTheme pt; + const _KeysSection({required this.pt}); + + @override + Widget build(BuildContext context) { + final keys = [ + ('Schnellsuche', 'Strg+K'), + ('Raum als gelesen markieren', 'Shift+Esc'), + ('Zu ungelesen springen', 'Strg+J'), + ('Linke Leiste ein-/ausblenden', 'Strg+B'), + ('Mitglieder ein-/ausblenden', 'Strg+Shift+U'), + ('Nachrichten durchsuchen', 'Strg+F'), + ('Antworten', 'R'), + ('Auswahl aufheben', 'Esc'), + ]; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Tastaturkürzel', + subtitle: 'Schnelle Aktionen per Tastatur.', + pt: pt), + _SettingsGroup( + title: 'Navigieren', + pt: pt, + children: keys.asMap().entries.map((entry) { + final (action, binding) = entry.value; + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + Expanded( + child: Text(action, + style: TextStyle(color: pt.fg, fontSize: 13))), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: pt.border), + ), + child: Text(binding, + style: TextStyle( + color: pt.fgMuted, + fontSize: 12, + fontFamily: 'monospace')), + ), + ], + ), + ), + if (entry.key < keys.length - 1) _Divider(pt: pt), + ], + ); + }).toList(), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/settings_notifications.dart b/lib/widgets/settings/settings_notifications.dart new file mode 100644 index 0000000..23e1ed7 --- /dev/null +++ b/lib/widgets/settings/settings_notifications.dart @@ -0,0 +1,168 @@ +part of '../settings_modal.dart'; + +class _NotificationsSection extends ConsumerWidget { + final PyramidTheme pt; + const _NotificationsSection({required this.pt}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final desktop = ref.watch(notifDesktopProvider); + final sound = ref.watch(notifSoundProvider); + final preview = ref.watch(notifPreviewProvider); + final dmMentionOnly = ref.watch(notifMentionOnlyDmProvider); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Benachrichtigungen', + subtitle: 'Bestimme, wann und wie du benachrichtigt wirst.', + pt: pt), + _SettingsGroup(title: 'Desktop', pt: pt, children: [ + _SettingsRow( + title: 'Desktop-Benachrichtigungen', + desc: 'Zeige Systembenachrichtigungen bei neuen Nachrichten', + pt: pt, + control: _Toggle( + value: desktop, + pt: pt, + onChanged: (v) => ref.read(notifDesktopProvider.notifier).set(v), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Benachrichtigungston', + desc: 'Sound abspielen wenn eine Nachricht eingeht', + pt: pt, + control: _Toggle( + value: sound, + pt: pt, + onChanged: (v) => ref.read(notifSoundProvider.notifier).set(v), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Nachrichtenvorschau', + desc: 'Nachrichteninhalt in Benachrichtigung anzeigen', + pt: pt, + control: _Toggle( + value: preview, + pt: pt, + onChanged: (v) => ref.read(notifPreviewProvider.notifier).set(v), + ), + ), + ]), + const SizedBox(height: 20), + _SettingsGroup(title: 'Filter', pt: pt, children: [ + _SettingsRow( + title: 'Nur Erwähnungen (DMs)', + desc: 'Benachrichtigung nur bei direkten @Erwähnungen', + pt: pt, + control: _Toggle( + value: dmMentionOnly, + pt: pt, + onChanged: (v) => ref.read(notifMentionOnlyDmProvider.notifier).set(v), + ), + ), + ]), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: pt.border), + ), + child: Row( + children: [ + Icon(Icons.info_outline_rounded, size: 16, color: pt.fgDim), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Push-Benachrichtigungen für Android/iOS sind in Vorbereitung.', + style: TextStyle(color: pt.fgMuted, fontSize: 12), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _NotificationsSettingsGroup extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _NotificationsSettingsGroup({required this.pt}); + @override + ConsumerState<_NotificationsSettingsGroup> createState() => + _NotificationsSettingsGroupState(); +} + +class _NotificationsSettingsGroupState + extends ConsumerState<_NotificationsSettingsGroup> { + bool _running = false; + String? _result; + bool _retryRunning = false; + String? _retryResult; + + Future _runDiag() async { + setState(() { _running = true; _result = null; }); + final client = await ref.read(matrixClientProvider.future); + final diag = await fcmDiagnostics(client); + if (mounted) setState(() { _running = false; _result = diag; }); + } + + Future _runRetry() async { + setState(() { _retryRunning = true; _retryResult = null; }); + final client = await ref.read(matrixClientProvider.future); + final result = await retryPusherRegistration(client); + if (mounted) setState(() { _retryRunning = false; _retryResult = result; }); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + if (!Platform.isAndroid) return const SizedBox.shrink(); + return _SettingsGroup(title: 'Push-Benachrichtigungen', pt: pt, children: [ + _SettingsRow( + title: 'Akku-Optimierung deaktivieren', + desc: 'Samsung / andere OEMs unterdrücken sonst Benachrichtigungen bei geschlossener App.', + pt: pt, + showArrow: true, + onTap: openBatteryOptimizationSettings, + control: Icon(Icons.battery_saver_outlined, size: 16, color: pt.fgDim), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'FCM Diagnose', + desc: _result ?? 'Prüft ob FCM-Token & Pusher korrekt registriert sind.', + pt: pt, + onTap: _running ? null : _runDiag, + control: _running + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent), + ) + : Icon(Icons.bug_report_outlined, size: 16, color: pt.fgDim), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Pusher neu registrieren', + desc: _retryResult ?? 'Pusher beim Homeserver erneut anmelden.', + pt: pt, + onTap: _retryRunning ? null : _runRetry, + control: _retryRunning + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent), + ) + : Icon(Icons.sync_outlined, size: 16, color: pt.fgDim), + ), + ]); + } +} diff --git a/lib/widgets/settings/settings_privacy.dart b/lib/widgets/settings/settings_privacy.dart new file mode 100644 index 0000000..799ef2b --- /dev/null +++ b/lib/widgets/settings/settings_privacy.dart @@ -0,0 +1,162 @@ +part of '../settings_modal.dart'; + +class _PrivacySection extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _PrivacySection({required this.pt}); + + @override + ConsumerState<_PrivacySection> createState() => _PrivacySectionState(); +} + +class _PrivacySectionState extends ConsumerState<_PrivacySection> { + List _ignoredUsers = []; + final Set _unblocking = {}; + + @override + void initState() { + super.initState(); + _loadIgnoredUsers(); + } + + Future _loadIgnoredUsers() async { + final client = await ref.read(matrixClientProvider.future); + if (mounted) setState(() => _ignoredUsers = List.from(client.ignoredUsers)); + } + + Future _unblockUser(String userId) async { + setState(() => _unblocking.add(userId)); + try { + final client = await ref.read(matrixClientProvider.future); + await client.unignoreUser(userId); + await _loadIgnoredUsers(); + } catch (e) { + // ignore — list will be stale but not crash + } finally { + if (mounted) setState(() => _unblocking.remove(userId)); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final presence = ref.watch(privacyPresenceProvider); + final readReceipts = ref.watch(privacyReadReceiptsProvider); + final typing = ref.watch(privacyTypingProvider); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Datenschutz', + subtitle: 'Steuere, was andere über dich erfahren.', + pt: pt), + _SettingsGroup(title: 'Sichtbarkeit', pt: pt, children: [ + _SettingsRow( + title: 'Online-Status anzeigen', + desc: 'Anderen anzeigen, wenn du aktiv bist', + pt: pt, + control: _Toggle( + value: presence, + pt: pt, + onChanged: (v) async { + ref.read(privacyPresenceProvider.notifier).set(v); + final client = await ref.read(matrixClientProvider.future); + await client.setPresence( + client.userID!, + v ? PresenceType.online : PresenceType.offline, + ).catchError((_) {}); + }, + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Lesebestätigungen senden', + desc: 'Anderen zeigen, dass du ihre Nachrichten gelesen hast', + pt: pt, + control: _Toggle( + value: readReceipts, + pt: pt, + onChanged: (v) => ref.read(privacyReadReceiptsProvider.notifier).set(v), + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Tipp-Indikator senden', + desc: '„schreibt…" Anzeige aktivieren', + pt: pt, + control: _Toggle( + value: typing, + pt: pt, + onChanged: (v) => ref.read(privacyTypingProvider.notifier).set(v), + ), + ), + ]), + const SizedBox(height: 20), + // ── Blocked users ────────────────────────────────────────────────── + _SettingsGroup(title: 'Blockierte Nutzer (${_ignoredUsers.length})', pt: pt, children: [ + if (_ignoredUsers.isEmpty) + Padding( + padding: const EdgeInsets.all(14), + child: Text('Keine blockierten Nutzer.', + style: TextStyle(color: pt.fgMuted, fontSize: 13)), + ) + else + ..._ignoredUsers.asMap().entries.map((entry) { + final i = entry.key; + final userId = entry.value; + final isUnblocking = _unblocking.contains(userId); + return Column(children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row(children: [ + Expanded( + child: Text(userId, + style: TextStyle(color: pt.fg, fontSize: 13), + overflow: TextOverflow.ellipsis), + ), + if (isUnblocking) + const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) + else + TextButton( + style: TextButton.styleFrom( + foregroundColor: pt.accent, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => _unblockUser(userId), + child: const Text('Entblocken', style: TextStyle(fontSize: 12)), + ), + ]), + ), + if (i < _ignoredUsers.length - 1) _Divider(pt: pt), + ]); + }), + ]), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.accentSoft, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: pt.accent.withAlpha(60)), + ), + child: Row( + children: [ + Icon(Icons.shield_outlined, size: 16, color: pt.accent), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Alle Nachrichten sind Ende-zu-Ende-verschlüsselt.', + style: TextStyle(color: pt.fg, fontSize: 12), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/settings_profile.dart b/lib/widgets/settings/settings_profile.dart new file mode 100644 index 0000000..beeec7e --- /dev/null +++ b/lib/widgets/settings/settings_profile.dart @@ -0,0 +1,513 @@ +part of '../settings_modal.dart'; + +class _ProfileSection extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _ProfileSection({required this.pt}); + + @override + ConsumerState<_ProfileSection> createState() => _ProfileSectionState(); +} + +class _ProfileSectionState extends ConsumerState<_ProfileSection> { + late TextEditingController _nameCtrl; + late TextEditingController _statusCtrl; + bool _saving = false; + bool _saved = false; + String? _error; + Uint8List? _pendingAvatar; + Uri? _avatarUri; + Uint8List? _pendingBanner; + String? _bannerMxcUri; + Uint8List? _pendingServerBanner; + bool _serverBannerSaving = false; + + @override + void initState() { + super.initState(); + _nameCtrl = TextEditingController(); + _statusCtrl = TextEditingController(); + _loadProfile(); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _statusCtrl.dispose(); + super.dispose(); + } + + Future _loadProfile() async { + final client = await ref.read(matrixClientProvider.future); + if (!mounted) return; + + // 1) SOFORT aus dem lokalen Cache füllen — kein Warten auf den Server. + _nameCtrl.text = client.userID ?? ''; + for (final room in client.rooms) { + final u = room.unsafeGetUserFromMemoryOrFallback(client.userID!); + if (u.displayName != null || u.avatarUrl != null) { + if (u.displayName != null) _nameCtrl.text = u.displayName!; + _avatarUri = u.avatarUrl; + break; + } + } + try { + final bannerData = client.accountData['com.pyramid.profile.banner']; + _bannerMxcUri = bannerData?.content['url'] as String?; + } catch (_) {} + setState(() {}); + + // 2) Server-Daten PARALLEL nachladen, UI aktualisiert sich pro Antwort. + client + .getProfileFromUserId(client.userID!) + .then((profile) { + if (!mounted) return; + setState(() { + if (profile.displayName != null) _nameCtrl.text = profile.displayName!; + _avatarUri = profile.avatarUrl ?? _avatarUri; + }); + }).catchError((_) {}); + + () async { + try { + final apiUri = client.homeserver!.replace( + path: + '/_matrix/client/v3/profile/${Uri.encodeComponent(client.userID!)}', + ); + final resp = await client.httpClient.get( + apiUri, + headers: {'Authorization': 'Bearer ${client.accessToken}'}, + ); + if (resp.statusCode == 200 && mounted) { + final data = jsonDecode(resp.body) as Map; + final url = data['io.pyramid.profile.banner_url'] as String?; + if (url != null) setState(() => _bannerMxcUri = url); + } + } catch (_) {} + }(); + } + + Future _pickAvatar() async { + final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); + final file = result?.files.first; + if (file == null) return; + final bytes = file.bytes ?? await file.xFile.readAsBytes(); + setState(() => _pendingAvatar = bytes); + } + + Future _pickBanner() async { + final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); + final file = result?.files.first; + if (file == null) return; + final raw = file.bytes ?? await file.xFile.readAsBytes(); + if (!mounted) return; + final cropped = await showDialog( + context: context, + builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 3.0), + ); + if (cropped == null || !mounted) return; + setState(() => _pendingBanner = cropped); + } + + Future _pickServerBanner() async { + final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); + final file = result?.files.first; + if (file == null) return; + final raw = file.bytes ?? await file.xFile.readAsBytes(); + if (!mounted) return; + final cropped = await showDialog( + context: context, + builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4), + ); + if (cropped == null || !mounted) return; + setState(() => _serverBannerSaving = true); + try { + final client = await ref.read(matrixClientProvider.future); + final uri = await client.uploadContent(cropped, filename: 'server_banner.png', contentType: 'image/png'); + await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), uri.toString()); + if (mounted) setState(() { _pendingServerBanner = cropped; _serverBannerSaving = false; }); + } catch (_) { + if (mounted) setState(() => _serverBannerSaving = false); + } + } + + Future _removeServerBanner() async { + final client = ref.read(matrixClientProvider).valueOrNull; + if (client == null) return; + await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), null); + if (mounted) setState(() => _pendingServerBanner = null); + } + + Future _save() async { + setState(() { _saving = true; _error = null; _saved = false; }); + try { + final client = await ref.read(matrixClientProvider.future); + final name = _nameCtrl.text.trim(); + if (name.isNotEmpty) { + await client.setProfileField( + client.userID!, + 'displayname', + {'displayname': name}, + ); + } + if (_pendingAvatar != null) { + await client.setAvatar(MatrixFile( + bytes: _pendingAvatar!, + name: 'avatar.jpg', + mimeType: 'image/jpeg', + )); + setState(() => _pendingAvatar = null); + } + if (_pendingBanner != null) { + final uri = await client.uploadContent( + _pendingBanner!, filename: 'banner.png', contentType: 'image/png'); + final uriStr = uri.toString(); + // Store as a profile custom field so other users can see it + await client.setProfileField( + client.userID!, + 'io.pyramid.profile.banner_url', + {'io.pyramid.profile.banner_url': uriStr}, + ); + // Also keep in account data as legacy fallback + await client.setAccountData(client.userID!, 'com.pyramid.profile.banner', + {'url': uriStr}); + setState(() { _bannerMxcUri = uriStr; _pendingBanner = null; }); + } + final status = _statusCtrl.text.trim(); + await client.setPresence( + client.userID!, + PresenceType.online, + statusMsg: status.isEmpty ? null : status, + ); + if (mounted) setState(() { _saving = false; _saved = true; }); + await Future.delayed(const Duration(seconds: 2)); + if (mounted) setState(() => _saved = false); + } catch (e) { + if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; }); + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + final clientAsync = ref.watch(matrixClientProvider); + final client = clientAsync.valueOrNull; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Mein Profil', + subtitle: 'Wie du in Pyramid erscheinst.', + pt: pt), + // Banner + GestureDetector( + onTap: _pickBanner, + child: Stack( + children: [ + Container( + height: 100, + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + clipBehavior: Clip.hardEdge, + child: _pendingBanner != null + ? Image.memory(_pendingBanner!, fit: BoxFit.cover, width: double.infinity) + : (client != null && _bannerMxcUri != null + ? MxcImage( + mxcUri: _bannerMxcUri!, + client: client, + fit: BoxFit.cover, + width: double.infinity, + ) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_photo_alternate_outlined, size: 28, color: pt.fgDim), + const SizedBox(height: 4), + Text('Profilbanner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + )), + ), + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: pt.bg0.withAlpha(200), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: pt.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted), + const SizedBox(width: 4), + Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + // Avatar row + Row( + children: [ + GestureDetector( + onTap: _pickAvatar, + child: Stack( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: pt.border, width: 2), + ), + child: ClipOval( + child: _pendingAvatar != null + ? Image.memory(_pendingAvatar!, fit: BoxFit.cover) + : (client != null && _avatarUri != null + ? MxcAvatar( + mxcUri: _avatarUri, + client: client, + size: 72, + placeholder: (_) => _AvatarFallback( + name: _nameCtrl.text, size: 72, pt: pt), + ) + : _AvatarFallback( + name: _nameCtrl.text, size: 72, pt: pt)), + ), + ), + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: pt.accent, + shape: BoxShape.circle, + border: Border.all(color: pt.bg1, width: 2), + ), + child: Icon(Icons.edit_rounded, size: 12, color: pt.accentFg), + ), + ), + ], + ), + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _nameCtrl.text.isNotEmpty ? _nameCtrl.text : (client?.userID ?? ''), + style: TextStyle( + color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + SelectableText( + client?.userID ?? '', + style: TextStyle(color: pt.fgDim, fontSize: 12), + ), + const SizedBox(height: 6), + if (_pendingAvatar != null) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: pt.accent.withAlpha(40), + borderRadius: BorderRadius.circular(6), + ), + child: Text('Neues Bild ausgewählt', + style: TextStyle(color: pt.accent, fontSize: 11)), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 24), + _SettingsGroup(title: 'Name & Status', pt: pt, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 4), + child: _PrefTextField( + controller: _nameCtrl, + label: 'Anzeigename', + pt: pt, + ), + ), + const SizedBox(height: 2), + Padding( + padding: const EdgeInsets.fromLTRB(14, 4, 14, 14), + child: _PrefTextField( + controller: _statusCtrl, + label: 'Statusnachricht (optional)', + pt: pt, + maxLength: 120, + ), + ), + ]), + const SizedBox(height: 20), + if (_error != null) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _StatusCard( + pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!), + ), + if (_saved) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _StatusCard( + pt: pt, + color: PyramidColors.success, + icon: Icons.check_circle_outline_rounded, + text: 'Profil gespeichert.'), + ), + _PrimaryBtn( + label: _saving ? 'Speichern…' : 'Speichern', + icon: Icons.save_outlined, + pt: pt, + loading: _saving, + onPressed: _save, + ), + const SizedBox(height: 24), + + // ── Server-Banner ───────────────────────────────────────────────── + _SectionTitle( + title: 'Server-Darstellung', + subtitle: 'Banner für ${client?.homeserver?.host ?? 'diesen Server'}', + pt: pt, + ), + Consumer(builder: (context, ref, _) { + final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {}; + final homeserver = client?.homeserver.toString(); + final existingMxc = homeserver != null ? serverBanners[homeserver] : null; + final hasBanner = _pendingServerBanner != null || existingMxc != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: _serverBannerSaving ? null : _pickServerBanner, + child: Container( + height: 90, + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rBase), + border: Border.all(color: pt.border), + ), + clipBehavior: Clip.hardEdge, + child: Stack( + fit: StackFit.expand, + children: [ + if (_pendingServerBanner != null) + Image.memory(_pendingServerBanner!, fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink()) + else if (existingMxc != null && client != null) + MxcImage(mxcUri: existingMxc, client: client, fit: BoxFit.cover) + else + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add_photo_alternate_outlined, size: 24, color: pt.fgDim), + const SizedBox(height: 4), + Text('Server-Banner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ), + if (_serverBannerSaving) + Container( + color: Colors.black38, + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + if (hasBanner) + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: pt.bg0.withAlpha(200), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: pt.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted), + const SizedBox(width: 4), + Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)), + ], + ), + ), + ), + ], + ), + ), + ), + if (hasBanner) ...[ + const SizedBox(height: 8), + GestureDetector( + onTap: _removeServerBanner, + child: Text( + 'Banner entfernen', + style: TextStyle(color: pt.danger, fontSize: 12), + ), + ), + ], + ], + ); + }), + + const SizedBox(height: 24), + _SettingsGroup(title: 'Konto', pt: pt, children: [ + _SettingsRow( + title: 'Passwort ändern', + desc: 'Aktuelles Passwort zum Ändern erforderlich', + pt: pt, + showArrow: true, + onTap: _changePassword, + ), + ]), + const SizedBox(height: 12), + _SettingsGroup(title: 'Gefahrenbereich', pt: pt, children: [ + _SettingsRow( + title: 'Konto löschen', + desc: 'Konto dauerhaft deaktivieren – nicht rückgängig zu machen', + pt: pt, + showArrow: true, + destructive: true, + onTap: _deleteAccount, + ), + ]), + ], + ), + ); + } + + Future _changePassword() async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _ChangePasswordDialog(pt: widget.pt), + ); + } + + Future _deleteAccount() async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _DeleteAccountDialog(pt: widget.pt), + ); + } +} diff --git a/lib/widgets/settings/settings_sessions.dart b/lib/widgets/settings/settings_sessions.dart new file mode 100644 index 0000000..fe61044 --- /dev/null +++ b/lib/widgets/settings/settings_sessions.dart @@ -0,0 +1,468 @@ +part of '../settings_modal.dart'; + +class _SessionsSection extends ConsumerStatefulWidget { + final PyramidTheme pt; + const _SessionsSection({required this.pt}); + + @override + ConsumerState<_SessionsSection> createState() => _SessionsSectionState(); +} + +class _SessionsSectionState extends ConsumerState<_SessionsSection> { + List _devices = []; + bool _loading = true; + String? _currentDeviceId; + final Set _revoking = {}; + bool _logoutAllLoading = false; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { _loading = true; _error = null; }); + try { + final client = await ref.read(matrixClientProvider.future); + _currentDeviceId = client.deviceID; + await client.updateUserDeviceKeys(additionalUsers: {client.userID!}); + final keys = client.userDeviceKeys[client.userID]; + if (mounted) { + setState(() { + _devices = keys?.deviceKeys.values.toList() ?? []; + _loading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _loading = false; + _error = e.toString().split('\n').first; + }); + } + } + } + + Future _revokeDevice(String deviceId) async { + setState(() => _revoking.add(deviceId)); + try { + final client = await ref.read(matrixClientProvider.future); + await client.deleteDevice(deviceId); + await _load(); + } catch (e) { + if (mounted) { + setState(() { + _revoking.remove(deviceId); + _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; + }); + } + } + } + + Future _renameCurrentDevice(BuildContext context) async { + final client = await ref.read(matrixClientProvider.future); + if (!context.mounted) return; + final currentName = _devices + .where((d) => d.deviceId == _currentDeviceId) + .map((d) => d.unsigned?['device_display_name'] as String? ?? d.deviceId ?? '') + .firstOrNull ?? ''; + + final ctrl = TextEditingController(text: currentName); + final pt = widget.pt; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + title: Text('Gerät umbenennen', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: TextField( + controller: ctrl, + autofocus: true, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + labelText: 'Gerätename', + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + filled: true, fillColor: pt.bg3, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), + ), + cursorColor: pt.accent, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Umbenennen'), + ), + ], + ), + ); + ctrl.dispose(); + if (confirmed != true || _currentDeviceId == null) return; + try { + await client.updateDevice(_currentDeviceId!, displayName: ctrl.text.trim()); + await _load(); + } catch (e) { + if (mounted) setState(() => _error = 'Umbenennen fehlgeschlagen: ${e.toString().split('\n').first}'); + } + } + + Future _logoutAllOtherDevices(BuildContext context) async { + final pt = widget.pt; + final otherIds = _devices + .where((d) => d.deviceId != _currentDeviceId && d.deviceId != null) + .map((d) => d.deviceId!) + .toList(); + if (otherIds.isEmpty) return; + + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), + title: Text('Alle anderen Sitzungen abmelden?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text( + '${otherIds.length} Gerät${otherIds.length == 1 ? '' : 'e'} werden abgemeldet. Nicht gespeicherte Nachrichten auf diesen Geräten können verloren gehen.', + style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Alle abmelden'), + ), + ], + ), + ); + if (confirm != true || !mounted) return; + setState(() { _logoutAllLoading = true; _error = null; }); + + final client = await ref.read(matrixClientProvider.future); + try { + await client.deleteDevices(otherIds); + } on MatrixException catch (e) { + if (e.requireAdditionalAuthentication && context.mounted) { + final password = await _askPassword(context, pt); + if (password == null || !mounted) { setState(() => _logoutAllLoading = false); return; } + try { + await client.deleteDevices(otherIds, auth: AuthenticationData( + type: AuthenticationTypes.password, + session: e.session, + additionalFields: { + 'identifier': {'type': 'm.id.user', 'user': client.userID}, + 'password': password, + }, + )); + } catch (e2) { + if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e2.toString().split('\n').first}'; }); + return; + } + } else { + if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; }); + return; + } + } catch (e) { + if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; }); + return; + } + await _load(); + if (mounted) setState(() => _logoutAllLoading = false); + } + + Future _askPassword(BuildContext context, PyramidTheme pt) { + final ctrl = TextEditingController(); + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), + title: Text('Passwort bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w700)), + content: TextField( + controller: ctrl, + autofocus: true, + obscureText: true, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + labelText: 'Passwort', + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + filled: true, fillColor: pt.bg3, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), + ), + cursorColor: pt.accent, + onSubmitted: (_) => Navigator.pop(ctx, ctrl.text), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, null), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, ctrl.text), + child: const Text('Bestätigen'), + ), + ], + ), + ).then((v) { ctrl.dispose(); return v; }); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Sitzungen', + subtitle: 'Geräte, auf denen du angemeldet bist.', + pt: pt), + if (_loading) + const Center( + child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive())) + else if (_error != null) + _StatusCard( + pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!) + else ...[ + _SettingsGroup( + title: 'Aktive Sitzungen (${_devices.length})', + pt: pt, + children: _devices.isEmpty + ? [ + Padding( + padding: const EdgeInsets.all(16), + child: Text('Keine Sitzungen gefunden.', + style: TextStyle(color: pt.fgMuted, fontSize: 13)), + ) + ] + : _devices.asMap().entries.map((entry) { + final i = entry.key; + final device = entry.value; + final isCurrent = device.deviceId == _currentDeviceId; + final isRevoking = _revoking.contains(device.deviceId); + final isVerified = device.verified; + final displayName = device.unsigned?['device_display_name'] as String? ?? + device.deviceId ?? + 'Unbekanntes Gerät'; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isCurrent + ? pt.accentSoft + : pt.bg3, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.devices_rounded, + size: 18, + color: isCurrent ? pt.accent : pt.fgMuted, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + displayName, + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + if (isCurrent) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: pt.accentSoft, + borderRadius: BorderRadius.circular(4), + ), + child: Text('Dieses Gerät', + style: TextStyle( + color: pt.accent, + fontSize: 10, + fontWeight: FontWeight.w600)), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _renameCurrentDevice(context), + child: Tooltip( + message: 'Gerät umbenennen', + child: Icon(Icons.edit_rounded, size: 13, color: pt.fgDim), + ), + ), + ], + if (isVerified == true && !isCurrent) + Icon(Icons.verified_rounded, + size: 14, color: PyramidColors.success), + if (isVerified == false && !isCurrent) + Icon(Icons.warning_amber_rounded, + size: 14, color: pt.away), + ], + ), + const SizedBox(height: 2), + SelectableText( + device.deviceId ?? '', + style: TextStyle( + color: pt.fgDim, + fontSize: 11, + fontFamily: 'monospace'), + ), + ], + ), + ), + if (!isCurrent) ...[ + const SizedBox(width: 8), + if (isVerified == false) + GestureDetector( + onTap: () => _verifyDevice(context, device, displayName), + child: Tooltip( + message: 'Verifizieren', + child: Icon(Icons.verified_user_outlined, + size: 16, color: pt.accent), + ), + ), + const SizedBox(width: 6), + if (isRevoking) + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: pt.danger), + ) + else + GestureDetector( + onTap: () => _confirmRevoke(context, device, displayName), + child: Tooltip( + message: 'Abmelden', + child: Icon(Icons.logout_rounded, + size: 16, color: pt.fgDim), + ), + ), + ], + ], + ), + ), + if (i < _devices.length - 1) _Divider(pt: pt), + ], + ); + }).toList(), + ), + const SizedBox(height: 12), + Row( + children: [ + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: pt.fgMuted, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onPressed: _load, + icon: const Icon(Icons.refresh_rounded, size: 14), + label: const Text('Aktualisieren', style: TextStyle(fontSize: 12)), + ), + const Spacer(), + if (_logoutAllLoading) + const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) + else if (_devices.where((d) => d.deviceId != _currentDeviceId).isNotEmpty) + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: pt.danger, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onPressed: () => _logoutAllOtherDevices(context), + icon: const Icon(Icons.logout_rounded, size: 14), + label: const Text('Alle anderen abmelden', style: TextStyle(fontSize: 12)), + ), + ], + ), + ], + ], + ), + ); + } + + Future _verifyDevice( + BuildContext context, DeviceKeys device, String displayName) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => _SasVerificationDialog( + pt: widget.pt, + deviceName: displayName, + device: device, + ), + ); + await _load(); + } + + Future _confirmRevoke( + BuildContext context, DeviceKeys device, String displayName) async { + final confirm = await showDialog( + context: context, + builder: (ctx) { + final pt = widget.pt; + return AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + title: Text('Gerät abmelden?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text( + '"$displayName" wird abgemeldet und alle lokalen Schlüssel werden gelöscht.', + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.danger, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Abmelden'), + ), + ], + ); + }, + ); + if (confirm == true && device.deviceId != null) { + await _revokeDevice(device.deviceId!); + } + } +} diff --git a/lib/widgets/settings/settings_shared.dart b/lib/widgets/settings/settings_shared.dart new file mode 100644 index 0000000..d5aed86 --- /dev/null +++ b/lib/widgets/settings/settings_shared.dart @@ -0,0 +1,639 @@ +part of '../settings_modal.dart'; + +class _MobileHeader extends StatelessWidget { + final String title; + final PyramidTheme pt; + final VoidCallback? onBack; + final VoidCallback onClose; + const _MobileHeader({required this.title, required this.pt, this.onBack, required this.onClose}); + + @override + Widget build(BuildContext context) { + return Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: pt.bg2, + border: Border(bottom: BorderSide(color: pt.border)), + ), + child: Row( + children: [ + if (onBack != null) + IconButton( + onPressed: onBack, + icon: Icon(Icons.arrow_back_rounded, size: 20, color: pt.fg), + tooltip: 'Zurück', + ) + else + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + onPressed: onClose, + icon: Icon(Icons.close_rounded, size: 20, color: pt.fgMuted), + tooltip: 'Schließen', + ), + ], + ), + ); + } +} + +class _MobileCategoryTile extends StatelessWidget { + final String label; + final IconData icon; + final PyramidTheme pt; + final VoidCallback onTap; + const _MobileCategoryTile({required this.label, required this.icon, required this.pt, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), + child: Row( + children: [ + Icon(icon, size: 21, color: pt.fgMuted), + const SizedBox(width: 16), + Expanded( + child: Text(label, style: TextStyle(color: pt.fg, fontSize: 15)), + ), + Icon(Icons.chevron_right_rounded, size: 20, color: pt.fgDim), + ], + ), + ), + ); + } +} + +class _NavSection extends StatelessWidget { + final String title; + final PyramidTheme pt; + const _NavSection(this.title, this.pt); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), + child: Text( + title.toUpperCase(), + style: TextStyle( + color: pt.fgDim, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.04 * 11, + ), + ), + ); + } +} + +class _NavItem extends StatefulWidget { + final String id; + final String label; + final IconData icon; + final String active; + final PyramidTheme pt; + final VoidCallback onTap; + + const _NavItem(this.id, this.label, this.icon, this.active, this.pt, this.onTap); + + @override + State<_NavItem> createState() => _NavItemState(); +} + +class _NavItemState extends State<_NavItem> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final isActive = widget.id == widget.active; + final pt = widget.pt; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.fromLTRB(8, 1, 8, 1), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: isActive + ? pt.accentSoft + : _hovered + ? pt.bgHover + : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + mainAxisAlignment: widget.label.isEmpty + ? MainAxisAlignment.center + : MainAxisAlignment.start, + children: [ + Icon( + widget.icon, + size: 14, + color: isActive ? pt.accent : _hovered ? pt.fg : pt.fgMuted, + ), + if (widget.label.isNotEmpty) ...[ + const SizedBox(width: 8), + Text( + widget.label, + style: TextStyle( + color: isActive ? pt.fg : _hovered ? pt.fg : pt.fgMuted, + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _LogoutNavButton extends ConsumerStatefulWidget { + final PyramidTheme pt; + final bool isMobile; + final VoidCallback onClose; + const _LogoutNavButton({required this.pt, required this.isMobile, required this.onClose}); + + @override + ConsumerState<_LogoutNavButton> createState() => _LogoutNavButtonState(); +} + +class _LogoutNavButtonState extends ConsumerState<_LogoutNavButton> { + bool _hovered = false; + bool _loading = false; + + Future _logout() async { + final pt = widget.pt; + final client = await ref.read(matrixClientProvider.future); + final backupMissing = await isKeyBackupMissing(client); + if (!mounted) return; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), + title: Text('Abmelden?', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), + content: Text( + backupMissing + ? 'Du wirst von diesem Gerät abgemeldet. Es ist kein Schlüssel-Backup eingerichtet — ' + 'ältere verschlüsselte Nachrichten sind danach auf keinem Gerät mehr lesbar.' + : 'Du wirst von diesem Gerät abgemeldet.', + style: TextStyle( + color: backupMissing ? pt.danger : pt.fgMuted, fontSize: 13, height: 1.4), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.danger, foregroundColor: Colors.white, + elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Abmelden'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + setState(() => _loading = true); + try { + widget.onClose(); + unawaited(AuthLog.write('User-initiated logout (Settings-Sidebar)')); + await client.logout(); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: _loading ? null : _logout, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + margin: const EdgeInsets.fromLTRB(8, 1, 8, 1), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: _hovered ? pt.danger.withAlpha(30) : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + mainAxisAlignment: widget.isMobile ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + _loading + ? SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: pt.danger)) + : Icon(Icons.logout_rounded, size: 14, color: pt.danger), + if (!widget.isMobile) ...[ + const SizedBox(width: 8), + Text('Abmelden', style: TextStyle(color: pt.danger, fontSize: 13)), + ], + ], + ), + ), + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + final String title; + final String subtitle; + final PyramidTheme pt; + const _SectionTitle({required this.title, required this.subtitle, required this.pt}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Text(subtitle, style: TextStyle(color: pt.fgMuted, fontSize: 13)), + const SizedBox(height: 24), + ], + ); + } +} + +class _SettingsGroup extends StatelessWidget { + final String title; + final PyramidTheme pt; + final List children; + + const _SettingsGroup({required this.title, required this.pt, required this.children}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title.toUpperCase(), + style: TextStyle( + color: pt.fgDim, + fontSize: 11, + fontWeight: FontWeight.w600, + letterSpacing: 0.04 * 11), + ), + const SizedBox(height: 10), + Container( + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: pt.border), + ), + child: Column(children: children), + ), + ], + ); + } +} + +class _SettingsRow extends StatelessWidget { + final String title; + final String? desc; + final PyramidTheme pt; + final Widget? control; + final VoidCallback? onTap; + final bool destructive; + final bool showArrow; + + const _SettingsRow({ + required this.title, + required this.pt, + this.desc, + this.control, + this.onTap, + this.destructive = false, + this.showArrow = false, + }); + + @override + Widget build(BuildContext context) { + Widget content = Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: destructive ? pt.danger : pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + if (desc != null) ...[ + const SizedBox(height: 2), + Text(desc!, style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ], + ), + ), + ?control, + if (showArrow) + Icon(Icons.chevron_right_rounded, size: 16, color: pt.fgDim), + ], + ), + ); + + if (onTap != null) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: content, + ), + ); + } + return content; + } +} + +class _Divider extends StatelessWidget { + final PyramidTheme pt; + const _Divider({required this.pt}); + + @override + Widget build(BuildContext context) { + return Container(height: 1, color: pt.border, margin: const EdgeInsets.only(left: 14)); + } +} + +class _Toggle extends StatelessWidget { + final bool value; + final ValueChanged onChanged; + final PyramidTheme pt; + const _Toggle({required this.value, required this.onChanged, required this.pt}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onChanged(!value), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 42, + height: 24, + decoration: BoxDecoration( + color: value ? pt.accent : pt.bg3, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: value ? pt.accent : pt.borderStrong), + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: AnimatedAlign( + duration: const Duration(milliseconds: 200), + alignment: value ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.black.withAlpha(60), blurRadius: 4)], + ), + ), + ), + ), + ), + ); + } +} + +class _SegControl extends StatelessWidget { + final List options; + final int selected; + final PyramidTheme pt; + final ValueChanged onSelect; + + const _SegControl( + {required this.options, required this.selected, required this.pt, required this.onSelect}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(pt.rSm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: options.asMap().entries.map((e) { + final isActive = e.key == selected; + return GestureDetector( + onTap: () => onSelect(e.key), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: isActive ? pt.accent : Colors.transparent, + borderRadius: BorderRadius.circular(pt.rSm - 2), + ), + child: Text( + e.value, + style: TextStyle( + color: isActive ? pt.accentFg : pt.fgMuted, + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ); + }).toList(), + ), + ); + } +} + +class _PrimaryBtn extends StatelessWidget { + final String label; + final IconData? icon; + final VoidCallback? onPressed; + final PyramidTheme pt; + final bool loading; + + const _PrimaryBtn({ + required this.label, + required this.pt, + this.icon, + this.onPressed, + this.loading = false, + }); + + @override + Widget build(BuildContext context) { + final bg = pt.accent; + final fg = pt.accentFg; + return ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: bg, + foregroundColor: fg, + disabledBackgroundColor: bg.withAlpha(100), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + elevation: 0, + ), + onPressed: loading ? null : onPressed, + icon: loading + ? SizedBox( + width: 14, height: 14, child: CircularProgressIndicator(color: fg, strokeWidth: 2)) + : (icon != null ? Icon(icon, size: 16) : const SizedBox.shrink()), + label: Text(label), + ); + } +} + +class _AvatarFallback extends StatelessWidget { + final String name; + final double size; + final PyramidTheme pt; + const _AvatarFallback({required this.name, required this.size, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + color: pt.accent, + child: Center( + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: + TextStyle(color: pt.accentFg, fontSize: size * 0.4, fontWeight: FontWeight.w700), + ), + ), + ); + } +} + +class _PrefTextField extends StatelessWidget { + final TextEditingController controller; + final String label; + final PyramidTheme pt; + final int? maxLength; + + const _PrefTextField({ + required this.controller, + required this.label, + required this.pt, + this.maxLength, + }); + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + maxLength: maxLength, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + labelText: label, + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + counterText: '', + filled: true, + fillColor: pt.bg3, + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), + ), + cursorColor: pt.accent, + ); + } +} + +class _PwField extends StatelessWidget { + final TextEditingController ctrl; + final String label; + final PyramidTheme pt; + final bool visible; + final VoidCallback? onToggle; + final ValueChanged? onSubmitted; + + const _PwField({ + required this.ctrl, required this.label, required this.pt, + required this.visible, required this.onToggle, this.onSubmitted, + }); + + @override + Widget build(BuildContext context) => TextField( + controller: ctrl, + obscureText: !visible, + onSubmitted: onSubmitted, + style: TextStyle(color: pt.fg, fontSize: 13), + decoration: InputDecoration( + labelText: label, + labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), + filled: true, fillColor: pt.bg3, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), + suffixIcon: onToggle != null + ? IconButton( + icon: Icon(visible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim), + onPressed: onToggle, + ) + : null, + ), + cursorColor: pt.accent, + ); +} + +class _StatusCard extends StatelessWidget { + final PyramidTheme pt; + final IconData icon; + final Color color; + final String text; + const _StatusCard( + {required this.pt, required this.icon, required this.color, required this.text}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: pt.border), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 10), + Expanded(child: Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 13))), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/settings_verification.dart b/lib/widgets/settings/settings_verification.dart new file mode 100644 index 0000000..66d0581 --- /dev/null +++ b/lib/widgets/settings/settings_verification.dart @@ -0,0 +1,482 @@ +part of '../settings_modal.dart'; + +class _SasVerificationDialog extends ConsumerStatefulWidget { + final PyramidTheme pt; + final String deviceName; + final DeviceKeys device; + + const _SasVerificationDialog({ + required this.pt, + required this.deviceName, + required this.device, + }); + + @override + ConsumerState<_SasVerificationDialog> createState() => _SasVerificationDialogState(); +} + +class _SasVerificationDialogState extends ConsumerState<_SasVerificationDialog> { + KeyVerification? _verification; + KeyVerificationState _state = KeyVerificationState.waitingAccept; + bool _starting = true; + String? _error; + + @override + void initState() { + super.initState(); + _startVerification(); + } + + @override + void dispose() { + final v = _verification; + if (v != null && !v.isDone) { + v.cancel('m.user').catchError((_) {}); + } + super.dispose(); + } + + Future _startVerification() async { + try { + final client = await ref.read(matrixClientProvider.future); + final deviceId = widget.device.deviceId; + if (deviceId == null) throw Exception('Keine Geräte-ID'); + + final deviceKeys = client.userDeviceKeys[client.userID]?.deviceKeys[deviceId]; + if (deviceKeys == null) throw Exception('Gerät nicht gefunden'); + + final verification = await deviceKeys.startVerification(); + _verification = verification; + + verification.onUpdate = () { + if (!mounted) return; + setState(() => _state = verification.state); + }; + + if (mounted) { + setState(() { + _state = verification.state; + _starting = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString().split('\n').first; + _starting = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + + return AlertDialog( + backgroundColor: pt.bg2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(pt.rXl), + side: BorderSide(color: pt.border)), + contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + title: Row( + children: [ + Icon(Icons.verified_user_outlined, size: 20, color: pt.accent), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Gerät verifizieren', + style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + ], + ), + content: SizedBox( + width: 380, + child: _buildContent(pt), + ), + actions: _buildActions(pt), + ); + } + + Widget _buildContent(PyramidTheme pt) { + if (_error != null) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _StatusCard( + pt: pt, + icon: Icons.error_outline_rounded, + color: pt.danger, + text: _error!), + ); + } + + if (_starting) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator.adaptive()), + ); + } + + final v = _verification; + + return switch (_state) { + KeyVerificationState.waitingAccept || KeyVerificationState.askAccept => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: 16), + Text( + 'Warte auf Bestätigung von „${widget.deviceName}"…', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 8), + Text( + 'Öffne Pyramid auf dem anderen Gerät und bestätige die Verifikationsanfrage.', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgDim, fontSize: 12), + ), + ], + ), + ), + KeyVerificationState.askChoice => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Das andere Gerät hat die Anfrage akzeptiert.\nWähle eine Verifikationsmethode:', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: () => v?.continueVerification('m.sas.v1').catchError((_) {}), + icon: const Icon(Icons.tag_faces_rounded, size: 18), + label: const Text('Emoji-Verifikation'), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: pt.bg3, + foregroundColor: pt.fg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: () => v?.continueVerification('m.qr_code.show.v1').catchError((_) {}), + icon: const Icon(Icons.qr_code_rounded, size: 18), + label: const Text('QR-Code anzeigen'), + ), + const SizedBox(height: 8), + ], + ), + ), + KeyVerificationState.confirmQRScan => _OutgoingQrCodeDisplay( + pt: pt, + request: v, + ), + KeyVerificationState.showQRSuccess => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _StatusCard( + pt: pt, + icon: Icons.verified_rounded, + color: PyramidColors.success, + text: 'QR-Code erfolgreich gescannt. Verifikation abgeschlossen.'), + ), + KeyVerificationState.askSas => _SasEmojiContent( + pt: pt, + verification: v!, + onConfirm: () => v.acceptSas().catchError((_) {}), + onReject: () { + v.rejectSas().catchError((_) {}); + Navigator.of(context).pop(); + }, + ), + KeyVerificationState.waitingSas => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: 16), + Text( + 'Warte auf Bestätigung des anderen Geräts…', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + ], + ), + ), + KeyVerificationState.done => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _StatusCard( + pt: pt, + icon: Icons.verified_rounded, + color: PyramidColors.success, + text: '„${widget.deviceName}" wurde erfolgreich verifiziert.'), + ), + _ => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _StatusCard( + pt: pt, + icon: Icons.error_outline_rounded, + color: pt.danger, + text: 'Verifizierung fehlgeschlagen oder abgebrochen.'), + ), + }; + } + + List _buildActions(PyramidTheme pt) { + if (_state == KeyVerificationState.done || + _state == KeyVerificationState.showQRSuccess || + _error != null || + _state == KeyVerificationState.error) { + return [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: pt.accent, + foregroundColor: pt.accentFg, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => Navigator.pop(context), + child: const Text('Schließen'), + ), + ]; + } + if (_state == KeyVerificationState.confirmQRScan) { + return [ + TextButton( + onPressed: () { + _verification?.cancel('m.user').catchError((_) {}); + Navigator.pop(context); + }, + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF22C55E), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), + onPressed: () => _verification?.acceptQRScanConfirmation().catchError((_) {}), + child: const Text('Gescannt bestätigen'), + ), + ]; + } + if (_state == KeyVerificationState.askSas || _state == KeyVerificationState.askChoice) { + return []; + } + return [ + TextButton( + onPressed: () { + _verification?.cancel('m.user').catchError((_) {}); + Navigator.pop(context); + }, + child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), + ), + ]; + } +} + +class _OutgoingQrCodeDisplay extends StatelessWidget { + final PyramidTheme pt; + final KeyVerification? request; + + const _OutgoingQrCodeDisplay({required this.pt, required this.request}); + + @override + Widget build(BuildContext context) { + final qrCode = request?.qrCode; + if (qrCode == null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator.adaptive(), + const SizedBox(height: 12), + Text('QR-Code wird generiert…', style: TextStyle(color: pt.fgMuted, fontSize: 13)), + ], + ), + ); + } + + try { + final rawBytes = Uint8List.fromList(qrCode.qrDataRawBytes.toList()); + final qr = QrCode.fromUint8List(data: rawBytes, errorCorrectLevel: QrErrorCorrectLevel.L); + final qrImage = QrImage(qr); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Scanne diesen QR-Code mit dem anderen Gerät', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 16), + Center( + child: Container( + width: 220, + height: 220, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: PrettyQrView(qrImage: qrImage), + ), + ), + const SizedBox(height: 12), + Text( + 'Sobald das andere Gerät den Code gescannt hat, tippe auf „Gescannt bestätigen".', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgDim, fontSize: 12), + ), + const SizedBox(height: 8), + ], + ), + ); + } catch (_) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _StatusCard( + pt: pt, + icon: Icons.error_outline_rounded, + color: pt.danger, + text: 'QR-Code konnte nicht generiert werden.', + ), + ); + } + } +} + +class _SasEmojiContent extends StatelessWidget { + final PyramidTheme pt; + final KeyVerification verification; // ignore: library_private_types_in_public_api + final VoidCallback onConfirm; + final VoidCallback onReject; + + const _SasEmojiContent({ + required this.pt, + required this.verification, + required this.onConfirm, + required this.onReject, + }); + + @override + Widget build(BuildContext context) { + final emojis = verification.sasEmojis; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Vergleiche die Emojis auf beiden Geräten', + textAlign: TextAlign.center, + style: TextStyle(color: pt.fgMuted, fontSize: 13), + ), + const SizedBox(height: 20), + if (emojis.isNotEmpty) + Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.center, + children: emojis.map((e) => _SasEmojiChip(emoji: e, pt: pt)).toList(), + ) + else + Text('Keine Emojis verfügbar.', style: TextStyle(color: pt.fgDim, fontSize: 13)), + const SizedBox(height: 24), + Text( + 'Stimmen alle Emojis überein?', + style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: pt.danger, + side: BorderSide(color: pt.danger.withAlpha(120)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: onReject, + icon: const Icon(Icons.close_rounded, size: 16), + label: const Text('Stimmt nicht überein'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: PyramidColors.success, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + onPressed: onConfirm, + icon: const Icon(Icons.check_rounded, size: 16), + label: const Text('Stimmt überein'), + ), + ), + ], + ), + const SizedBox(height: 8), + ], + ), + ); + } +} + +class _SasEmojiChip extends StatelessWidget { + final KeyVerificationEmoji emoji; + final PyramidTheme pt; + + const _SasEmojiChip({required this.emoji, required this.pt}); + + @override + Widget build(BuildContext context) { + return Container( + width: 68, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: pt.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(emoji.emoji, style: const TextStyle(fontSize: 26)), + const SizedBox(height: 4), + Text( + emoji.name, + style: TextStyle(color: pt.fgMuted, fontSize: 10), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/settings/settings_voice.dart b/lib/widgets/settings/settings_voice.dart new file mode 100644 index 0000000..216c2b7 --- /dev/null +++ b/lib/widgets/settings/settings_voice.dart @@ -0,0 +1,433 @@ +part of '../settings_modal.dart'; + +class _VoiceSection extends StatefulWidget { + final PyramidTheme pt; + const _VoiceSection({required this.pt}); + + @override + State<_VoiceSection> createState() => _VoiceSectionState(); +} + +class _VoiceSectionState extends State<_VoiceSection> { + List _audioInputs = []; + List _audioOutputs = []; + List _videoInputs = []; + String? _selectedMic; + String? _selectedSpeaker; + String? _selectedCamera; + double _outputVolume = 1.0; + bool _loading = true; + String? _loadError; + + static const _kMic = 'voice_mic_device'; + static const _kSpeaker = 'voice_speaker_device'; + static const _kCamera = 'voice_camera_device'; + static const _kOutputVolume = 'voice_output_volume'; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + final prefs = await SharedPreferences.getInstance(); + List devices = []; + String? loadError; + try { + devices = await navigator.mediaDevices.enumerateDevices(); + } catch (_) { + devices = []; + } + // Windows (und teils Android) liefert eine LEERE Liste ohne Fehler, + // solange in diesem Prozess noch nie ein Media-Stream geöffnet wurde. + // Deshalb nicht nur bei Exception, sondern auch bei leerer Liste einmal + // kurz getUserMedia anfordern und erneut enumerieren — und zwar SOLANGE + // der Stream noch offen ist (nach stop() melden manche Systeme wieder 0). + final hasAudioInput = + devices.any((d) => d.kind?.toLowerCase() == 'audioinput'); + if (devices.isEmpty || !hasAudioInput) { + try { + final stream = await navigator.mediaDevices + .getUserMedia({'audio': true, 'video': false}); + try { + devices = await navigator.mediaDevices.enumerateDevices(); + } finally { + for (final t in stream.getTracks()) { + t.stop(); + } + await stream.dispose(); + } + if (devices.isEmpty) { + // Bekanntes libwebrtc-Problem auf manchen Windows-Systemen: das + // Audio-Device-Module meldet 0 Geräte, obwohl Aufnahme/Wiedergabe + // über das Standardgerät einwandfrei funktionieren. + loadError = + 'Die Audiotreiber melden keine Geräteliste an WebRTC ' + '(bekanntes libwebrtc-Problem auf manchen Windows-Systemen). ' + 'Anrufe funktionieren trotzdem — es wird das Windows-Standardgerät verwendet.'; + } + } catch (e2) { + loadError = 'getUserMedia: ${e2.toString().split('\n').first}'; + } + } + if (!mounted) return; + setState(() { + _audioInputs = devices.where((d) => d.kind?.toLowerCase() == 'audioinput').toList(); + _audioOutputs = devices.where((d) => d.kind?.toLowerCase() == 'audiooutput').toList(); + _videoInputs = devices.where((d) => d.kind?.toLowerCase() == 'videoinput').toList(); + _selectedMic = prefs.getString(_kMic); + _selectedSpeaker = prefs.getString(_kSpeaker); + _selectedCamera = prefs.getString(_kCamera); + _outputVolume = prefs.getDouble(_kOutputVolume) ?? 1.0; + _loadError = loadError; + _loading = false; + }); + } + + Future _setOutputVolume(double v) async { + setState(() => _outputVolume = v); + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_kOutputVolume, v); + // Live auf laufende Calls anwenden. + LiveKitCallManager.instance.setOutputVolume(v).catchError((_) {}); + try { + PyramidVoipManager.instance.applyOutputVolume(v).catchError((_) {}); + } catch (_) {} // Instanz existiert evtl. noch nicht + } + + Future _save(String key, String? value) async { + final prefs = await SharedPreferences.getInstance(); + if (value == null || value.isEmpty) { + await prefs.remove(key); + } else { + await prefs.setString(key, value); + } + } + + Widget _deviceDropdown({ + required List devices, + required String? selected, + required String placeholder, + required IconData icon, + required ValueChanged onChanged, + }) { + final pt = widget.pt; + if (devices.isEmpty) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: pt.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: pt.fgDim), + const SizedBox(width: 6), + Text('Kein Gerät', style: TextStyle(color: pt.fgDim, fontSize: 12)), + ], + ), + ); + } + + final validSelected = devices.any((d) => d.deviceId == selected) ? selected : null; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: pt.bg3, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: pt.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: validSelected, + hint: Row(children: [ + Icon(icon, size: 13, color: pt.fgDim), + const SizedBox(width: 6), + Text(placeholder, style: TextStyle(color: pt.fgDim, fontSize: 12)), + ]), + dropdownColor: pt.bg2, + iconEnabledColor: pt.fgDim, + isDense: true, + style: TextStyle(color: pt.fg, fontSize: 12), + onChanged: onChanged, + items: devices.map((d) { + final label = d.label.isNotEmpty ? d.label : 'Gerät ${d.deviceId.substring(0, 6)}'; + return DropdownMenuItem( + value: d.deviceId, + child: Text(label, overflow: TextOverflow.ellipsis), + ); + }).toList(), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final pt = widget.pt; + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle( + title: 'Sprache & Video', + subtitle: 'Mikrofon, Kamera und Anrufeinstellungen.', + pt: pt), + if (_loading) + const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive())) + else ...[ + if (_loadError != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: pt.bg2, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: pt.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(Icons.warning_amber_rounded, size: 16, color: pt.away), + const SizedBox(width: 8), + Text('Geräteliste nicht verfügbar', + style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), + ]), + const SizedBox(height: 6), + Text(_loadError!, style: TextStyle(color: pt.fgDim, fontSize: 11)), + const SizedBox(height: 6), + Text( + 'Mikrofon und Lautsprecher legst du in diesem Fall über die ' + 'Windows-Soundeinstellungen (Standardgerät) fest. ' + 'Falls dort alles stimmt: Windows-Einstellungen → Datenschutz → Mikrofon prüfen.', + style: TextStyle(color: pt.fgMuted, fontSize: 11), + ), + ], + ), + ), + ), + _SettingsGroup(title: 'Geräte', pt: pt, children: [ + _SettingsRow( + title: 'Mikrofon', + desc: _audioInputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioInputs.length} Gerät(e) gefunden', + pt: pt, + control: _deviceDropdown( + devices: _audioInputs, + selected: _selectedMic, + placeholder: 'Standard', + icon: Icons.mic_rounded, + onChanged: (v) { + setState(() => _selectedMic = v); + _save(_kMic, v); + }, + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Lautsprecher', + desc: _audioOutputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioOutputs.length} Gerät(e) gefunden', + pt: pt, + control: _deviceDropdown( + devices: _audioOutputs, + selected: _selectedSpeaker, + placeholder: 'Standard', + icon: Icons.volume_up_rounded, + onChanged: (v) { + setState(() => _selectedSpeaker = v); + _save(_kSpeaker, v); + }, + ), + ), + _Divider(pt: pt), + _SettingsRow( + title: 'Kamera', + desc: _videoInputs.isEmpty ? 'Kein Gerät gefunden' : '${_videoInputs.length} Gerät(e) gefunden', + pt: pt, + control: _deviceDropdown( + devices: _videoInputs, + selected: _selectedCamera, + placeholder: 'Standard', + icon: Icons.videocam_rounded, + onChanged: (v) { + setState(() => _selectedCamera = v); + _save(_kCamera, v); + }, + ), + ), + ]), + const SizedBox(height: 16), + _SettingsGroup(title: 'Pegel', pt: pt, children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.volume_up_rounded, size: 15, color: pt.fgMuted), + const SizedBox(width: 8), + Text('Ausgabelautstärke', + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500)), + const Spacer(), + Text('${(_outputVolume * 100).round()} %', + style: TextStyle( + color: pt.fgMuted, + fontSize: 12, + fontFamily: 'monospace')), + ], + ), + SliderTheme( + data: SliderThemeData( + activeTrackColor: pt.accent, + inactiveTrackColor: pt.bg3, + thumbColor: pt.accent, + overlayColor: pt.accent.withAlpha(30), + trackHeight: 3, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 7), + ), + child: Slider( + value: _outputVolume.clamp(0.0, 1.5), + min: 0.0, + max: 1.5, + divisions: 30, + onChanged: _setOutputVolume, + ), + ), + Text( + 'Lautstärke der anderen Teilnehmer in Calls. ' + 'Wirkt sofort auf laufende Anrufe.', + style: TextStyle(color: pt.fgDim, fontSize: 11), + ), + ], + ), + ), + ]), + const SizedBox(height: 16), + _TurnUsageCard(pt: pt), + const SizedBox(height: 12), + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: pt.fgMuted, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onPressed: () { setState(() { _loading = true; _loadError = null; }); _init(); }, + icon: const Icon(Icons.refresh_rounded, size: 14), + label: const Text('Geräte neu laden', style: TextStyle(fontSize: 12)), + ), + ], + ], + ), + ); + } +} + +class _TurnUsageCard extends StatefulWidget { + final PyramidTheme pt; + const _TurnUsageCard({required this.pt}); + + @override + State<_TurnUsageCard> createState() => _TurnUsageCardState(); +} + +class _TurnUsageCardState extends State<_TurnUsageCard> { + Map? _status; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final resp = await http + .get(Uri.parse( + 'https://steggi-matrix.work/.well-known/pyramid/turn-status.json')) + .timeout(const Duration(seconds: 5)); + if (resp.statusCode == 200 && mounted) { + setState( + () => _status = jsonDecode(resp.body) as Map); + } + } catch (_) { + // Endpoint (noch) nicht vorhanden — Karte einfach nicht anzeigen. + } + } + + @override + Widget build(BuildContext context) { + final s = _status; + if (s == null) return const SizedBox.shrink(); + final pt = widget.pt; + final used = (s['egress_gb'] as num?)?.toDouble() ?? 0; + final limit = (s['limit_gb'] as num?)?.toDouble() ?? 990; + final pct = limit > 0 ? (used / limit).clamp(0.0, 1.0) : 0.0; + final active = s['turn_active'] != false; + final barColor = !active + ? pt.danger + : pct > 0.8 + ? pt.away + : pt.accent; + + return _SettingsGroup(title: 'Call-Relay (TURN)', pt: pt, children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(active ? Icons.cloud_done_rounded : Icons.cloud_off_rounded, + size: 15, color: active ? pt.online : pt.danger), + const SizedBox(width: 8), + Text( + active + ? 'Relay aktiv' + : 'Relay deaktiviert (Limit erreicht)', + style: TextStyle( + color: pt.fg, + fontSize: 13, + fontWeight: FontWeight.w500)), + const Spacer(), + Text( + '${used.toStringAsFixed(1)} / ${limit.toStringAsFixed(0)} GB', + style: TextStyle( + color: pt.fgMuted, + fontSize: 12, + fontFamily: 'monospace')), + ]), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(99), + child: LinearProgressIndicator( + value: pct, + minHeight: 5, + backgroundColor: pt.bg3, + color: barColor, + ), + ), + const SizedBox(height: 6), + Text( + active + ? 'Cloudflare-TURN-Traffic diesen Monat (Free Tier: ${limit.toStringAsFixed(0)} GB). ' + 'Bei Erreichen des Limits laufen Calls bis Monatsende ohne Relay weiter.' + : 'Monatslimit erreicht — Calls nutzen bis Monatsende nur direkte Verbindungen (STUN).', + style: TextStyle(color: pt.fgDim, fontSize: 11), + ), + ], + ), + ), + ]); + } +} diff --git a/lib/widgets/settings_modal.dart b/lib/widgets/settings_modal.dart index 61ccd86..756abfb 100644 --- a/lib/widgets/settings_modal.dart +++ b/lib/widgets/settings_modal.dart @@ -38,134 +38,18 @@ import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/pyramid_loader.dart'; import 'package:shared_preferences/shared_preferences.dart'; -// ── Megolm key export / import – top-level so compute() can use them ───────── - -const _kMegolmHeader = '-----BEGIN MEGOLM SESSION DATA-----'; -const _kMegolmFooter = '-----END MEGOLM SESSION DATA-----'; - -// PBKDF2-HMAC-SHA512. args: {pass: Uint8List, salt: Uint8List, iter: int} -Uint8List _runPbkdf2(Map args) { - final pass = args['pass'] as Uint8List; - final salt = args['salt'] as Uint8List; - final iterations = args['iter'] as int; - const blockLen = 64; // SHA-512 digest size - const dkLen = 64; - - final hmac = mcrypto.Hmac(mcrypto.sha512, pass); - final blockCount = (dkLen / blockLen).ceil(); - final dk = Uint8List(blockCount * blockLen); - - for (var i = 1; i <= blockCount; i++) { - final saltI = Uint8List(salt.length + 4); - saltI.setRange(0, salt.length, salt); - ByteData.sublistView(saltI, salt.length).setUint32(0, i); - - var u = Uint8List.fromList(hmac.convert(saltI).bytes); - final block = Uint8List.fromList(u); - - for (var j = 1; j < iterations; j++) { - u = Uint8List.fromList(hmac.convert(u).bytes); - for (var k = 0; k < blockLen; k++) { block[k] ^= u[k]; } - } - dk.setRange((i - 1) * blockLen, i * blockLen, block); - } - return dk.sublist(0, dkLen); -} - -Uint8List _aesCtr(Uint8List key, Uint8List iv, Uint8List data) { - final cipher = pc.SICStreamCipher(pc.AESEngine()) - ..init(true, pc.ParametersWithIV(pc.KeyParameter(key), iv)); - final out = Uint8List(data.length); - cipher.processBytes(data, 0, data.length, out, 0); - return out; -} - -// args: {passphrase: String, sessions: List} → encrypted String -String _encryptMegolmKeys(Map args) { - final pass = args['passphrase'] as String; - final sessions = args['sessions'] as List; - - final rng = Random.secure(); - final salt = Uint8List.fromList(List.generate(16, (_) => rng.nextInt(256))); - // IV: 8 random bytes as nonce, last 8 bytes 0x00 (counter starts at 0) - final iv = Uint8List(16); - for (var i = 0; i < 8; i++) { iv[i] = rng.nextInt(256); } - - const iterations = 100000; - final dk = _runPbkdf2({ - 'pass': Uint8List.fromList(utf8.encode(pass)), - 'salt': salt, - 'iter': iterations, - }); - final aesKey = dk.sublist(0, 32); - final hmacKey = dk.sublist(32, 64); - - final plaintext = Uint8List.fromList(utf8.encode(jsonEncode(sessions))); - final encrypted = _aesCtr(aesKey, iv, plaintext); - - final iterBe = Uint8List(4)..buffer.asByteData().setUint32(0, iterations); - - // header bytes: 0x01 | salt(16) | iv(16) | iterBe(4) | encrypted(n) - final header = Uint8List(1 + 16 + 16 + 4 + encrypted.length); - var off = 0; - header[off++] = 0x01; - header.setRange(off, off += 16, salt); - header.setRange(off, off += 16, iv); - header.setRange(off, off += 4, iterBe); - header.setRange(off, off + encrypted.length, encrypted); - - final mac = Uint8List.fromList( - mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(header).bytes, - ); - - final full = Uint8List(header.length + 32); - full.setRange(0, header.length, header); - full.setRange(header.length, full.length, mac); - - return '$_kMegolmHeader\n${base64.encode(full)}\n$_kMegolmFooter'; -} - -// args: {passphrase: String, data: List} → List sessions -List _decryptMegolmKeys(Map args) { - final pass = args['passphrase'] as String; - final raw = Uint8List.fromList(args['data'] as List); - - if (raw.length < 1 + 16 + 16 + 4 + 32 || raw[0] != 0x01) { - throw Exception('Ungültiges Dateiformat (Version ${raw.isEmpty ? "?" : raw[0]}).'); - } - - final salt = raw.sublist(1, 17); - final iv = raw.sublist(17, 33); - final iterations = raw.buffer.asByteData().getUint32(33); - final encrypted = raw.sublist(37, raw.length - 32); - final storedMac = raw.sublist(raw.length - 32); - - if (iterations < 1 || iterations > 10000000) { - throw Exception('Iterationsanzahl außerhalb des erlaubten Bereichs.'); - } - - final dk = _runPbkdf2({ - 'pass': Uint8List.fromList(utf8.encode(pass)), - 'salt': Uint8List.fromList(salt), - 'iter': iterations, - }); - final aesKey = dk.sublist(0, 32); - final hmacKey = dk.sublist(32, 64); - - // Verify MAC over all bytes except the appended MAC itself - final toMac = raw.sublist(0, raw.length - 32); - final expectedMac = mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(toMac).bytes; - var macOk = true; - for (var i = 0; i < 32; i++) { - if (expectedMac[i] != storedMac[i]) { macOk = false; break; } - } - if (!macOk) throw Exception('Falsches Passwort oder beschädigte Datei.'); - - final decrypted = _aesCtr(aesKey, Uint8List.fromList(iv), Uint8List.fromList(encrypted)); - return jsonDecode(utf8.decode(decrypted)) as List; -} - -// ───────────────────────────────────────────────────────────────────────────── +part 'settings/settings_shared.dart'; +part 'settings/settings_profile.dart'; +part 'settings/settings_notifications.dart'; +part 'settings/settings_appearance.dart'; +part 'settings/settings_voice.dart'; +part 'settings/settings_keybinds.dart'; +part 'settings/settings_privacy.dart'; +part 'settings/settings_sessions.dart'; +part 'settings/settings_verification.dart'; +part 'settings/settings_encryption.dart'; +part 'settings/settings_account.dart'; +part 'settings/settings_about.dart'; class SettingsModal extends ConsumerStatefulWidget { final VoidCallback onClose; @@ -384,5158 +268,3 @@ class _SettingsModalState extends ConsumerState { }; } } - -// ── Mobile helpers ────────────────────────────────────────────────────────── - -class _MobileHeader extends StatelessWidget { - final String title; - final PyramidTheme pt; - final VoidCallback? onBack; - final VoidCallback onClose; - const _MobileHeader({required this.title, required this.pt, this.onBack, required this.onClose}); - - @override - Widget build(BuildContext context) { - return Container( - height: 52, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: pt.bg2, - border: Border(bottom: BorderSide(color: pt.border)), - ), - child: Row( - children: [ - if (onBack != null) - IconButton( - onPressed: onBack, - icon: Icon(Icons.arrow_back_rounded, size: 20, color: pt.fg), - tooltip: 'Zurück', - ) - else - const SizedBox(width: 8), - Expanded( - child: Text( - title, - style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), - overflow: TextOverflow.ellipsis, - ), - ), - IconButton( - onPressed: onClose, - icon: Icon(Icons.close_rounded, size: 20, color: pt.fgMuted), - tooltip: 'Schließen', - ), - ], - ), - ); - } -} - -class _MobileCategoryTile extends StatelessWidget { - final String label; - final IconData icon; - final PyramidTheme pt; - final VoidCallback onTap; - const _MobileCategoryTile({required this.label, required this.icon, required this.pt, required this.onTap}); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), - child: Row( - children: [ - Icon(icon, size: 21, color: pt.fgMuted), - const SizedBox(width: 16), - Expanded( - child: Text(label, style: TextStyle(color: pt.fg, fontSize: 15)), - ), - Icon(Icons.chevron_right_rounded, size: 20, color: pt.fgDim), - ], - ), - ), - ); - } -} - -// ── Nav helpers ─────────────────────────────────────────────────────────────── - -class _NavSection extends StatelessWidget { - final String title; - final PyramidTheme pt; - const _NavSection(this.title, this.pt); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), - child: Text( - title.toUpperCase(), - style: TextStyle( - color: pt.fgDim, - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.04 * 11, - ), - ), - ); - } -} - -class _NavItem extends StatefulWidget { - final String id; - final String label; - final IconData icon; - final String active; - final PyramidTheme pt; - final VoidCallback onTap; - - const _NavItem(this.id, this.label, this.icon, this.active, this.pt, this.onTap); - - @override - State<_NavItem> createState() => _NavItemState(); -} - -class _NavItemState extends State<_NavItem> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final isActive = widget.id == widget.active; - final pt = widget.pt; - - return MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: GestureDetector( - onTap: widget.onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - margin: const EdgeInsets.fromLTRB(8, 1, 8, 1), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - decoration: BoxDecoration( - color: isActive - ? pt.accentSoft - : _hovered - ? pt.bgHover - : Colors.transparent, - borderRadius: BorderRadius.circular(pt.rSm), - ), - child: Row( - mainAxisAlignment: widget.label.isEmpty - ? MainAxisAlignment.center - : MainAxisAlignment.start, - children: [ - Icon( - widget.icon, - size: 14, - color: isActive ? pt.accent : _hovered ? pt.fg : pt.fgMuted, - ), - if (widget.label.isNotEmpty) ...[ - const SizedBox(width: 8), - Text( - widget.label, - style: TextStyle( - color: isActive ? pt.fg : _hovered ? pt.fg : pt.fgMuted, - fontSize: 13, - fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, - ), - ), - ], - ], - ), - ), - ), - ); - } -} - -class _LogoutNavButton extends ConsumerStatefulWidget { - final PyramidTheme pt; - final bool isMobile; - final VoidCallback onClose; - const _LogoutNavButton({required this.pt, required this.isMobile, required this.onClose}); - - @override - ConsumerState<_LogoutNavButton> createState() => _LogoutNavButtonState(); -} - -class _LogoutNavButtonState extends ConsumerState<_LogoutNavButton> { - bool _hovered = false; - bool _loading = false; - - Future _logout() async { - final pt = widget.pt; - final client = await ref.read(matrixClientProvider.future); - final backupMissing = await isKeyBackupMissing(client); - if (!mounted) return; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), - title: Text('Abmelden?', - style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), - content: Text( - backupMissing - ? 'Du wirst von diesem Gerät abgemeldet. Es ist kein Schlüssel-Backup eingerichtet — ' - 'ältere verschlüsselte Nachrichten sind danach auf keinem Gerät mehr lesbar.' - : 'Du wirst von diesem Gerät abgemeldet.', - style: TextStyle( - color: backupMissing ? pt.danger : pt.fgMuted, fontSize: 13, height: 1.4), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: pt.danger, foregroundColor: Colors.white, - elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Abmelden'), - ), - ], - ), - ); - if (confirmed != true || !mounted) return; - setState(() => _loading = true); - try { - widget.onClose(); - unawaited(AuthLog.write('User-initiated logout (Settings-Sidebar)')); - await client.logout(); - } catch (_) {} - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - return MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: GestureDetector( - onTap: _loading ? null : _logout, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - margin: const EdgeInsets.fromLTRB(8, 1, 8, 1), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - decoration: BoxDecoration( - color: _hovered ? pt.danger.withAlpha(30) : Colors.transparent, - borderRadius: BorderRadius.circular(pt.rSm), - ), - child: Row( - mainAxisAlignment: widget.isMobile ? MainAxisAlignment.center : MainAxisAlignment.start, - children: [ - _loading - ? SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: pt.danger)) - : Icon(Icons.logout_rounded, size: 14, color: pt.danger), - if (!widget.isMobile) ...[ - const SizedBox(width: 8), - Text('Abmelden', style: TextStyle(color: pt.danger, fontSize: 13)), - ], - ], - ), - ), - ), - ); - } -} - -// ── Shared UI primitives ────────────────────────────────────────────────────── - -class _SectionTitle extends StatelessWidget { - final String title; - final String subtitle; - final PyramidTheme pt; - const _SectionTitle({required this.title, required this.subtitle, required this.pt}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, - style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)), - const SizedBox(height: 4), - Text(subtitle, style: TextStyle(color: pt.fgMuted, fontSize: 13)), - const SizedBox(height: 24), - ], - ); - } -} - -class _SettingsGroup extends StatelessWidget { - final String title; - final PyramidTheme pt; - final List children; - - const _SettingsGroup({required this.title, required this.pt, required this.children}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title.toUpperCase(), - style: TextStyle( - color: pt.fgDim, - fontSize: 11, - fontWeight: FontWeight.w600, - letterSpacing: 0.04 * 11), - ), - const SizedBox(height: 10), - Container( - decoration: BoxDecoration( - color: pt.bg2, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: pt.border), - ), - child: Column(children: children), - ), - ], - ); - } -} - -class _SettingsRow extends StatelessWidget { - final String title; - final String? desc; - final PyramidTheme pt; - final Widget? control; - final VoidCallback? onTap; - final bool destructive; - final bool showArrow; - - const _SettingsRow({ - required this.title, - required this.pt, - this.desc, - this.control, - this.onTap, - this.destructive = false, - this.showArrow = false, - }); - - @override - Widget build(BuildContext context) { - Widget content = Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: TextStyle( - color: destructive ? pt.danger : pt.fg, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - if (desc != null) ...[ - const SizedBox(height: 2), - Text(desc!, style: TextStyle(color: pt.fgDim, fontSize: 12)), - ], - ], - ), - ), - ?control, - if (showArrow) - Icon(Icons.chevron_right_rounded, size: 16, color: pt.fgDim), - ], - ), - ); - - if (onTap != null) { - return Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: content, - ), - ); - } - return content; - } -} - -class _Divider extends StatelessWidget { - final PyramidTheme pt; - const _Divider({required this.pt}); - - @override - Widget build(BuildContext context) { - return Container(height: 1, color: pt.border, margin: const EdgeInsets.only(left: 14)); - } -} - -class _Toggle extends StatelessWidget { - final bool value; - final ValueChanged onChanged; - final PyramidTheme pt; - const _Toggle({required this.value, required this.onChanged, required this.pt}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () => onChanged(!value), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 42, - height: 24, - decoration: BoxDecoration( - color: value ? pt.accent : pt.bg3, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: value ? pt.accent : pt.borderStrong), - ), - child: Padding( - padding: const EdgeInsets.all(2), - child: AnimatedAlign( - duration: const Duration(milliseconds: 200), - alignment: value ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - width: 18, - height: 18, - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Colors.black.withAlpha(60), blurRadius: 4)], - ), - ), - ), - ), - ), - ); - } -} - -class _SegControl extends StatelessWidget { - final List options; - final int selected; - final PyramidTheme pt; - final ValueChanged onSelect; - - const _SegControl( - {required this.options, required this.selected, required this.pt, required this.onSelect}); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: pt.bg3, - borderRadius: BorderRadius.circular(pt.rSm), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: options.asMap().entries.map((e) { - final isActive = e.key == selected; - return GestureDetector( - onTap: () => onSelect(e.key), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), - decoration: BoxDecoration( - color: isActive ? pt.accent : Colors.transparent, - borderRadius: BorderRadius.circular(pt.rSm - 2), - ), - child: Text( - e.value, - style: TextStyle( - color: isActive ? pt.accentFg : pt.fgMuted, - fontSize: 13, - fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, - ), - ), - ), - ); - }).toList(), - ), - ); - } -} - -class _PrimaryBtn extends StatelessWidget { - final String label; - final IconData? icon; - final VoidCallback? onPressed; - final PyramidTheme pt; - final bool loading; - - const _PrimaryBtn({ - required this.label, - required this.pt, - this.icon, - this.onPressed, - this.loading = false, - }); - - @override - Widget build(BuildContext context) { - final bg = pt.accent; - final fg = pt.accentFg; - return ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: bg, - foregroundColor: fg, - disabledBackgroundColor: bg.withAlpha(100), - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - elevation: 0, - ), - onPressed: loading ? null : onPressed, - icon: loading - ? SizedBox( - width: 14, height: 14, child: CircularProgressIndicator(color: fg, strokeWidth: 2)) - : (icon != null ? Icon(icon, size: 16) : const SizedBox.shrink()), - label: Text(label), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// PROFILE -// ───────────────────────────────────────────────────────────────────────────── - -class _ProfileSection extends ConsumerStatefulWidget { - final PyramidTheme pt; - const _ProfileSection({required this.pt}); - - @override - ConsumerState<_ProfileSection> createState() => _ProfileSectionState(); -} - -class _ProfileSectionState extends ConsumerState<_ProfileSection> { - late TextEditingController _nameCtrl; - late TextEditingController _statusCtrl; - bool _saving = false; - bool _saved = false; - String? _error; - Uint8List? _pendingAvatar; - Uri? _avatarUri; - Uint8List? _pendingBanner; - String? _bannerMxcUri; - Uint8List? _pendingServerBanner; - bool _serverBannerSaving = false; - - @override - void initState() { - super.initState(); - _nameCtrl = TextEditingController(); - _statusCtrl = TextEditingController(); - _loadProfile(); - } - - @override - void dispose() { - _nameCtrl.dispose(); - _statusCtrl.dispose(); - super.dispose(); - } - - Future _loadProfile() async { - final client = await ref.read(matrixClientProvider.future); - if (!mounted) return; - - // 1) SOFORT aus dem lokalen Cache füllen — kein Warten auf den Server. - _nameCtrl.text = client.userID ?? ''; - for (final room in client.rooms) { - final u = room.unsafeGetUserFromMemoryOrFallback(client.userID!); - if (u.displayName != null || u.avatarUrl != null) { - if (u.displayName != null) _nameCtrl.text = u.displayName!; - _avatarUri = u.avatarUrl; - break; - } - } - try { - final bannerData = client.accountData['com.pyramid.profile.banner']; - _bannerMxcUri = bannerData?.content['url'] as String?; - } catch (_) {} - setState(() {}); - - // 2) Server-Daten PARALLEL nachladen, UI aktualisiert sich pro Antwort. - client - .getProfileFromUserId(client.userID!) - .then((profile) { - if (!mounted) return; - setState(() { - if (profile.displayName != null) _nameCtrl.text = profile.displayName!; - _avatarUri = profile.avatarUrl ?? _avatarUri; - }); - }).catchError((_) {}); - - () async { - try { - final apiUri = client.homeserver!.replace( - path: - '/_matrix/client/v3/profile/${Uri.encodeComponent(client.userID!)}', - ); - final resp = await client.httpClient.get( - apiUri, - headers: {'Authorization': 'Bearer ${client.accessToken}'}, - ); - if (resp.statusCode == 200 && mounted) { - final data = jsonDecode(resp.body) as Map; - final url = data['io.pyramid.profile.banner_url'] as String?; - if (url != null) setState(() => _bannerMxcUri = url); - } - } catch (_) {} - }(); - } - - Future _pickAvatar() async { - final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); - final file = result?.files.first; - if (file == null) return; - final bytes = file.bytes ?? await file.xFile.readAsBytes(); - setState(() => _pendingAvatar = bytes); - } - - Future _pickBanner() async { - final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); - final file = result?.files.first; - if (file == null) return; - final raw = file.bytes ?? await file.xFile.readAsBytes(); - if (!mounted) return; - final cropped = await showDialog( - context: context, - builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 3.0), - ); - if (cropped == null || !mounted) return; - setState(() => _pendingBanner = cropped); - } - - Future _pickServerBanner() async { - final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); - final file = result?.files.first; - if (file == null) return; - final raw = file.bytes ?? await file.xFile.readAsBytes(); - if (!mounted) return; - final cropped = await showDialog( - context: context, - builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4), - ); - if (cropped == null || !mounted) return; - setState(() => _serverBannerSaving = true); - try { - final client = await ref.read(matrixClientProvider.future); - final uri = await client.uploadContent(cropped, filename: 'server_banner.png', contentType: 'image/png'); - await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), uri.toString()); - if (mounted) setState(() { _pendingServerBanner = cropped; _serverBannerSaving = false; }); - } catch (_) { - if (mounted) setState(() => _serverBannerSaving = false); - } - } - - Future _removeServerBanner() async { - final client = ref.read(matrixClientProvider).valueOrNull; - if (client == null) return; - await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), null); - if (mounted) setState(() => _pendingServerBanner = null); - } - - Future _save() async { - setState(() { _saving = true; _error = null; _saved = false; }); - try { - final client = await ref.read(matrixClientProvider.future); - final name = _nameCtrl.text.trim(); - if (name.isNotEmpty) { - await client.setProfileField( - client.userID!, - 'displayname', - {'displayname': name}, - ); - } - if (_pendingAvatar != null) { - await client.setAvatar(MatrixFile( - bytes: _pendingAvatar!, - name: 'avatar.jpg', - mimeType: 'image/jpeg', - )); - setState(() => _pendingAvatar = null); - } - if (_pendingBanner != null) { - final uri = await client.uploadContent( - _pendingBanner!, filename: 'banner.png', contentType: 'image/png'); - final uriStr = uri.toString(); - // Store as a profile custom field so other users can see it - await client.setProfileField( - client.userID!, - 'io.pyramid.profile.banner_url', - {'io.pyramid.profile.banner_url': uriStr}, - ); - // Also keep in account data as legacy fallback - await client.setAccountData(client.userID!, 'com.pyramid.profile.banner', - {'url': uriStr}); - setState(() { _bannerMxcUri = uriStr; _pendingBanner = null; }); - } - final status = _statusCtrl.text.trim(); - await client.setPresence( - client.userID!, - PresenceType.online, - statusMsg: status.isEmpty ? null : status, - ); - if (mounted) setState(() { _saving = false; _saved = true; }); - await Future.delayed(const Duration(seconds: 2)); - if (mounted) setState(() => _saved = false); - } catch (e) { - if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; }); - } - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - final clientAsync = ref.watch(matrixClientProvider); - final client = clientAsync.valueOrNull; - - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle( - title: 'Mein Profil', - subtitle: 'Wie du in Pyramid erscheinst.', - pt: pt), - // Banner - GestureDetector( - onTap: _pickBanner, - child: Stack( - children: [ - Container( - height: 100, - decoration: BoxDecoration( - color: pt.bg3, - borderRadius: BorderRadius.circular(pt.rBase), - border: Border.all(color: pt.border), - ), - clipBehavior: Clip.hardEdge, - child: _pendingBanner != null - ? Image.memory(_pendingBanner!, fit: BoxFit.cover, width: double.infinity) - : (client != null && _bannerMxcUri != null - ? MxcImage( - mxcUri: _bannerMxcUri!, - client: client, - fit: BoxFit.cover, - width: double.infinity, - ) - : Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.add_photo_alternate_outlined, size: 28, color: pt.fgDim), - const SizedBox(height: 4), - Text('Profilbanner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)), - ], - ), - )), - ), - Positioned( - right: 8, - bottom: 8, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: pt.bg0.withAlpha(200), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: pt.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted), - const SizedBox(width: 4), - Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)), - ], - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - // Avatar row - Row( - children: [ - GestureDetector( - onTap: _pickAvatar, - child: Stack( - children: [ - Container( - width: 72, - height: 72, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: pt.border, width: 2), - ), - child: ClipOval( - child: _pendingAvatar != null - ? Image.memory(_pendingAvatar!, fit: BoxFit.cover) - : (client != null && _avatarUri != null - ? MxcAvatar( - mxcUri: _avatarUri, - client: client, - size: 72, - placeholder: (_) => _AvatarFallback( - name: _nameCtrl.text, size: 72, pt: pt), - ) - : _AvatarFallback( - name: _nameCtrl.text, size: 72, pt: pt)), - ), - ), - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: pt.accent, - shape: BoxShape.circle, - border: Border.all(color: pt.bg1, width: 2), - ), - child: Icon(Icons.edit_rounded, size: 12, color: pt.accentFg), - ), - ), - ], - ), - ), - const SizedBox(width: 20), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _nameCtrl.text.isNotEmpty ? _nameCtrl.text : (client?.userID ?? ''), - style: TextStyle( - color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 2), - SelectableText( - client?.userID ?? '', - style: TextStyle(color: pt.fgDim, fontSize: 12), - ), - const SizedBox(height: 6), - if (_pendingAvatar != null) - Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: pt.accent.withAlpha(40), - borderRadius: BorderRadius.circular(6), - ), - child: Text('Neues Bild ausgewählt', - style: TextStyle(color: pt.accent, fontSize: 11)), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 24), - _SettingsGroup(title: 'Name & Status', pt: pt, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(14, 14, 14, 4), - child: _PrefTextField( - controller: _nameCtrl, - label: 'Anzeigename', - pt: pt, - ), - ), - const SizedBox(height: 2), - Padding( - padding: const EdgeInsets.fromLTRB(14, 4, 14, 14), - child: _PrefTextField( - controller: _statusCtrl, - label: 'Statusnachricht (optional)', - pt: pt, - maxLength: 120, - ), - ), - ]), - const SizedBox(height: 20), - if (_error != null) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _StatusCard( - pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!), - ), - if (_saved) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _StatusCard( - pt: pt, - color: PyramidColors.success, - icon: Icons.check_circle_outline_rounded, - text: 'Profil gespeichert.'), - ), - _PrimaryBtn( - label: _saving ? 'Speichern…' : 'Speichern', - icon: Icons.save_outlined, - pt: pt, - loading: _saving, - onPressed: _save, - ), - const SizedBox(height: 24), - - // ── Server-Banner ───────────────────────────────────────────────── - _SectionTitle( - title: 'Server-Darstellung', - subtitle: 'Banner für ${client?.homeserver?.host ?? 'diesen Server'}', - pt: pt, - ), - Consumer(builder: (context, ref, _) { - final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {}; - final homeserver = client?.homeserver.toString(); - final existingMxc = homeserver != null ? serverBanners[homeserver] : null; - final hasBanner = _pendingServerBanner != null || existingMxc != null; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: _serverBannerSaving ? null : _pickServerBanner, - child: Container( - height: 90, - decoration: BoxDecoration( - color: pt.bg3, - borderRadius: BorderRadius.circular(pt.rBase), - border: Border.all(color: pt.border), - ), - clipBehavior: Clip.hardEdge, - child: Stack( - fit: StackFit.expand, - children: [ - if (_pendingServerBanner != null) - Image.memory(_pendingServerBanner!, fit: BoxFit.cover, - errorBuilder: (_, _, _) => const SizedBox.shrink()) - else if (existingMxc != null && client != null) - MxcImage(mxcUri: existingMxc, client: client, fit: BoxFit.cover) - else - Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.add_photo_alternate_outlined, size: 24, color: pt.fgDim), - const SizedBox(height: 4), - Text('Server-Banner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)), - ], - ), - ), - if (_serverBannerSaving) - Container( - color: Colors.black38, - child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), - ), - if (hasBanner) - Positioned( - right: 8, - bottom: 8, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: pt.bg0.withAlpha(200), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: pt.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted), - const SizedBox(width: 4), - Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)), - ], - ), - ), - ), - ], - ), - ), - ), - if (hasBanner) ...[ - const SizedBox(height: 8), - GestureDetector( - onTap: _removeServerBanner, - child: Text( - 'Banner entfernen', - style: TextStyle(color: pt.danger, fontSize: 12), - ), - ), - ], - ], - ); - }), - - const SizedBox(height: 24), - _SettingsGroup(title: 'Konto', pt: pt, children: [ - _SettingsRow( - title: 'Passwort ändern', - desc: 'Aktuelles Passwort zum Ändern erforderlich', - pt: pt, - showArrow: true, - onTap: _changePassword, - ), - ]), - const SizedBox(height: 12), - _SettingsGroup(title: 'Gefahrenbereich', pt: pt, children: [ - _SettingsRow( - title: 'Konto löschen', - desc: 'Konto dauerhaft deaktivieren – nicht rückgängig zu machen', - pt: pt, - showArrow: true, - destructive: true, - onTap: _deleteAccount, - ), - ]), - ], - ), - ); - } - - Future _changePassword() async { - await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => _ChangePasswordDialog(pt: widget.pt), - ); - } - - Future _deleteAccount() async { - await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => _DeleteAccountDialog(pt: widget.pt), - ); - } -} - -class _AvatarFallback extends StatelessWidget { - final String name; - final double size; - final PyramidTheme pt; - const _AvatarFallback({required this.name, required this.size, required this.pt}); - - @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - color: pt.accent, - child: Center( - child: Text( - name.isNotEmpty ? name[0].toUpperCase() : '?', - style: - TextStyle(color: pt.accentFg, fontSize: size * 0.4, fontWeight: FontWeight.w700), - ), - ), - ); - } -} - -class _PrefTextField extends StatelessWidget { - final TextEditingController controller; - final String label; - final PyramidTheme pt; - final int? maxLength; - - const _PrefTextField({ - required this.controller, - required this.label, - required this.pt, - this.maxLength, - }); - - @override - Widget build(BuildContext context) { - return TextField( - controller: controller, - maxLength: maxLength, - style: TextStyle(color: pt.fg, fontSize: 13), - decoration: InputDecoration( - labelText: label, - labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), - counterText: '', - filled: true, - fillColor: pt.bg3, - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), - ), - cursorColor: pt.accent, - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// NOTIFICATIONS -// ───────────────────────────────────────────────────────────────────────────── - -class _NotificationsSection extends ConsumerWidget { - final PyramidTheme pt; - const _NotificationsSection({required this.pt}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final desktop = ref.watch(notifDesktopProvider); - final sound = ref.watch(notifSoundProvider); - final preview = ref.watch(notifPreviewProvider); - final dmMentionOnly = ref.watch(notifMentionOnlyDmProvider); - - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle( - title: 'Benachrichtigungen', - subtitle: 'Bestimme, wann und wie du benachrichtigt wirst.', - pt: pt), - _SettingsGroup(title: 'Desktop', pt: pt, children: [ - _SettingsRow( - title: 'Desktop-Benachrichtigungen', - desc: 'Zeige Systembenachrichtigungen bei neuen Nachrichten', - pt: pt, - control: _Toggle( - value: desktop, - pt: pt, - onChanged: (v) => ref.read(notifDesktopProvider.notifier).set(v), - ), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Benachrichtigungston', - desc: 'Sound abspielen wenn eine Nachricht eingeht', - pt: pt, - control: _Toggle( - value: sound, - pt: pt, - onChanged: (v) => ref.read(notifSoundProvider.notifier).set(v), - ), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Nachrichtenvorschau', - desc: 'Nachrichteninhalt in Benachrichtigung anzeigen', - pt: pt, - control: _Toggle( - value: preview, - pt: pt, - onChanged: (v) => ref.read(notifPreviewProvider.notifier).set(v), - ), - ), - ]), - const SizedBox(height: 20), - _SettingsGroup(title: 'Filter', pt: pt, children: [ - _SettingsRow( - title: 'Nur Erwähnungen (DMs)', - desc: 'Benachrichtigung nur bei direkten @Erwähnungen', - pt: pt, - control: _Toggle( - value: dmMentionOnly, - pt: pt, - onChanged: (v) => ref.read(notifMentionOnlyDmProvider.notifier).set(v), - ), - ), - ]), - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: pt.bg2, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: pt.border), - ), - child: Row( - children: [ - Icon(Icons.info_outline_rounded, size: 16, color: pt.fgDim), - const SizedBox(width: 10), - Expanded( - child: Text( - 'Push-Benachrichtigungen für Android/iOS sind in Vorbereitung.', - style: TextStyle(color: pt.fgMuted, fontSize: 12), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// APPEARANCE -// ───────────────────────────────────────────────────────────────────────────── - -class _AppearanceSection extends StatelessWidget { - final PyramidTheme pt; - final bool isDark; - final int accentIdx; - final double radius; - final double density; - final double motion; - final WidgetRef ref; - - const _AppearanceSection({ - required this.pt, - required this.isDark, - required this.accentIdx, - required this.radius, - required this.density, - required this.motion, - required this.ref, - }); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle( - title: 'Erscheinungsbild', - subtitle: 'Gestalte Pyramid nach deinem Geschmack.', - pt: pt), - _SettingsGroup(title: 'Theme', pt: pt, children: [ - _SettingsRow( - title: 'Farbmodus', - desc: 'Hell oder dunkel', - pt: pt, - control: _SegControl( - options: const ['Dunkel', 'Hell'], - selected: isDark ? 0 : 1, - pt: pt, - onSelect: (i) { - final dark = i == 0; - ref.read(themeModeProvider.notifier).state = dark; - saveThemePrefs(isDark: dark); - }, - ), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Akzentfarbe', - desc: 'Farbe für Hervorhebungen und Buttons', - pt: pt, - control: Row( - children: accentPresets.asMap().entries.map((e) { - final i = e.key; - final preset = e.value; - final isActive = accentIdx == i; - return GestureDetector( - onTap: () { - ref.read(accentProvider.notifier).state = i; - saveThemePrefs(accentIdx: i); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: 22, - height: 22, - margin: const EdgeInsets.only(right: 6), - decoration: BoxDecoration( - color: preset.color, - shape: BoxShape.circle, - border: isActive ? Border.all(color: Colors.white, width: 2) : null, - boxShadow: isActive - ? [BoxShadow(color: preset.color.withAlpha(100), blurRadius: 8)] - : null, - ), - ), - ); - }).toList(), - ), - ), - ]), - const SizedBox(height: 20), - _SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [ - _SettingsRow( - title: 'Eckenrundung', - desc: '${radius.round()} px', - pt: pt, - control: SizedBox( - width: 150, - child: SliderTheme( - data: SliderThemeData( - activeTrackColor: pt.accent, - thumbColor: pt.accent, - inactiveTrackColor: pt.border, - trackHeight: 3, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), - ), - child: Slider( - value: radius, - min: 4, - max: 20, - onChanged: (v) { - ref.read(radiusProvider.notifier).state = v; - saveThemePrefs(radius: v); - }, - ), - ), - ), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Animationen', - desc: '${(motion * 100).round()}%', - pt: pt, - control: SizedBox( - width: 150, - child: SliderTheme( - data: SliderThemeData( - activeTrackColor: pt.accent, - thumbColor: pt.accent, - inactiveTrackColor: pt.border, - trackHeight: 3, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), - ), - child: Slider( - value: motion, - min: 0, - max: 1, - onChanged: (v) { - ref.read(motionProvider.notifier).state = v; - saveThemePrefs(motion: v); - }, - ), - ), - ), - ), - ]), - const SizedBox(height: 20), - _SettingsGroup(title: 'Vorschau', pt: pt, children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Expanded( - child: Column( - children: [ - Container( - height: 100, - decoration: BoxDecoration( - color: pt.bg3, - borderRadius: BorderRadius.circular(pt.rBase), - border: Border.all(color: pt.border), - ), - child: const Center(child: PyramidLoader(size: 70, useLottie: true)), - ), - const SizedBox(height: 6), - Text('Lottie', style: TextStyle(color: pt.fgMuted, fontSize: 11)), - ], - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - children: [ - Container( - height: 100, - decoration: BoxDecoration( - color: pt.bg3, - borderRadius: BorderRadius.circular(pt.rBase), - border: Border.all(color: pt.border), - ), - child: const Center(child: PyramidLoader(size: 70, useLottie: false)), - ), - const SizedBox(height: 6), - Text('SVG', style: TextStyle(color: pt.fgMuted, fontSize: 11)), - ], - ), - ), - ], - ), - ), - ]), - ], - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// VOICE & VIDEO -// ───────────────────────────────────────────────────────────────────────────── - -class _VoiceSection extends StatefulWidget { - final PyramidTheme pt; - const _VoiceSection({required this.pt}); - - @override - State<_VoiceSection> createState() => _VoiceSectionState(); -} - -class _VoiceSectionState extends State<_VoiceSection> { - List _audioInputs = []; - List _audioOutputs = []; - List _videoInputs = []; - String? _selectedMic; - String? _selectedSpeaker; - String? _selectedCamera; - double _outputVolume = 1.0; - bool _loading = true; - String? _loadError; - - static const _kMic = 'voice_mic_device'; - static const _kSpeaker = 'voice_speaker_device'; - static const _kCamera = 'voice_camera_device'; - static const _kOutputVolume = 'voice_output_volume'; - - @override - void initState() { - super.initState(); - _init(); - } - - Future _init() async { - final prefs = await SharedPreferences.getInstance(); - List devices = []; - String? loadError; - try { - devices = await navigator.mediaDevices.enumerateDevices(); - } catch (_) { - devices = []; - } - // Windows (und teils Android) liefert eine LEERE Liste ohne Fehler, - // solange in diesem Prozess noch nie ein Media-Stream geöffnet wurde. - // Deshalb nicht nur bei Exception, sondern auch bei leerer Liste einmal - // kurz getUserMedia anfordern und erneut enumerieren — und zwar SOLANGE - // der Stream noch offen ist (nach stop() melden manche Systeme wieder 0). - final hasAudioInput = - devices.any((d) => d.kind?.toLowerCase() == 'audioinput'); - if (devices.isEmpty || !hasAudioInput) { - try { - final stream = await navigator.mediaDevices - .getUserMedia({'audio': true, 'video': false}); - try { - devices = await navigator.mediaDevices.enumerateDevices(); - } finally { - for (final t in stream.getTracks()) { - t.stop(); - } - await stream.dispose(); - } - if (devices.isEmpty) { - // Bekanntes libwebrtc-Problem auf manchen Windows-Systemen: das - // Audio-Device-Module meldet 0 Geräte, obwohl Aufnahme/Wiedergabe - // über das Standardgerät einwandfrei funktionieren. - loadError = - 'Die Audiotreiber melden keine Geräteliste an WebRTC ' - '(bekanntes libwebrtc-Problem auf manchen Windows-Systemen). ' - 'Anrufe funktionieren trotzdem — es wird das Windows-Standardgerät verwendet.'; - } - } catch (e2) { - loadError = 'getUserMedia: ${e2.toString().split('\n').first}'; - } - } - if (!mounted) return; - setState(() { - _audioInputs = devices.where((d) => d.kind?.toLowerCase() == 'audioinput').toList(); - _audioOutputs = devices.where((d) => d.kind?.toLowerCase() == 'audiooutput').toList(); - _videoInputs = devices.where((d) => d.kind?.toLowerCase() == 'videoinput').toList(); - _selectedMic = prefs.getString(_kMic); - _selectedSpeaker = prefs.getString(_kSpeaker); - _selectedCamera = prefs.getString(_kCamera); - _outputVolume = prefs.getDouble(_kOutputVolume) ?? 1.0; - _loadError = loadError; - _loading = false; - }); - } - - Future _setOutputVolume(double v) async { - setState(() => _outputVolume = v); - final prefs = await SharedPreferences.getInstance(); - await prefs.setDouble(_kOutputVolume, v); - // Live auf laufende Calls anwenden. - LiveKitCallManager.instance.setOutputVolume(v).catchError((_) {}); - try { - PyramidVoipManager.instance.applyOutputVolume(v).catchError((_) {}); - } catch (_) {} // Instanz existiert evtl. noch nicht - } - - Future _save(String key, String? value) async { - final prefs = await SharedPreferences.getInstance(); - if (value == null || value.isEmpty) { - await prefs.remove(key); - } else { - await prefs.setString(key, value); - } - } - - Widget _deviceDropdown({ - required List devices, - required String? selected, - required String placeholder, - required IconData icon, - required ValueChanged onChanged, - }) { - final pt = widget.pt; - if (devices.isEmpty) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: pt.bg3, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: pt.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 13, color: pt.fgDim), - const SizedBox(width: 6), - Text('Kein Gerät', style: TextStyle(color: pt.fgDim, fontSize: 12)), - ], - ), - ); - } - - final validSelected = devices.any((d) => d.deviceId == selected) ? selected : null; - - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 220), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: pt.bg3, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: pt.border), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: validSelected, - hint: Row(children: [ - Icon(icon, size: 13, color: pt.fgDim), - const SizedBox(width: 6), - Text(placeholder, style: TextStyle(color: pt.fgDim, fontSize: 12)), - ]), - dropdownColor: pt.bg2, - iconEnabledColor: pt.fgDim, - isDense: true, - style: TextStyle(color: pt.fg, fontSize: 12), - onChanged: onChanged, - items: devices.map((d) { - final label = d.label.isNotEmpty ? d.label : 'Gerät ${d.deviceId.substring(0, 6)}'; - return DropdownMenuItem( - value: d.deviceId, - child: Text(label, overflow: TextOverflow.ellipsis), - ); - }).toList(), - ), - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle( - title: 'Sprache & Video', - subtitle: 'Mikrofon, Kamera und Anrufeinstellungen.', - pt: pt), - if (_loading) - const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive())) - else ...[ - if (_loadError != null) - Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: pt.bg2, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: pt.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Icon(Icons.warning_amber_rounded, size: 16, color: pt.away), - const SizedBox(width: 8), - Text('Geräteliste nicht verfügbar', - style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), - ]), - const SizedBox(height: 6), - Text(_loadError!, style: TextStyle(color: pt.fgDim, fontSize: 11)), - const SizedBox(height: 6), - Text( - 'Mikrofon und Lautsprecher legst du in diesem Fall über die ' - 'Windows-Soundeinstellungen (Standardgerät) fest. ' - 'Falls dort alles stimmt: Windows-Einstellungen → Datenschutz → Mikrofon prüfen.', - style: TextStyle(color: pt.fgMuted, fontSize: 11), - ), - ], - ), - ), - ), - _SettingsGroup(title: 'Geräte', pt: pt, children: [ - _SettingsRow( - title: 'Mikrofon', - desc: _audioInputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioInputs.length} Gerät(e) gefunden', - pt: pt, - control: _deviceDropdown( - devices: _audioInputs, - selected: _selectedMic, - placeholder: 'Standard', - icon: Icons.mic_rounded, - onChanged: (v) { - setState(() => _selectedMic = v); - _save(_kMic, v); - }, - ), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Lautsprecher', - desc: _audioOutputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioOutputs.length} Gerät(e) gefunden', - pt: pt, - control: _deviceDropdown( - devices: _audioOutputs, - selected: _selectedSpeaker, - placeholder: 'Standard', - icon: Icons.volume_up_rounded, - onChanged: (v) { - setState(() => _selectedSpeaker = v); - _save(_kSpeaker, v); - }, - ), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Kamera', - desc: _videoInputs.isEmpty ? 'Kein Gerät gefunden' : '${_videoInputs.length} Gerät(e) gefunden', - pt: pt, - control: _deviceDropdown( - devices: _videoInputs, - selected: _selectedCamera, - placeholder: 'Standard', - icon: Icons.videocam_rounded, - onChanged: (v) { - setState(() => _selectedCamera = v); - _save(_kCamera, v); - }, - ), - ), - ]), - const SizedBox(height: 16), - _SettingsGroup(title: 'Pegel', pt: pt, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.volume_up_rounded, size: 15, color: pt.fgMuted), - const SizedBox(width: 8), - Text('Ausgabelautstärke', - style: TextStyle( - color: pt.fg, - fontSize: 13, - fontWeight: FontWeight.w500)), - const Spacer(), - Text('${(_outputVolume * 100).round()} %', - style: TextStyle( - color: pt.fgMuted, - fontSize: 12, - fontFamily: 'monospace')), - ], - ), - SliderTheme( - data: SliderThemeData( - activeTrackColor: pt.accent, - inactiveTrackColor: pt.bg3, - thumbColor: pt.accent, - overlayColor: pt.accent.withAlpha(30), - trackHeight: 3, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 7), - ), - child: Slider( - value: _outputVolume.clamp(0.0, 1.5), - min: 0.0, - max: 1.5, - divisions: 30, - onChanged: _setOutputVolume, - ), - ), - Text( - 'Lautstärke der anderen Teilnehmer in Calls. ' - 'Wirkt sofort auf laufende Anrufe.', - style: TextStyle(color: pt.fgDim, fontSize: 11), - ), - ], - ), - ), - ]), - const SizedBox(height: 16), - _TurnUsageCard(pt: pt), - const SizedBox(height: 12), - TextButton.icon( - style: TextButton.styleFrom( - foregroundColor: pt.fgMuted, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - onPressed: () { setState(() { _loading = true; _loadError = null; }); _init(); }, - icon: const Icon(Icons.refresh_rounded, size: 14), - label: const Text('Geräte neu laden', style: TextStyle(fontSize: 12)), - ), - ], - ], - ), - ); - } -} - -// ── TURN-Verbrauch (Cloudflare Free Tier, vom Pi überwacht) ────────────────── - -/// Zeigt den TURN-Relay-Verbrauch des laufenden Monats an. Die Daten kommen -/// aus der turn-status.json, die der Guard-Cron auf dem Pi stündlich schreibt. -/// Solange der Endpoint nicht existiert (Analytics-Token fehlt), bleibt die -/// Karte unsichtbar. -class _TurnUsageCard extends StatefulWidget { - final PyramidTheme pt; - const _TurnUsageCard({required this.pt}); - - @override - State<_TurnUsageCard> createState() => _TurnUsageCardState(); -} - -class _TurnUsageCardState extends State<_TurnUsageCard> { - Map? _status; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - try { - final resp = await http - .get(Uri.parse( - 'https://steggi-matrix.work/.well-known/pyramid/turn-status.json')) - .timeout(const Duration(seconds: 5)); - if (resp.statusCode == 200 && mounted) { - setState( - () => _status = jsonDecode(resp.body) as Map); - } - } catch (_) { - // Endpoint (noch) nicht vorhanden — Karte einfach nicht anzeigen. - } - } - - @override - Widget build(BuildContext context) { - final s = _status; - if (s == null) return const SizedBox.shrink(); - final pt = widget.pt; - final used = (s['egress_gb'] as num?)?.toDouble() ?? 0; - final limit = (s['limit_gb'] as num?)?.toDouble() ?? 990; - final pct = limit > 0 ? (used / limit).clamp(0.0, 1.0) : 0.0; - final active = s['turn_active'] != false; - final barColor = !active - ? pt.danger - : pct > 0.8 - ? pt.away - : pt.accent; - - return _SettingsGroup(title: 'Call-Relay (TURN)', pt: pt, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Icon(active ? Icons.cloud_done_rounded : Icons.cloud_off_rounded, - size: 15, color: active ? pt.online : pt.danger), - const SizedBox(width: 8), - Text( - active - ? 'Relay aktiv' - : 'Relay deaktiviert (Limit erreicht)', - style: TextStyle( - color: pt.fg, - fontSize: 13, - fontWeight: FontWeight.w500)), - const Spacer(), - Text( - '${used.toStringAsFixed(1)} / ${limit.toStringAsFixed(0)} GB', - style: TextStyle( - color: pt.fgMuted, - fontSize: 12, - fontFamily: 'monospace')), - ]), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(99), - child: LinearProgressIndicator( - value: pct, - minHeight: 5, - backgroundColor: pt.bg3, - color: barColor, - ), - ), - const SizedBox(height: 6), - Text( - active - ? 'Cloudflare-TURN-Traffic diesen Monat (Free Tier: ${limit.toStringAsFixed(0)} GB). ' - 'Bei Erreichen des Limits laufen Calls bis Monatsende ohne Relay weiter.' - : 'Monatslimit erreicht — Calls nutzen bis Monatsende nur direkte Verbindungen (STUN).', - style: TextStyle(color: pt.fgDim, fontSize: 11), - ), - ], - ), - ), - ]); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// KEYBINDS -// ───────────────────────────────────────────────────────────────────────────── - -class _KeysSection extends StatelessWidget { - final PyramidTheme pt; - const _KeysSection({required this.pt}); - - @override - Widget build(BuildContext context) { - final keys = [ - ('Schnellsuche', 'Strg+K'), - ('Raum als gelesen markieren', 'Shift+Esc'), - ('Zu ungelesen springen', 'Strg+J'), - ('Linke Leiste ein-/ausblenden', 'Strg+B'), - ('Mitglieder ein-/ausblenden', 'Strg+Shift+U'), - ('Nachrichten durchsuchen', 'Strg+F'), - ('Antworten', 'R'), - ('Auswahl aufheben', 'Esc'), - ]; - - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle( - title: 'Tastaturkürzel', - subtitle: 'Schnelle Aktionen per Tastatur.', - pt: pt), - _SettingsGroup( - title: 'Navigieren', - pt: pt, - children: keys.asMap().entries.map((entry) { - final (action, binding) = entry.value; - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row( - children: [ - Expanded( - child: Text(action, - style: TextStyle(color: pt.fg, fontSize: 13))), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: pt.bg3, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: pt.border), - ), - child: Text(binding, - style: TextStyle( - color: pt.fgMuted, - fontSize: 12, - fontFamily: 'monospace')), - ), - ], - ), - ), - if (entry.key < keys.length - 1) _Divider(pt: pt), - ], - ); - }).toList(), - ), - ], - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// PRIVACY -// ───────────────────────────────────────────────────────────────────────────── - -class _PrivacySection extends ConsumerStatefulWidget { - final PyramidTheme pt; - const _PrivacySection({required this.pt}); - - @override - ConsumerState<_PrivacySection> createState() => _PrivacySectionState(); -} - -class _PrivacySectionState extends ConsumerState<_PrivacySection> { - List _ignoredUsers = []; - final Set _unblocking = {}; - - @override - void initState() { - super.initState(); - _loadIgnoredUsers(); - } - - Future _loadIgnoredUsers() async { - final client = await ref.read(matrixClientProvider.future); - if (mounted) setState(() => _ignoredUsers = List.from(client.ignoredUsers)); - } - - Future _unblockUser(String userId) async { - setState(() => _unblocking.add(userId)); - try { - final client = await ref.read(matrixClientProvider.future); - await client.unignoreUser(userId); - await _loadIgnoredUsers(); - } catch (e) { - // ignore — list will be stale but not crash - } finally { - if (mounted) setState(() => _unblocking.remove(userId)); - } - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - final presence = ref.watch(privacyPresenceProvider); - final readReceipts = ref.watch(privacyReadReceiptsProvider); - final typing = ref.watch(privacyTypingProvider); - - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle( - title: 'Datenschutz', - subtitle: 'Steuere, was andere über dich erfahren.', - pt: pt), - _SettingsGroup(title: 'Sichtbarkeit', pt: pt, children: [ - _SettingsRow( - title: 'Online-Status anzeigen', - desc: 'Anderen anzeigen, wenn du aktiv bist', - pt: pt, - control: _Toggle( - value: presence, - pt: pt, - onChanged: (v) async { - ref.read(privacyPresenceProvider.notifier).set(v); - final client = await ref.read(matrixClientProvider.future); - await client.setPresence( - client.userID!, - v ? PresenceType.online : PresenceType.offline, - ).catchError((_) {}); - }, - ), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Lesebestätigungen senden', - desc: 'Anderen zeigen, dass du ihre Nachrichten gelesen hast', - pt: pt, - control: _Toggle( - value: readReceipts, - pt: pt, - onChanged: (v) => ref.read(privacyReadReceiptsProvider.notifier).set(v), - ), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Tipp-Indikator senden', - desc: '„schreibt…" Anzeige aktivieren', - pt: pt, - control: _Toggle( - value: typing, - pt: pt, - onChanged: (v) => ref.read(privacyTypingProvider.notifier).set(v), - ), - ), - ]), - const SizedBox(height: 20), - // ── Blocked users ────────────────────────────────────────────────── - _SettingsGroup(title: 'Blockierte Nutzer (${_ignoredUsers.length})', pt: pt, children: [ - if (_ignoredUsers.isEmpty) - Padding( - padding: const EdgeInsets.all(14), - child: Text('Keine blockierten Nutzer.', - style: TextStyle(color: pt.fgMuted, fontSize: 13)), - ) - else - ..._ignoredUsers.asMap().entries.map((entry) { - final i = entry.key; - final userId = entry.value; - final isUnblocking = _unblocking.contains(userId); - return Column(children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: Row(children: [ - Expanded( - child: Text(userId, - style: TextStyle(color: pt.fg, fontSize: 13), - overflow: TextOverflow.ellipsis), - ), - if (isUnblocking) - const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) - else - TextButton( - style: TextButton.styleFrom( - foregroundColor: pt.accent, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: () => _unblockUser(userId), - child: const Text('Entblocken', style: TextStyle(fontSize: 12)), - ), - ]), - ), - if (i < _ignoredUsers.length - 1) _Divider(pt: pt), - ]); - }), - ]), - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: pt.accentSoft, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: pt.accent.withAlpha(60)), - ), - child: Row( - children: [ - Icon(Icons.shield_outlined, size: 16, color: pt.accent), - const SizedBox(width: 10), - Expanded( - child: Text( - 'Alle Nachrichten sind Ende-zu-Ende-verschlüsselt.', - style: TextStyle(color: pt.fg, fontSize: 12), - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SESSIONS -// ───────────────────────────────────────────────────────────────────────────── - -class _SessionsSection extends ConsumerStatefulWidget { - final PyramidTheme pt; - const _SessionsSection({required this.pt}); - - @override - ConsumerState<_SessionsSection> createState() => _SessionsSectionState(); -} - -class _SessionsSectionState extends ConsumerState<_SessionsSection> { - List _devices = []; - bool _loading = true; - String? _currentDeviceId; - final Set _revoking = {}; - bool _logoutAllLoading = false; - String? _error; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - setState(() { _loading = true; _error = null; }); - try { - final client = await ref.read(matrixClientProvider.future); - _currentDeviceId = client.deviceID; - await client.updateUserDeviceKeys(additionalUsers: {client.userID!}); - final keys = client.userDeviceKeys[client.userID]; - if (mounted) { - setState(() { - _devices = keys?.deviceKeys.values.toList() ?? []; - _loading = false; - }); - } - } catch (e) { - if (mounted) { - setState(() { - _loading = false; - _error = e.toString().split('\n').first; - }); - } - } - } - - Future _revokeDevice(String deviceId) async { - setState(() => _revoking.add(deviceId)); - try { - final client = await ref.read(matrixClientProvider.future); - await client.deleteDevice(deviceId); - await _load(); - } catch (e) { - if (mounted) { - setState(() { - _revoking.remove(deviceId); - _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; - }); - } - } - } - - Future _renameCurrentDevice(BuildContext context) async { - final client = await ref.read(matrixClientProvider.future); - if (!context.mounted) return; - final currentName = _devices - .where((d) => d.deviceId == _currentDeviceId) - .map((d) => d.unsigned?['device_display_name'] as String? ?? d.deviceId ?? '') - .firstOrNull ?? ''; - - final ctrl = TextEditingController(text: currentName); - final pt = widget.pt; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), - side: BorderSide(color: pt.border)), - title: Text('Gerät umbenennen', - style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), - content: TextField( - controller: ctrl, - autofocus: true, - style: TextStyle(color: pt.fg, fontSize: 13), - decoration: InputDecoration( - labelText: 'Gerätename', - labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), - filled: true, fillColor: pt.bg3, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), - ), - cursorColor: pt.accent, - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Umbenennen'), - ), - ], - ), - ); - ctrl.dispose(); - if (confirmed != true || _currentDeviceId == null) return; - try { - await client.updateDevice(_currentDeviceId!, displayName: ctrl.text.trim()); - await _load(); - } catch (e) { - if (mounted) setState(() => _error = 'Umbenennen fehlgeschlagen: ${e.toString().split('\n').first}'); - } - } - - Future _logoutAllOtherDevices(BuildContext context) async { - final pt = widget.pt; - final otherIds = _devices - .where((d) => d.deviceId != _currentDeviceId && d.deviceId != null) - .map((d) => d.deviceId!) - .toList(); - if (otherIds.isEmpty) return; - - final confirm = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), - title: Text('Alle anderen Sitzungen abmelden?', - style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), - content: Text( - '${otherIds.length} Gerät${otherIds.length == 1 ? '' : 'e'} werden abgemeldet. Nicht gespeicherte Nachrichten auf diesen Geräten können verloren gehen.', - style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Alle abmelden'), - ), - ], - ), - ); - if (confirm != true || !mounted) return; - setState(() { _logoutAllLoading = true; _error = null; }); - - final client = await ref.read(matrixClientProvider.future); - try { - await client.deleteDevices(otherIds); - } on MatrixException catch (e) { - if (e.requireAdditionalAuthentication && context.mounted) { - final password = await _askPassword(context, pt); - if (password == null || !mounted) { setState(() => _logoutAllLoading = false); return; } - try { - await client.deleteDevices(otherIds, auth: AuthenticationData( - type: AuthenticationTypes.password, - session: e.session, - additionalFields: { - 'identifier': {'type': 'm.id.user', 'user': client.userID}, - 'password': password, - }, - )); - } catch (e2) { - if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e2.toString().split('\n').first}'; }); - return; - } - } else { - if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; }); - return; - } - } catch (e) { - if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; }); - return; - } - await _load(); - if (mounted) setState(() => _logoutAllLoading = false); - } - - Future _askPassword(BuildContext context, PyramidTheme pt) { - final ctrl = TextEditingController(); - return showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), - title: Text('Passwort bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w700)), - content: TextField( - controller: ctrl, - autofocus: true, - obscureText: true, - style: TextStyle(color: pt.fg, fontSize: 13), - decoration: InputDecoration( - labelText: 'Passwort', - labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), - filled: true, fillColor: pt.bg3, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), - ), - cursorColor: pt.accent, - onSubmitted: (_) => Navigator.pop(ctx, ctrl.text), - ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, null), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), - ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => Navigator.pop(ctx, ctrl.text), - child: const Text('Bestätigen'), - ), - ], - ), - ).then((v) { ctrl.dispose(); return v; }); - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle( - title: 'Sitzungen', - subtitle: 'Geräte, auf denen du angemeldet bist.', - pt: pt), - if (_loading) - const Center( - child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive())) - else if (_error != null) - _StatusCard( - pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!) - else ...[ - _SettingsGroup( - title: 'Aktive Sitzungen (${_devices.length})', - pt: pt, - children: _devices.isEmpty - ? [ - Padding( - padding: const EdgeInsets.all(16), - child: Text('Keine Sitzungen gefunden.', - style: TextStyle(color: pt.fgMuted, fontSize: 13)), - ) - ] - : _devices.asMap().entries.map((entry) { - final i = entry.key; - final device = entry.value; - final isCurrent = device.deviceId == _currentDeviceId; - final isRevoking = _revoking.contains(device.deviceId); - final isVerified = device.verified; - final displayName = device.unsigned?['device_display_name'] as String? ?? - device.deviceId ?? - 'Unbekanntes Gerät'; - - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, vertical: 12), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: isCurrent - ? pt.accentSoft - : pt.bg3, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.devices_rounded, - size: 18, - color: isCurrent ? pt.accent : pt.fgMuted, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - displayName, - style: TextStyle( - color: pt.fg, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - if (isCurrent) ...[ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: pt.accentSoft, - borderRadius: BorderRadius.circular(4), - ), - child: Text('Dieses Gerät', - style: TextStyle( - color: pt.accent, - fontSize: 10, - fontWeight: FontWeight.w600)), - ), - const SizedBox(width: 4), - GestureDetector( - onTap: () => _renameCurrentDevice(context), - child: Tooltip( - message: 'Gerät umbenennen', - child: Icon(Icons.edit_rounded, size: 13, color: pt.fgDim), - ), - ), - ], - if (isVerified == true && !isCurrent) - Icon(Icons.verified_rounded, - size: 14, color: PyramidColors.success), - if (isVerified == false && !isCurrent) - Icon(Icons.warning_amber_rounded, - size: 14, color: pt.away), - ], - ), - const SizedBox(height: 2), - SelectableText( - device.deviceId ?? '', - style: TextStyle( - color: pt.fgDim, - fontSize: 11, - fontFamily: 'monospace'), - ), - ], - ), - ), - if (!isCurrent) ...[ - const SizedBox(width: 8), - if (isVerified == false) - GestureDetector( - onTap: () => _verifyDevice(context, device, displayName), - child: Tooltip( - message: 'Verifizieren', - child: Icon(Icons.verified_user_outlined, - size: 16, color: pt.accent), - ), - ), - const SizedBox(width: 6), - if (isRevoking) - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, color: pt.danger), - ) - else - GestureDetector( - onTap: () => _confirmRevoke(context, device, displayName), - child: Tooltip( - message: 'Abmelden', - child: Icon(Icons.logout_rounded, - size: 16, color: pt.fgDim), - ), - ), - ], - ], - ), - ), - if (i < _devices.length - 1) _Divider(pt: pt), - ], - ); - }).toList(), - ), - const SizedBox(height: 12), - Row( - children: [ - TextButton.icon( - style: TextButton.styleFrom( - foregroundColor: pt.fgMuted, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - onPressed: _load, - icon: const Icon(Icons.refresh_rounded, size: 14), - label: const Text('Aktualisieren', style: TextStyle(fontSize: 12)), - ), - const Spacer(), - if (_logoutAllLoading) - const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) - else if (_devices.where((d) => d.deviceId != _currentDeviceId).isNotEmpty) - TextButton.icon( - style: TextButton.styleFrom( - foregroundColor: pt.danger, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - onPressed: () => _logoutAllOtherDevices(context), - icon: const Icon(Icons.logout_rounded, size: 14), - label: const Text('Alle anderen abmelden', style: TextStyle(fontSize: 12)), - ), - ], - ), - ], - ], - ), - ); - } - - Future _verifyDevice( - BuildContext context, DeviceKeys device, String displayName) async { - await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => _SasVerificationDialog( - pt: widget.pt, - deviceName: displayName, - device: device, - ), - ); - await _load(); - } - - Future _confirmRevoke( - BuildContext context, DeviceKeys device, String displayName) async { - final confirm = await showDialog( - context: context, - builder: (ctx) { - final pt = widget.pt; - return AlertDialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), - side: BorderSide(color: pt.border)), - title: Text('Gerät abmelden?', - style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), - content: Text( - '"$displayName" wird abgemeldet und alle lokalen Schlüssel werden gelöscht.', - style: TextStyle(color: pt.fgMuted, fontSize: 13), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: pt.danger, - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Abmelden'), - ), - ], - ); - }, - ); - if (confirm == true && device.deviceId != null) { - await _revokeDevice(device.deviceId!); - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SAS VERIFICATION DIALOG -// ───────────────────────────────────────────────────────────────────────────── - -class _SasVerificationDialog extends ConsumerStatefulWidget { - final PyramidTheme pt; - final String deviceName; - final DeviceKeys device; - - const _SasVerificationDialog({ - required this.pt, - required this.deviceName, - required this.device, - }); - - @override - ConsumerState<_SasVerificationDialog> createState() => _SasVerificationDialogState(); -} - -class _SasVerificationDialogState extends ConsumerState<_SasVerificationDialog> { - KeyVerification? _verification; - KeyVerificationState _state = KeyVerificationState.waitingAccept; - bool _starting = true; - String? _error; - - @override - void initState() { - super.initState(); - _startVerification(); - } - - @override - void dispose() { - final v = _verification; - if (v != null && !v.isDone) { - v.cancel('m.user').catchError((_) {}); - } - super.dispose(); - } - - Future _startVerification() async { - try { - final client = await ref.read(matrixClientProvider.future); - final deviceId = widget.device.deviceId; - if (deviceId == null) throw Exception('Keine Geräte-ID'); - - final deviceKeys = client.userDeviceKeys[client.userID]?.deviceKeys[deviceId]; - if (deviceKeys == null) throw Exception('Gerät nicht gefunden'); - - final verification = await deviceKeys.startVerification(); - _verification = verification; - - verification.onUpdate = () { - if (!mounted) return; - setState(() => _state = verification.state); - }; - - if (mounted) { - setState(() { - _state = verification.state; - _starting = false; - }); - } - } catch (e) { - if (mounted) { - setState(() { - _error = e.toString().split('\n').first; - _starting = false; - }); - } - } - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - - return AlertDialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), - side: BorderSide(color: pt.border)), - contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), - title: Row( - children: [ - Icon(Icons.verified_user_outlined, size: 20, color: pt.accent), - const SizedBox(width: 10), - Expanded( - child: Text( - 'Gerät verifizieren', - style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700), - ), - ), - ], - ), - content: SizedBox( - width: 380, - child: _buildContent(pt), - ), - actions: _buildActions(pt), - ); - } - - Widget _buildContent(PyramidTheme pt) { - if (_error != null) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: _StatusCard( - pt: pt, - icon: Icons.error_outline_rounded, - color: pt.danger, - text: _error!), - ); - } - - if (_starting) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 24), - child: Center(child: CircularProgressIndicator.adaptive()), - ); - } - - final v = _verification; - - return switch (_state) { - KeyVerificationState.waitingAccept || KeyVerificationState.askAccept => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator.adaptive(), - const SizedBox(height: 16), - Text( - 'Warte auf Bestätigung von „${widget.deviceName}"…', - textAlign: TextAlign.center, - style: TextStyle(color: pt.fgMuted, fontSize: 13), - ), - const SizedBox(height: 8), - Text( - 'Öffne Pyramid auf dem anderen Gerät und bestätige die Verifikationsanfrage.', - textAlign: TextAlign.center, - style: TextStyle(color: pt.fgDim, fontSize: 12), - ), - ], - ), - ), - KeyVerificationState.askChoice => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Das andere Gerät hat die Anfrage akzeptiert.\nWähle eine Verifikationsmethode:', - textAlign: TextAlign.center, - style: TextStyle(color: pt.fgMuted, fontSize: 13), - ), - const SizedBox(height: 16), - ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: pt.accent, - foregroundColor: pt.accentFg, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - onPressed: () => v?.continueVerification('m.sas.v1').catchError((_) {}), - icon: const Icon(Icons.tag_faces_rounded, size: 18), - label: const Text('Emoji-Verifikation'), - ), - const SizedBox(height: 8), - ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: pt.bg3, - foregroundColor: pt.fg, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - onPressed: () => v?.continueVerification('m.qr_code.show.v1').catchError((_) {}), - icon: const Icon(Icons.qr_code_rounded, size: 18), - label: const Text('QR-Code anzeigen'), - ), - const SizedBox(height: 8), - ], - ), - ), - KeyVerificationState.confirmQRScan => _OutgoingQrCodeDisplay( - pt: pt, - request: v, - ), - KeyVerificationState.showQRSuccess => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: _StatusCard( - pt: pt, - icon: Icons.verified_rounded, - color: PyramidColors.success, - text: 'QR-Code erfolgreich gescannt. Verifikation abgeschlossen.'), - ), - KeyVerificationState.askSas => _SasEmojiContent( - pt: pt, - verification: v!, - onConfirm: () => v.acceptSas().catchError((_) {}), - onReject: () { - v.rejectSas().catchError((_) {}); - Navigator.of(context).pop(); - }, - ), - KeyVerificationState.waitingSas => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator.adaptive(), - const SizedBox(height: 16), - Text( - 'Warte auf Bestätigung des anderen Geräts…', - textAlign: TextAlign.center, - style: TextStyle(color: pt.fgMuted, fontSize: 13), - ), - ], - ), - ), - KeyVerificationState.done => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: _StatusCard( - pt: pt, - icon: Icons.verified_rounded, - color: PyramidColors.success, - text: '„${widget.deviceName}" wurde erfolgreich verifiziert.'), - ), - _ => Padding( - padding: const EdgeInsets.only(bottom: 16), - child: _StatusCard( - pt: pt, - icon: Icons.error_outline_rounded, - color: pt.danger, - text: 'Verifizierung fehlgeschlagen oder abgebrochen.'), - ), - }; - } - - List _buildActions(PyramidTheme pt) { - if (_state == KeyVerificationState.done || - _state == KeyVerificationState.showQRSuccess || - _error != null || - _state == KeyVerificationState.error) { - return [ - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: pt.accent, - foregroundColor: pt.accentFg, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => Navigator.pop(context), - child: const Text('Schließen'), - ), - ]; - } - if (_state == KeyVerificationState.confirmQRScan) { - return [ - TextButton( - onPressed: () { - _verification?.cancel('m.user').catchError((_) {}); - Navigator.pop(context); - }, - child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF22C55E), - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => _verification?.acceptQRScanConfirmation().catchError((_) {}), - child: const Text('Gescannt bestätigen'), - ), - ]; - } - if (_state == KeyVerificationState.askSas || _state == KeyVerificationState.askChoice) { - return []; - } - return [ - TextButton( - onPressed: () { - _verification?.cancel('m.user').catchError((_) {}); - Navigator.pop(context); - }, - child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), - ), - ]; - } -} - -class _OutgoingQrCodeDisplay extends StatelessWidget { - final PyramidTheme pt; - final KeyVerification? request; - - const _OutgoingQrCodeDisplay({required this.pt, required this.request}); - - @override - Widget build(BuildContext context) { - final qrCode = request?.qrCode; - if (qrCode == null) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator.adaptive(), - const SizedBox(height: 12), - Text('QR-Code wird generiert…', style: TextStyle(color: pt.fgMuted, fontSize: 13)), - ], - ), - ); - } - - try { - final rawBytes = Uint8List.fromList(qrCode.qrDataRawBytes.toList()); - final qr = QrCode.fromUint8List(data: rawBytes, errorCorrectLevel: QrErrorCorrectLevel.L); - final qrImage = QrImage(qr); - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Scanne diesen QR-Code mit dem anderen Gerät', - textAlign: TextAlign.center, - style: TextStyle(color: pt.fgMuted, fontSize: 13), - ), - const SizedBox(height: 16), - Center( - child: Container( - width: 220, - height: 220, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - ), - child: PrettyQrView(qrImage: qrImage), - ), - ), - const SizedBox(height: 12), - Text( - 'Sobald das andere Gerät den Code gescannt hat, tippe auf „Gescannt bestätigen".', - textAlign: TextAlign.center, - style: TextStyle(color: pt.fgDim, fontSize: 12), - ), - const SizedBox(height: 8), - ], - ), - ); - } catch (_) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: _StatusCard( - pt: pt, - icon: Icons.error_outline_rounded, - color: pt.danger, - text: 'QR-Code konnte nicht generiert werden.', - ), - ); - } - } -} - -class _SasEmojiContent extends StatelessWidget { - final PyramidTheme pt; - final KeyVerification verification; // ignore: library_private_types_in_public_api - final VoidCallback onConfirm; - final VoidCallback onReject; - - const _SasEmojiContent({ - required this.pt, - required this.verification, - required this.onConfirm, - required this.onReject, - }); - - @override - Widget build(BuildContext context) { - final emojis = verification.sasEmojis; - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Vergleiche die Emojis auf beiden Geräten', - textAlign: TextAlign.center, - style: TextStyle(color: pt.fgMuted, fontSize: 13), - ), - const SizedBox(height: 20), - if (emojis.isNotEmpty) - Wrap( - spacing: 12, - runSpacing: 12, - alignment: WrapAlignment.center, - children: emojis.map((e) => _SasEmojiChip(emoji: e, pt: pt)).toList(), - ) - else - Text('Keine Emojis verfügbar.', style: TextStyle(color: pt.fgDim, fontSize: 13)), - const SizedBox(height: 24), - Text( - 'Stimmen alle Emojis überein?', - style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - foregroundColor: pt.danger, - side: BorderSide(color: pt.danger.withAlpha(120)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - onPressed: onReject, - icon: const Icon(Icons.close_rounded, size: 16), - label: const Text('Stimmt nicht überein'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom( - backgroundColor: PyramidColors.success, - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - onPressed: onConfirm, - icon: const Icon(Icons.check_rounded, size: 16), - label: const Text('Stimmt überein'), - ), - ), - ], - ), - const SizedBox(height: 8), - ], - ), - ); - } -} - -class _SasEmojiChip extends StatelessWidget { - final KeyVerificationEmoji emoji; - final PyramidTheme pt; - - const _SasEmojiChip({required this.emoji, required this.pt}); - - @override - Widget build(BuildContext context) { - return Container( - width: 68, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), - decoration: BoxDecoration( - color: pt.bg3, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: pt.border), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(emoji.emoji, style: const TextStyle(fontSize: 26)), - const SizedBox(height: 4), - Text( - emoji.name, - style: TextStyle(color: pt.fgMuted, fontSize: 10), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// ENCRYPTION -// ───────────────────────────────────────────────────────────────────────────── - -enum _EncState { idle, loading, needsKey, unlocking, done, error } -enum _RecoveryStep { loading, showKey, confirmKey, applying, done, error } - -class _EncryptionSection extends ConsumerStatefulWidget { - final PyramidTheme pt; - final WidgetRef ref; - const _EncryptionSection({required this.pt, required this.ref}); - - @override - ConsumerState<_EncryptionSection> createState() => _EncryptionSectionState(); -} - -class _EncryptionSectionState extends ConsumerState<_EncryptionSection> { - _EncState _state = _EncState.idle; - Bootstrap? _bootstrap; - final _keyCtrl = TextEditingController(); - String? _error; - - // Status loaded from encryption object - bool? _crossSigningEnabled; - bool? _crossSigningCached; - bool? _keyBackupEnabled; - bool _statusLoaded = false; - - // Key file export / import - bool _exportLoading = false; - bool _importLoading = false; - String? _keyFileMsg; - bool _keyFileMsgIsError = false; - bool _diagUploading = false; - String? _diagMsg; - bool _diagMsgIsError = false; - - @override - void initState() { - super.initState(); - _loadStatus(); - } - - @override - void dispose() { - _keyCtrl.dispose(); - super.dispose(); - } - - Future _loadStatus() async { - try { - final client = await ref.read(matrixClientProvider.future); - final enc = client.encryption; - if (enc == null) return; - final cached = await enc.crossSigning.isCached().catchError((_) => false); - if (!mounted) return; - setState(() { - _crossSigningEnabled = enc.crossSigning.enabled; - _crossSigningCached = cached; - _keyBackupEnabled = enc.keyManager.enabled; - _statusLoaded = true; - }); - } catch (_) {} - } - - Future _start() async { - setState(() { _state = _EncState.loading; _error = null; }); - try { - final client = await ref.read(matrixClientProvider.future); - if (client.encryption == null) { - setState(() { _state = _EncState.error; _error = 'Verschlüsselung nicht verfügbar.'; }); - return; - } - final enc = client.encryption!; - final cached = await enc.keyManager.isCached().catchError((_) => false); - if (cached) { - await enc.keyManager.loadAllKeys().catchError((_) {}); - if (mounted) setState(() { _state = _EncState.done; _error = null; }); - await _loadStatus(); - return; - } - - await client.roomsLoading; - await client.accountDataLoading; - await client.userDeviceKeysLoading; - - final completer = Completer(); - _bootstrap = enc.bootstrap(onUpdate: (bs) { - if (!mounted) return; - switch (bs.state) { - case BootstrapState.askWipeSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(false)); - case BootstrapState.askBadSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); - case BootstrapState.askUseExistingSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(true)); - case BootstrapState.openExistingSsss: - if (!completer.isCompleted) completer.complete(true); - case BootstrapState.askNewSsss: - if (!completer.isCompleted) completer.complete(false); - case BootstrapState.error: - if (!completer.isCompleted) completer.complete(false); - if (mounted) setState(() { _state = _EncState.error; _error = 'Bootstrap-Fehler.'; }); - default: - break; - } - }); - - final needsKey = await completer.future - .timeout(const Duration(seconds: 30), onTimeout: () => false); - if (!mounted) return; - setState(() => _state = needsKey ? _EncState.needsKey : _EncState.done); - await _loadStatus(); - } catch (e) { - if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); - } - } - - Future _unlock() async { - final bs = _bootstrap; - if (bs == null || bs.newSsssKey == null) return; - final key = _keyCtrl.text.trim(); - if (key.isEmpty) { setState(() => _error = 'Bitte Wiederherstellungsschlüssel eingeben.'); return; } - setState(() { _state = _EncState.unlocking; _error = null; }); - try { - await bs.newSsssKey!.unlock(keyOrPassphrase: key); - await bs.openExistingSsss(); - final client = await ref.read(matrixClientProvider.future); - if (bs.encryption.crossSigning.enabled) { - await client.encryption!.crossSigning.selfSign(recoveryKey: key); - } - await client.encryption!.keyManager.loadAllKeys(); - if (mounted) setState(() => _state = _EncState.done); - await _loadStatus(); - } on InvalidPassphraseException catch (_) { - if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Falscher Schlüssel.'; }); - } on FormatException catch (_) { - if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Ungültiges Format.'; }); - } catch (e) { - if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); - } - } - - Future _resetSecurity() async { - final confirm = await showDialog( - context: context, - builder: (ctx) { - final pt = widget.pt; - return AlertDialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), - side: BorderSide(color: pt.border)), - title: Text('Sicherheit zurücksetzen?', - style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), - content: Text( - 'Dies löscht deine aktuellen Cross-Signing-Schlüssel und den Online-Schlüssel-Backup. Nicht gesicherte Sitzungen können Nachrichten möglicherweise nicht mehr entschlüsseln.', - style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Zurücksetzen'), - ), - ], - ); - }, - ); - if (confirm != true || !mounted) return; - setState(() { _state = _EncState.loading; _error = null; }); - try { - final client = await ref.read(matrixClientProvider.future); - final enc = client.encryption; - if (enc == null) throw Exception('Verschlüsselung nicht verfügbar'); - await client.roomsLoading; - await client.accountDataLoading; - await client.userDeviceKeysLoading; - final completer = Completer(); - enc.bootstrap(onUpdate: (bs) { - if (!mounted) return; - switch (bs.state) { - case BootstrapState.askWipeSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(true)); - case BootstrapState.askBadSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); - case BootstrapState.askUseExistingSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(false)); - case BootstrapState.askNewSsss: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss()); - case BootstrapState.askWipeCrossSigning: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeCrossSigning(true)); - case BootstrapState.askSetupCrossSigning: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupCrossSigning( - setupMasterKey: true, setupSelfSigningKey: true, setupUserSigningKey: true)); - case BootstrapState.askWipeOnlineKeyBackup: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(true)); - case BootstrapState.askSetupOnlineKeyBackup: - WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true)); - case BootstrapState.done: - if (!completer.isCompleted) completer.complete(); - case BootstrapState.error: - if (!completer.isCompleted) completer.completeError('Bootstrap-Fehler'); - default: - break; - } - }); - await completer.future.timeout(const Duration(seconds: 60)); - if (mounted) setState(() => _state = _EncState.done); - await _loadStatus(); - } catch (e) { - if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); - } - } - - Future _showPassphraseDialog({required bool forExport}) { - return showDialog( - context: context, - builder: (ctx) => _PassphraseDialog(pt: widget.pt, forExport: forExport), - ); - } - - Future _exportKeys() async { - final passphrase = await _showPassphraseDialog(forExport: true); - if (passphrase == null || !mounted) return; - - setState(() { _exportLoading = true; _keyFileMsg = null; }); - try { - final client = await ref.read(matrixClientProvider.future); - final db = client.database; - - final stored = await db.getAllInboundGroupSessions(); - final sessions = >[]; - - for (final s in stored) { - try { - final content = jsonDecode(s.content) as Map; - final sessionKey = content['session_key'] as String?; - if (sessionKey == null || sessionKey.isEmpty) continue; - - final claimedKeys = s.senderClaimedKeys.isNotEmpty - ? (jsonDecode(s.senderClaimedKeys) as Map) - .map((k, v) => MapEntry(k, v.toString())) - : {}; - - sessions.add({ - 'algorithm': 'm.megolm.v1.aes-sha2', - 'forwarding_curve25519_key_chain': [], - 'room_id': s.roomId, - 'sender_key': s.senderKey, - 'sender_claimed_keys': claimedKeys, - 'session_id': s.sessionId, - 'session_key': sessionKey, - }); - } catch (_) { continue; } - } - - if (sessions.isEmpty) throw Exception('Keine exportierbaren Sitzungsschlüssel gefunden.'); - - final encrypted = await compute(_encryptMegolmKeys, { - 'passphrase': passphrase, - 'sessions': sessions, - }); - - final dir = await getApplicationDocumentsDirectory(); - final now = DateTime.now(); - final stamp = '${now.year}' - '${now.month.toString().padLeft(2, '0')}' - '${now.day.toString().padLeft(2, '0')}'; - final file = File('${dir.path}/pyramid_keys_$stamp.txt'); - await file.writeAsString(encrypted); - - if (mounted) { - setState(() { - _exportLoading = false; - _keyFileMsgIsError = false; - _keyFileMsg = '${sessions.length} Schlüssel exportiert → ${file.path}'; - }); - } - } catch (e) { - if (mounted) { - setState(() { - _exportLoading = false; - _keyFileMsgIsError = true; - _keyFileMsg = 'Export fehlgeschlagen: ${e.toString().split('\n').first}'; - }); - } - } - } - - Future _uploadDiagnostics() async { - setState(() { _diagUploading = true; _diagMsg = null; }); - try { - final notifier = ref.read(e2eeDiagnosticsProvider.notifier); - final msg = await notifier.upload( - serverUrl: 'https://dashboard.steggi-matrix.work', - token: 'J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI', - ); - if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = false; _diagMsg = msg; }); - } catch (e) { - if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = true; _diagMsg = e.toString(); }); - } - } - - Future _importKeys() async { - final result = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: ['txt', 'key', 'keys'], - allowMultiple: false, - ); - if (result == null || result.files.isEmpty || result.files.first.path == null) return; - if (!mounted) return; - - final passphrase = await _showPassphraseDialog(forExport: false); - if (passphrase == null || !mounted) return; - - setState(() { _importLoading = true; _keyFileMsg = null; }); - try { - final fileContent = await File(result.files.first.path!).readAsString(); - final trimmed = fileContent.trim(); - - if (!trimmed.startsWith(_kMegolmHeader) || !trimmed.endsWith(_kMegolmFooter)) { - throw Exception('Ungültiges Dateiformat – kein Megolm-Schlüsseldatei-Header.'); - } - - final lines = trimmed.split('\n'); - // Strip \r so Windows line endings (\r\n) don't corrupt the base64 string. - final b64 = lines.skip(1).take(lines.length - 2).join('').replaceAll(RegExp(r'\s'), ''); - final padded = b64 + '=' * ((4 - b64.length % 4) % 4); - final dataBytes = base64.decode(padded); - - final sessions = await compute(_decryptMegolmKeys, { - 'passphrase': passphrase, - 'data': dataBytes.toList(), - }); - - final client = await ref.read(matrixClientProvider.future); - final km = client.encryption?.keyManager; - if (km == null) throw Exception('Verschlüsselung nicht initialisiert.'); - - var imported = 0; - for (final raw in sessions) { - try { - final session = raw as Map; - final roomId = session['room_id'] as String?; - final sessionId = session['session_id'] as String?; - final senderKey = session['sender_key'] as String?; - if (roomId == null || sessionId == null || senderKey == null) continue; - - final claimedKeys = (session['sender_claimed_keys'] as Map?) - ?.map((k, v) => MapEntry(k.toString(), v.toString())) ?? - {}; - - await km.setInboundGroupSession( - roomId, sessionId, senderKey, - Map.from(session), - forwarded: true, - senderClaimedKeys: claimedKeys, - ); - imported++; - } catch (_) { continue; } - } - - if (mounted) { - setState(() { - _importLoading = false; - _keyFileMsgIsError = false; - _keyFileMsg = '$imported von ${sessions.length} Schlüssel importiert.'; - }); - } - } catch (e) { - if (mounted) { - setState(() { - _importLoading = false; - _keyFileMsgIsError = true; - _keyFileMsg = 'Import fehlgeschlagen: ${e.toString().split('\n').first}'; - }); - } - } - } - - Future _changeRecoveryKey() async { - final changed = await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => _ChangeRecoveryKeyDialog(pt: widget.pt), - ); - if (changed == true && mounted) { - await _loadStatus(); - } - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle( - title: 'Verschlüsselung', - subtitle: 'Ende-zu-Ende-Verschlüsselung & Sicherheit verwalten.', - pt: pt), - - // ── Status overview ─────────────────────────────────────────────── - if (_statusLoaded) ...[ - _SettingsGroup(title: 'Sicherheitsstatus', pt: pt, children: [ - _SettingsRow( - title: 'Cross-Signing', - desc: _crossSigningEnabled == true - ? (_crossSigningCached == true ? 'Eingerichtet & verifiziert' : 'Eingerichtet, Schlüssel nicht im Cache') - : 'Nicht eingerichtet', - pt: pt, - control: Icon( - _crossSigningEnabled == true && _crossSigningCached == true - ? Icons.verified_rounded - : (_crossSigningEnabled == true ? Icons.warning_amber_rounded : Icons.cancel_outlined), - size: 16, - color: _crossSigningEnabled == true && _crossSigningCached == true - ? PyramidColors.success - : (_crossSigningEnabled == true ? pt.away : pt.danger), - ), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Online-Schlüsselbackup', - desc: _keyBackupEnabled == true ? 'Aktiv – Sitzungsschlüssel gesichert' : 'Nicht aktiviert', - pt: pt, - control: Icon( - _keyBackupEnabled == true ? Icons.cloud_done_rounded : Icons.cloud_off_rounded, - size: 16, - color: _keyBackupEnabled == true ? PyramidColors.success : pt.fgDim, - ), - ), - ]), - const SizedBox(height: 20), - _FingerprintGroup(pt: pt), - const SizedBox(height: 20), - ], - - // ── Key recovery / unlock ───────────────────────────────────────── - _SettingsGroup(title: 'Wiederherstellung', pt: pt, children: [ - _SettingsRow( - title: 'Schlüssel wiederherstellen', - desc: 'Nachrichten mit dem Wiederherstellungsschlüssel entschlüsseln', - pt: pt, - showArrow: true, - onTap: _state == _EncState.loading ? null : _start, - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Wiederherstellungsschlüssel ändern', - desc: 'Neuen Schlüssel generieren, notieren und aktivieren', - pt: pt, - showArrow: true, - onTap: _state == _EncState.loading ? null : _changeRecoveryKey, - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Sicherheit neu einrichten', - desc: 'Cross-Signing & Backup zurücksetzen und neu aufsetzen', - pt: pt, - showArrow: true, - destructive: true, - onTap: _state == _EncState.loading ? null : _resetSecurity, - ), - ]), - const SizedBox(height: 20), - - // ── Key file export / import ─────────────────────────────────────── - _SettingsGroup(title: 'Schlüsseldatei', pt: pt, children: [ - _SettingsRow( - title: 'Schlüssel exportieren', - desc: 'Sitzungsschlüssel als verschlüsselte Datei sichern (kompatibel mit Element)', - pt: pt, - showArrow: !_exportLoading, - control: _exportLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ) - : null, - onTap: (_exportLoading || _importLoading) ? null : _exportKeys, - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Schlüssel importieren', - desc: 'Sitzungsschlüssel aus exportierter Datei laden', - pt: pt, - showArrow: !_importLoading, - control: _importLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ) - : null, - onTap: (_exportLoading || _importLoading) ? null : _importKeys, - ), - ]), - - if (_keyFileMsg != null) ...[ - const SizedBox(height: 12), - _StatusCard( - pt: pt, - icon: _keyFileMsgIsError - ? Icons.error_outline_rounded - : Icons.check_circle_outline_rounded, - color: _keyFileMsgIsError ? pt.danger : PyramidColors.success, - text: _keyFileMsg!, - ), - ], - const SizedBox(height: 20), - - // ── E2EE Diagnostics ────────────────────────────────────────────── - Consumer(builder: (context, ref, _) { - final count = ref.watch(e2eeDiagnosticsProvider).length; - return _SettingsGroup(title: 'Diagnose', pt: pt, children: [ - _SettingsRow( - title: 'Entschlüsselungsfehler hochladen', - desc: count == 0 - ? 'Keine Fehler gesammelt' - : '$count Fehler gesammelt – zum Server hochladen', - pt: pt, - showArrow: !_diagUploading && count > 0, - control: _diagUploading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ) - : count > 0 - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: pt.danger.withAlpha(30), - borderRadius: BorderRadius.circular(10), - ), - child: Text('$count', style: TextStyle(color: pt.danger, fontSize: 12)), - ) - : null, - onTap: (_diagUploading || count == 0) ? null : _uploadDiagnostics, - ), - ]); - }), - - if (_diagMsg != null) ...[ - const SizedBox(height: 12), - _StatusCard( - pt: pt, - icon: _diagMsgIsError - ? Icons.error_outline_rounded - : Icons.cloud_done_rounded, - color: _diagMsgIsError ? pt.danger : PyramidColors.success, - text: _diagMsg!, - ), - ], - const SizedBox(height: 20), - - // ── Inline state UI ─────────────────────────────────────────────── - if (_state == _EncState.loading) - const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive())), - - if (_state == _EncState.done) ...[ - _StatusCard( - pt: pt, - icon: Icons.check_circle_outline_rounded, - color: PyramidColors.success, - text: 'Alle Schlüssel importiert. Nachrichten werden entschlüsselt.'), - const SizedBox(height: 12), - ], - - if (_state == _EncState.needsKey || _state == _EncState.unlocking) ...[ - _StatusCard( - pt: pt, - icon: Icons.lock_outline_rounded, - color: pt.accent, - text: 'Wiederherstellungsschlüssel erforderlich.'), - const SizedBox(height: 16), - TextField( - controller: _keyCtrl, - readOnly: _state == _EncState.unlocking, - autofocus: true, - autocorrect: false, - style: TextStyle(color: pt.fg, fontSize: 13, fontFamily: 'monospace'), - decoration: InputDecoration( - hintText: 'EsXX XXXX XXXX XXXX …', - hintStyle: TextStyle(color: pt.fgDim, fontSize: 12), - labelText: 'Wiederherstellungsschlüssel', - labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), - errorText: _error, - errorMaxLines: 2, - filled: true, - fillColor: pt.bg2, - prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), - isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - ), - cursorColor: pt.accent, - onSubmitted: (_) => _unlock(), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: _PrimaryBtn( - label: _state == _EncState.unlocking ? 'Entschlüssele…' : 'Nachrichten entschlüsseln', - icon: Icons.lock_open_outlined, - pt: pt, - loading: _state == _EncState.unlocking, - onPressed: _unlock, - ), - ), - const SizedBox(height: 12), - ], - - if (_state == _EncState.error) ...[ - _StatusCard( - pt: pt, icon: Icons.error_outline_rounded, color: PyramidColors.danger, - text: _error ?? 'Unbekannter Fehler.'), - const SizedBox(height: 12), - OutlinedButton.icon( - style: OutlinedButton.styleFrom( - foregroundColor: pt.fg, - side: BorderSide(color: pt.border), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - onPressed: _start, - icon: const Icon(Icons.refresh_rounded, size: 16), - label: const Text('Erneut versuchen'), - ), - ], - - if (_state == _EncState.idle && !_statusLoaded) - _StatusCard( - pt: pt, - icon: Icons.shield_outlined, - color: pt.fgMuted, - text: 'Lade Sicherheitsstatus…'), - ], - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// CHANGE RECOVERY KEY DIALOG -// ───────────────────────────────────────────────────────────────────────────── - -class _ChangeRecoveryKeyDialog extends ConsumerStatefulWidget { - final PyramidTheme pt; - const _ChangeRecoveryKeyDialog({required this.pt}); - - @override - ConsumerState<_ChangeRecoveryKeyDialog> createState() => - _ChangeRecoveryKeyDialogState(); -} - -class _ChangeRecoveryKeyDialogState - extends ConsumerState<_ChangeRecoveryKeyDialog> { - _RecoveryStep _step = _RecoveryStep.loading; - String? _generatedKey; - String? _error; - bool _copied = false; - // Completer signals when bootstrap reaches `done` - final _doneCompleter = Completer(); - - @override - void initState() { - super.initState(); - _startBootstrap(); - } - - Future _startBootstrap() async { - try { - final client = await ref.read(matrixClientProvider.future); - final enc = client.encryption; - if (enc == null) throw Exception('Verschlüsselung nicht verfügbar.'); - await client.roomsLoading; - await client.accountDataLoading; - await client.userDeviceKeysLoading; - enc.bootstrap(onUpdate: (bs) { - if (!mounted) return; - _onBootstrapUpdate(bs); - }); - } catch (e) { - if (mounted) { - setState(() { - _step = _RecoveryStep.error; - _error = e.toString().split('\n').first; - }); - } - } - } - - // After newSsss() calls checkCrossSigning(), the bootstrap immediately - // transitions to askWipeCrossSigning (or askSetupCrossSigning). All post- - // newSsss states must therefore auto-advance unconditionally — guarding on - // _step would leave them stuck. - void _onBootstrapUpdate(Bootstrap bs) { - switch (bs.state) { - case BootstrapState.askWipeSsss: - WidgetsBinding.instance.addPostFrameCallback((_) { - if (bs.state == BootstrapState.askWipeSsss) bs.wipeSsss(true); - }); - case BootstrapState.askBadSsss: - WidgetsBinding.instance.addPostFrameCallback((_) { - if (bs.state == BootstrapState.askBadSsss) bs.ignoreBadSecrets(true); - }); - case BootstrapState.askUseExistingSsss: - WidgetsBinding.instance.addPostFrameCallback((_) { - if (bs.state == BootstrapState.askUseExistingSsss) bs.useExistingSsss(false); - }); - case BootstrapState.askUnlockSsss: - WidgetsBinding.instance.addPostFrameCallback((_) { - if (bs.state == BootstrapState.askUnlockSsss) bs.unlockedSsss(); - }); - case BootstrapState.askNewSsss: - // Only generate once - if (_step == _RecoveryStep.loading) { - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (bs.state != BootstrapState.askNewSsss) return; - try { - await bs.newSsss(); - // newSsss() internally calls checkCrossSigning() which already - // transitions to askWipeCrossSigning; capture key after it returns. - final key = bs.newSsssKey?.recoveryKey; - if (mounted) setState(() { _generatedKey = key; _step = _RecoveryStep.showKey; }); - } catch (e) { - if (mounted) setState(() { _step = _RecoveryStep.error; _error = e.toString().split('\n').first; }); - } - }); - } - case BootstrapState.askWipeCrossSigning: - WidgetsBinding.instance.addPostFrameCallback((_) { - if (bs.state == BootstrapState.askWipeCrossSigning) bs.wipeCrossSigning(false); - }); - case BootstrapState.askSetupCrossSigning: - WidgetsBinding.instance.addPostFrameCallback((_) { - if (bs.state == BootstrapState.askSetupCrossSigning) { - bs.askSetupCrossSigning( - setupMasterKey: true, - setupSelfSigningKey: true, - setupUserSigningKey: true, - ); - } - }); - case BootstrapState.askWipeOnlineKeyBackup: - WidgetsBinding.instance.addPostFrameCallback((_) { - if (bs.state == BootstrapState.askWipeOnlineKeyBackup) bs.wipeOnlineKeyBackup(true); - }); - case BootstrapState.askSetupOnlineKeyBackup: - WidgetsBinding.instance.addPostFrameCallback((_) { - if (bs.state == BootstrapState.askSetupOnlineKeyBackup) bs.askSetupOnlineKeyBackup(true); - }); - case BootstrapState.done: - if (!_doneCompleter.isCompleted) _doneCompleter.complete(); - // Only auto-advance to done if user already clicked through (applying). - // If still on showKey/confirmKey, the user must finish reading first. - if (mounted && _step == _RecoveryStep.applying) { - setState(() => _step = _RecoveryStep.done); - } - case BootstrapState.error: - if (!_doneCompleter.isCompleted) _doneCompleter.completeError('Bootstrap-Fehler'); - if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Fehler bei der Schlüsselgenerierung.'; }); - default: - break; - } - } - - // Called when user taps "Schlüssel gespeichert – weiter" on the showKey screen. - void _onKeySaved() { - setState(() => _step = _RecoveryStep.confirmKey); - } - - // Called when user submits the correct key confirmation. - // Bootstrap may still be running (cross-signing + key backup); wait for done. - Future _onKeyConfirmed() async { - setState(() => _step = _RecoveryStep.applying); - try { - await _doneCompleter.future.timeout(const Duration(seconds: 90)); - if (mounted) setState(() => _step = _RecoveryStep.done); - } catch (e) { - if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Timeout: Bootstrap abgebrochen.'; }); - } - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - final canClose = _step != _RecoveryStep.loading && _step != _RecoveryStep.applying && _step != _RecoveryStep.confirmKey; - - return Dialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), - side: BorderSide(color: pt.border), - ), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 440, minWidth: 320), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 12, 12), - child: Row( - children: [ - Icon(Icons.key_rounded, size: 18, color: pt.accent), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Wiederherstellungsschlüssel', - style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), - ), - ), - if (canClose) - IconButton( - icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), - onPressed: () => Navigator.of(context).pop(_step == _RecoveryStep.done), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), - ), - ], - ), - ), - Divider(color: pt.border, height: 1), - Padding( - padding: const EdgeInsets.all(20), - child: _buildBody(pt), - ), - ], - ), - ), - ); - } - - Widget _buildBody(PyramidTheme pt) { - return switch (_step) { - _RecoveryStep.loading => const Center( - child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive()), - ), - _RecoveryStep.showKey => _ShowKeyBody( - pt: pt, - generatedKey: _generatedKey ?? '', - copied: _copied, - onCopy: () { - Clipboard.setData(ClipboardData(text: _generatedKey ?? '')); - setState(() => _copied = true); - }, - onKeySaved: _onKeySaved, - ), - _RecoveryStep.confirmKey => _ConfirmKeyBody( - pt: pt, - expectedKey: _generatedKey ?? '', - onConfirmed: _onKeyConfirmed, - ), - _RecoveryStep.applying => Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator.adaptive(), - const SizedBox(height: 16), - Text( - 'Schlüssel wird aktiviert…\nCross-Signing und Backup werden eingerichtet.', - textAlign: TextAlign.center, - style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), - ), - ], - ), - _RecoveryStep.done => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52), - const SizedBox(height: 12), - Text( - 'Wiederherstellungsschlüssel wurde erfolgreich geändert.', - textAlign: TextAlign.center, - style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 6), - Text( - 'Bewahre deinen Schlüssel sicher auf – du brauchst ihn bei Geräteverlust.', - textAlign: TextAlign.center, - style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.4), - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: pt.accent, foregroundColor: pt.accentFg, - elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - onPressed: () => Navigator.of(context).pop(true), - child: const Text('Fertig'), - ), - ), - ], - ), - _RecoveryStep.error => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48), - const SizedBox(height: 12), - Text( - _error ?? 'Ein unbekannter Fehler ist aufgetreten.', - textAlign: TextAlign.center, - style: TextStyle(color: pt.fgMuted, fontSize: 13), - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: OutlinedButton( - style: OutlinedButton.styleFrom( - foregroundColor: pt.fg, side: BorderSide(color: pt.border), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - onPressed: () => Navigator.of(context).pop(false), - child: const Text('Schließen'), - ), - ), - ], - ), - }; - } -} - -class _ShowKeyBody extends StatelessWidget { - final PyramidTheme pt; - final String generatedKey; - final bool copied; - final VoidCallback onCopy; - final VoidCallback onKeySaved; - - const _ShowKeyBody({ - required this.pt, - required this.generatedKey, - required this.copied, - required this.onCopy, - required this.onKeySaved, - }); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Dein neuer Wiederherstellungsschlüssel', - style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Text( - 'Speichere diesen Schlüssel sicher – du brauchst ihn, wenn du den Zugang zu allen Geräten verlierst.', - style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), - ), - const SizedBox(height: 16), - Container( - decoration: BoxDecoration( - color: pt.bg3, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: pt.border), - ), - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: SelectableText( - generatedKey, - style: TextStyle( - color: pt.accent, fontSize: 13, - fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6, - ), - ), - ), - ), - IconButton( - onPressed: onCopy, - icon: Icon( - copied ? Icons.check_rounded : Icons.copy_rounded, - size: 18, - color: copied ? PyramidColors.success : pt.fgDim, - ), - tooltip: copied ? 'Kopiert!' : 'In Zwischenablage kopieren', - ), - ], - ), - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.orange.withAlpha(28), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.orange.withAlpha(80)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.warning_amber_rounded, size: 16, color: Colors.orange.shade700), - const SizedBox(width: 8), - Expanded( - child: Text( - 'Schreibe diesen Schlüssel auf oder speichere ihn in einem Passwort-Manager. Er kann nicht wiederhergestellt werden.', - style: TextStyle(color: Colors.orange.shade800, fontSize: 12, height: 1.4), - ), - ), - ], - ), - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: pt.accent, foregroundColor: pt.accentFg, - elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - onPressed: onKeySaved, - child: const Text('Schlüssel gespeichert – weiter'), - ), - ), - ], - ); - } -} - -class _ConfirmKeyBody extends StatefulWidget { - final PyramidTheme pt; - final String expectedKey; - final VoidCallback onConfirmed; - - const _ConfirmKeyBody({ - required this.pt, - required this.expectedKey, - required this.onConfirmed, - }); - - @override - State<_ConfirmKeyBody> createState() => _ConfirmKeyBodyState(); -} - -class _ConfirmKeyBodyState extends State<_ConfirmKeyBody> { - final _controller = TextEditingController(); - bool _mismatch = false; - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _submit() { - final entered = _controller.text.trim(); - if (entered == widget.expectedKey) { - widget.onConfirmed(); - } else { - setState(() => _mismatch = true); - } - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Schlüssel bestätigen', - style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Text( - 'Gib deinen neuen Wiederherstellungsschlüssel zur Bestätigung ein.', - style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), - ), - const SizedBox(height: 16), - TextField( - controller: _controller, - autofocus: true, - style: TextStyle( - color: pt.fg, fontSize: 13, - fontFamily: 'monospace', letterSpacing: 0.4, - ), - decoration: InputDecoration( - hintText: 'EsTX XXXX XXXX …', - hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), - filled: true, - fillColor: pt.bg3, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: pt.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.accent, width: 1.5), - ), - errorText: _mismatch ? 'Schlüssel stimmt nicht überein.' : null, - ), - onChanged: (_) { if (_mismatch) setState(() => _mismatch = false); }, - onSubmitted: (_) => _submit(), - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: pt.accent, foregroundColor: pt.accentFg, - elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - onPressed: _submit, - child: const Text('Aktivieren'), - ), - ), - ], - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// FINGERPRINT GROUP -// ───────────────────────────────────────────────────────────────────────────── - -class _FingerprintGroup extends ConsumerWidget { - final PyramidTheme pt; - const _FingerprintGroup({required this.pt}); - - static String _fmt(String key) { - final clean = key.replaceAll(RegExp(r'\s'), ''); - final groups = []; - for (var i = 0; i < clean.length; i += 4) { - groups.add(clean.substring(i, (i + 4).clamp(0, clean.length))); - } - return groups.join(' '); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final clientAsync = ref.watch(matrixClientProvider); - final client = clientAsync.valueOrNull; - if (client == null) return const SizedBox.shrink(); - - final fingerprint = _fmt(client.fingerprintKey); - final deviceId = client.deviceID ?? ''; - - return _SettingsGroup(title: 'Dieses Gerät', pt: pt, children: [ - _SettingsRow( - title: 'Geräte-ID', - desc: deviceId, - pt: pt, - control: IconButton( - icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), - tooltip: 'Kopieren', - padding: EdgeInsets.zero, - constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), - onPressed: () => Clipboard.setData(ClipboardData(text: deviceId)), - ), - ), - _Divider(pt: pt), - Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text('Ed25519-Fingerabdruck', - style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), - const Spacer(), - IconButton( - icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), - tooltip: 'Fingerabdruck kopieren', - padding: EdgeInsets.zero, - constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), - onPressed: () => Clipboard.setData(ClipboardData(text: client.fingerprintKey)), - ), - ], - ), - const SizedBox(height: 4), - SelectableText( - fingerprint, - style: TextStyle( - color: pt.fgMuted, fontSize: 11, - fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6, - ), - ), - ], - ), - ), - ]); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// CHANGE PASSWORD DIALOG -// ───────────────────────────────────────────────────────────────────────────── - -class _ChangePasswordDialog extends ConsumerStatefulWidget { - final PyramidTheme pt; - const _ChangePasswordDialog({required this.pt}); - - @override - ConsumerState<_ChangePasswordDialog> createState() => _ChangePasswordDialogState(); -} - -class _ChangePasswordDialogState extends ConsumerState<_ChangePasswordDialog> { - final _oldCtrl = TextEditingController(); - final _newCtrl = TextEditingController(); - final _confirmCtrl = TextEditingController(); - bool _oldVisible = false; - bool _newVisible = false; - bool _loading = false; - String? _error; - bool _done = false; - - @override - void dispose() { - _oldCtrl.dispose(); - _newCtrl.dispose(); - _confirmCtrl.dispose(); - super.dispose(); - } - - Future _submit() async { - final oldPw = _oldCtrl.text; - final newPw = _newCtrl.text; - final confirm = _confirmCtrl.text; - if (oldPw.isEmpty || newPw.isEmpty) { - setState(() => _error = 'Bitte alle Felder ausfüllen.'); - return; - } - if (newPw != confirm) { - setState(() => _error = 'Die neuen Passwörter stimmen nicht überein.'); - return; - } - if (newPw.length < 8) { - setState(() => _error = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.'); - return; - } - setState(() { _loading = true; _error = null; }); - try { - final client = await ref.read(matrixClientProvider.future); - await client.changePassword(newPw, oldPassword: oldPw); - if (mounted) setState(() { _loading = false; _done = true; }); - } on MatrixException catch (e) { - if (mounted) setState(() { _loading = false; _error = e.errorMessage; }); - } catch (e) { - if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; }); - } - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - return Dialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420, minWidth: 300), - child: Padding( - padding: const EdgeInsets.all(24), - child: _done ? _buildDone(pt) : _buildForm(pt), - ), - ), - ); - } - - Widget _buildDone(PyramidTheme pt) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52), - const SizedBox(height: 12), - Text('Passwort geändert', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), - const SizedBox(height: 6), - Text('Dein Passwort wurde erfolgreich aktualisiert.', - textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)), - const SizedBox(height: 20), - SizedBox(width: double.infinity, child: ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, - elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => Navigator.of(context).pop(), - child: const Text('Schließen'), - )), - ], - ); - - Widget _buildForm(PyramidTheme pt) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Icon(Icons.lock_outline_rounded, size: 18, color: pt.accent), - const SizedBox(width: 8), - Text('Passwort ändern', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), - const Spacer(), - IconButton( - icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), - onPressed: () => Navigator.of(context).pop(), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), - ), - ]), - const SizedBox(height: 20), - _PwField(ctrl: _oldCtrl, label: 'Aktuelles Passwort', pt: pt, - visible: _oldVisible, onToggle: () => setState(() => _oldVisible = !_oldVisible)), - const SizedBox(height: 12), - _PwField(ctrl: _newCtrl, label: 'Neues Passwort', pt: pt, - visible: _newVisible, onToggle: () => setState(() => _newVisible = !_newVisible)), - const SizedBox(height: 12), - _PwField(ctrl: _confirmCtrl, label: 'Neues Passwort bestätigen', pt: pt, - visible: _newVisible, onToggle: null, - onSubmitted: (_) => _submit()), - if (_error != null) ...[ - const SizedBox(height: 10), - _StatusCard(pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!), - ], - const SizedBox(height: 20), - SizedBox(width: double.infinity, child: ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, - elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: _loading ? null : _submit, - child: _loading - ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) - : const Text('Passwort ändern'), - )), - ], - ); -} - -class _PwField extends StatelessWidget { - final TextEditingController ctrl; - final String label; - final PyramidTheme pt; - final bool visible; - final VoidCallback? onToggle; - final ValueChanged? onSubmitted; - - const _PwField({ - required this.ctrl, required this.label, required this.pt, - required this.visible, required this.onToggle, this.onSubmitted, - }); - - @override - Widget build(BuildContext context) => TextField( - controller: ctrl, - obscureText: !visible, - onSubmitted: onSubmitted, - style: TextStyle(color: pt.fg, fontSize: 13), - decoration: InputDecoration( - labelText: label, - labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), - filled: true, fillColor: pt.bg3, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), - suffixIcon: onToggle != null - ? IconButton( - icon: Icon(visible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim), - onPressed: onToggle, - ) - : null, - ), - cursorColor: pt.accent, - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// DELETE ACCOUNT DIALOG -// ───────────────────────────────────────────────────────────────────────────── - -class _DeleteAccountDialog extends ConsumerStatefulWidget { - final PyramidTheme pt; - const _DeleteAccountDialog({required this.pt}); - - @override - ConsumerState<_DeleteAccountDialog> createState() => _DeleteAccountDialogState(); -} - -enum _DeleteStep { confirm, auth, deleting, error } - -class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> { - _DeleteStep _step = _DeleteStep.confirm; - final _pwCtrl = TextEditingController(); - bool _pwVisible = false; - bool _eraseData = true; - String? _error; - - @override - void dispose() { _pwCtrl.dispose(); super.dispose(); } - - Future _proceed() async { - setState(() { _step = _DeleteStep.deleting; _error = null; }); - final client = await ref.read(matrixClientProvider.future); - try { - await client.deactivateAccount(erase: _eraseData); - } on MatrixException catch (e) { - if (e.requireAdditionalAuthentication && mounted) { - // UIA required — try with password - try { - await client.deactivateAccount( - erase: _eraseData, - auth: AuthenticationData( - type: AuthenticationTypes.password, - session: e.session, - additionalFields: { - 'identifier': {'type': 'm.id.user', 'user': client.userID}, - 'password': _pwCtrl.text, - }, - ), - ); - } on MatrixException catch (e2) { - if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.errorMessage; }); - return; - } catch (e2) { - if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.toString().split('\n').first; }); - return; - } - } else { - if (mounted) setState(() { _step = _DeleteStep.error; _error = e.errorMessage; }); - return; - } - } catch (e) { - if (mounted) setState(() { _step = _DeleteStep.error; _error = e.toString().split('\n').first; }); - return; - } - // Konto wurde gelöscht — ausloggen - unawaited(AuthLog.write('User-initiated logout (Konto gelöscht)')); - try { await client.logout(); } catch (_) {} - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - return Dialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 440, minWidth: 300), - child: Padding( - padding: const EdgeInsets.all(24), - child: switch (_step) { - _DeleteStep.confirm => _buildConfirm(pt), - _DeleteStep.auth => _buildAuth(pt), - _DeleteStep.deleting => const Center( - child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()), - ), - _DeleteStep.error => _buildError(pt), - }, - ), - ), - ); - } - - Widget _buildConfirm(PyramidTheme pt) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Icon(Icons.warning_rounded, size: 20, color: pt.danger), - const SizedBox(width: 8), - Text('Konto löschen', style: TextStyle(color: pt.danger, fontSize: 15, fontWeight: FontWeight.w700)), - const Spacer(), - IconButton( - icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), - onPressed: () => Navigator.of(context).pop(), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), - ), - ]), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: pt.danger.withAlpha(20), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: pt.danger.withAlpha(60)), - ), - child: Text( - 'Diese Aktion ist unwiderruflich. Dein Konto, alle Sitzungen und ' - 'deine Mitgliedschaft in Räumen werden dauerhaft gelöscht.', - style: TextStyle(color: pt.fg, fontSize: 13, height: 1.5), - ), - ), - const SizedBox(height: 16), - GestureDetector( - onTap: () => setState(() => _eraseData = !_eraseData), - child: Row(children: [ - SizedBox( - width: 18, height: 18, - child: Checkbox( - value: _eraseData, - onChanged: (v) => setState(() => _eraseData = v ?? true), - activeColor: pt.accent, - side: BorderSide(color: pt.border), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - const SizedBox(width: 8), - Expanded(child: Text('Nachrichten und Daten vom Server löschen', - style: TextStyle(color: pt.fg, fontSize: 13))), - ]), - ), - const SizedBox(height: 20), - Row(children: [ - Expanded(child: OutlinedButton( - style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => Navigator.of(context).pop(), - child: const Text('Abbrechen'), - )), - const SizedBox(width: 12), - Expanded(child: ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, - elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => setState(() => _step = _DeleteStep.auth), - child: const Text('Weiter'), - )), - ]), - ], - ); - - Widget _buildAuth(PyramidTheme pt) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Icon(Icons.lock_outline_rounded, size: 18, color: pt.danger), - const SizedBox(width: 8), - Text('Konto löschen bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), - ]), - const SizedBox(height: 16), - Text('Gib dein Passwort ein, um die Löschung zu bestätigen.', - style: TextStyle(color: pt.fgMuted, fontSize: 13)), - const SizedBox(height: 16), - TextField( - controller: _pwCtrl, - autofocus: true, - obscureText: !_pwVisible, - style: TextStyle(color: pt.fg, fontSize: 13), - decoration: InputDecoration( - labelText: 'Passwort', - labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), - filled: true, fillColor: pt.bg3, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.danger)), - suffixIcon: IconButton( - icon: Icon(_pwVisible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim), - onPressed: () => setState(() => _pwVisible = !_pwVisible), - ), - ), - cursorColor: pt.danger, - onSubmitted: (_) => _proceed(), - ), - const SizedBox(height: 20), - Row(children: [ - Expanded(child: OutlinedButton( - style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => setState(() => _step = _DeleteStep.confirm), - child: const Text('Zurück'), - )), - const SizedBox(width: 12), - Expanded(child: ElevatedButton( - style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, - elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: _proceed, - child: const Text('Konto endgültig löschen'), - )), - ]), - ], - ); - - Widget _buildError(PyramidTheme pt) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.error_outline_rounded, color: pt.danger, size: 48), - const SizedBox(height: 12), - Text(_error ?? 'Ein Fehler ist aufgetreten.', - textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)), - const SizedBox(height: 20), - SizedBox(width: double.infinity, child: OutlinedButton( - style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => Navigator.of(context).pop(), - child: const Text('Schließen'), - )), - ], - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// ABOUT -// ───────────────────────────────────────────────────────────────────────────── - -class _AboutSection extends ConsumerWidget { - final PyramidTheme pt; - const _AboutSection({required this.pt}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final clientAsync = ref.watch(matrixClientProvider); - final client = clientAsync.valueOrNull; - - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionTitle(title: 'Über Pyramid', subtitle: 'Version & Konto-Infos.', pt: pt), - _SettingsGroup(title: 'Version', pt: pt, children: [ - _SettingsRow( - title: 'Pyramid', - desc: '0.1.0 · Flutter · Matrix SDK', - pt: pt, - control: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: pt.accentSoft, - borderRadius: BorderRadius.circular(6)), - child: Text('Beta', - style: TextStyle( - color: pt.accent, fontSize: 10, fontWeight: FontWeight.w700)), - )), - _Divider(pt: pt), - _SettingsRow( - title: 'Matrix SDK', - desc: 'matrix-dart-sdk ^6.2.0', - pt: pt), - ]), - const SizedBox(height: 20), - _UpdatesSettingsGroup(pt: pt), - const SizedBox(height: 20), - _NotificationsSettingsGroup(pt: pt), - const SizedBox(height: 20), - if (client != null) - _SettingsGroup(title: 'Konto', pt: pt, children: [ - _SettingsRow( - title: 'Matrix-ID', - desc: client.userID ?? '', - pt: pt, - control: GestureDetector( - onTap: () => Clipboard.setData(ClipboardData(text: client.userID ?? '')), - child: Tooltip( - message: 'Kopieren', - child: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), - ), - ), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Homeserver', - desc: client.homeserver?.toString() ?? '', - pt: pt, - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Gerät', - desc: client.deviceID ?? '', - pt: pt, - control: GestureDetector( - onTap: () => - Clipboard.setData(ClipboardData(text: client.deviceID ?? '')), - child: Tooltip( - message: 'Kopieren', - child: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), - ), - ), - ), - ]), - const SizedBox(height: 20), - _SettingsGroup(title: 'Konto-Aktionen', pt: pt, children: [ - _SettingsRow( - title: 'Abmelden', - desc: 'Auf diesem Gerät ausloggen', - pt: pt, - destructive: true, - showArrow: true, - onTap: () => _confirmLogout(context, ref), - ), - ]), - ], - ), - ); - } - - Future _confirmLogout(BuildContext context, WidgetRef ref) async { - final client = await ref.read(matrixClientProvider.future); - final backupMissing = await isKeyBackupMissing(client); - if (!context.mounted) return; - final confirm = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), - side: BorderSide(color: pt.border)), - title: Text('Abmelden?', - style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), - content: Text( - backupMissing - ? 'Du wirst auf diesem Gerät abgemeldet. Es ist kein Schlüssel-Backup eingerichtet — ' - 'ältere verschlüsselte Nachrichten sind danach auf keinem Gerät mehr lesbar.' - : 'Du wirst auf diesem Gerät abgemeldet. Deine Nachrichten bleiben erhalten.', - style: TextStyle( - color: backupMissing ? pt.danger : pt.fgMuted, fontSize: 13, height: 1.4), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: pt.danger, - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Abmelden'), - ), - ], - ), - ); - if (confirm == true) { - unawaited(AuthLog.write('User-initiated logout (Settings)')); - await client.logout().catchError((_) {}); - } - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// NOTIFICATIONS SETTINGS GROUP -// ───────────────────────────────────────────────────────────────────────────── - -class _NotificationsSettingsGroup extends ConsumerStatefulWidget { - final PyramidTheme pt; - const _NotificationsSettingsGroup({required this.pt}); - @override - ConsumerState<_NotificationsSettingsGroup> createState() => - _NotificationsSettingsGroupState(); -} - -class _NotificationsSettingsGroupState - extends ConsumerState<_NotificationsSettingsGroup> { - bool _running = false; - String? _result; - bool _retryRunning = false; - String? _retryResult; - - Future _runDiag() async { - setState(() { _running = true; _result = null; }); - final client = await ref.read(matrixClientProvider.future); - final diag = await fcmDiagnostics(client); - if (mounted) setState(() { _running = false; _result = diag; }); - } - - Future _runRetry() async { - setState(() { _retryRunning = true; _retryResult = null; }); - final client = await ref.read(matrixClientProvider.future); - final result = await retryPusherRegistration(client); - if (mounted) setState(() { _retryRunning = false; _retryResult = result; }); - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - if (!Platform.isAndroid) return const SizedBox.shrink(); - return _SettingsGroup(title: 'Push-Benachrichtigungen', pt: pt, children: [ - _SettingsRow( - title: 'Akku-Optimierung deaktivieren', - desc: 'Samsung / andere OEMs unterdrücken sonst Benachrichtigungen bei geschlossener App.', - pt: pt, - showArrow: true, - onTap: openBatteryOptimizationSettings, - control: Icon(Icons.battery_saver_outlined, size: 16, color: pt.fgDim), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'FCM Diagnose', - desc: _result ?? 'Prüft ob FCM-Token & Pusher korrekt registriert sind.', - pt: pt, - onTap: _running ? null : _runDiag, - control: _running - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent), - ) - : Icon(Icons.bug_report_outlined, size: 16, color: pt.fgDim), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Pusher neu registrieren', - desc: _retryResult ?? 'Pusher beim Homeserver erneut anmelden.', - pt: pt, - onTap: _retryRunning ? null : _runRetry, - control: _retryRunning - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent), - ) - : Icon(Icons.sync_outlined, size: 16, color: pt.fgDim), - ), - ]); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// UPDATE SETTINGS GROUP -// ───────────────────────────────────────────────────────────────────────────── - -class _UpdatesSettingsGroup extends ConsumerStatefulWidget { - final PyramidTheme pt; - const _UpdatesSettingsGroup({required this.pt}); - - @override - ConsumerState<_UpdatesSettingsGroup> createState() => - _UpdatesSettingsGroupState(); -} - -class _UpdatesSettingsGroupState extends ConsumerState<_UpdatesSettingsGroup> { - bool _checking = false; - - Future _checkNow() async { - setState(() => _checking = true); - await resetUpdateCheckTimer(); - ref.invalidate(updateInfoProvider); - // Give the async provider a moment to resolve before resetting the spinner. - await Future.delayed(const Duration(seconds: 2)); - if (mounted) setState(() => _checking = false); - } - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - final updateAsync = ref.watch(updateInfoProvider); - - return _SettingsGroup(title: 'Updates', pt: pt, children: [ - updateAsync.when( - loading: () => _SettingsRow( - title: 'Nach Updates suchen…', - desc: 'Gitea Releases wird geprüft', - pt: pt, - control: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: pt.accent, - ), - ), - ), - error: (e, _) => _SettingsRow( - title: 'Update-Prüfung fehlgeschlagen', - desc: 'Keine Verbindung oder Gitea nicht erreichbar (nur im Heimnetz).', - pt: pt, - control: _checking - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, color: pt.accent), - ) - : GestureDetector( - onTap: _checkNow, - child: Tooltip( - message: 'Erneut prüfen', - child: Icon(Icons.refresh_rounded, - size: 16, color: pt.fgDim), - ), - ), - ), - data: (info) { - if (info == null) { - return _SettingsRow( - title: 'Pyramid ist aktuell', - desc: 'Du verwendest die neueste Version.', - pt: pt, - onTap: _checking ? null : _checkNow, - control: _checking - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, color: pt.accent), - ) - : Icon(Icons.refresh_rounded, size: 16, color: pt.fgDim), - ); - } - // New version available - return Column( - children: [ - _SettingsRow( - title: 'Update verfügbar: ${info.tagName}', - desc: info.releaseName, - pt: pt, - showArrow: true, - onTap: () async { - if (info.canDownload) { - await showUpdateDownloadDialog(context, info); - } else { - final uri = Uri.parse(info.htmlUrl); - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } - }, - control: Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: pt.accent, - borderRadius: BorderRadius.circular(6)), - child: Text( - 'Neu', - style: TextStyle( - color: pt.accentFg, - fontSize: 10, - fontWeight: FontWeight.w700), - ), - ), - ), - _Divider(pt: pt), - _SettingsRow( - title: 'Diese Version überspringen', - desc: '${info.tagName} nicht mehr anzeigen', - pt: pt, - onTap: () async { - await skipUpdateVersion(info.tagName); - ref.invalidate(updateInfoProvider); - }, - ), - ], - ); - }, - ), - ]); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Passphrase dialog (for key export/import) -// ───────────────────────────────────────────────────────────────────────────── - -class _PassphraseDialog extends StatefulWidget { - final PyramidTheme pt; - final bool forExport; - const _PassphraseDialog({required this.pt, required this.forExport}); - - @override - State<_PassphraseDialog> createState() => _PassphraseDialogState(); -} - -class _PassphraseDialogState extends State<_PassphraseDialog> { - final _ctrl1 = TextEditingController(); - final _ctrl2 = TextEditingController(); - bool _obscure = true; - String? _error; - - @override - void dispose() { - _ctrl1.dispose(); - _ctrl2.dispose(); - super.dispose(); - } - - void _submit() { - final pass = _ctrl1.text.trim(); - if (pass.isEmpty) { - setState(() => _error = 'Bitte ein Passwort eingeben.'); - return; - } - if (widget.forExport && pass != _ctrl2.text.trim()) { - setState(() => _error = 'Passwörter stimmen nicht überein.'); - return; - } - Navigator.pop(context, pass); - } - - InputDecoration _inputDeco(String label, PyramidTheme pt) => InputDecoration( - labelText: label, - labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), - errorText: _error, - filled: true, - fillColor: pt.bg3, - prefixIcon: Icon(Icons.lock_outline_rounded, color: pt.fgDim, size: 18), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), - isDense: true, - errorMaxLines: 2, - suffixIcon: IconButton( - icon: Icon( - _obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined, - size: 18, - color: pt.fgDim, - ), - onPressed: () => setState(() => _obscure = !_obscure), - ), - ); - - @override - Widget build(BuildContext context) { - final pt = widget.pt; - return AlertDialog( - backgroundColor: pt.bg2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(pt.rXl), - side: BorderSide(color: pt.border), - ), - title: Text( - widget.forExport ? 'Schlüssel exportieren' : 'Schlüssel importieren', - style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700), - ), - content: SizedBox( - width: 340, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.forExport - ? 'Wähle ein Passwort zum Verschlüsseln der Schlüsseldatei.' - : 'Gib das Passwort ein, mit dem die Datei verschlüsselt wurde.', - style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), - ), - const SizedBox(height: 16), - TextField( - controller: _ctrl1, - obscureText: _obscure, - autofocus: true, - style: TextStyle(color: pt.fg, fontSize: 13), - decoration: _inputDeco('Passwort', pt).copyWith( - // Only show error on confirm field if export, else here - errorText: widget.forExport ? null : _error, - ), - cursorColor: pt.accent, - onSubmitted: widget.forExport ? null : (_) => _submit(), - ), - if (widget.forExport) ...[ - const SizedBox(height: 12), - TextField( - controller: _ctrl2, - obscureText: _obscure, - style: TextStyle(color: pt.fg, fontSize: 13), - decoration: _inputDeco('Passwort bestätigen', pt), - cursorColor: pt.accent, - onSubmitted: (_) => _submit(), - ), - ], - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: pt.accent, - foregroundColor: pt.accentFg, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - onPressed: _submit, - child: Text(widget.forExport ? 'Exportieren' : 'Importieren'), - ), - ], - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Shared small widgets -// ───────────────────────────────────────────────────────────────────────────── - -class _StatusCard extends StatelessWidget { - final PyramidTheme pt; - final IconData icon; - final Color color; - final String text; - const _StatusCard( - {required this.pt, required this.icon, required this.color, required this.text}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: pt.bg2, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: pt.border), - ), - child: Row( - children: [ - Icon(icon, color: color, size: 18), - const SizedBox(width: 10), - Expanded(child: Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 13))), - ], - ), - ); - } -}