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
+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