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:
@@ -0,0 +1,265 @@
|
||||
part of '../settings_modal.dart';
|
||||
|
||||
class _AboutSection extends ConsumerWidget {
|
||||
final PyramidTheme pt;
|
||||
const _AboutSection({required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final clientAsync = ref.watch(matrixClientProvider);
|
||||
final client = clientAsync.valueOrNull;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(title: 'Über Pyramid', subtitle: 'Version & Konto-Infos.', pt: pt),
|
||||
_SettingsGroup(title: 'Version', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Pyramid',
|
||||
desc: '0.1.0 · Flutter · Matrix SDK',
|
||||
pt: pt,
|
||||
control: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accentSoft,
|
||||
borderRadius: BorderRadius.circular(6)),
|
||||
child: Text('Beta',
|
||||
style: TextStyle(
|
||||
color: pt.accent, fontSize: 10, fontWeight: FontWeight.w700)),
|
||||
)),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Matrix SDK',
|
||||
desc: 'matrix-dart-sdk ^6.2.0',
|
||||
pt: pt),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_UpdatesSettingsGroup(pt: pt),
|
||||
const SizedBox(height: 20),
|
||||
_NotificationsSettingsGroup(pt: pt),
|
||||
const SizedBox(height: 20),
|
||||
if (client != null)
|
||||
_SettingsGroup(title: 'Konto', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Matrix-ID',
|
||||
desc: client.userID ?? '',
|
||||
pt: pt,
|
||||
control: GestureDetector(
|
||||
onTap: () => Clipboard.setData(ClipboardData(text: client.userID ?? '')),
|
||||
child: Tooltip(
|
||||
message: 'Kopieren',
|
||||
child: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim),
|
||||
),
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Homeserver',
|
||||
desc: client.homeserver?.toString() ?? '',
|
||||
pt: pt,
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Gerät',
|
||||
desc: client.deviceID ?? '',
|
||||
pt: pt,
|
||||
control: GestureDetector(
|
||||
onTap: () =>
|
||||
Clipboard.setData(ClipboardData(text: client.deviceID ?? '')),
|
||||
child: Tooltip(
|
||||
message: 'Kopieren',
|
||||
child: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_SettingsGroup(title: 'Konto-Aktionen', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Abmelden',
|
||||
desc: 'Auf diesem Gerät ausloggen',
|
||||
pt: pt,
|
||||
destructive: true,
|
||||
showArrow: true,
|
||||
onTap: () => _confirmLogout(context, ref),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final backupMissing = await isKeyBackupMissing(client);
|
||||
if (!context.mounted) return;
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rXl),
|
||||
side: BorderSide(color: pt.border)),
|
||||
title: Text('Abmelden?',
|
||||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
content: Text(
|
||||
backupMissing
|
||||
? 'Du wirst auf diesem Gerät abgemeldet. Es ist kein Schlüssel-Backup eingerichtet — '
|
||||
'ältere verschlüsselte Nachrichten sind danach auf keinem Gerät mehr lesbar.'
|
||||
: 'Du wirst auf diesem Gerät abgemeldet. Deine Nachrichten bleiben erhalten.',
|
||||
style: TextStyle(
|
||||
color: backupMissing ? pt.danger : pt.fgMuted, fontSize: 13, height: 1.4),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: pt.danger,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Abmelden'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true) {
|
||||
unawaited(AuthLog.write('User-initiated logout (Settings)'));
|
||||
await client.logout().catchError((_) {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _UpdatesSettingsGroup extends ConsumerStatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
const _UpdatesSettingsGroup({required this.pt});
|
||||
|
||||
@override
|
||||
ConsumerState<_UpdatesSettingsGroup> createState() =>
|
||||
_UpdatesSettingsGroupState();
|
||||
}
|
||||
|
||||
class _UpdatesSettingsGroupState extends ConsumerState<_UpdatesSettingsGroup> {
|
||||
bool _checking = false;
|
||||
|
||||
Future<void> _checkNow() async {
|
||||
setState(() => _checking = true);
|
||||
await resetUpdateCheckTimer();
|
||||
ref.invalidate(updateInfoProvider);
|
||||
// Give the async provider a moment to resolve before resetting the spinner.
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
if (mounted) setState(() => _checking = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final updateAsync = ref.watch(updateInfoProvider);
|
||||
|
||||
return _SettingsGroup(title: 'Updates', pt: pt, children: [
|
||||
updateAsync.when(
|
||||
loading: () => _SettingsRow(
|
||||
title: 'Nach Updates suchen…',
|
||||
desc: 'Gitea Releases wird geprüft',
|
||||
pt: pt,
|
||||
control: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: pt.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (e, _) => _SettingsRow(
|
||||
title: 'Update-Prüfung fehlgeschlagen',
|
||||
desc: 'Keine Verbindung oder Gitea nicht erreichbar (nur im Heimnetz).',
|
||||
pt: pt,
|
||||
control: _checking
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: pt.accent),
|
||||
)
|
||||
: GestureDetector(
|
||||
onTap: _checkNow,
|
||||
child: Tooltip(
|
||||
message: 'Erneut prüfen',
|
||||
child: Icon(Icons.refresh_rounded,
|
||||
size: 16, color: pt.fgDim),
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (info) {
|
||||
if (info == null) {
|
||||
return _SettingsRow(
|
||||
title: 'Pyramid ist aktuell',
|
||||
desc: 'Du verwendest die neueste Version.',
|
||||
pt: pt,
|
||||
onTap: _checking ? null : _checkNow,
|
||||
control: _checking
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: pt.accent),
|
||||
)
|
||||
: Icon(Icons.refresh_rounded, size: 16, color: pt.fgDim),
|
||||
);
|
||||
}
|
||||
// New version available
|
||||
return Column(
|
||||
children: [
|
||||
_SettingsRow(
|
||||
title: 'Update verfügbar: ${info.tagName}',
|
||||
desc: info.releaseName,
|
||||
pt: pt,
|
||||
showArrow: true,
|
||||
onTap: () async {
|
||||
if (info.canDownload) {
|
||||
await showUpdateDownloadDialog(context, info);
|
||||
} else {
|
||||
final uri = Uri.parse(info.htmlUrl);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
},
|
||||
control: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accent,
|
||||
borderRadius: BorderRadius.circular(6)),
|
||||
child: Text(
|
||||
'Neu',
|
||||
style: TextStyle(
|
||||
color: pt.accentFg,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Diese Version überspringen',
|
||||
desc: '${info.tagName} nicht mehr anzeigen',
|
||||
pt: pt,
|
||||
onTap: () async {
|
||||
await skipUpdateVersion(info.tagName);
|
||||
ref.invalidate(updateInfoProvider);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
part of '../settings_modal.dart';
|
||||
|
||||
class _ChangePasswordDialog extends ConsumerStatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
const _ChangePasswordDialog({required this.pt});
|
||||
|
||||
@override
|
||||
ConsumerState<_ChangePasswordDialog> createState() => _ChangePasswordDialogState();
|
||||
}
|
||||
|
||||
class _ChangePasswordDialogState extends ConsumerState<_ChangePasswordDialog> {
|
||||
final _oldCtrl = TextEditingController();
|
||||
final _newCtrl = TextEditingController();
|
||||
final _confirmCtrl = TextEditingController();
|
||||
bool _oldVisible = false;
|
||||
bool _newVisible = false;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
bool _done = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_oldCtrl.dispose();
|
||||
_newCtrl.dispose();
|
||||
_confirmCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final oldPw = _oldCtrl.text;
|
||||
final newPw = _newCtrl.text;
|
||||
final confirm = _confirmCtrl.text;
|
||||
if (oldPw.isEmpty || newPw.isEmpty) {
|
||||
setState(() => _error = 'Bitte alle Felder ausfüllen.');
|
||||
return;
|
||||
}
|
||||
if (newPw != confirm) {
|
||||
setState(() => _error = 'Die neuen Passwörter stimmen nicht überein.');
|
||||
return;
|
||||
}
|
||||
if (newPw.length < 8) {
|
||||
setState(() => _error = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.');
|
||||
return;
|
||||
}
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
await client.changePassword(newPw, oldPassword: oldPw);
|
||||
if (mounted) setState(() { _loading = false; _done = true; });
|
||||
} on MatrixException catch (e) {
|
||||
if (mounted) setState(() { _loading = false; _error = e.errorMessage; });
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; });
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return Dialog(
|
||||
backgroundColor: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420, minWidth: 300),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: _done ? _buildDone(pt) : _buildForm(pt),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDone(PyramidTheme pt) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52),
|
||||
const SizedBox(height: 12),
|
||||
Text('Passwort geändert', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 6),
|
||||
Text('Dein Passwort wurde erfolgreich aktualisiert.',
|
||||
textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||||
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Schließen'),
|
||||
)),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _buildForm(PyramidTheme pt) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.lock_outline_rounded, size: 18, color: pt.accent),
|
||||
const SizedBox(width: 8),
|
||||
Text('Passwort ändern', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_PwField(ctrl: _oldCtrl, label: 'Aktuelles Passwort', pt: pt,
|
||||
visible: _oldVisible, onToggle: () => setState(() => _oldVisible = !_oldVisible)),
|
||||
const SizedBox(height: 12),
|
||||
_PwField(ctrl: _newCtrl, label: 'Neues Passwort', pt: pt,
|
||||
visible: _newVisible, onToggle: () => setState(() => _newVisible = !_newVisible)),
|
||||
const SizedBox(height: 12),
|
||||
_PwField(ctrl: _confirmCtrl, label: 'Neues Passwort bestätigen', pt: pt,
|
||||
visible: _newVisible, onToggle: null,
|
||||
onSubmitted: (_) => _submit()),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_StatusCard(pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||||
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: _loading
|
||||
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
|
||||
: const Text('Passwort ändern'),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _DeleteAccountDialog extends ConsumerStatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
const _DeleteAccountDialog({required this.pt});
|
||||
|
||||
@override
|
||||
ConsumerState<_DeleteAccountDialog> createState() => _DeleteAccountDialogState();
|
||||
}
|
||||
|
||||
enum _DeleteStep { confirm, auth, deleting, error }
|
||||
|
||||
class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> {
|
||||
_DeleteStep _step = _DeleteStep.confirm;
|
||||
final _pwCtrl = TextEditingController();
|
||||
bool _pwVisible = false;
|
||||
bool _eraseData = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() { _pwCtrl.dispose(); super.dispose(); }
|
||||
|
||||
Future<void> _proceed() async {
|
||||
setState(() { _step = _DeleteStep.deleting; _error = null; });
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
try {
|
||||
await client.deactivateAccount(erase: _eraseData);
|
||||
} on MatrixException catch (e) {
|
||||
if (e.requireAdditionalAuthentication && mounted) {
|
||||
// UIA required — try with password
|
||||
try {
|
||||
await client.deactivateAccount(
|
||||
erase: _eraseData,
|
||||
auth: AuthenticationData(
|
||||
type: AuthenticationTypes.password,
|
||||
session: e.session,
|
||||
additionalFields: {
|
||||
'identifier': {'type': 'm.id.user', 'user': client.userID},
|
||||
'password': _pwCtrl.text,
|
||||
},
|
||||
),
|
||||
);
|
||||
} on MatrixException catch (e2) {
|
||||
if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.errorMessage; });
|
||||
return;
|
||||
} catch (e2) {
|
||||
if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.toString().split('\n').first; });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (mounted) setState(() { _step = _DeleteStep.error; _error = e.errorMessage; });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _step = _DeleteStep.error; _error = e.toString().split('\n').first; });
|
||||
return;
|
||||
}
|
||||
// Konto wurde gelöscht — ausloggen
|
||||
unawaited(AuthLog.write('User-initiated logout (Konto gelöscht)'));
|
||||
try { await client.logout(); } catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return Dialog(
|
||||
backgroundColor: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 440, minWidth: 300),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: switch (_step) {
|
||||
_DeleteStep.confirm => _buildConfirm(pt),
|
||||
_DeleteStep.auth => _buildAuth(pt),
|
||||
_DeleteStep.deleting => const Center(
|
||||
child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()),
|
||||
),
|
||||
_DeleteStep.error => _buildError(pt),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfirm(PyramidTheme pt) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.warning_rounded, size: 20, color: pt.danger),
|
||||
const SizedBox(width: 8),
|
||||
Text('Konto löschen', style: TextStyle(color: pt.danger, fontSize: 15, fontWeight: FontWeight.w700)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.danger.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: pt.danger.withAlpha(60)),
|
||||
),
|
||||
child: Text(
|
||||
'Diese Aktion ist unwiderruflich. Dein Konto, alle Sitzungen und '
|
||||
'deine Mitgliedschaft in Räumen werden dauerhaft gelöscht.',
|
||||
style: TextStyle(color: pt.fg, fontSize: 13, height: 1.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _eraseData = !_eraseData),
|
||||
child: Row(children: [
|
||||
SizedBox(
|
||||
width: 18, height: 18,
|
||||
child: Checkbox(
|
||||
value: _eraseData,
|
||||
onChanged: (v) => setState(() => _eraseData = v ?? true),
|
||||
activeColor: pt.accent,
|
||||
side: BorderSide(color: pt.border),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text('Nachrichten und Daten vom Server löschen',
|
||||
style: TextStyle(color: pt.fg, fontSize: 13))),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(children: [
|
||||
Expanded(child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Abbrechen'),
|
||||
)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white,
|
||||
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => setState(() => _step = _DeleteStep.auth),
|
||||
child: const Text('Weiter'),
|
||||
)),
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _buildAuth(PyramidTheme pt) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.lock_outline_rounded, size: 18, color: pt.danger),
|
||||
const SizedBox(width: 8),
|
||||
Text('Konto löschen bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
Text('Gib dein Passwort ein, um die Löschung zu bestätigen.',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _pwCtrl,
|
||||
autofocus: true,
|
||||
obscureText: !_pwVisible,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Passwort',
|
||||
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
filled: true, fillColor: pt.bg3,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.danger)),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_pwVisible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim),
|
||||
onPressed: () => setState(() => _pwVisible = !_pwVisible),
|
||||
),
|
||||
),
|
||||
cursorColor: pt.danger,
|
||||
onSubmitted: (_) => _proceed(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(children: [
|
||||
Expanded(child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => setState(() => _step = _DeleteStep.confirm),
|
||||
child: const Text('Zurück'),
|
||||
)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white,
|
||||
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: _proceed,
|
||||
child: const Text('Konto endgültig löschen'),
|
||||
)),
|
||||
]),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _buildError(PyramidTheme pt) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline_rounded, color: pt.danger, size: 48),
|
||||
const SizedBox(height: 12),
|
||||
Text(_error ?? 'Ein Fehler ist aufgetreten.',
|
||||
textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(width: double.infinity, child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Schließen'),
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
part of '../settings_modal.dart';
|
||||
|
||||
class _AppearanceSection extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final bool isDark;
|
||||
final int accentIdx;
|
||||
final double radius;
|
||||
final double density;
|
||||
final double motion;
|
||||
final WidgetRef ref;
|
||||
|
||||
const _AppearanceSection({
|
||||
required this.pt,
|
||||
required this.isDark,
|
||||
required this.accentIdx,
|
||||
required this.radius,
|
||||
required this.density,
|
||||
required this.motion,
|
||||
required this.ref,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(
|
||||
title: 'Erscheinungsbild',
|
||||
subtitle: 'Gestalte Pyramid nach deinem Geschmack.',
|
||||
pt: pt),
|
||||
_SettingsGroup(title: 'Theme', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Farbmodus',
|
||||
desc: 'Hell oder dunkel',
|
||||
pt: pt,
|
||||
control: _SegControl(
|
||||
options: const ['Dunkel', 'Hell'],
|
||||
selected: isDark ? 0 : 1,
|
||||
pt: pt,
|
||||
onSelect: (i) {
|
||||
final dark = i == 0;
|
||||
ref.read(themeModeProvider.notifier).state = dark;
|
||||
saveThemePrefs(isDark: dark);
|
||||
},
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Akzentfarbe',
|
||||
desc: 'Farbe für Hervorhebungen und Buttons',
|
||||
pt: pt,
|
||||
control: Row(
|
||||
children: accentPresets.asMap().entries.map((e) {
|
||||
final i = e.key;
|
||||
final preset = e.value;
|
||||
final isActive = accentIdx == i;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
ref.read(accentProvider.notifier).state = i;
|
||||
saveThemePrefs(accentIdx: i);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: 22,
|
||||
height: 22,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: preset.color,
|
||||
shape: BoxShape.circle,
|
||||
border: isActive ? Border.all(color: Colors.white, width: 2) : null,
|
||||
boxShadow: isActive
|
||||
? [BoxShadow(color: preset.color.withAlpha(100), blurRadius: 8)]
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Eckenrundung',
|
||||
desc: '${radius.round()} px',
|
||||
pt: pt,
|
||||
control: SizedBox(
|
||||
width: 150,
|
||||
child: SliderTheme(
|
||||
data: SliderThemeData(
|
||||
activeTrackColor: pt.accent,
|
||||
thumbColor: pt.accent,
|
||||
inactiveTrackColor: pt.border,
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
),
|
||||
child: Slider(
|
||||
value: radius,
|
||||
min: 4,
|
||||
max: 20,
|
||||
onChanged: (v) {
|
||||
ref.read(radiusProvider.notifier).state = v;
|
||||
saveThemePrefs(radius: v);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Animationen',
|
||||
desc: '${(motion * 100).round()}%',
|
||||
pt: pt,
|
||||
control: SizedBox(
|
||||
width: 150,
|
||||
child: SliderTheme(
|
||||
data: SliderThemeData(
|
||||
activeTrackColor: pt.accent,
|
||||
thumbColor: pt.accent,
|
||||
inactiveTrackColor: pt.border,
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
),
|
||||
child: Slider(
|
||||
value: motion,
|
||||
min: 0,
|
||||
max: 1,
|
||||
onChanged: (v) {
|
||||
ref.read(motionProvider.notifier).state = v;
|
||||
saveThemePrefs(motion: v);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_SettingsGroup(title: 'Vorschau', pt: pt, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(pt.rBase),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: const Center(child: PyramidLoader(size: 70, useLottie: true)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text('Lottie', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(pt.rBase),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: const Center(child: PyramidLoader(size: 70, useLottie: false)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text('SVG', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,67 @@
|
||||
part of '../settings_modal.dart';
|
||||
|
||||
class _KeysSection extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _KeysSection({required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keys = [
|
||||
('Schnellsuche', 'Strg+K'),
|
||||
('Raum als gelesen markieren', 'Shift+Esc'),
|
||||
('Zu ungelesen springen', 'Strg+J'),
|
||||
('Linke Leiste ein-/ausblenden', 'Strg+B'),
|
||||
('Mitglieder ein-/ausblenden', 'Strg+Shift+U'),
|
||||
('Nachrichten durchsuchen', 'Strg+F'),
|
||||
('Antworten', 'R'),
|
||||
('Auswahl aufheben', 'Esc'),
|
||||
];
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(
|
||||
title: 'Tastaturkürzel',
|
||||
subtitle: 'Schnelle Aktionen per Tastatur.',
|
||||
pt: pt),
|
||||
_SettingsGroup(
|
||||
title: 'Navigieren',
|
||||
pt: pt,
|
||||
children: keys.asMap().entries.map((entry) {
|
||||
final (action, binding) = entry.value;
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(action,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13))),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Text(binding,
|
||||
style: TextStyle(
|
||||
color: pt.fgMuted,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (entry.key < keys.length - 1) _Divider(pt: pt),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
part of '../settings_modal.dart';
|
||||
|
||||
class _NotificationsSection extends ConsumerWidget {
|
||||
final PyramidTheme pt;
|
||||
const _NotificationsSection({required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final desktop = ref.watch(notifDesktopProvider);
|
||||
final sound = ref.watch(notifSoundProvider);
|
||||
final preview = ref.watch(notifPreviewProvider);
|
||||
final dmMentionOnly = ref.watch(notifMentionOnlyDmProvider);
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(
|
||||
title: 'Benachrichtigungen',
|
||||
subtitle: 'Bestimme, wann und wie du benachrichtigt wirst.',
|
||||
pt: pt),
|
||||
_SettingsGroup(title: 'Desktop', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Desktop-Benachrichtigungen',
|
||||
desc: 'Zeige Systembenachrichtigungen bei neuen Nachrichten',
|
||||
pt: pt,
|
||||
control: _Toggle(
|
||||
value: desktop,
|
||||
pt: pt,
|
||||
onChanged: (v) => ref.read(notifDesktopProvider.notifier).set(v),
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Benachrichtigungston',
|
||||
desc: 'Sound abspielen wenn eine Nachricht eingeht',
|
||||
pt: pt,
|
||||
control: _Toggle(
|
||||
value: sound,
|
||||
pt: pt,
|
||||
onChanged: (v) => ref.read(notifSoundProvider.notifier).set(v),
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Nachrichtenvorschau',
|
||||
desc: 'Nachrichteninhalt in Benachrichtigung anzeigen',
|
||||
pt: pt,
|
||||
control: _Toggle(
|
||||
value: preview,
|
||||
pt: pt,
|
||||
onChanged: (v) => ref.read(notifPreviewProvider.notifier).set(v),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
_SettingsGroup(title: 'Filter', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Nur Erwähnungen (DMs)',
|
||||
desc: 'Benachrichtigung nur bei direkten @Erwähnungen',
|
||||
pt: pt,
|
||||
control: _Toggle(
|
||||
value: dmMentionOnly,
|
||||
pt: pt,
|
||||
onChanged: (v) => ref.read(notifMentionOnlyDmProvider.notifier).set(v),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, size: 16, color: pt.fgDim),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Push-Benachrichtigungen für Android/iOS sind in Vorbereitung.',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationsSettingsGroup extends ConsumerStatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
const _NotificationsSettingsGroup({required this.pt});
|
||||
@override
|
||||
ConsumerState<_NotificationsSettingsGroup> createState() =>
|
||||
_NotificationsSettingsGroupState();
|
||||
}
|
||||
|
||||
class _NotificationsSettingsGroupState
|
||||
extends ConsumerState<_NotificationsSettingsGroup> {
|
||||
bool _running = false;
|
||||
String? _result;
|
||||
bool _retryRunning = false;
|
||||
String? _retryResult;
|
||||
|
||||
Future<void> _runDiag() async {
|
||||
setState(() { _running = true; _result = null; });
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final diag = await fcmDiagnostics(client);
|
||||
if (mounted) setState(() { _running = false; _result = diag; });
|
||||
}
|
||||
|
||||
Future<void> _runRetry() async {
|
||||
setState(() { _retryRunning = true; _retryResult = null; });
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final result = await retryPusherRegistration(client);
|
||||
if (mounted) setState(() { _retryRunning = false; _retryResult = result; });
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
if (!Platform.isAndroid) return const SizedBox.shrink();
|
||||
return _SettingsGroup(title: 'Push-Benachrichtigungen', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Akku-Optimierung deaktivieren',
|
||||
desc: 'Samsung / andere OEMs unterdrücken sonst Benachrichtigungen bei geschlossener App.',
|
||||
pt: pt,
|
||||
showArrow: true,
|
||||
onTap: openBatteryOptimizationSettings,
|
||||
control: Icon(Icons.battery_saver_outlined, size: 16, color: pt.fgDim),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'FCM Diagnose',
|
||||
desc: _result ?? 'Prüft ob FCM-Token & Pusher korrekt registriert sind.',
|
||||
pt: pt,
|
||||
onTap: _running ? null : _runDiag,
|
||||
control: _running
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent),
|
||||
)
|
||||
: Icon(Icons.bug_report_outlined, size: 16, color: pt.fgDim),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Pusher neu registrieren',
|
||||
desc: _retryResult ?? 'Pusher beim Homeserver erneut anmelden.',
|
||||
pt: pt,
|
||||
onTap: _retryRunning ? null : _runRetry,
|
||||
control: _retryRunning
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent),
|
||||
)
|
||||
: Icon(Icons.sync_outlined, size: 16, color: pt.fgDim),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
part of '../settings_modal.dart';
|
||||
|
||||
class _PrivacySection extends ConsumerStatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
const _PrivacySection({required this.pt});
|
||||
|
||||
@override
|
||||
ConsumerState<_PrivacySection> createState() => _PrivacySectionState();
|
||||
}
|
||||
|
||||
class _PrivacySectionState extends ConsumerState<_PrivacySection> {
|
||||
List<String> _ignoredUsers = [];
|
||||
final Set<String> _unblocking = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadIgnoredUsers();
|
||||
}
|
||||
|
||||
Future<void> _loadIgnoredUsers() async {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
if (mounted) setState(() => _ignoredUsers = List.from(client.ignoredUsers));
|
||||
}
|
||||
|
||||
Future<void> _unblockUser(String userId) async {
|
||||
setState(() => _unblocking.add(userId));
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
await client.unignoreUser(userId);
|
||||
await _loadIgnoredUsers();
|
||||
} catch (e) {
|
||||
// ignore — list will be stale but not crash
|
||||
} finally {
|
||||
if (mounted) setState(() => _unblocking.remove(userId));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final presence = ref.watch(privacyPresenceProvider);
|
||||
final readReceipts = ref.watch(privacyReadReceiptsProvider);
|
||||
final typing = ref.watch(privacyTypingProvider);
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(
|
||||
title: 'Datenschutz',
|
||||
subtitle: 'Steuere, was andere über dich erfahren.',
|
||||
pt: pt),
|
||||
_SettingsGroup(title: 'Sichtbarkeit', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Online-Status anzeigen',
|
||||
desc: 'Anderen anzeigen, wenn du aktiv bist',
|
||||
pt: pt,
|
||||
control: _Toggle(
|
||||
value: presence,
|
||||
pt: pt,
|
||||
onChanged: (v) async {
|
||||
ref.read(privacyPresenceProvider.notifier).set(v);
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
await client.setPresence(
|
||||
client.userID!,
|
||||
v ? PresenceType.online : PresenceType.offline,
|
||||
).catchError((_) {});
|
||||
},
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Lesebestätigungen senden',
|
||||
desc: 'Anderen zeigen, dass du ihre Nachrichten gelesen hast',
|
||||
pt: pt,
|
||||
control: _Toggle(
|
||||
value: readReceipts,
|
||||
pt: pt,
|
||||
onChanged: (v) => ref.read(privacyReadReceiptsProvider.notifier).set(v),
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Tipp-Indikator senden',
|
||||
desc: '„schreibt…" Anzeige aktivieren',
|
||||
pt: pt,
|
||||
control: _Toggle(
|
||||
value: typing,
|
||||
pt: pt,
|
||||
onChanged: (v) => ref.read(privacyTypingProvider.notifier).set(v),
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
// ── Blocked users ──────────────────────────────────────────────────
|
||||
_SettingsGroup(title: 'Blockierte Nutzer (${_ignoredUsers.length})', pt: pt, children: [
|
||||
if (_ignoredUsers.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Text('Keine blockierten Nutzer.',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||
)
|
||||
else
|
||||
..._ignoredUsers.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final userId = entry.value;
|
||||
final isUnblocking = _unblocking.contains(userId);
|
||||
return Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(userId,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
if (isUnblocking)
|
||||
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
|
||||
else
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: pt.accent,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
onPressed: () => _unblockUser(userId),
|
||||
child: const Text('Entblocken', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
if (i < _ignoredUsers.length - 1) _Divider(pt: pt),
|
||||
]);
|
||||
}),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accentSoft,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: pt.accent.withAlpha(60)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.shield_outlined, size: 16, color: pt.accent),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Alle Nachrichten sind Ende-zu-Ende-verschlüsselt.',
|
||||
style: TextStyle(color: pt.fg, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
part of '../settings_modal.dart';
|
||||
|
||||
class _ProfileSection extends ConsumerStatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
const _ProfileSection({required this.pt});
|
||||
|
||||
@override
|
||||
ConsumerState<_ProfileSection> createState() => _ProfileSectionState();
|
||||
}
|
||||
|
||||
class _ProfileSectionState extends ConsumerState<_ProfileSection> {
|
||||
late TextEditingController _nameCtrl;
|
||||
late TextEditingController _statusCtrl;
|
||||
bool _saving = false;
|
||||
bool _saved = false;
|
||||
String? _error;
|
||||
Uint8List? _pendingAvatar;
|
||||
Uri? _avatarUri;
|
||||
Uint8List? _pendingBanner;
|
||||
String? _bannerMxcUri;
|
||||
Uint8List? _pendingServerBanner;
|
||||
bool _serverBannerSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nameCtrl = TextEditingController();
|
||||
_statusCtrl = TextEditingController();
|
||||
_loadProfile();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_statusCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
if (!mounted) return;
|
||||
|
||||
// 1) SOFORT aus dem lokalen Cache füllen — kein Warten auf den Server.
|
||||
_nameCtrl.text = client.userID ?? '';
|
||||
for (final room in client.rooms) {
|
||||
final u = room.unsafeGetUserFromMemoryOrFallback(client.userID!);
|
||||
if (u.displayName != null || u.avatarUrl != null) {
|
||||
if (u.displayName != null) _nameCtrl.text = u.displayName!;
|
||||
_avatarUri = u.avatarUrl;
|
||||
break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
final bannerData = client.accountData['com.pyramid.profile.banner'];
|
||||
_bannerMxcUri = bannerData?.content['url'] as String?;
|
||||
} catch (_) {}
|
||||
setState(() {});
|
||||
|
||||
// 2) Server-Daten PARALLEL nachladen, UI aktualisiert sich pro Antwort.
|
||||
client
|
||||
.getProfileFromUserId(client.userID!)
|
||||
.then((profile) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (profile.displayName != null) _nameCtrl.text = profile.displayName!;
|
||||
_avatarUri = profile.avatarUrl ?? _avatarUri;
|
||||
});
|
||||
}).catchError((_) {});
|
||||
|
||||
() async {
|
||||
try {
|
||||
final apiUri = client.homeserver!.replace(
|
||||
path:
|
||||
'/_matrix/client/v3/profile/${Uri.encodeComponent(client.userID!)}',
|
||||
);
|
||||
final resp = await client.httpClient.get(
|
||||
apiUri,
|
||||
headers: {'Authorization': 'Bearer ${client.accessToken}'},
|
||||
);
|
||||
if (resp.statusCode == 200 && mounted) {
|
||||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||||
final url = data['io.pyramid.profile.banner_url'] as String?;
|
||||
if (url != null) setState(() => _bannerMxcUri = url);
|
||||
}
|
||||
} catch (_) {}
|
||||
}();
|
||||
}
|
||||
|
||||
Future<void> _pickAvatar() async {
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true);
|
||||
final file = result?.files.first;
|
||||
if (file == null) return;
|
||||
final bytes = file.bytes ?? await file.xFile.readAsBytes();
|
||||
setState(() => _pendingAvatar = bytes);
|
||||
}
|
||||
|
||||
Future<void> _pickBanner() async {
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true);
|
||||
final file = result?.files.first;
|
||||
if (file == null) return;
|
||||
final raw = file.bytes ?? await file.xFile.readAsBytes();
|
||||
if (!mounted) return;
|
||||
final cropped = await showDialog<Uint8List>(
|
||||
context: context,
|
||||
builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 3.0),
|
||||
);
|
||||
if (cropped == null || !mounted) return;
|
||||
setState(() => _pendingBanner = cropped);
|
||||
}
|
||||
|
||||
Future<void> _pickServerBanner() async {
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true);
|
||||
final file = result?.files.first;
|
||||
if (file == null) return;
|
||||
final raw = file.bytes ?? await file.xFile.readAsBytes();
|
||||
if (!mounted) return;
|
||||
final cropped = await showDialog<Uint8List>(
|
||||
context: context,
|
||||
builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4),
|
||||
);
|
||||
if (cropped == null || !mounted) return;
|
||||
setState(() => _serverBannerSaving = true);
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final uri = await client.uploadContent(cropped, filename: 'server_banner.png', contentType: 'image/png');
|
||||
await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), uri.toString());
|
||||
if (mounted) setState(() { _pendingServerBanner = cropped; _serverBannerSaving = false; });
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _serverBannerSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeServerBanner() async {
|
||||
final client = ref.read(matrixClientProvider).valueOrNull;
|
||||
if (client == null) return;
|
||||
await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), null);
|
||||
if (mounted) setState(() => _pendingServerBanner = null);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() { _saving = true; _error = null; _saved = false; });
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final name = _nameCtrl.text.trim();
|
||||
if (name.isNotEmpty) {
|
||||
await client.setProfileField(
|
||||
client.userID!,
|
||||
'displayname',
|
||||
{'displayname': name},
|
||||
);
|
||||
}
|
||||
if (_pendingAvatar != null) {
|
||||
await client.setAvatar(MatrixFile(
|
||||
bytes: _pendingAvatar!,
|
||||
name: 'avatar.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
));
|
||||
setState(() => _pendingAvatar = null);
|
||||
}
|
||||
if (_pendingBanner != null) {
|
||||
final uri = await client.uploadContent(
|
||||
_pendingBanner!, filename: 'banner.png', contentType: 'image/png');
|
||||
final uriStr = uri.toString();
|
||||
// Store as a profile custom field so other users can see it
|
||||
await client.setProfileField(
|
||||
client.userID!,
|
||||
'io.pyramid.profile.banner_url',
|
||||
{'io.pyramid.profile.banner_url': uriStr},
|
||||
);
|
||||
// Also keep in account data as legacy fallback
|
||||
await client.setAccountData(client.userID!, 'com.pyramid.profile.banner',
|
||||
{'url': uriStr});
|
||||
setState(() { _bannerMxcUri = uriStr; _pendingBanner = null; });
|
||||
}
|
||||
final status = _statusCtrl.text.trim();
|
||||
await client.setPresence(
|
||||
client.userID!,
|
||||
PresenceType.online,
|
||||
statusMsg: status.isEmpty ? null : status,
|
||||
);
|
||||
if (mounted) setState(() { _saving = false; _saved = true; });
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (mounted) setState(() => _saved = false);
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; });
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
final clientAsync = ref.watch(matrixClientProvider);
|
||||
final client = clientAsync.valueOrNull;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(
|
||||
title: 'Mein Profil',
|
||||
subtitle: 'Wie du in Pyramid erscheinst.',
|
||||
pt: pt),
|
||||
// Banner
|
||||
GestureDetector(
|
||||
onTap: _pickBanner,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(pt.rBase),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: _pendingBanner != null
|
||||
? Image.memory(_pendingBanner!, fit: BoxFit.cover, width: double.infinity)
|
||||
: (client != null && _bannerMxcUri != null
|
||||
? MxcImage(
|
||||
mxcUri: _bannerMxcUri!,
|
||||
client: client,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
)
|
||||
: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate_outlined, size: 28, color: pt.fgDim),
|
||||
const SizedBox(height: 4),
|
||||
Text('Profilbanner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg0.withAlpha(200),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Avatar row
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _pickAvatar,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: pt.border, width: 2),
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _pendingAvatar != null
|
||||
? Image.memory(_pendingAvatar!, fit: BoxFit.cover)
|
||||
: (client != null && _avatarUri != null
|
||||
? MxcAvatar(
|
||||
mxcUri: _avatarUri,
|
||||
client: client,
|
||||
size: 72,
|
||||
placeholder: (_) => _AvatarFallback(
|
||||
name: _nameCtrl.text, size: 72, pt: pt),
|
||||
)
|
||||
: _AvatarFallback(
|
||||
name: _nameCtrl.text, size: 72, pt: pt)),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: pt.bg1, width: 2),
|
||||
),
|
||||
child: Icon(Icons.edit_rounded, size: 12, color: pt.accentFg),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_nameCtrl.text.isNotEmpty ? _nameCtrl.text : (client?.userID ?? ''),
|
||||
style: TextStyle(
|
||||
color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SelectableText(
|
||||
client?.userID ?? '',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
if (_pendingAvatar != null)
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accent.withAlpha(40),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text('Neues Bild ausgewählt',
|
||||
style: TextStyle(color: pt.accent, fontSize: 11)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SettingsGroup(title: 'Name & Status', pt: pt, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 4),
|
||||
child: _PrefTextField(
|
||||
controller: _nameCtrl,
|
||||
label: 'Anzeigename',
|
||||
pt: pt,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 4, 14, 14),
|
||||
child: _PrefTextField(
|
||||
controller: _statusCtrl,
|
||||
label: 'Statusnachricht (optional)',
|
||||
pt: pt,
|
||||
maxLength: 120,
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _StatusCard(
|
||||
pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!),
|
||||
),
|
||||
if (_saved)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _StatusCard(
|
||||
pt: pt,
|
||||
color: PyramidColors.success,
|
||||
icon: Icons.check_circle_outline_rounded,
|
||||
text: 'Profil gespeichert.'),
|
||||
),
|
||||
_PrimaryBtn(
|
||||
label: _saving ? 'Speichern…' : 'Speichern',
|
||||
icon: Icons.save_outlined,
|
||||
pt: pt,
|
||||
loading: _saving,
|
||||
onPressed: _save,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Server-Banner ─────────────────────────────────────────────────
|
||||
_SectionTitle(
|
||||
title: 'Server-Darstellung',
|
||||
subtitle: 'Banner für ${client?.homeserver?.host ?? 'diesen Server'}',
|
||||
pt: pt,
|
||||
),
|
||||
Consumer(builder: (context, ref, _) {
|
||||
final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {};
|
||||
final homeserver = client?.homeserver.toString();
|
||||
final existingMxc = homeserver != null ? serverBanners[homeserver] : null;
|
||||
final hasBanner = _pendingServerBanner != null || existingMxc != null;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _serverBannerSaving ? null : _pickServerBanner,
|
||||
child: Container(
|
||||
height: 90,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(pt.rBase),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_pendingServerBanner != null)
|
||||
Image.memory(_pendingServerBanner!, fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink())
|
||||
else if (existingMxc != null && client != null)
|
||||
MxcImage(mxcUri: existingMxc, client: client, fit: BoxFit.cover)
|
||||
else
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate_outlined, size: 24, color: pt.fgDim),
|
||||
const SizedBox(height: 4),
|
||||
Text('Server-Banner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_serverBannerSaving)
|
||||
Container(
|
||||
color: Colors.black38,
|
||||
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
),
|
||||
if (hasBanner)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg0.withAlpha(200),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasBanner) ...[
|
||||
const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: _removeServerBanner,
|
||||
child: Text(
|
||||
'Banner entfernen',
|
||||
style: TextStyle(color: pt.danger, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
_SettingsGroup(title: 'Konto', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Passwort ändern',
|
||||
desc: 'Aktuelles Passwort zum Ändern erforderlich',
|
||||
pt: pt,
|
||||
showArrow: true,
|
||||
onTap: _changePassword,
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
_SettingsGroup(title: 'Gefahrenbereich', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Konto löschen',
|
||||
desc: 'Konto dauerhaft deaktivieren – nicht rückgängig zu machen',
|
||||
pt: pt,
|
||||
showArrow: true,
|
||||
destructive: true,
|
||||
onTap: _deleteAccount,
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _changePassword() async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _ChangePasswordDialog(pt: widget.pt),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteAccount() async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _DeleteAccountDialog(pt: widget.pt),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
part of '../settings_modal.dart';
|
||||
|
||||
class _SessionsSection extends ConsumerStatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
const _SessionsSection({required this.pt});
|
||||
|
||||
@override
|
||||
ConsumerState<_SessionsSection> createState() => _SessionsSectionState();
|
||||
}
|
||||
|
||||
class _SessionsSectionState extends ConsumerState<_SessionsSection> {
|
||||
List<DeviceKeys> _devices = [];
|
||||
bool _loading = true;
|
||||
String? _currentDeviceId;
|
||||
final Set<String> _revoking = {};
|
||||
bool _logoutAllLoading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() { _loading = true; _error = null; });
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
_currentDeviceId = client.deviceID;
|
||||
await client.updateUserDeviceKeys(additionalUsers: {client.userID!});
|
||||
final keys = client.userDeviceKeys[client.userID];
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_devices = keys?.deviceKeys.values.toList() ?? [];
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e.toString().split('\n').first;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _revokeDevice(String deviceId) async {
|
||||
setState(() => _revoking.add(deviceId));
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
await client.deleteDevice(deviceId);
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_revoking.remove(deviceId);
|
||||
_error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _renameCurrentDevice(BuildContext context) async {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
if (!context.mounted) return;
|
||||
final currentName = _devices
|
||||
.where((d) => d.deviceId == _currentDeviceId)
|
||||
.map((d) => d.unsigned?['device_display_name'] as String? ?? d.deviceId ?? '')
|
||||
.firstOrNull ?? '';
|
||||
|
||||
final ctrl = TextEditingController(text: currentName);
|
||||
final pt = widget.pt;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rXl),
|
||||
side: BorderSide(color: pt.border)),
|
||||
title: Text('Gerät umbenennen',
|
||||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
autofocus: true,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Gerätename',
|
||||
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
filled: true, fillColor: pt.bg3,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
|
||||
),
|
||||
cursorColor: pt.accent,
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Umbenennen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
ctrl.dispose();
|
||||
if (confirmed != true || _currentDeviceId == null) return;
|
||||
try {
|
||||
await client.updateDevice(_currentDeviceId!, displayName: ctrl.text.trim());
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = 'Umbenennen fehlgeschlagen: ${e.toString().split('\n').first}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _logoutAllOtherDevices(BuildContext context) async {
|
||||
final pt = widget.pt;
|
||||
final otherIds = _devices
|
||||
.where((d) => d.deviceId != _currentDeviceId && d.deviceId != null)
|
||||
.map((d) => d.deviceId!)
|
||||
.toList();
|
||||
if (otherIds.isEmpty) return;
|
||||
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
|
||||
title: Text('Alle anderen Sitzungen abmelden?',
|
||||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
content: Text(
|
||||
'${otherIds.length} Gerät${otherIds.length == 1 ? '' : 'e'} werden abgemeldet. Nicht gespeicherte Nachrichten auf diesen Geräten können verloren gehen.',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Alle abmelden'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm != true || !mounted) return;
|
||||
setState(() { _logoutAllLoading = true; _error = null; });
|
||||
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
try {
|
||||
await client.deleteDevices(otherIds);
|
||||
} on MatrixException catch (e) {
|
||||
if (e.requireAdditionalAuthentication && context.mounted) {
|
||||
final password = await _askPassword(context, pt);
|
||||
if (password == null || !mounted) { setState(() => _logoutAllLoading = false); return; }
|
||||
try {
|
||||
await client.deleteDevices(otherIds, auth: AuthenticationData(
|
||||
type: AuthenticationTypes.password,
|
||||
session: e.session,
|
||||
additionalFields: {
|
||||
'identifier': {'type': 'm.id.user', 'user': client.userID},
|
||||
'password': password,
|
||||
},
|
||||
));
|
||||
} catch (e2) {
|
||||
if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e2.toString().split('\n').first}'; });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; });
|
||||
return;
|
||||
}
|
||||
await _load();
|
||||
if (mounted) setState(() => _logoutAllLoading = false);
|
||||
}
|
||||
|
||||
Future<String?> _askPassword(BuildContext context, PyramidTheme pt) {
|
||||
final ctrl = TextEditingController();
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
|
||||
title: Text('Passwort bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w700)),
|
||||
content: TextField(
|
||||
controller: ctrl,
|
||||
autofocus: true,
|
||||
obscureText: true,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Passwort',
|
||||
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
filled: true, fillColor: pt.bg3,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
|
||||
),
|
||||
cursorColor: pt.accent,
|
||||
onSubmitted: (_) => Navigator.pop(ctx, ctrl.text),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, null), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => Navigator.pop(ctx, ctrl.text),
|
||||
child: const Text('Bestätigen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
).then((v) { ctrl.dispose(); return v; });
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(
|
||||
title: 'Sitzungen',
|
||||
subtitle: 'Geräte, auf denen du angemeldet bist.',
|
||||
pt: pt),
|
||||
if (_loading)
|
||||
const Center(
|
||||
child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()))
|
||||
else if (_error != null)
|
||||
_StatusCard(
|
||||
pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!)
|
||||
else ...[
|
||||
_SettingsGroup(
|
||||
title: 'Aktive Sitzungen (${_devices.length})',
|
||||
pt: pt,
|
||||
children: _devices.isEmpty
|
||||
? [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('Keine Sitzungen gefunden.',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||
)
|
||||
]
|
||||
: _devices.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final device = entry.value;
|
||||
final isCurrent = device.deviceId == _currentDeviceId;
|
||||
final isRevoking = _revoking.contains(device.deviceId);
|
||||
final isVerified = device.verified;
|
||||
final displayName = device.unsigned?['device_display_name'] as String? ??
|
||||
device.deviceId ??
|
||||
'Unbekanntes Gerät';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrent
|
||||
? pt.accentSoft
|
||||
: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.devices_rounded,
|
||||
size: 18,
|
||||
color: isCurrent ? pt.accent : pt.fgMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
displayName,
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isCurrent) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.accentSoft,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text('Dieses Gerät',
|
||||
style: TextStyle(
|
||||
color: pt.accent,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: () => _renameCurrentDevice(context),
|
||||
child: Tooltip(
|
||||
message: 'Gerät umbenennen',
|
||||
child: Icon(Icons.edit_rounded, size: 13, color: pt.fgDim),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isVerified == true && !isCurrent)
|
||||
Icon(Icons.verified_rounded,
|
||||
size: 14, color: PyramidColors.success),
|
||||
if (isVerified == false && !isCurrent)
|
||||
Icon(Icons.warning_amber_rounded,
|
||||
size: 14, color: pt.away),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
SelectableText(
|
||||
device.deviceId ?? '',
|
||||
style: TextStyle(
|
||||
color: pt.fgDim,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isCurrent) ...[
|
||||
const SizedBox(width: 8),
|
||||
if (isVerified == false)
|
||||
GestureDetector(
|
||||
onTap: () => _verifyDevice(context, device, displayName),
|
||||
child: Tooltip(
|
||||
message: 'Verifizieren',
|
||||
child: Icon(Icons.verified_user_outlined,
|
||||
size: 16, color: pt.accent),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
if (isRevoking)
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: pt.danger),
|
||||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () => _confirmRevoke(context, device, displayName),
|
||||
child: Tooltip(
|
||||
message: 'Abmelden',
|
||||
child: Icon(Icons.logout_rounded,
|
||||
size: 16, color: pt.fgDim),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (i < _devices.length - 1) _Divider(pt: pt),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: pt.fgMuted,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh_rounded, size: 14),
|
||||
label: const Text('Aktualisieren', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
const Spacer(),
|
||||
if (_logoutAllLoading)
|
||||
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
|
||||
else if (_devices.where((d) => d.deviceId != _currentDeviceId).isNotEmpty)
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: pt.danger,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
onPressed: () => _logoutAllOtherDevices(context),
|
||||
icon: const Icon(Icons.logout_rounded, size: 14),
|
||||
label: const Text('Alle anderen abmelden', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _verifyDevice(
|
||||
BuildContext context, DeviceKeys device, String displayName) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _SasVerificationDialog(
|
||||
pt: widget.pt,
|
||||
deviceName: displayName,
|
||||
device: device,
|
||||
),
|
||||
);
|
||||
await _load();
|
||||
}
|
||||
|
||||
Future<void> _confirmRevoke(
|
||||
BuildContext context, DeviceKeys device, String displayName) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
final pt = widget.pt;
|
||||
return AlertDialog(
|
||||
backgroundColor: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rXl),
|
||||
side: BorderSide(color: pt.border)),
|
||||
title: Text('Gerät abmelden?',
|
||||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
content: Text(
|
||||
'"$displayName" wird abgemeldet und alle lokalen Schlüssel werden gelöscht.',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: pt.danger,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Abmelden'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (confirm == true && device.deviceId != null) {
|
||||
await _revokeDevice(device.deviceId!);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
part of '../settings_modal.dart';
|
||||
|
||||
class _MobileHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback onClose;
|
||||
const _MobileHeader({required this.title, required this.pt, this.onBack, required this.onClose});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
border: Border(bottom: BorderSide(color: pt.border)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (onBack != null)
|
||||
IconButton(
|
||||
onPressed: onBack,
|
||||
icon: Icon(Icons.arrow_back_rounded, size: 20, color: pt.fg),
|
||||
tooltip: 'Zurück',
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onClose,
|
||||
icon: Icon(Icons.close_rounded, size: 20, color: pt.fgMuted),
|
||||
tooltip: 'Schließen',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileCategoryTile extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
const _MobileCategoryTile({required this.label, required this.icon, required this.pt, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 21, color: pt.fgMuted),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(label, style: TextStyle(color: pt.fg, fontSize: 15)),
|
||||
),
|
||||
Icon(Icons.chevron_right_rounded, size: 20, color: pt.fgDim),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavSection extends StatelessWidget {
|
||||
final String title;
|
||||
final PyramidTheme pt;
|
||||
const _NavSection(this.title, this.pt);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: pt.fgDim,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.04 * 11,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem extends StatefulWidget {
|
||||
final String id;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final String active;
|
||||
final PyramidTheme pt;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _NavItem(this.id, this.label, this.icon, this.active, this.pt, this.onTap);
|
||||
|
||||
@override
|
||||
State<_NavItem> createState() => _NavItemState();
|
||||
}
|
||||
|
||||
class _NavItemState extends State<_NavItem> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isActive = widget.id == widget.active;
|
||||
final pt = widget.pt;
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
margin: const EdgeInsets.fromLTRB(8, 1, 8, 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? pt.accentSoft
|
||||
: _hovered
|
||||
? pt.bgHover
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: widget.label.isEmpty
|
||||
? MainAxisAlignment.center
|
||||
: MainAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
widget.icon,
|
||||
size: 14,
|
||||
color: isActive ? pt.accent : _hovered ? pt.fg : pt.fgMuted,
|
||||
),
|
||||
if (widget.label.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
color: isActive ? pt.fg : _hovered ? pt.fg : pt.fgMuted,
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogoutNavButton extends ConsumerStatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
final bool isMobile;
|
||||
final VoidCallback onClose;
|
||||
const _LogoutNavButton({required this.pt, required this.isMobile, required this.onClose});
|
||||
|
||||
@override
|
||||
ConsumerState<_LogoutNavButton> createState() => _LogoutNavButtonState();
|
||||
}
|
||||
|
||||
class _LogoutNavButtonState extends ConsumerState<_LogoutNavButton> {
|
||||
bool _hovered = false;
|
||||
bool _loading = false;
|
||||
|
||||
Future<void> _logout() async {
|
||||
final pt = widget.pt;
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final backupMissing = await isKeyBackupMissing(client);
|
||||
if (!mounted) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
|
||||
title: Text('Abmelden?',
|
||||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
content: Text(
|
||||
backupMissing
|
||||
? 'Du wirst von diesem Gerät abgemeldet. Es ist kein Schlüssel-Backup eingerichtet — '
|
||||
'ältere verschlüsselte Nachrichten sind danach auf keinem Gerät mehr lesbar.'
|
||||
: 'Du wirst von diesem Gerät abgemeldet.',
|
||||
style: TextStyle(
|
||||
color: backupMissing ? pt.danger : pt.fgMuted, fontSize: 13, height: 1.4),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: pt.danger, foregroundColor: Colors.white,
|
||||
elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Abmelden'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
widget.onClose();
|
||||
unawaited(AuthLog.write('User-initiated logout (Settings-Sidebar)'));
|
||||
await client.logout();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: _loading ? null : _logout,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
margin: const EdgeInsets.fromLTRB(8, 1, 8, 1),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: _hovered ? pt.danger.withAlpha(30) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: widget.isMobile ? MainAxisAlignment.center : MainAxisAlignment.start,
|
||||
children: [
|
||||
_loading
|
||||
? SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: pt.danger))
|
||||
: Icon(Icons.logout_rounded, size: 14, color: pt.danger),
|
||||
if (!widget.isMobile) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text('Abmelden', style: TextStyle(color: pt.danger, fontSize: 13)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final PyramidTheme pt;
|
||||
const _SectionTitle({required this.title, required this.subtitle, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle, style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsGroup extends StatelessWidget {
|
||||
final String title;
|
||||
final PyramidTheme pt;
|
||||
final List<Widget> children;
|
||||
|
||||
const _SettingsGroup({required this.title, required this.pt, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: pt.fgDim,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.04 * 11),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Column(children: children),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsRow extends StatelessWidget {
|
||||
final String title;
|
||||
final String? desc;
|
||||
final PyramidTheme pt;
|
||||
final Widget? control;
|
||||
final VoidCallback? onTap;
|
||||
final bool destructive;
|
||||
final bool showArrow;
|
||||
|
||||
const _SettingsRow({
|
||||
required this.title,
|
||||
required this.pt,
|
||||
this.desc,
|
||||
this.control,
|
||||
this.onTap,
|
||||
this.destructive = false,
|
||||
this.showArrow = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget content = Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: destructive ? pt.danger : pt.fg,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (desc != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(desc!, style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
?control,
|
||||
if (showArrow)
|
||||
Icon(Icons.chevron_right_rounded, size: 16, color: pt.fgDim),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
class _Divider extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
const _Divider({required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(height: 1, color: pt.border, margin: const EdgeInsets.only(left: 14));
|
||||
}
|
||||
}
|
||||
|
||||
class _Toggle extends StatelessWidget {
|
||||
final bool value;
|
||||
final ValueChanged<bool> onChanged;
|
||||
final PyramidTheme pt;
|
||||
const _Toggle({required this.value, required this.onChanged, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => onChanged(!value),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 42,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: value ? pt.accent : pt.bg3,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: value ? pt.accent : pt.borderStrong),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: AnimatedAlign(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Colors.black.withAlpha(60), blurRadius: 4)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SegControl extends StatelessWidget {
|
||||
final List<String> options;
|
||||
final int selected;
|
||||
final PyramidTheme pt;
|
||||
final ValueChanged<int> onSelect;
|
||||
|
||||
const _SegControl(
|
||||
{required this.options, required this.selected, required this.pt, required this.onSelect});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(pt.rSm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: options.asMap().entries.map((e) {
|
||||
final isActive = e.key == selected;
|
||||
return GestureDetector(
|
||||
onTap: () => onSelect(e.key),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? pt.accent : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(pt.rSm - 2),
|
||||
),
|
||||
child: Text(
|
||||
e.value,
|
||||
style: TextStyle(
|
||||
color: isActive ? pt.accentFg : pt.fgMuted,
|
||||
fontSize: 13,
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrimaryBtn extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final VoidCallback? onPressed;
|
||||
final PyramidTheme pt;
|
||||
final bool loading;
|
||||
|
||||
const _PrimaryBtn({
|
||||
required this.label,
|
||||
required this.pt,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
this.loading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bg = pt.accent;
|
||||
final fg = pt.accentFg;
|
||||
return ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: bg,
|
||||
foregroundColor: fg,
|
||||
disabledBackgroundColor: bg.withAlpha(100),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
elevation: 0,
|
||||
),
|
||||
onPressed: loading ? null : onPressed,
|
||||
icon: loading
|
||||
? SizedBox(
|
||||
width: 14, height: 14, child: CircularProgressIndicator(color: fg, strokeWidth: 2))
|
||||
: (icon != null ? Icon(icon, size: 16) : const SizedBox.shrink()),
|
||||
label: Text(label),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AvatarFallback extends StatelessWidget {
|
||||
final String name;
|
||||
final double size;
|
||||
final PyramidTheme pt;
|
||||
const _AvatarFallback({required this.name, required this.size, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
color: pt.accent,
|
||||
child: Center(
|
||||
child: Text(
|
||||
name.isNotEmpty ? name[0].toUpperCase() : '?',
|
||||
style:
|
||||
TextStyle(color: pt.accentFg, fontSize: size * 0.4, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrefTextField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final PyramidTheme pt;
|
||||
final int? maxLength;
|
||||
|
||||
const _PrefTextField({
|
||||
required this.controller,
|
||||
required this.label,
|
||||
required this.pt,
|
||||
this.maxLength,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
maxLength: maxLength,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
counterText: '',
|
||||
filled: true,
|
||||
fillColor: pt.bg3,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
|
||||
),
|
||||
cursorColor: pt.accent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PwField extends StatelessWidget {
|
||||
final TextEditingController ctrl;
|
||||
final String label;
|
||||
final PyramidTheme pt;
|
||||
final bool visible;
|
||||
final VoidCallback? onToggle;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
|
||||
const _PwField({
|
||||
required this.ctrl, required this.label, required this.pt,
|
||||
required this.visible, required this.onToggle, this.onSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => TextField(
|
||||
controller: ctrl,
|
||||
obscureText: !visible,
|
||||
onSubmitted: onSubmitted,
|
||||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
filled: true, fillColor: pt.bg3,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
|
||||
suffixIcon: onToggle != null
|
||||
? IconButton(
|
||||
icon: Icon(visible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim),
|
||||
onPressed: onToggle,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
cursorColor: pt.accent,
|
||||
);
|
||||
}
|
||||
|
||||
class _StatusCard extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String text;
|
||||
const _StatusCard(
|
||||
{required this.pt, required this.icon, required this.color, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 13))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
part of '../settings_modal.dart';
|
||||
|
||||
class _SasVerificationDialog extends ConsumerStatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
final String deviceName;
|
||||
final DeviceKeys device;
|
||||
|
||||
const _SasVerificationDialog({
|
||||
required this.pt,
|
||||
required this.deviceName,
|
||||
required this.device,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<_SasVerificationDialog> createState() => _SasVerificationDialogState();
|
||||
}
|
||||
|
||||
class _SasVerificationDialogState extends ConsumerState<_SasVerificationDialog> {
|
||||
KeyVerification? _verification;
|
||||
KeyVerificationState _state = KeyVerificationState.waitingAccept;
|
||||
bool _starting = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startVerification();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final v = _verification;
|
||||
if (v != null && !v.isDone) {
|
||||
v.cancel('m.user').catchError((_) {});
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _startVerification() async {
|
||||
try {
|
||||
final client = await ref.read(matrixClientProvider.future);
|
||||
final deviceId = widget.device.deviceId;
|
||||
if (deviceId == null) throw Exception('Keine Geräte-ID');
|
||||
|
||||
final deviceKeys = client.userDeviceKeys[client.userID]?.deviceKeys[deviceId];
|
||||
if (deviceKeys == null) throw Exception('Gerät nicht gefunden');
|
||||
|
||||
final verification = await deviceKeys.startVerification();
|
||||
_verification = verification;
|
||||
|
||||
verification.onUpdate = () {
|
||||
if (!mounted) return;
|
||||
setState(() => _state = verification.state);
|
||||
};
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_state = verification.state;
|
||||
_starting = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString().split('\n').first;
|
||||
_starting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
|
||||
return AlertDialog(
|
||||
backgroundColor: pt.bg2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(pt.rXl),
|
||||
side: BorderSide(color: pt.border)),
|
||||
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.verified_user_outlined, size: 20, color: pt.accent),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Gerät verifizieren',
|
||||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 380,
|
||||
child: _buildContent(pt),
|
||||
),
|
||||
actions: _buildActions(pt),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(PyramidTheme pt) {
|
||||
if (_error != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _StatusCard(
|
||||
pt: pt,
|
||||
icon: Icons.error_outline_rounded,
|
||||
color: pt.danger,
|
||||
text: _error!),
|
||||
);
|
||||
}
|
||||
|
||||
if (_starting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 24),
|
||||
child: Center(child: CircularProgressIndicator.adaptive()),
|
||||
);
|
||||
}
|
||||
|
||||
final v = _verification;
|
||||
|
||||
return switch (_state) {
|
||||
KeyVerificationState.waitingAccept || KeyVerificationState.askAccept => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator.adaptive(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Warte auf Bestätigung von „${widget.deviceName}"…',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Öffne Pyramid auf dem anderen Gerät und bestätige die Verifikationsanfrage.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
KeyVerificationState.askChoice => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Das andere Gerät hat die Anfrage akzeptiert.\nWähle eine Verifikationsmethode:',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: pt.accent,
|
||||
foregroundColor: pt.accentFg,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: () => v?.continueVerification('m.sas.v1').catchError((_) {}),
|
||||
icon: const Icon(Icons.tag_faces_rounded, size: 18),
|
||||
label: const Text('Emoji-Verifikation'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: pt.bg3,
|
||||
foregroundColor: pt.fg,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: () => v?.continueVerification('m.qr_code.show.v1').catchError((_) {}),
|
||||
icon: const Icon(Icons.qr_code_rounded, size: 18),
|
||||
label: const Text('QR-Code anzeigen'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
KeyVerificationState.confirmQRScan => _OutgoingQrCodeDisplay(
|
||||
pt: pt,
|
||||
request: v,
|
||||
),
|
||||
KeyVerificationState.showQRSuccess => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _StatusCard(
|
||||
pt: pt,
|
||||
icon: Icons.verified_rounded,
|
||||
color: PyramidColors.success,
|
||||
text: 'QR-Code erfolgreich gescannt. Verifikation abgeschlossen.'),
|
||||
),
|
||||
KeyVerificationState.askSas => _SasEmojiContent(
|
||||
pt: pt,
|
||||
verification: v!,
|
||||
onConfirm: () => v.acceptSas().catchError((_) {}),
|
||||
onReject: () {
|
||||
v.rejectSas().catchError((_) {});
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
KeyVerificationState.waitingSas => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator.adaptive(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Warte auf Bestätigung des anderen Geräts…',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
KeyVerificationState.done => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _StatusCard(
|
||||
pt: pt,
|
||||
icon: Icons.verified_rounded,
|
||||
color: PyramidColors.success,
|
||||
text: '„${widget.deviceName}" wurde erfolgreich verifiziert.'),
|
||||
),
|
||||
_ => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _StatusCard(
|
||||
pt: pt,
|
||||
icon: Icons.error_outline_rounded,
|
||||
color: pt.danger,
|
||||
text: 'Verifizierung fehlgeschlagen oder abgebrochen.'),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
List<Widget> _buildActions(PyramidTheme pt) {
|
||||
if (_state == KeyVerificationState.done ||
|
||||
_state == KeyVerificationState.showQRSuccess ||
|
||||
_error != null ||
|
||||
_state == KeyVerificationState.error) {
|
||||
return [
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: pt.accent,
|
||||
foregroundColor: pt.accentFg,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Schließen'),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (_state == KeyVerificationState.confirmQRScan) {
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_verification?.cancel('m.user').catchError((_) {});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF22C55E),
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onPressed: () => _verification?.acceptQRScanConfirmation().catchError((_) {}),
|
||||
child: const Text('Gescannt bestätigen'),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (_state == KeyVerificationState.askSas || _state == KeyVerificationState.askChoice) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_verification?.cancel('m.user').catchError((_) {});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class _OutgoingQrCodeDisplay extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final KeyVerification? request;
|
||||
|
||||
const _OutgoingQrCodeDisplay({required this.pt, required this.request});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final qrCode = request?.qrCode;
|
||||
if (qrCode == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator.adaptive(),
|
||||
const SizedBox(height: 12),
|
||||
Text('QR-Code wird generiert…', style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final rawBytes = Uint8List.fromList(qrCode.qrDataRawBytes.toList());
|
||||
final qr = QrCode.fromUint8List(data: rawBytes, errorCorrectLevel: QrErrorCorrectLevel.L);
|
||||
final qrImage = QrImage(qr);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Scanne diesen QR-Code mit dem anderen Gerät',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 220,
|
||||
height: 220,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: PrettyQrView(qrImage: qrImage),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Sobald das andere Gerät den Code gescannt hat, tippe auf „Gescannt bestätigen".',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _StatusCard(
|
||||
pt: pt,
|
||||
icon: Icons.error_outline_rounded,
|
||||
color: pt.danger,
|
||||
text: 'QR-Code konnte nicht generiert werden.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SasEmojiContent extends StatelessWidget {
|
||||
final PyramidTheme pt;
|
||||
final KeyVerification verification; // ignore: library_private_types_in_public_api
|
||||
final VoidCallback onConfirm;
|
||||
final VoidCallback onReject;
|
||||
|
||||
const _SasEmojiContent({
|
||||
required this.pt,
|
||||
required this.verification,
|
||||
required this.onConfirm,
|
||||
required this.onReject,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final emojis = verification.sasEmojis;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Vergleiche die Emojis auf beiden Geräten',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
if (emojis.isNotEmpty)
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
alignment: WrapAlignment.center,
|
||||
children: emojis.map((e) => _SasEmojiChip(emoji: e, pt: pt)).toList(),
|
||||
)
|
||||
else
|
||||
Text('Keine Emojis verfügbar.', style: TextStyle(color: pt.fgDim, fontSize: 13)),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Stimmen alle Emojis überein?',
|
||||
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: pt.danger,
|
||||
side: BorderSide(color: pt.danger.withAlpha(120)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: onReject,
|
||||
icon: const Icon(Icons.close_rounded, size: 16),
|
||||
label: const Text('Stimmt nicht überein'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: PyramidColors.success,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: onConfirm,
|
||||
icon: const Icon(Icons.check_rounded, size: 16),
|
||||
label: const Text('Stimmt überein'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SasEmojiChip extends StatelessWidget {
|
||||
final KeyVerificationEmoji emoji;
|
||||
final PyramidTheme pt;
|
||||
|
||||
const _SasEmojiChip({required this.emoji, required this.pt});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 68,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(emoji.emoji, style: const TextStyle(fontSize: 26)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
emoji.name,
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 10),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
part of '../settings_modal.dart';
|
||||
|
||||
class _VoiceSection extends StatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
const _VoiceSection({required this.pt});
|
||||
|
||||
@override
|
||||
State<_VoiceSection> createState() => _VoiceSectionState();
|
||||
}
|
||||
|
||||
class _VoiceSectionState extends State<_VoiceSection> {
|
||||
List<MediaDeviceInfo> _audioInputs = [];
|
||||
List<MediaDeviceInfo> _audioOutputs = [];
|
||||
List<MediaDeviceInfo> _videoInputs = [];
|
||||
String? _selectedMic;
|
||||
String? _selectedSpeaker;
|
||||
String? _selectedCamera;
|
||||
double _outputVolume = 1.0;
|
||||
bool _loading = true;
|
||||
String? _loadError;
|
||||
|
||||
static const _kMic = 'voice_mic_device';
|
||||
static const _kSpeaker = 'voice_speaker_device';
|
||||
static const _kCamera = 'voice_camera_device';
|
||||
static const _kOutputVolume = 'voice_output_volume';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
List<MediaDeviceInfo> devices = [];
|
||||
String? loadError;
|
||||
try {
|
||||
devices = await navigator.mediaDevices.enumerateDevices();
|
||||
} catch (_) {
|
||||
devices = [];
|
||||
}
|
||||
// Windows (und teils Android) liefert eine LEERE Liste ohne Fehler,
|
||||
// solange in diesem Prozess noch nie ein Media-Stream geöffnet wurde.
|
||||
// Deshalb nicht nur bei Exception, sondern auch bei leerer Liste einmal
|
||||
// kurz getUserMedia anfordern und erneut enumerieren — und zwar SOLANGE
|
||||
// der Stream noch offen ist (nach stop() melden manche Systeme wieder 0).
|
||||
final hasAudioInput =
|
||||
devices.any((d) => d.kind?.toLowerCase() == 'audioinput');
|
||||
if (devices.isEmpty || !hasAudioInput) {
|
||||
try {
|
||||
final stream = await navigator.mediaDevices
|
||||
.getUserMedia({'audio': true, 'video': false});
|
||||
try {
|
||||
devices = await navigator.mediaDevices.enumerateDevices();
|
||||
} finally {
|
||||
for (final t in stream.getTracks()) {
|
||||
t.stop();
|
||||
}
|
||||
await stream.dispose();
|
||||
}
|
||||
if (devices.isEmpty) {
|
||||
// Bekanntes libwebrtc-Problem auf manchen Windows-Systemen: das
|
||||
// Audio-Device-Module meldet 0 Geräte, obwohl Aufnahme/Wiedergabe
|
||||
// über das Standardgerät einwandfrei funktionieren.
|
||||
loadError =
|
||||
'Die Audiotreiber melden keine Geräteliste an WebRTC '
|
||||
'(bekanntes libwebrtc-Problem auf manchen Windows-Systemen). '
|
||||
'Anrufe funktionieren trotzdem — es wird das Windows-Standardgerät verwendet.';
|
||||
}
|
||||
} catch (e2) {
|
||||
loadError = 'getUserMedia: ${e2.toString().split('\n').first}';
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_audioInputs = devices.where((d) => d.kind?.toLowerCase() == 'audioinput').toList();
|
||||
_audioOutputs = devices.where((d) => d.kind?.toLowerCase() == 'audiooutput').toList();
|
||||
_videoInputs = devices.where((d) => d.kind?.toLowerCase() == 'videoinput').toList();
|
||||
_selectedMic = prefs.getString(_kMic);
|
||||
_selectedSpeaker = prefs.getString(_kSpeaker);
|
||||
_selectedCamera = prefs.getString(_kCamera);
|
||||
_outputVolume = prefs.getDouble(_kOutputVolume) ?? 1.0;
|
||||
_loadError = loadError;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _setOutputVolume(double v) async {
|
||||
setState(() => _outputVolume = v);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_kOutputVolume, v);
|
||||
// Live auf laufende Calls anwenden.
|
||||
LiveKitCallManager.instance.setOutputVolume(v).catchError((_) {});
|
||||
try {
|
||||
PyramidVoipManager.instance.applyOutputVolume(v).catchError((_) {});
|
||||
} catch (_) {} // Instanz existiert evtl. noch nicht
|
||||
}
|
||||
|
||||
Future<void> _save(String key, String? value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (value == null || value.isEmpty) {
|
||||
await prefs.remove(key);
|
||||
} else {
|
||||
await prefs.setString(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _deviceDropdown({
|
||||
required List<MediaDeviceInfo> devices,
|
||||
required String? selected,
|
||||
required String placeholder,
|
||||
required IconData icon,
|
||||
required ValueChanged<String?> onChanged,
|
||||
}) {
|
||||
final pt = widget.pt;
|
||||
if (devices.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 13, color: pt.fgDim),
|
||||
const SizedBox(width: 6),
|
||||
Text('Kein Gerät', style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final validSelected = devices.any((d) => d.deviceId == selected) ? selected : null;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 220),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg3,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String?>(
|
||||
value: validSelected,
|
||||
hint: Row(children: [
|
||||
Icon(icon, size: 13, color: pt.fgDim),
|
||||
const SizedBox(width: 6),
|
||||
Text(placeholder, style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||||
]),
|
||||
dropdownColor: pt.bg2,
|
||||
iconEnabledColor: pt.fgDim,
|
||||
isDense: true,
|
||||
style: TextStyle(color: pt.fg, fontSize: 12),
|
||||
onChanged: onChanged,
|
||||
items: devices.map((d) {
|
||||
final label = d.label.isNotEmpty ? d.label : 'Gerät ${d.deviceId.substring(0, 6)}';
|
||||
return DropdownMenuItem<String?>(
|
||||
value: d.deviceId,
|
||||
child: Text(label, overflow: TextOverflow.ellipsis),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = widget.pt;
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(
|
||||
title: 'Sprache & Video',
|
||||
subtitle: 'Mikrofon, Kamera und Anrufeinstellungen.',
|
||||
pt: pt),
|
||||
if (_loading)
|
||||
const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()))
|
||||
else ...[
|
||||
if (_loadError != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg2,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: pt.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 16, color: pt.away),
|
||||
const SizedBox(width: 8),
|
||||
Text('Geräteliste nicht verfügbar',
|
||||
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text(_loadError!, style: TextStyle(color: pt.fgDim, fontSize: 11)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Mikrofon und Lautsprecher legst du in diesem Fall über die '
|
||||
'Windows-Soundeinstellungen (Standardgerät) fest. '
|
||||
'Falls dort alles stimmt: Windows-Einstellungen → Datenschutz → Mikrofon prüfen.',
|
||||
style: TextStyle(color: pt.fgMuted, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_SettingsGroup(title: 'Geräte', pt: pt, children: [
|
||||
_SettingsRow(
|
||||
title: 'Mikrofon',
|
||||
desc: _audioInputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioInputs.length} Gerät(e) gefunden',
|
||||
pt: pt,
|
||||
control: _deviceDropdown(
|
||||
devices: _audioInputs,
|
||||
selected: _selectedMic,
|
||||
placeholder: 'Standard',
|
||||
icon: Icons.mic_rounded,
|
||||
onChanged: (v) {
|
||||
setState(() => _selectedMic = v);
|
||||
_save(_kMic, v);
|
||||
},
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Lautsprecher',
|
||||
desc: _audioOutputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioOutputs.length} Gerät(e) gefunden',
|
||||
pt: pt,
|
||||
control: _deviceDropdown(
|
||||
devices: _audioOutputs,
|
||||
selected: _selectedSpeaker,
|
||||
placeholder: 'Standard',
|
||||
icon: Icons.volume_up_rounded,
|
||||
onChanged: (v) {
|
||||
setState(() => _selectedSpeaker = v);
|
||||
_save(_kSpeaker, v);
|
||||
},
|
||||
),
|
||||
),
|
||||
_Divider(pt: pt),
|
||||
_SettingsRow(
|
||||
title: 'Kamera',
|
||||
desc: _videoInputs.isEmpty ? 'Kein Gerät gefunden' : '${_videoInputs.length} Gerät(e) gefunden',
|
||||
pt: pt,
|
||||
control: _deviceDropdown(
|
||||
devices: _videoInputs,
|
||||
selected: _selectedCamera,
|
||||
placeholder: 'Standard',
|
||||
icon: Icons.videocam_rounded,
|
||||
onChanged: (v) {
|
||||
setState(() => _selectedCamera = v);
|
||||
_save(_kCamera, v);
|
||||
},
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_SettingsGroup(title: 'Pegel', pt: pt, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.volume_up_rounded, size: 15, color: pt.fgMuted),
|
||||
const SizedBox(width: 8),
|
||||
Text('Ausgabelautstärke',
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500)),
|
||||
const Spacer(),
|
||||
Text('${(_outputVolume * 100).round()} %',
|
||||
style: TextStyle(
|
||||
color: pt.fgMuted,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace')),
|
||||
],
|
||||
),
|
||||
SliderTheme(
|
||||
data: SliderThemeData(
|
||||
activeTrackColor: pt.accent,
|
||||
inactiveTrackColor: pt.bg3,
|
||||
thumbColor: pt.accent,
|
||||
overlayColor: pt.accent.withAlpha(30),
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 7),
|
||||
),
|
||||
child: Slider(
|
||||
value: _outputVolume.clamp(0.0, 1.5),
|
||||
min: 0.0,
|
||||
max: 1.5,
|
||||
divisions: 30,
|
||||
onChanged: _setOutputVolume,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Lautstärke der anderen Teilnehmer in Calls. '
|
||||
'Wirkt sofort auf laufende Anrufe.',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_TurnUsageCard(pt: pt),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: pt.fgMuted,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
onPressed: () { setState(() { _loading = true; _loadError = null; }); _init(); },
|
||||
icon: const Icon(Icons.refresh_rounded, size: 14),
|
||||
label: const Text('Geräte neu laden', style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TurnUsageCard extends StatefulWidget {
|
||||
final PyramidTheme pt;
|
||||
const _TurnUsageCard({required this.pt});
|
||||
|
||||
@override
|
||||
State<_TurnUsageCard> createState() => _TurnUsageCardState();
|
||||
}
|
||||
|
||||
class _TurnUsageCardState extends State<_TurnUsageCard> {
|
||||
Map<String, dynamic>? _status;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final resp = await http
|
||||
.get(Uri.parse(
|
||||
'https://steggi-matrix.work/.well-known/pyramid/turn-status.json'))
|
||||
.timeout(const Duration(seconds: 5));
|
||||
if (resp.statusCode == 200 && mounted) {
|
||||
setState(
|
||||
() => _status = jsonDecode(resp.body) as Map<String, dynamic>);
|
||||
}
|
||||
} catch (_) {
|
||||
// Endpoint (noch) nicht vorhanden — Karte einfach nicht anzeigen.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = _status;
|
||||
if (s == null) return const SizedBox.shrink();
|
||||
final pt = widget.pt;
|
||||
final used = (s['egress_gb'] as num?)?.toDouble() ?? 0;
|
||||
final limit = (s['limit_gb'] as num?)?.toDouble() ?? 990;
|
||||
final pct = limit > 0 ? (used / limit).clamp(0.0, 1.0) : 0.0;
|
||||
final active = s['turn_active'] != false;
|
||||
final barColor = !active
|
||||
? pt.danger
|
||||
: pct > 0.8
|
||||
? pt.away
|
||||
: pt.accent;
|
||||
|
||||
return _SettingsGroup(title: 'Call-Relay (TURN)', pt: pt, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(active ? Icons.cloud_done_rounded : Icons.cloud_off_rounded,
|
||||
size: 15, color: active ? pt.online : pt.danger),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
active
|
||||
? 'Relay aktiv'
|
||||
: 'Relay deaktiviert (Limit erreicht)',
|
||||
style: TextStyle(
|
||||
color: pt.fg,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500)),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${used.toStringAsFixed(1)} / ${limit.toStringAsFixed(0)} GB',
|
||||
style: TextStyle(
|
||||
color: pt.fgMuted,
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace')),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
child: LinearProgressIndicator(
|
||||
value: pct,
|
||||
minHeight: 5,
|
||||
backgroundColor: pt.bg3,
|
||||
color: barColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
active
|
||||
? 'Cloudflare-TURN-Traffic diesen Monat (Free Tier: ${limit.toStringAsFixed(0)} GB). '
|
||||
'Bei Erreichen des Limits laufen Calls bis Monatsende ohne Relay weiter.'
|
||||
: 'Monatslimit erreicht — Calls nutzen bis Monatsende nur direkte Verbindungen (STUN).',
|
||||
style: TextStyle(color: pt.fgDim, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+12
-5283
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user