refactor: Gott-Datei settings_modal.dart in 12 Dateien aufgeteilt
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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/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/PROGRESS.md
|
||||||
2026-07-03 10:29 [Edit] /home/steggi/pyramid/ROADMAP.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
|
||||||
|
|||||||
+76
@@ -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)
|
## 2026-07-03 – M2: Toten Code & Duplikate entfernt (Chat-Timeline)
|
||||||
|
|
||||||
**Erledigt:** Vorgriff auf den ROADMAP-Punkt „Toten Code & Duplikate entfernen"
|
**Erledigt:** Vorgriff auf den ROADMAP-Punkt „Toten Code & Duplikate entfernen"
|
||||||
|
|||||||
+6
-2
@@ -77,8 +77,12 @@ lassen, ohne dass andere Module angefasst werden müssen.
|
|||||||
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
- [ ] Einheitliches State-Management (Riverpod konsequent, keine Misch-Patterns)
|
||||||
- [ ] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
- [ ] Toten Code & Duplikate entfernen, Ordnerstruktur vereinheitlichen
|
||||||
(angefangen, PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt,
|
(angefangen, PROGRESS.md 2026-07-03: `message_bubble.dart` tot entfernt,
|
||||||
doppelte `_formatTime` in `message_group.dart` zusammengeführt – weitere
|
doppelte `_formatTime` in `message_group.dart` zusammengeführt;
|
||||||
Fundstellen aus der Ist-Analyse noch offen, Ordnerstruktur noch nicht angefasst)
|
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
|
- [ ] Nach JEDEM Schritt: App läuft, Kernflows getestet, Commit
|
||||||
|
|
||||||
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
## M3 – Calls: Logik, UI & Audio (Discord als Referenz)
|
||||||
|
|||||||
@@ -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<void> _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<bool>(
|
||||||
|
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<void> _checkNow() async {
|
||||||
|
setState(() => _checking = true);
|
||||||
|
await resetUpdateCheckTimer();
|
||||||
|
ref.invalidate(updateInfoProvider);
|
||||||
|
// Give the async provider a moment to resolve before resetting the spinner.
|
||||||
|
await Future<void>.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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> _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<void> _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'),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> _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<void> _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),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> _ignoredUsers = [];
|
||||||
|
final Set<String> _unblocking = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadIgnoredUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadIgnoredUsers() async {
|
||||||
|
final client = await ref.read(matrixClientProvider.future);
|
||||||
|
if (mounted) setState(() => _ignoredUsers = List.from(client.ignoredUsers));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> _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<String, dynamic>;
|
||||||
|
final url = data['io.pyramid.profile.banner_url'] as String?;
|
||||||
|
if (url != null) setState(() => _bannerMxcUri = url);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<Uint8List>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 3.0),
|
||||||
|
);
|
||||||
|
if (cropped == null || !mounted) return;
|
||||||
|
setState(() => _pendingBanner = cropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<Uint8List>(
|
||||||
|
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<void> _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<void> _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<void> _changePassword() async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => _ChangePasswordDialog(pt: widget.pt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteAccount() async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => _DeleteAccountDialog(pt: widget.pt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DeviceKeys> _devices = [];
|
||||||
|
bool _loading = true;
|
||||||
|
String? _currentDeviceId;
|
||||||
|
final Set<String> _revoking = {};
|
||||||
|
bool _logoutAllLoading = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<void> _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<bool>(
|
||||||
|
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<void> _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<bool>(
|
||||||
|
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<String?> _askPassword(BuildContext context, PyramidTheme pt) {
|
||||||
|
final ctrl = TextEditingController();
|
||||||
|
return showDialog<String>(
|
||||||
|
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<void> _verifyDevice(
|
||||||
|
BuildContext context, DeviceKeys device, String displayName) async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => _SasVerificationDialog(
|
||||||
|
pt: widget.pt,
|
||||||
|
deviceName: displayName,
|
||||||
|
device: device,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmRevoke(
|
||||||
|
BuildContext context, DeviceKeys device, String displayName) async {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> _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<bool>(
|
||||||
|
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<Widget> 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<bool> 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<String> options;
|
||||||
|
final int selected;
|
||||||
|
final PyramidTheme pt;
|
||||||
|
final ValueChanged<int> 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<String>? 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))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> _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<Widget> _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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MediaDeviceInfo> _audioInputs = [];
|
||||||
|
List<MediaDeviceInfo> _audioOutputs = [];
|
||||||
|
List<MediaDeviceInfo> _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<void> _init() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
List<MediaDeviceInfo> 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<void> _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<void> _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<MediaDeviceInfo> devices,
|
||||||
|
required String? selected,
|
||||||
|
required String placeholder,
|
||||||
|
required IconData icon,
|
||||||
|
required ValueChanged<String?> 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<String?>(
|
||||||
|
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<String?>(
|
||||||
|
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<String, dynamic>? _status;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<String, dynamic>);
|
||||||
|
}
|
||||||
|
} 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-5283
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user