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:
Bernd Steckmeister
2026-07-03 10:56:48 +02:00
parent 6400fb639c
commit 5d772389d6
16 changed files with 5309 additions and 5285 deletions
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+265
View File
@@ -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);
},
),
],
);
},
),
]);
}
}
+364
View File
@@ -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),
),
]);
}
}
+162
View File
@@ -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),
),
),
],
),
),
],
),
);
}
}
+513
View File
@@ -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),
);
}
}
+468
View File
@@ -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!);
}
}
}
+639
View File
@@ -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,
),
],
),
);
}
}
+433
View File
@@ -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),
),
],
),
),
]);
}
}
File diff suppressed because it is too large Load Diff