import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; import 'package:crypto/crypto.dart' as mcrypto; import 'package:pyramid/core/fcm_push_service.dart'; import 'package:pyramid/core/notification_service.dart' show openBatteryOptimizationSettings; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart' show compute; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:http/http.dart' as http; import 'package:matrix/encryption/ssss.dart'; import 'package:matrix/encryption/utils/bootstrap.dart'; import 'package:matrix/encryption/utils/key_verification.dart' show KeyVerification, KeyVerificationEmoji, KeyVerificationState; import 'package:matrix/matrix.dart'; import 'package:pretty_qr_code/pretty_qr_code.dart'; import 'package:path_provider/path_provider.dart'; import 'package:pointycastle/export.dart' as pc; import 'package:pyramid/core/app_state.dart'; import 'package:pyramid/core/e2ee_diagnostics.dart'; import 'package:pyramid/core/livekit_call_manager.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/voip_manager.dart'; import 'package:pyramid/core/settings_prefs.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/core/update_checker.dart'; import 'package:pyramid/widgets/update_download_dialog.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:pyramid/widgets/banner_crop_dialog.dart'; import 'package:pyramid/widgets/hover_region.dart'; import 'package:pyramid/widgets/mxc_image.dart'; import 'package:pyramid/widgets/pyramid_loader.dart'; import 'package:shared_preferences/shared_preferences.dart'; // ── Megolm key export / import – top-level so compute() can use them ───────── const _kMegolmHeader = '-----BEGIN MEGOLM SESSION DATA-----'; const _kMegolmFooter = '-----END MEGOLM SESSION DATA-----'; // PBKDF2-HMAC-SHA512. args: {pass: Uint8List, salt: Uint8List, iter: int} Uint8List _runPbkdf2(Map args) { final pass = args['pass'] as Uint8List; final salt = args['salt'] as Uint8List; final iterations = args['iter'] as int; const blockLen = 64; // SHA-512 digest size const dkLen = 64; final hmac = mcrypto.Hmac(mcrypto.sha512, pass); final blockCount = (dkLen / blockLen).ceil(); final dk = Uint8List(blockCount * blockLen); for (var i = 1; i <= blockCount; i++) { final saltI = Uint8List(salt.length + 4); saltI.setRange(0, salt.length, salt); ByteData.sublistView(saltI, salt.length).setUint32(0, i); var u = Uint8List.fromList(hmac.convert(saltI).bytes); final block = Uint8List.fromList(u); for (var j = 1; j < iterations; j++) { u = Uint8List.fromList(hmac.convert(u).bytes); for (var k = 0; k < blockLen; k++) { block[k] ^= u[k]; } } dk.setRange((i - 1) * blockLen, i * blockLen, block); } return dk.sublist(0, dkLen); } Uint8List _aesCtr(Uint8List key, Uint8List iv, Uint8List data) { final cipher = pc.SICStreamCipher(pc.AESEngine()) ..init(true, pc.ParametersWithIV(pc.KeyParameter(key), iv)); final out = Uint8List(data.length); cipher.processBytes(data, 0, data.length, out, 0); return out; } // args: {passphrase: String, sessions: List} → encrypted String String _encryptMegolmKeys(Map args) { final pass = args['passphrase'] as String; final sessions = args['sessions'] as List; final rng = Random.secure(); final salt = Uint8List.fromList(List.generate(16, (_) => rng.nextInt(256))); // IV: 8 random bytes as nonce, last 8 bytes 0x00 (counter starts at 0) final iv = Uint8List(16); for (var i = 0; i < 8; i++) { iv[i] = rng.nextInt(256); } const iterations = 100000; final dk = _runPbkdf2({ 'pass': Uint8List.fromList(utf8.encode(pass)), 'salt': salt, 'iter': iterations, }); final aesKey = dk.sublist(0, 32); final hmacKey = dk.sublist(32, 64); final plaintext = Uint8List.fromList(utf8.encode(jsonEncode(sessions))); final encrypted = _aesCtr(aesKey, iv, plaintext); final iterBe = Uint8List(4)..buffer.asByteData().setUint32(0, iterations); // header bytes: 0x01 | salt(16) | iv(16) | iterBe(4) | encrypted(n) final header = Uint8List(1 + 16 + 16 + 4 + encrypted.length); var off = 0; header[off++] = 0x01; header.setRange(off, off += 16, salt); header.setRange(off, off += 16, iv); header.setRange(off, off += 4, iterBe); header.setRange(off, off + encrypted.length, encrypted); final mac = Uint8List.fromList( mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(header).bytes, ); final full = Uint8List(header.length + 32); full.setRange(0, header.length, header); full.setRange(header.length, full.length, mac); return '$_kMegolmHeader\n${base64.encode(full)}\n$_kMegolmFooter'; } // args: {passphrase: String, data: List} → List sessions List _decryptMegolmKeys(Map args) { final pass = args['passphrase'] as String; final raw = Uint8List.fromList(args['data'] as List); if (raw.length < 1 + 16 + 16 + 4 + 32 || raw[0] != 0x01) { throw Exception('Ungültiges Dateiformat (Version ${raw.isEmpty ? "?" : raw[0]}).'); } final salt = raw.sublist(1, 17); final iv = raw.sublist(17, 33); final iterations = raw.buffer.asByteData().getUint32(33); final encrypted = raw.sublist(37, raw.length - 32); final storedMac = raw.sublist(raw.length - 32); if (iterations < 1 || iterations > 10000000) { throw Exception('Iterationsanzahl außerhalb des erlaubten Bereichs.'); } final dk = _runPbkdf2({ 'pass': Uint8List.fromList(utf8.encode(pass)), 'salt': Uint8List.fromList(salt), 'iter': iterations, }); final aesKey = dk.sublist(0, 32); final hmacKey = dk.sublist(32, 64); // Verify MAC over all bytes except the appended MAC itself final toMac = raw.sublist(0, raw.length - 32); final expectedMac = mcrypto.Hmac(mcrypto.sha256, hmacKey).convert(toMac).bytes; var macOk = true; for (var i = 0; i < 32; i++) { if (expectedMac[i] != storedMac[i]) { macOk = false; break; } } if (!macOk) throw Exception('Falsches Passwort oder beschädigte Datei.'); final decrypted = _aesCtr(aesKey, Uint8List.fromList(iv), Uint8List.fromList(encrypted)); return jsonDecode(utf8.decode(decrypted)) as List; } // ───────────────────────────────────────────────────────────────────────────── class SettingsModal extends ConsumerStatefulWidget { final VoidCallback onClose; const SettingsModal({super.key, required this.onClose}); @override ConsumerState createState() => _SettingsModalState(); } class _SettingsModalState extends ConsumerState { // null = show the category list (mobile). On desktop it falls back to the // first section so content is always visible. String? _section; // Categories shared by the desktop sidebar and the mobile list/detail. static const List<(String, String, IconData)> _categories = [ ('profile', 'Mein Profil', Icons.person_outline_rounded), ('notifications', 'Benachrichtigungen', Icons.notifications_none_rounded), ('appearance', 'Erscheinungsbild', Icons.palette_outlined), ('voice', 'Sprache & Video', Icons.headphones_rounded), ('keys', 'Tastaturkürzel', Icons.keyboard_rounded), ('privacy', 'Datenschutz', Icons.lock_outline_rounded), ('sessions', 'Sitzungen', Icons.devices_rounded), ('encryption', 'Verschlüsselung', Icons.shield_outlined), ('about', 'Über Pyramid', Icons.info_outline_rounded), ]; String _titleFor(String id) => _categories.firstWhere((c) => c.$1 == id, orElse: () => (id, id, Icons.settings)).$2; @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); final isDark = ref.watch(themeModeProvider); final accentIdx = ref.watch(accentProvider); final radius = ref.watch(radiusProvider); final density = ref.watch(densityProvider); final motion = ref.watch(motionProvider); final size = MediaQuery.sizeOf(context); final isMobile = size.width < 700; return GestureDetector( onTap: widget.onClose, child: Container( color: Colors.black.withAlpha(150), child: Center( child: GestureDetector( onTap: () {}, child: Container( constraints: const BoxConstraints(maxWidth: 820, maxHeight: 660), width: isMobile ? size.width * 0.97 : 740, height: isMobile ? size.height * 0.92 : 560, decoration: BoxDecoration( color: pt.bg1, borderRadius: BorderRadius.circular(pt.rXl), border: Border.all(color: pt.border), boxShadow: [ BoxShadow( color: Colors.black.withAlpha(130), blurRadius: 40, offset: const Offset(0, 12), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(pt.rXl), child: isMobile ? _mobileLayout(pt, isDark, accentIdx, radius, density, motion) : _desktopLayout(pt, isDark, accentIdx, radius, density, motion), ), ), ), ), ), ); } // ── Desktop: sidebar + content side by side ────────────────────────────── Widget _desktopLayout(PyramidTheme pt, bool isDark, int accentIdx, double radius, double density, double motion) { final active = _section ?? 'appearance'; return Row( children: [ Container( width: 200, decoration: BoxDecoration( color: pt.bg2, border: Border(right: BorderSide(color: pt.border)), ), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 16), _NavSection('Account', pt), _NavItem('profile', 'Mein Profil', Icons.person_outline_rounded, active, pt, () => setState(() => _section = 'profile')), _NavItem('notifications', 'Benachrichtigungen', Icons.notifications_none_rounded, active, pt, () => setState(() => _section = 'notifications')), const SizedBox(height: 14), _NavSection('App', pt), _NavItem('appearance', 'Erscheinungsbild', Icons.palette_outlined, active, pt, () => setState(() => _section = 'appearance')), _NavItem('voice', 'Sprache & Video', Icons.headphones_rounded, active, pt, () => setState(() => _section = 'voice')), _NavItem('keys', 'Tastaturkürzel', Icons.keyboard_rounded, active, pt, () => setState(() => _section = 'keys')), const SizedBox(height: 14), _NavSection('Sicherheit', pt), _NavItem('privacy', 'Datenschutz', Icons.lock_outline_rounded, active, pt, () => setState(() => _section = 'privacy')), _NavItem('sessions', 'Sitzungen', Icons.devices_rounded, active, pt, () => setState(() => _section = 'sessions')), _NavItem('encryption', 'Verschlüsselung', Icons.shield_outlined, active, pt, () => setState(() => _section = 'encryption')), const SizedBox(height: 14), _NavItem('about', 'Über Pyramid', Icons.info_outline_rounded, active, pt, () => setState(() => _section = 'about')), const Divider(height: 24, indent: 12, endIndent: 12), _LogoutNavButton(pt: pt, isMobile: false, onClose: widget.onClose), const SizedBox(height: 16), ], ), ), ), Expanded( child: Stack( children: [ Padding( padding: const EdgeInsets.all(28), child: _buildContent(pt, isDark, accentIdx, radius, density, motion, active), ), Positioned( top: 8, right: 8, child: PyrIconBtn( icon: const Icon(Icons.close_rounded, size: 20), tooltip: 'Schließen', onPressed: widget.onClose, ), ), ], ), ), ], ); } // ── Mobile: full-width category list → detail with a back arrow ─────────── Widget _mobileLayout(PyramidTheme pt, bool isDark, int accentIdx, double radius, double density, double motion) { final section = _section; if (section == null) { // Category list return Column( children: [ _MobileHeader(title: 'Einstellungen', pt: pt, onClose: widget.onClose), Expanded( child: ListView( padding: const EdgeInsets.symmetric(vertical: 8), children: [ for (final c in _categories) _MobileCategoryTile( label: c.$2, icon: c.$3, pt: pt, onTap: () => setState(() => _section = c.$1), ), const Divider(height: 24, indent: 16, endIndent: 16), _LogoutNavButton(pt: pt, isMobile: false, onClose: widget.onClose), const SizedBox(height: 16), ], ), ), ], ); } // Detail view with a back arrow return Column( children: [ _MobileHeader( title: _titleFor(section), pt: pt, onBack: () => setState(() => _section = null), onClose: widget.onClose, ), Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), child: _buildContent(pt, isDark, accentIdx, radius, density, motion, section), ), ), ], ); } Widget _buildContent(PyramidTheme pt, bool isDark, int accentIdx, double radius, double density, double motion, String section) { return switch (section) { 'appearance' => _AppearanceSection( pt: pt, isDark: isDark, accentIdx: accentIdx, radius: radius, density: density, motion: motion, ref: ref), 'profile' => _ProfileSection(pt: pt), 'notifications' => _NotificationsSection(pt: pt), 'voice' => _VoiceSection(pt: pt), 'keys' => _KeysSection(pt: pt), 'privacy' => _PrivacySection(pt: pt), 'sessions' => _SessionsSection(pt: pt), 'encryption' => _EncryptionSection(pt: pt, ref: ref), 'about' => _AboutSection(pt: pt), _ => _AppearanceSection( pt: pt, isDark: isDark, accentIdx: accentIdx, radius: radius, density: density, motion: motion, ref: ref), }; } } // ── Mobile helpers ────────────────────────────────────────────────────────── class _MobileHeader extends StatelessWidget { final String title; final PyramidTheme pt; final VoidCallback? onBack; final VoidCallback onClose; const _MobileHeader({required this.title, required this.pt, this.onBack, required this.onClose}); @override Widget build(BuildContext context) { return Container( height: 52, padding: const EdgeInsets.symmetric(horizontal: 8), decoration: BoxDecoration( color: pt.bg2, border: Border(bottom: BorderSide(color: pt.border)), ), child: Row( children: [ if (onBack != null) IconButton( onPressed: onBack, icon: Icon(Icons.arrow_back_rounded, size: 20, color: pt.fg), tooltip: 'Zurück', ) else const SizedBox(width: 8), Expanded( child: Text( title, style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis, ), ), IconButton( onPressed: onClose, icon: Icon(Icons.close_rounded, size: 20, color: pt.fgMuted), tooltip: 'Schließen', ), ], ), ); } } class _MobileCategoryTile extends StatelessWidget { final String label; final IconData icon; final PyramidTheme pt; final VoidCallback onTap; const _MobileCategoryTile({required this.label, required this.icon, required this.pt, required this.onTap}); @override Widget build(BuildContext context) { return InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), child: Row( children: [ Icon(icon, size: 21, color: pt.fgMuted), const SizedBox(width: 16), Expanded( child: Text(label, style: TextStyle(color: pt.fg, fontSize: 15)), ), Icon(Icons.chevron_right_rounded, size: 20, color: pt.fgDim), ], ), ), ); } } // ── Nav helpers ─────────────────────────────────────────────────────────────── class _NavSection extends StatelessWidget { final String title; final PyramidTheme pt; const _NavSection(this.title, this.pt); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 4), child: Text( title.toUpperCase(), style: TextStyle( color: pt.fgDim, fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.04 * 11, ), ), ); } } class _NavItem extends StatefulWidget { final String id; final String label; final IconData icon; final String active; final PyramidTheme pt; final VoidCallback onTap; const _NavItem(this.id, this.label, this.icon, this.active, this.pt, this.onTap); @override State<_NavItem> createState() => _NavItemState(); } class _NavItemState extends State<_NavItem> { bool _hovered = false; @override Widget build(BuildContext context) { final isActive = widget.id == widget.active; final pt = widget.pt; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: widget.onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 150), margin: const EdgeInsets.fromLTRB(8, 1, 8, 1), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), decoration: BoxDecoration( color: isActive ? pt.accentSoft : _hovered ? pt.bgHover : Colors.transparent, borderRadius: BorderRadius.circular(pt.rSm), ), child: Row( mainAxisAlignment: widget.label.isEmpty ? MainAxisAlignment.center : MainAxisAlignment.start, children: [ Icon( widget.icon, size: 14, color: isActive ? pt.accent : _hovered ? pt.fg : pt.fgMuted, ), if (widget.label.isNotEmpty) ...[ const SizedBox(width: 8), Text( widget.label, style: TextStyle( color: isActive ? pt.fg : _hovered ? pt.fg : pt.fgMuted, fontSize: 13, fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, ), ), ], ], ), ), ), ); } } class _LogoutNavButton extends ConsumerStatefulWidget { final PyramidTheme pt; final bool isMobile; final VoidCallback onClose; const _LogoutNavButton({required this.pt, required this.isMobile, required this.onClose}); @override ConsumerState<_LogoutNavButton> createState() => _LogoutNavButtonState(); } class _LogoutNavButtonState extends ConsumerState<_LogoutNavButton> { bool _hovered = false; bool _loading = false; Future _logout() async { final pt = widget.pt; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), title: Text('Abmelden?', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), content: Text('Du wirst von diesem Gerät abgemeldet.', 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 (confirmed != true || !mounted) return; setState(() => _loading = true); try { widget.onClose(); final client = await ref.read(matrixClientProvider.future); await client.logout(); } catch (_) {} } @override Widget build(BuildContext context) { final pt = widget.pt; return MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( onTap: _loading ? null : _logout, child: AnimatedContainer( duration: const Duration(milliseconds: 150), margin: const EdgeInsets.fromLTRB(8, 1, 8, 1), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), decoration: BoxDecoration( color: _hovered ? pt.danger.withAlpha(30) : Colors.transparent, borderRadius: BorderRadius.circular(pt.rSm), ), child: Row( mainAxisAlignment: widget.isMobile ? MainAxisAlignment.center : MainAxisAlignment.start, children: [ _loading ? SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: pt.danger)) : Icon(Icons.logout_rounded, size: 14, color: pt.danger), if (!widget.isMobile) ...[ const SizedBox(width: 8), Text('Abmelden', style: TextStyle(color: pt.danger, fontSize: 13)), ], ], ), ), ), ); } } // ── Shared UI primitives ────────────────────────────────────────────────────── class _SectionTitle extends StatelessWidget { final String title; final String subtitle; final PyramidTheme pt; const _SectionTitle({required this.title, required this.subtitle, required this.pt}); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: TextStyle(color: pt.fg, fontSize: 20, fontWeight: FontWeight.w700)), const SizedBox(height: 4), Text(subtitle, style: TextStyle(color: pt.fgMuted, fontSize: 13)), const SizedBox(height: 24), ], ); } } class _SettingsGroup extends StatelessWidget { final String title; final PyramidTheme pt; final List children; const _SettingsGroup({required this.title, required this.pt, required this.children}); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title.toUpperCase(), style: TextStyle( color: pt.fgDim, fontSize: 11, fontWeight: FontWeight.w600, letterSpacing: 0.04 * 11), ), const SizedBox(height: 10), Container( decoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(10), border: Border.all(color: pt.border), ), child: Column(children: children), ), ], ); } } class _SettingsRow extends StatelessWidget { final String title; final String? desc; final PyramidTheme pt; final Widget? control; final VoidCallback? onTap; final bool destructive; final bool showArrow; const _SettingsRow({ required this.title, required this.pt, this.desc, this.control, this.onTap, this.destructive = false, this.showArrow = false, }); @override Widget build(BuildContext context) { Widget content = Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: TextStyle( color: destructive ? pt.danger : pt.fg, fontSize: 13, fontWeight: FontWeight.w500, ), ), if (desc != null) ...[ const SizedBox(height: 2), Text(desc!, style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ], ), ), ?control, if (showArrow) Icon(Icons.chevron_right_rounded, size: 16, color: pt.fgDim), ], ), ); if (onTap != null) { return Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(10), child: content, ), ); } return content; } } class _Divider extends StatelessWidget { final PyramidTheme pt; const _Divider({required this.pt}); @override Widget build(BuildContext context) { return Container(height: 1, color: pt.border, margin: const EdgeInsets.only(left: 14)); } } class _Toggle extends StatelessWidget { final bool value; final ValueChanged onChanged; final PyramidTheme pt; const _Toggle({required this.value, required this.onChanged, required this.pt}); @override Widget build(BuildContext context) { return GestureDetector( onTap: () => onChanged(!value), child: AnimatedContainer( duration: const Duration(milliseconds: 200), width: 42, height: 24, decoration: BoxDecoration( color: value ? pt.accent : pt.bg3, borderRadius: BorderRadius.circular(12), border: Border.all(color: value ? pt.accent : pt.borderStrong), ), child: Padding( padding: const EdgeInsets.all(2), child: AnimatedAlign( duration: const Duration(milliseconds: 200), alignment: value ? Alignment.centerRight : Alignment.centerLeft, child: Container( width: 18, height: 18, decoration: BoxDecoration( color: Colors.white, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Colors.black.withAlpha(60), blurRadius: 4)], ), ), ), ), ), ); } } class _SegControl extends StatelessWidget { final List options; final int selected; final PyramidTheme pt; final ValueChanged onSelect; const _SegControl( {required this.options, required this.selected, required this.pt, required this.onSelect}); @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(pt.rSm), ), child: Row( mainAxisSize: MainAxisSize.min, children: options.asMap().entries.map((e) { final isActive = e.key == selected; return GestureDetector( onTap: () => onSelect(e.key), child: AnimatedContainer( duration: const Duration(milliseconds: 150), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( color: isActive ? pt.accent : Colors.transparent, borderRadius: BorderRadius.circular(pt.rSm - 2), ), child: Text( e.value, style: TextStyle( color: isActive ? pt.accentFg : pt.fgMuted, fontSize: 13, fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, ), ), ), ); }).toList(), ), ); } } class _PrimaryBtn extends StatelessWidget { final String label; final IconData? icon; final VoidCallback? onPressed; final PyramidTheme pt; final bool loading; const _PrimaryBtn({ required this.label, required this.pt, this.icon, this.onPressed, this.loading = false, }); @override Widget build(BuildContext context) { final bg = pt.accent; final fg = pt.accentFg; return ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: bg, foregroundColor: fg, disabledBackgroundColor: bg.withAlpha(100), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), elevation: 0, ), onPressed: loading ? null : onPressed, icon: loading ? SizedBox( width: 14, height: 14, child: CircularProgressIndicator(color: fg, strokeWidth: 2)) : (icon != null ? Icon(icon, size: 16) : const SizedBox.shrink()), label: Text(label), ); } } // ───────────────────────────────────────────────────────────────────────────── // PROFILE // ───────────────────────────────────────────────────────────────────────────── class _ProfileSection extends ConsumerStatefulWidget { final PyramidTheme pt; const _ProfileSection({required this.pt}); @override ConsumerState<_ProfileSection> createState() => _ProfileSectionState(); } class _ProfileSectionState extends ConsumerState<_ProfileSection> { late TextEditingController _nameCtrl; late TextEditingController _statusCtrl; bool _saving = false; bool _saved = false; String? _error; Uint8List? _pendingAvatar; Uri? _avatarUri; Uint8List? _pendingBanner; String? _bannerMxcUri; Uint8List? _pendingServerBanner; bool _serverBannerSaving = false; @override void initState() { super.initState(); _nameCtrl = TextEditingController(); _statusCtrl = TextEditingController(); _loadProfile(); } @override void dispose() { _nameCtrl.dispose(); _statusCtrl.dispose(); super.dispose(); } Future _loadProfile() async { final client = await ref.read(matrixClientProvider.future); if (!mounted) return; // 1) SOFORT aus dem lokalen Cache füllen — kein Warten auf den Server. _nameCtrl.text = client.userID ?? ''; for (final room in client.rooms) { final u = room.unsafeGetUserFromMemoryOrFallback(client.userID!); if (u.displayName != null || u.avatarUrl != null) { if (u.displayName != null) _nameCtrl.text = u.displayName!; _avatarUri = u.avatarUrl; break; } } try { final bannerData = client.accountData['com.pyramid.profile.banner']; _bannerMxcUri = bannerData?.content['url'] as String?; } catch (_) {} setState(() {}); // 2) Server-Daten PARALLEL nachladen, UI aktualisiert sich pro Antwort. client .getProfileFromUserId(client.userID!) .then((profile) { if (!mounted) return; setState(() { if (profile.displayName != null) _nameCtrl.text = profile.displayName!; _avatarUri = profile.avatarUrl ?? _avatarUri; }); }).catchError((_) {}); () async { try { final apiUri = client.homeserver!.replace( path: '/_matrix/client/v3/profile/${Uri.encodeComponent(client.userID!)}', ); final resp = await client.httpClient.get( apiUri, headers: {'Authorization': 'Bearer ${client.accessToken}'}, ); if (resp.statusCode == 200 && mounted) { final data = jsonDecode(resp.body) as Map; final url = data['io.pyramid.profile.banner_url'] as String?; if (url != null) setState(() => _bannerMxcUri = url); } } catch (_) {} }(); } Future _pickAvatar() async { final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); final file = result?.files.first; if (file == null) return; final bytes = file.bytes ?? await file.xFile.readAsBytes(); setState(() => _pendingAvatar = bytes); } Future _pickBanner() async { final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); final file = result?.files.first; if (file == null) return; final raw = file.bytes ?? await file.xFile.readAsBytes(); if (!mounted) return; final cropped = await showDialog( context: context, builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 3.0), ); if (cropped == null || !mounted) return; setState(() => _pendingBanner = cropped); } Future _pickServerBanner() async { final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); final file = result?.files.first; if (file == null) return; final raw = file.bytes ?? await file.xFile.readAsBytes(); if (!mounted) return; final cropped = await showDialog( context: context, builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4), ); if (cropped == null || !mounted) return; setState(() => _serverBannerSaving = true); try { final client = await ref.read(matrixClientProvider.future); final uri = await client.uploadContent(cropped, filename: 'server_banner.png', contentType: 'image/png'); await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), uri.toString()); if (mounted) setState(() { _pendingServerBanner = cropped; _serverBannerSaving = false; }); } catch (_) { if (mounted) setState(() => _serverBannerSaving = false); } } Future _removeServerBanner() async { final client = ref.read(matrixClientProvider).valueOrNull; if (client == null) return; await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), null); if (mounted) setState(() => _pendingServerBanner = null); } Future _save() async { setState(() { _saving = true; _error = null; _saved = false; }); try { final client = await ref.read(matrixClientProvider.future); final name = _nameCtrl.text.trim(); if (name.isNotEmpty) { await client.setProfileField( client.userID!, 'displayname', {'displayname': name}, ); } if (_pendingAvatar != null) { await client.setAvatar(MatrixFile( bytes: _pendingAvatar!, name: 'avatar.jpg', mimeType: 'image/jpeg', )); setState(() => _pendingAvatar = null); } if (_pendingBanner != null) { final uri = await client.uploadContent( _pendingBanner!, filename: 'banner.png', contentType: 'image/png'); final uriStr = uri.toString(); // Store as a profile custom field so other users can see it await client.setProfileField( client.userID!, 'io.pyramid.profile.banner_url', {'io.pyramid.profile.banner_url': uriStr}, ); // Also keep in account data as legacy fallback await client.setAccountData(client.userID!, 'com.pyramid.profile.banner', {'url': uriStr}); setState(() { _bannerMxcUri = uriStr; _pendingBanner = null; }); } final status = _statusCtrl.text.trim(); await client.setPresence( client.userID!, PresenceType.online, statusMsg: status.isEmpty ? null : status, ); if (mounted) setState(() { _saving = false; _saved = true; }); await Future.delayed(const Duration(seconds: 2)); if (mounted) setState(() => _saved = false); } catch (e) { if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; }); } } @override Widget build(BuildContext context) { final pt = widget.pt; final clientAsync = ref.watch(matrixClientProvider); final client = clientAsync.valueOrNull; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle( title: 'Mein Profil', subtitle: 'Wie du in Pyramid erscheinst.', pt: pt), // Banner GestureDetector( onTap: _pickBanner, child: Stack( children: [ Container( height: 100, decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), clipBehavior: Clip.hardEdge, child: _pendingBanner != null ? Image.memory(_pendingBanner!, fit: BoxFit.cover, width: double.infinity) : (client != null && _bannerMxcUri != null ? MxcImage( mxcUri: _bannerMxcUri!, client: client, fit: BoxFit.cover, width: double.infinity, ) : Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.add_photo_alternate_outlined, size: 28, color: pt.fgDim), const SizedBox(height: 4), Text('Profilbanner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), )), ), Positioned( right: 8, bottom: 8, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: pt.bg0.withAlpha(200), borderRadius: BorderRadius.circular(6), border: Border.all(color: pt.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted), const SizedBox(width: 4), Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)), ], ), ), ), ], ), ), const SizedBox(height: 16), // Avatar row Row( children: [ GestureDetector( onTap: _pickAvatar, child: Stack( children: [ Container( width: 72, height: 72, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: pt.border, width: 2), ), child: ClipOval( child: _pendingAvatar != null ? Image.memory(_pendingAvatar!, fit: BoxFit.cover) : (client != null && _avatarUri != null ? MxcAvatar( mxcUri: _avatarUri, client: client, size: 72, placeholder: (_) => _AvatarFallback( name: _nameCtrl.text, size: 72, pt: pt), ) : _AvatarFallback( name: _nameCtrl.text, size: 72, pt: pt)), ), ), Positioned( right: 0, bottom: 0, child: Container( width: 24, height: 24, decoration: BoxDecoration( color: pt.accent, shape: BoxShape.circle, border: Border.all(color: pt.bg1, width: 2), ), child: Icon(Icons.edit_rounded, size: 12, color: pt.accentFg), ), ), ], ), ), const SizedBox(width: 20), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _nameCtrl.text.isNotEmpty ? _nameCtrl.text : (client?.userID ?? ''), style: TextStyle( color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), ), const SizedBox(height: 2), SelectableText( client?.userID ?? '', style: TextStyle(color: pt.fgDim, fontSize: 12), ), const SizedBox(height: 6), if (_pendingAvatar != null) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: pt.accent.withAlpha(40), borderRadius: BorderRadius.circular(6), ), child: Text('Neues Bild ausgewählt', style: TextStyle(color: pt.accent, fontSize: 11)), ), ], ), ), ], ), const SizedBox(height: 24), _SettingsGroup(title: 'Name & Status', pt: pt, children: [ Padding( padding: const EdgeInsets.fromLTRB(14, 14, 14, 4), child: _PrefTextField( controller: _nameCtrl, label: 'Anzeigename', pt: pt, ), ), const SizedBox(height: 2), Padding( padding: const EdgeInsets.fromLTRB(14, 4, 14, 14), child: _PrefTextField( controller: _statusCtrl, label: 'Statusnachricht (optional)', pt: pt, maxLength: 120, ), ), ]), const SizedBox(height: 20), if (_error != null) Padding( padding: const EdgeInsets.only(bottom: 12), child: _StatusCard( pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!), ), if (_saved) Padding( padding: const EdgeInsets.only(bottom: 12), child: _StatusCard( pt: pt, color: PyramidColors.success, icon: Icons.check_circle_outline_rounded, text: 'Profil gespeichert.'), ), _PrimaryBtn( label: _saving ? 'Speichern…' : 'Speichern', icon: Icons.save_outlined, pt: pt, loading: _saving, onPressed: _save, ), const SizedBox(height: 24), // ── Server-Banner ───────────────────────────────────────────────── _SectionTitle( title: 'Server-Darstellung', subtitle: 'Banner für ${client?.homeserver?.host ?? 'diesen Server'}', pt: pt, ), Consumer(builder: (context, ref, _) { final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {}; final homeserver = client?.homeserver.toString(); final existingMxc = homeserver != null ? serverBanners[homeserver] : null; final hasBanner = _pendingServerBanner != null || existingMxc != null; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: _serverBannerSaving ? null : _pickServerBanner, child: Container( height: 90, decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), clipBehavior: Clip.hardEdge, child: Stack( fit: StackFit.expand, children: [ if (_pendingServerBanner != null) Image.memory(_pendingServerBanner!, fit: BoxFit.cover, errorBuilder: (_, _, _) => const SizedBox.shrink()) else if (existingMxc != null && client != null) MxcImage(mxcUri: existingMxc, client: client, fit: BoxFit.cover) else Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.add_photo_alternate_outlined, size: 24, color: pt.fgDim), const SizedBox(height: 4), Text('Server-Banner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ), if (_serverBannerSaving) Container( color: Colors.black38, child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), ), if (hasBanner) Positioned( right: 8, bottom: 8, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: pt.bg0.withAlpha(200), borderRadius: BorderRadius.circular(6), border: Border.all(color: pt.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted), const SizedBox(width: 4), Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)), ], ), ), ), ], ), ), ), if (hasBanner) ...[ const SizedBox(height: 8), GestureDetector( onTap: _removeServerBanner, child: Text( 'Banner entfernen', style: TextStyle(color: pt.danger, fontSize: 12), ), ), ], ], ); }), const SizedBox(height: 24), _SettingsGroup(title: 'Konto', pt: pt, children: [ _SettingsRow( title: 'Passwort ändern', desc: 'Aktuelles Passwort zum Ändern erforderlich', pt: pt, showArrow: true, onTap: _changePassword, ), ]), const SizedBox(height: 12), _SettingsGroup(title: 'Gefahrenbereich', pt: pt, children: [ _SettingsRow( title: 'Konto löschen', desc: 'Konto dauerhaft deaktivieren – nicht rückgängig zu machen', pt: pt, showArrow: true, destructive: true, onTap: _deleteAccount, ), ]), ], ), ); } Future _changePassword() async { await showDialog( context: context, barrierDismissible: false, builder: (ctx) => _ChangePasswordDialog(pt: widget.pt), ); } Future _deleteAccount() async { await showDialog( context: context, barrierDismissible: false, builder: (ctx) => _DeleteAccountDialog(pt: widget.pt), ); } } class _AvatarFallback extends StatelessWidget { final String name; final double size; final PyramidTheme pt; const _AvatarFallback({required this.name, required this.size, required this.pt}); @override Widget build(BuildContext context) { return Container( width: size, height: size, color: pt.accent, child: Center( child: Text( name.isNotEmpty ? name[0].toUpperCase() : '?', style: TextStyle(color: pt.accentFg, fontSize: size * 0.4, fontWeight: FontWeight.w700), ), ), ); } } class _PrefTextField extends StatelessWidget { final TextEditingController controller; final String label; final PyramidTheme pt; final int? maxLength; const _PrefTextField({ required this.controller, required this.label, required this.pt, this.maxLength, }); @override Widget build(BuildContext context) { return TextField( controller: controller, maxLength: maxLength, style: TextStyle(color: pt.fg, fontSize: 13), decoration: InputDecoration( labelText: label, labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), counterText: '', filled: true, fillColor: pt.bg3, isDense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), ), cursorColor: pt.accent, ); } } // ───────────────────────────────────────────────────────────────────────────── // NOTIFICATIONS // ───────────────────────────────────────────────────────────────────────────── class _NotificationsSection extends ConsumerWidget { final PyramidTheme pt; const _NotificationsSection({required this.pt}); @override Widget build(BuildContext context, WidgetRef ref) { final desktop = ref.watch(notifDesktopProvider); final sound = ref.watch(notifSoundProvider); final preview = ref.watch(notifPreviewProvider); final dmMentionOnly = ref.watch(notifMentionOnlyDmProvider); return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle( title: 'Benachrichtigungen', subtitle: 'Bestimme, wann und wie du benachrichtigt wirst.', pt: pt), _SettingsGroup(title: 'Desktop', pt: pt, children: [ _SettingsRow( title: 'Desktop-Benachrichtigungen', desc: 'Zeige Systembenachrichtigungen bei neuen Nachrichten', pt: pt, control: _Toggle( value: desktop, pt: pt, onChanged: (v) => ref.read(notifDesktopProvider.notifier).set(v), ), ), _Divider(pt: pt), _SettingsRow( title: 'Benachrichtigungston', desc: 'Sound abspielen wenn eine Nachricht eingeht', pt: pt, control: _Toggle( value: sound, pt: pt, onChanged: (v) => ref.read(notifSoundProvider.notifier).set(v), ), ), _Divider(pt: pt), _SettingsRow( title: 'Nachrichtenvorschau', desc: 'Nachrichteninhalt in Benachrichtigung anzeigen', pt: pt, control: _Toggle( value: preview, pt: pt, onChanged: (v) => ref.read(notifPreviewProvider.notifier).set(v), ), ), ]), const SizedBox(height: 20), _SettingsGroup(title: 'Filter', pt: pt, children: [ _SettingsRow( title: 'Nur Erwähnungen (DMs)', desc: 'Benachrichtigung nur bei direkten @Erwähnungen', pt: pt, control: _Toggle( value: dmMentionOnly, pt: pt, onChanged: (v) => ref.read(notifMentionOnlyDmProvider.notifier).set(v), ), ), ]), const SizedBox(height: 20), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(10), border: Border.all(color: pt.border), ), child: Row( children: [ Icon(Icons.info_outline_rounded, size: 16, color: pt.fgDim), const SizedBox(width: 10), Expanded( child: Text( 'Push-Benachrichtigungen für Android/iOS sind in Vorbereitung.', style: TextStyle(color: pt.fgMuted, fontSize: 12), ), ), ], ), ), ], ), ); } } // ───────────────────────────────────────────────────────────────────────────── // APPEARANCE // ───────────────────────────────────────────────────────────────────────────── class _AppearanceSection extends StatelessWidget { final PyramidTheme pt; final bool isDark; final int accentIdx; final double radius; final double density; final double motion; final WidgetRef ref; const _AppearanceSection({ required this.pt, required this.isDark, required this.accentIdx, required this.radius, required this.density, required this.motion, required this.ref, }); @override Widget build(BuildContext context) { return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle( title: 'Erscheinungsbild', subtitle: 'Gestalte Pyramid nach deinem Geschmack.', pt: pt), _SettingsGroup(title: 'Theme', pt: pt, children: [ _SettingsRow( title: 'Farbmodus', desc: 'Hell oder dunkel', pt: pt, control: _SegControl( options: const ['Dunkel', 'Hell'], selected: isDark ? 0 : 1, pt: pt, onSelect: (i) { final dark = i == 0; ref.read(themeModeProvider.notifier).state = dark; saveThemePrefs(isDark: dark); }, ), ), _Divider(pt: pt), _SettingsRow( title: 'Akzentfarbe', desc: 'Farbe für Hervorhebungen und Buttons', pt: pt, control: Row( children: accentPresets.asMap().entries.map((e) { final i = e.key; final preset = e.value; final isActive = accentIdx == i; return GestureDetector( onTap: () { ref.read(accentProvider.notifier).state = i; saveThemePrefs(accentIdx: i); }, child: AnimatedContainer( duration: const Duration(milliseconds: 150), width: 22, height: 22, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration( color: preset.color, shape: BoxShape.circle, border: isActive ? Border.all(color: Colors.white, width: 2) : null, boxShadow: isActive ? [BoxShadow(color: preset.color.withAlpha(100), blurRadius: 8)] : null, ), ), ); }).toList(), ), ), ]), const SizedBox(height: 20), _SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [ _SettingsRow( title: 'Eckenrundung', desc: '${radius.round()} px', pt: pt, control: SizedBox( width: 150, child: SliderTheme( data: SliderThemeData( activeTrackColor: pt.accent, thumbColor: pt.accent, inactiveTrackColor: pt.border, trackHeight: 3, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), ), child: Slider( value: radius, min: 4, max: 20, onChanged: (v) { ref.read(radiusProvider.notifier).state = v; saveThemePrefs(radius: v); }, ), ), ), ), _Divider(pt: pt), _SettingsRow( title: 'Animationen', desc: '${(motion * 100).round()}%', pt: pt, control: SizedBox( width: 150, child: SliderTheme( data: SliderThemeData( activeTrackColor: pt.accent, thumbColor: pt.accent, inactiveTrackColor: pt.border, trackHeight: 3, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), ), child: Slider( value: motion, min: 0, max: 1, onChanged: (v) { ref.read(motionProvider.notifier).state = v; saveThemePrefs(motion: v); }, ), ), ), ), ]), const SizedBox(height: 20), _SettingsGroup(title: 'Vorschau', pt: pt, children: [ Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Expanded( child: Column( children: [ Container( height: 100, decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), child: const Center(child: PyramidLoader(size: 70, useLottie: true)), ), const SizedBox(height: 6), Text('Lottie', style: TextStyle(color: pt.fgMuted, fontSize: 11)), ], ), ), const SizedBox(width: 12), Expanded( child: Column( children: [ Container( height: 100, decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), child: const Center(child: PyramidLoader(size: 70, useLottie: false)), ), const SizedBox(height: 6), Text('SVG', style: TextStyle(color: pt.fgMuted, fontSize: 11)), ], ), ), ], ), ), ]), ], ), ); } } // ───────────────────────────────────────────────────────────────────────────── // VOICE & VIDEO // ───────────────────────────────────────────────────────────────────────────── class _VoiceSection extends StatefulWidget { final PyramidTheme pt; const _VoiceSection({required this.pt}); @override State<_VoiceSection> createState() => _VoiceSectionState(); } class _VoiceSectionState extends State<_VoiceSection> { List _audioInputs = []; List _audioOutputs = []; List _videoInputs = []; String? _selectedMic; String? _selectedSpeaker; String? _selectedCamera; double _outputVolume = 1.0; bool _loading = true; String? _loadError; static const _kMic = 'voice_mic_device'; static const _kSpeaker = 'voice_speaker_device'; static const _kCamera = 'voice_camera_device'; static const _kOutputVolume = 'voice_output_volume'; @override void initState() { super.initState(); _init(); } Future _init() async { final prefs = await SharedPreferences.getInstance(); List devices = []; String? loadError; try { devices = await navigator.mediaDevices.enumerateDevices(); } catch (_) { devices = []; } // Windows (und teils Android) liefert eine LEERE Liste ohne Fehler, // solange in diesem Prozess noch nie ein Media-Stream geöffnet wurde. // Deshalb nicht nur bei Exception, sondern auch bei leerer Liste einmal // kurz getUserMedia anfordern und erneut enumerieren — und zwar SOLANGE // der Stream noch offen ist (nach stop() melden manche Systeme wieder 0). final hasAudioInput = devices.any((d) => d.kind?.toLowerCase() == 'audioinput'); if (devices.isEmpty || !hasAudioInput) { try { final stream = await navigator.mediaDevices .getUserMedia({'audio': true, 'video': false}); try { devices = await navigator.mediaDevices.enumerateDevices(); } finally { for (final t in stream.getTracks()) { t.stop(); } await stream.dispose(); } if (devices.isEmpty) { // Bekanntes libwebrtc-Problem auf manchen Windows-Systemen: das // Audio-Device-Module meldet 0 Geräte, obwohl Aufnahme/Wiedergabe // über das Standardgerät einwandfrei funktionieren. loadError = 'Die Audiotreiber melden keine Geräteliste an WebRTC ' '(bekanntes libwebrtc-Problem auf manchen Windows-Systemen). ' 'Anrufe funktionieren trotzdem — es wird das Windows-Standardgerät verwendet.'; } } catch (e2) { loadError = 'getUserMedia: ${e2.toString().split('\n').first}'; } } if (!mounted) return; setState(() { _audioInputs = devices.where((d) => d.kind?.toLowerCase() == 'audioinput').toList(); _audioOutputs = devices.where((d) => d.kind?.toLowerCase() == 'audiooutput').toList(); _videoInputs = devices.where((d) => d.kind?.toLowerCase() == 'videoinput').toList(); _selectedMic = prefs.getString(_kMic); _selectedSpeaker = prefs.getString(_kSpeaker); _selectedCamera = prefs.getString(_kCamera); _outputVolume = prefs.getDouble(_kOutputVolume) ?? 1.0; _loadError = loadError; _loading = false; }); } Future _setOutputVolume(double v) async { setState(() => _outputVolume = v); final prefs = await SharedPreferences.getInstance(); await prefs.setDouble(_kOutputVolume, v); // Live auf laufende Calls anwenden. LiveKitCallManager.instance.setOutputVolume(v).catchError((_) {}); try { PyramidVoipManager.instance.applyOutputVolume(v).catchError((_) {}); } catch (_) {} // Instanz existiert evtl. noch nicht } Future _save(String key, String? value) async { final prefs = await SharedPreferences.getInstance(); if (value == null || value.isEmpty) { await prefs.remove(key); } else { await prefs.setString(key, value); } } Widget _deviceDropdown({ required List devices, required String? selected, required String placeholder, required IconData icon, required ValueChanged onChanged, }) { final pt = widget.pt; if (devices.isEmpty) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(6), border: Border.all(color: pt.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 13, color: pt.fgDim), const SizedBox(width: 6), Text('Kein Gerät', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ); } final validSelected = devices.any((d) => d.deviceId == selected) ? selected : null; return ConstrainedBox( constraints: const BoxConstraints(maxWidth: 220), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(6), border: Border.all(color: pt.border), ), child: DropdownButtonHideUnderline( child: DropdownButton( value: validSelected, hint: Row(children: [ Icon(icon, size: 13, color: pt.fgDim), const SizedBox(width: 6), Text(placeholder, style: TextStyle(color: pt.fgDim, fontSize: 12)), ]), dropdownColor: pt.bg2, iconEnabledColor: pt.fgDim, isDense: true, style: TextStyle(color: pt.fg, fontSize: 12), onChanged: onChanged, items: devices.map((d) { final label = d.label.isNotEmpty ? d.label : 'Gerät ${d.deviceId.substring(0, 6)}'; return DropdownMenuItem( value: d.deviceId, child: Text(label, overflow: TextOverflow.ellipsis), ); }).toList(), ), ), ), ); } @override Widget build(BuildContext context) { final pt = widget.pt; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle( title: 'Sprache & Video', subtitle: 'Mikrofon, Kamera und Anrufeinstellungen.', pt: pt), if (_loading) const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive())) else ...[ if (_loadError != null) Padding( padding: const EdgeInsets.only(bottom: 16), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(8), border: Border.all(color: pt.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(Icons.warning_amber_rounded, size: 16, color: pt.away), const SizedBox(width: 8), Text('Geräteliste nicht verfügbar', style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), ]), const SizedBox(height: 6), Text(_loadError!, style: TextStyle(color: pt.fgDim, fontSize: 11)), const SizedBox(height: 6), Text( 'Mikrofon und Lautsprecher legst du in diesem Fall über die ' 'Windows-Soundeinstellungen (Standardgerät) fest. ' 'Falls dort alles stimmt: Windows-Einstellungen → Datenschutz → Mikrofon prüfen.', style: TextStyle(color: pt.fgMuted, fontSize: 11), ), ], ), ), ), _SettingsGroup(title: 'Geräte', pt: pt, children: [ _SettingsRow( title: 'Mikrofon', desc: _audioInputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioInputs.length} Gerät(e) gefunden', pt: pt, control: _deviceDropdown( devices: _audioInputs, selected: _selectedMic, placeholder: 'Standard', icon: Icons.mic_rounded, onChanged: (v) { setState(() => _selectedMic = v); _save(_kMic, v); }, ), ), _Divider(pt: pt), _SettingsRow( title: 'Lautsprecher', desc: _audioOutputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioOutputs.length} Gerät(e) gefunden', pt: pt, control: _deviceDropdown( devices: _audioOutputs, selected: _selectedSpeaker, placeholder: 'Standard', icon: Icons.volume_up_rounded, onChanged: (v) { setState(() => _selectedSpeaker = v); _save(_kSpeaker, v); }, ), ), _Divider(pt: pt), _SettingsRow( title: 'Kamera', desc: _videoInputs.isEmpty ? 'Kein Gerät gefunden' : '${_videoInputs.length} Gerät(e) gefunden', pt: pt, control: _deviceDropdown( devices: _videoInputs, selected: _selectedCamera, placeholder: 'Standard', icon: Icons.videocam_rounded, onChanged: (v) { setState(() => _selectedCamera = v); _save(_kCamera, v); }, ), ), ]), const SizedBox(height: 16), _SettingsGroup(title: 'Pegel', pt: pt, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.volume_up_rounded, size: 15, color: pt.fgMuted), const SizedBox(width: 8), Text('Ausgabelautstärke', style: TextStyle( color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), const Spacer(), Text('${(_outputVolume * 100).round()} %', style: TextStyle( color: pt.fgMuted, fontSize: 12, fontFamily: 'monospace')), ], ), SliderTheme( data: SliderThemeData( activeTrackColor: pt.accent, inactiveTrackColor: pt.bg3, thumbColor: pt.accent, overlayColor: pt.accent.withAlpha(30), trackHeight: 3, thumbShape: const RoundSliderThumbShape( enabledThumbRadius: 7), ), child: Slider( value: _outputVolume.clamp(0.0, 1.5), min: 0.0, max: 1.5, divisions: 30, onChanged: _setOutputVolume, ), ), Text( 'Lautstärke der anderen Teilnehmer in Calls. ' 'Wirkt sofort auf laufende Anrufe.', style: TextStyle(color: pt.fgDim, fontSize: 11), ), ], ), ), ]), const SizedBox(height: 16), _TurnUsageCard(pt: pt), const SizedBox(height: 12), TextButton.icon( style: TextButton.styleFrom( foregroundColor: pt.fgMuted, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), onPressed: () { setState(() { _loading = true; _loadError = null; }); _init(); }, icon: const Icon(Icons.refresh_rounded, size: 14), label: const Text('Geräte neu laden', style: TextStyle(fontSize: 12)), ), ], ], ), ); } } // ── TURN-Verbrauch (Cloudflare Free Tier, vom Pi überwacht) ────────────────── /// Zeigt den TURN-Relay-Verbrauch des laufenden Monats an. Die Daten kommen /// aus der turn-status.json, die der Guard-Cron auf dem Pi stündlich schreibt. /// Solange der Endpoint nicht existiert (Analytics-Token fehlt), bleibt die /// Karte unsichtbar. class _TurnUsageCard extends StatefulWidget { final PyramidTheme pt; const _TurnUsageCard({required this.pt}); @override State<_TurnUsageCard> createState() => _TurnUsageCardState(); } class _TurnUsageCardState extends State<_TurnUsageCard> { Map? _status; @override void initState() { super.initState(); _load(); } Future _load() async { try { final resp = await http .get(Uri.parse( 'https://steggi-matrix.work/.well-known/pyramid/turn-status.json')) .timeout(const Duration(seconds: 5)); if (resp.statusCode == 200 && mounted) { setState( () => _status = jsonDecode(resp.body) as Map); } } catch (_) { // Endpoint (noch) nicht vorhanden — Karte einfach nicht anzeigen. } } @override Widget build(BuildContext context) { final s = _status; if (s == null) return const SizedBox.shrink(); final pt = widget.pt; final used = (s['egress_gb'] as num?)?.toDouble() ?? 0; final limit = (s['limit_gb'] as num?)?.toDouble() ?? 990; final pct = limit > 0 ? (used / limit).clamp(0.0, 1.0) : 0.0; final active = s['turn_active'] != false; final barColor = !active ? pt.danger : pct > 0.8 ? pt.away : pt.accent; return _SettingsGroup(title: 'Call-Relay (TURN)', pt: pt, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(active ? Icons.cloud_done_rounded : Icons.cloud_off_rounded, size: 15, color: active ? pt.online : pt.danger), const SizedBox(width: 8), Text( active ? 'Relay aktiv' : 'Relay deaktiviert (Limit erreicht)', style: TextStyle( color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), const Spacer(), Text( '${used.toStringAsFixed(1)} / ${limit.toStringAsFixed(0)} GB', style: TextStyle( color: pt.fgMuted, fontSize: 12, fontFamily: 'monospace')), ]), const SizedBox(height: 8), ClipRRect( borderRadius: BorderRadius.circular(99), child: LinearProgressIndicator( value: pct, minHeight: 5, backgroundColor: pt.bg3, color: barColor, ), ), const SizedBox(height: 6), Text( active ? 'Cloudflare-TURN-Traffic diesen Monat (Free Tier: ${limit.toStringAsFixed(0)} GB). ' 'Bei Erreichen des Limits laufen Calls bis Monatsende ohne Relay weiter.' : 'Monatslimit erreicht — Calls nutzen bis Monatsende nur direkte Verbindungen (STUN).', style: TextStyle(color: pt.fgDim, fontSize: 11), ), ], ), ), ]); } } // ───────────────────────────────────────────────────────────────────────────── // KEYBINDS // ───────────────────────────────────────────────────────────────────────────── class _KeysSection extends StatelessWidget { final PyramidTheme pt; const _KeysSection({required this.pt}); @override Widget build(BuildContext context) { final keys = [ ('Schnellsuche', 'Strg+K'), ('Raum als gelesen markieren', 'Shift+Esc'), ('Zu ungelesen springen', 'Strg+J'), ('Linke Leiste ein-/ausblenden', 'Strg+B'), ('Mitglieder ein-/ausblenden', 'Strg+Shift+U'), ('Nachrichten durchsuchen', 'Strg+F'), ('Antworten', 'R'), ('Auswahl aufheben', 'Esc'), ]; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle( title: 'Tastaturkürzel', subtitle: 'Schnelle Aktionen per Tastatur.', pt: pt), _SettingsGroup( title: 'Navigieren', pt: pt, children: keys.asMap().entries.map((entry) { final (action, binding) = entry.value; return Column( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Row( children: [ Expanded( child: Text(action, style: TextStyle(color: pt.fg, fontSize: 13))), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(6), border: Border.all(color: pt.border), ), child: Text(binding, style: TextStyle( color: pt.fgMuted, fontSize: 12, fontFamily: 'monospace')), ), ], ), ), if (entry.key < keys.length - 1) _Divider(pt: pt), ], ); }).toList(), ), ], ), ); } } // ───────────────────────────────────────────────────────────────────────────── // PRIVACY // ───────────────────────────────────────────────────────────────────────────── class _PrivacySection extends ConsumerStatefulWidget { final PyramidTheme pt; const _PrivacySection({required this.pt}); @override ConsumerState<_PrivacySection> createState() => _PrivacySectionState(); } class _PrivacySectionState extends ConsumerState<_PrivacySection> { List _ignoredUsers = []; final Set _unblocking = {}; @override void initState() { super.initState(); _loadIgnoredUsers(); } Future _loadIgnoredUsers() async { final client = await ref.read(matrixClientProvider.future); if (mounted) setState(() => _ignoredUsers = List.from(client.ignoredUsers)); } Future _unblockUser(String userId) async { setState(() => _unblocking.add(userId)); try { final client = await ref.read(matrixClientProvider.future); await client.unignoreUser(userId); await _loadIgnoredUsers(); } catch (e) { // ignore — list will be stale but not crash } finally { if (mounted) setState(() => _unblocking.remove(userId)); } } @override Widget build(BuildContext context) { final pt = widget.pt; final presence = ref.watch(privacyPresenceProvider); final readReceipts = ref.watch(privacyReadReceiptsProvider); final typing = ref.watch(privacyTypingProvider); return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle( title: 'Datenschutz', subtitle: 'Steuere, was andere über dich erfahren.', pt: pt), _SettingsGroup(title: 'Sichtbarkeit', pt: pt, children: [ _SettingsRow( title: 'Online-Status anzeigen', desc: 'Anderen anzeigen, wenn du aktiv bist', pt: pt, control: _Toggle( value: presence, pt: pt, onChanged: (v) async { ref.read(privacyPresenceProvider.notifier).set(v); final client = await ref.read(matrixClientProvider.future); await client.setPresence( client.userID!, v ? PresenceType.online : PresenceType.offline, ).catchError((_) {}); }, ), ), _Divider(pt: pt), _SettingsRow( title: 'Lesebestätigungen senden', desc: 'Anderen zeigen, dass du ihre Nachrichten gelesen hast', pt: pt, control: _Toggle( value: readReceipts, pt: pt, onChanged: (v) => ref.read(privacyReadReceiptsProvider.notifier).set(v), ), ), _Divider(pt: pt), _SettingsRow( title: 'Tipp-Indikator senden', desc: '„schreibt…" Anzeige aktivieren', pt: pt, control: _Toggle( value: typing, pt: pt, onChanged: (v) => ref.read(privacyTypingProvider.notifier).set(v), ), ), ]), const SizedBox(height: 20), // ── Blocked users ────────────────────────────────────────────────── _SettingsGroup(title: 'Blockierte Nutzer (${_ignoredUsers.length})', pt: pt, children: [ if (_ignoredUsers.isEmpty) Padding( padding: const EdgeInsets.all(14), child: Text('Keine blockierten Nutzer.', style: TextStyle(color: pt.fgMuted, fontSize: 13)), ) else ..._ignoredUsers.asMap().entries.map((entry) { final i = entry.key; final userId = entry.value; final isUnblocking = _unblocking.contains(userId); return Column(children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Row(children: [ Expanded( child: Text(userId, style: TextStyle(color: pt.fg, fontSize: 13), overflow: TextOverflow.ellipsis), ), if (isUnblocking) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) else TextButton( style: TextButton.styleFrom( foregroundColor: pt.accent, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), onPressed: () => _unblockUser(userId), child: const Text('Entblocken', style: TextStyle(fontSize: 12)), ), ]), ), if (i < _ignoredUsers.length - 1) _Divider(pt: pt), ]); }), ]), const SizedBox(height: 20), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: pt.accentSoft, borderRadius: BorderRadius.circular(10), border: Border.all(color: pt.accent.withAlpha(60)), ), child: Row( children: [ Icon(Icons.shield_outlined, size: 16, color: pt.accent), const SizedBox(width: 10), Expanded( child: Text( 'Alle Nachrichten sind Ende-zu-Ende-verschlüsselt.', style: TextStyle(color: pt.fg, fontSize: 12), ), ), ], ), ), ], ), ); } } // ───────────────────────────────────────────────────────────────────────────── // SESSIONS // ───────────────────────────────────────────────────────────────────────────── class _SessionsSection extends ConsumerStatefulWidget { final PyramidTheme pt; const _SessionsSection({required this.pt}); @override ConsumerState<_SessionsSection> createState() => _SessionsSectionState(); } class _SessionsSectionState extends ConsumerState<_SessionsSection> { List _devices = []; bool _loading = true; String? _currentDeviceId; final Set _revoking = {}; bool _logoutAllLoading = false; String? _error; @override void initState() { super.initState(); _load(); } Future _load() async { setState(() { _loading = true; _error = null; }); try { final client = await ref.read(matrixClientProvider.future); _currentDeviceId = client.deviceID; await client.updateUserDeviceKeys(additionalUsers: {client.userID!}); final keys = client.userDeviceKeys[client.userID]; if (mounted) { setState(() { _devices = keys?.deviceKeys.values.toList() ?? []; _loading = false; }); } } catch (e) { if (mounted) { setState(() { _loading = false; _error = e.toString().split('\n').first; }); } } } Future _revokeDevice(String deviceId) async { setState(() => _revoking.add(deviceId)); try { final client = await ref.read(matrixClientProvider.future); await client.deleteDevice(deviceId); await _load(); } catch (e) { if (mounted) { setState(() { _revoking.remove(deviceId); _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; }); } } } Future _renameCurrentDevice(BuildContext context) async { final client = await ref.read(matrixClientProvider.future); final currentName = _devices .where((d) => d.deviceId == _currentDeviceId) .map((d) => d.unsigned?['device_display_name'] as String? ?? d.deviceId ?? '') .firstOrNull ?? ''; final ctrl = TextEditingController(text: currentName); final pt = widget.pt; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), title: Text('Gerät umbenennen', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), content: TextField( controller: ctrl, autofocus: true, style: TextStyle(color: pt.fg, fontSize: 13), decoration: InputDecoration( labelText: 'Gerätename', labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), filled: true, fillColor: pt.bg3, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), ), cursorColor: pt.accent, ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => Navigator.pop(ctx, true), child: const Text('Umbenennen'), ), ], ), ); ctrl.dispose(); if (confirmed != true || _currentDeviceId == null) return; try { await client.updateDevice(_currentDeviceId!, displayName: ctrl.text.trim()); await _load(); } catch (e) { if (mounted) setState(() => _error = 'Umbenennen fehlgeschlagen: ${e.toString().split('\n').first}'); } } Future _logoutAllOtherDevices(BuildContext context) async { final pt = widget.pt; final otherIds = _devices .where((d) => d.deviceId != _currentDeviceId && d.deviceId != null) .map((d) => d.deviceId!) .toList(); if (otherIds.isEmpty) return; final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), title: Text('Alle anderen Sitzungen abmelden?', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), content: Text( '${otherIds.length} Gerät${otherIds.length == 1 ? '' : 'e'} werden abgemeldet. Nicht gespeicherte Nachrichten auf diesen Geräten können verloren gehen.', style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => Navigator.pop(ctx, true), child: const Text('Alle abmelden'), ), ], ), ); if (confirm != true || !mounted) return; setState(() { _logoutAllLoading = true; _error = null; }); final client = await ref.read(matrixClientProvider.future); try { await client.deleteDevices(otherIds); } on MatrixException catch (e) { if (e.requireAdditionalAuthentication && mounted) { final password = await _askPassword(context, pt); if (password == null || !mounted) { setState(() => _logoutAllLoading = false); return; } try { await client.deleteDevices(otherIds, auth: AuthenticationData( type: AuthenticationTypes.password, session: e.session, additionalFields: { 'identifier': {'type': 'm.id.user', 'user': client.userID}, 'password': password, }, )); } catch (e2) { if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e2.toString().split('\n').first}'; }); return; } } else { if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; }); return; } } catch (e) { if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; }); return; } await _load(); if (mounted) setState(() => _logoutAllLoading = false); } Future _askPassword(BuildContext context, PyramidTheme pt) { final ctrl = TextEditingController(); return showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), title: Text('Passwort bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w700)), content: TextField( controller: ctrl, autofocus: true, obscureText: true, style: TextStyle(color: pt.fg, fontSize: 13), decoration: InputDecoration( labelText: 'Passwort', labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), filled: true, fillColor: pt.bg3, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), ), cursorColor: pt.accent, onSubmitted: (_) => Navigator.pop(ctx, ctrl.text), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, null), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))), ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => Navigator.pop(ctx, ctrl.text), child: const Text('Bestätigen'), ), ], ), ).then((v) { ctrl.dispose(); return v; }); } @override Widget build(BuildContext context) { final pt = widget.pt; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle( title: 'Sitzungen', subtitle: 'Geräte, auf denen du angemeldet bist.', pt: pt), if (_loading) const Center( child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive())) else if (_error != null) _StatusCard( pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!) else ...[ _SettingsGroup( title: 'Aktive Sitzungen (${_devices.length})', pt: pt, children: _devices.isEmpty ? [ Padding( padding: const EdgeInsets.all(16), child: Text('Keine Sitzungen gefunden.', style: TextStyle(color: pt.fgMuted, fontSize: 13)), ) ] : _devices.asMap().entries.map((entry) { final i = entry.key; final device = entry.value; final isCurrent = device.deviceId == _currentDeviceId; final isRevoking = _revoking.contains(device.deviceId); final isVerified = device.verified; final displayName = device.unsigned?['device_display_name'] as String? ?? device.deviceId ?? 'Unbekanntes Gerät'; return Column( children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 12), child: Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: isCurrent ? pt.accentSoft : pt.bg3, borderRadius: BorderRadius.circular(8), ), child: Icon( Icons.devices_rounded, size: 18, color: isCurrent ? pt.accent : pt.fgMuted, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( displayName, style: TextStyle( color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500, ), ), ), if (isCurrent) ...[ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2), decoration: BoxDecoration( color: pt.accentSoft, borderRadius: BorderRadius.circular(4), ), child: Text('Dieses Gerät', style: TextStyle( color: pt.accent, fontSize: 10, fontWeight: FontWeight.w600)), ), const SizedBox(width: 4), GestureDetector( onTap: () => _renameCurrentDevice(context), child: Tooltip( message: 'Gerät umbenennen', child: Icon(Icons.edit_rounded, size: 13, color: pt.fgDim), ), ), ], if (isVerified == true && !isCurrent) Icon(Icons.verified_rounded, size: 14, color: PyramidColors.success), if (isVerified == false && !isCurrent) Icon(Icons.warning_amber_rounded, size: 14, color: pt.away), ], ), const SizedBox(height: 2), SelectableText( device.deviceId ?? '', style: TextStyle( color: pt.fgDim, fontSize: 11, fontFamily: 'monospace'), ), ], ), ), if (!isCurrent) ...[ const SizedBox(width: 8), if (isVerified == false) GestureDetector( onTap: () => _verifyDevice(context, device, displayName), child: Tooltip( message: 'Verifizieren', child: Icon(Icons.verified_user_outlined, size: 16, color: pt.accent), ), ), const SizedBox(width: 6), if (isRevoking) SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: pt.danger), ) else GestureDetector( onTap: () => _confirmRevoke(context, device, displayName), child: Tooltip( message: 'Abmelden', child: Icon(Icons.logout_rounded, size: 16, color: pt.fgDim), ), ), ], ], ), ), if (i < _devices.length - 1) _Divider(pt: pt), ], ); }).toList(), ), const SizedBox(height: 12), Row( children: [ TextButton.icon( style: TextButton.styleFrom( foregroundColor: pt.fgMuted, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), onPressed: _load, icon: const Icon(Icons.refresh_rounded, size: 14), label: const Text('Aktualisieren', style: TextStyle(fontSize: 12)), ), const Spacer(), if (_logoutAllLoading) const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) else if (_devices.where((d) => d.deviceId != _currentDeviceId).isNotEmpty) TextButton.icon( style: TextButton.styleFrom( foregroundColor: pt.danger, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), onPressed: () => _logoutAllOtherDevices(context), icon: const Icon(Icons.logout_rounded, size: 14), label: const Text('Alle anderen abmelden', style: TextStyle(fontSize: 12)), ), ], ), ], ], ), ); } Future _verifyDevice( BuildContext context, DeviceKeys device, String displayName) async { await showDialog( context: context, barrierDismissible: false, builder: (ctx) => _SasVerificationDialog( pt: widget.pt, deviceName: displayName, device: device, ), ); await _load(); } Future _confirmRevoke( BuildContext context, DeviceKeys device, String displayName) async { final confirm = await showDialog( context: context, builder: (ctx) { final pt = widget.pt; return AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), title: Text('Gerät abmelden?', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), content: Text( '"$displayName" wird abgemeldet und alle lokalen Schlüssel werden gelöscht.', style: TextStyle(color: pt.fgMuted, fontSize: 13), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => Navigator.pop(ctx, true), child: const Text('Abmelden'), ), ], ); }, ); if (confirm == true && device.deviceId != null) { await _revokeDevice(device.deviceId!); } } } // ───────────────────────────────────────────────────────────────────────────── // SAS VERIFICATION DIALOG // ───────────────────────────────────────────────────────────────────────────── class _SasVerificationDialog extends ConsumerStatefulWidget { final PyramidTheme pt; final String deviceName; final DeviceKeys device; const _SasVerificationDialog({ required this.pt, required this.deviceName, required this.device, }); @override ConsumerState<_SasVerificationDialog> createState() => _SasVerificationDialogState(); } class _SasVerificationDialogState extends ConsumerState<_SasVerificationDialog> { KeyVerification? _verification; KeyVerificationState _state = KeyVerificationState.waitingAccept; bool _starting = true; String? _error; @override void initState() { super.initState(); _startVerification(); } @override void dispose() { final v = _verification; if (v != null && !v.isDone) { v.cancel('m.user').catchError((_) {}); } super.dispose(); } Future _startVerification() async { try { final client = await ref.read(matrixClientProvider.future); final deviceId = widget.device.deviceId; if (deviceId == null) throw Exception('Keine Geräte-ID'); final deviceKeys = client.userDeviceKeys[client.userID]?.deviceKeys[deviceId]; if (deviceKeys == null) throw Exception('Gerät nicht gefunden'); final verification = await deviceKeys.startVerification(); _verification = verification; verification.onUpdate = () { if (!mounted) return; setState(() => _state = verification.state); }; if (mounted) { setState(() { _state = verification.state; _starting = false; }); } } catch (e) { if (mounted) { setState(() { _error = e.toString().split('\n').first; _starting = false; }); } } } @override Widget build(BuildContext context) { final pt = widget.pt; return AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), title: Row( children: [ Icon(Icons.verified_user_outlined, size: 20, color: pt.accent), const SizedBox(width: 10), Expanded( child: Text( 'Gerät verifizieren', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700), ), ), ], ), content: SizedBox( width: 380, child: _buildContent(pt), ), actions: _buildActions(pt), ); } Widget _buildContent(PyramidTheme pt) { if (_error != null) { return Padding( padding: const EdgeInsets.only(bottom: 16), child: _StatusCard( pt: pt, icon: Icons.error_outline_rounded, color: pt.danger, text: _error!), ); } if (_starting) { return const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center(child: CircularProgressIndicator.adaptive()), ); } final v = _verification; return switch (_state) { KeyVerificationState.waitingAccept || KeyVerificationState.askAccept => Padding( padding: const EdgeInsets.only(bottom: 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator.adaptive(), const SizedBox(height: 16), Text( 'Warte auf Bestätigung von „${widget.deviceName}"…', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13), ), const SizedBox(height: 8), Text( 'Öffne Pyramid auf dem anderen Gerät und bestätige die Verifikationsanfrage.', textAlign: TextAlign.center, style: TextStyle(color: pt.fgDim, fontSize: 12), ), ], ), ), KeyVerificationState.askChoice => Padding( padding: const EdgeInsets.only(bottom: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Das andere Gerät hat die Anfrage akzeptiert.\nWähle eine Verifikationsmethode:', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13), ), const SizedBox(height: 16), ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(vertical: 12), ), onPressed: () => v?.continueVerification('m.sas.v1').catchError((_) {}), icon: const Icon(Icons.tag_faces_rounded, size: 18), label: const Text('Emoji-Verifikation'), ), const SizedBox(height: 8), ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: pt.bg3, foregroundColor: pt.fg, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(vertical: 12), ), onPressed: () => v?.continueVerification('m.qr_code.show.v1').catchError((_) {}), icon: const Icon(Icons.qr_code_rounded, size: 18), label: const Text('QR-Code anzeigen'), ), const SizedBox(height: 8), ], ), ), KeyVerificationState.confirmQRScan => _OutgoingQrCodeDisplay( pt: pt, request: v, ), KeyVerificationState.showQRSuccess => Padding( padding: const EdgeInsets.only(bottom: 16), child: _StatusCard( pt: pt, icon: Icons.verified_rounded, color: PyramidColors.success, text: 'QR-Code erfolgreich gescannt. Verifikation abgeschlossen.'), ), KeyVerificationState.askSas => _SasEmojiContent( pt: pt, verification: v!, onConfirm: () => v.acceptSas().catchError((_) {}), onReject: () { v.rejectSas().catchError((_) {}); Navigator.of(context).pop(); }, ), KeyVerificationState.waitingSas => Padding( padding: const EdgeInsets.only(bottom: 16), child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator.adaptive(), const SizedBox(height: 16), Text( 'Warte auf Bestätigung des anderen Geräts…', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13), ), ], ), ), KeyVerificationState.done => Padding( padding: const EdgeInsets.only(bottom: 16), child: _StatusCard( pt: pt, icon: Icons.verified_rounded, color: PyramidColors.success, text: '„${widget.deviceName}" wurde erfolgreich verifiziert.'), ), _ => Padding( padding: const EdgeInsets.only(bottom: 16), child: _StatusCard( pt: pt, icon: Icons.error_outline_rounded, color: pt.danger, text: 'Verifizierung fehlgeschlagen oder abgebrochen.'), ), }; } List _buildActions(PyramidTheme pt) { if (_state == KeyVerificationState.done || _state == KeyVerificationState.showQRSuccess || _error != null || _state == KeyVerificationState.error) { return [ ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => Navigator.pop(context), child: const Text('Schließen'), ), ]; } if (_state == KeyVerificationState.confirmQRScan) { return [ TextButton( onPressed: () { _verification?.cancel('m.user').catchError((_) {}); Navigator.pop(context); }, child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF22C55E), foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => _verification?.acceptQRScanConfirmation().catchError((_) {}), child: const Text('Gescannt bestätigen'), ), ]; } if (_state == KeyVerificationState.askSas || _state == KeyVerificationState.askChoice) { return []; } return [ TextButton( onPressed: () { _verification?.cancel('m.user').catchError((_) {}); Navigator.pop(context); }, child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), ), ]; } } class _OutgoingQrCodeDisplay extends StatelessWidget { final PyramidTheme pt; final KeyVerification? request; const _OutgoingQrCodeDisplay({required this.pt, required this.request}); @override Widget build(BuildContext context) { final qrCode = request?.qrCode; if (qrCode == null) { return Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator.adaptive(), const SizedBox(height: 12), Text('QR-Code wird generiert…', style: TextStyle(color: pt.fgMuted, fontSize: 13)), ], ), ); } try { final rawBytes = Uint8List.fromList(qrCode.qrDataRawBytes.toList()); final qr = QrCode.fromUint8List(data: rawBytes, errorCorrectLevel: QrErrorCorrectLevel.L); final qrImage = QrImage(qr); return Padding( padding: const EdgeInsets.only(bottom: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Scanne diesen QR-Code mit dem anderen Gerät', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13), ), const SizedBox(height: 16), Center( child: Container( width: 220, height: 220, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), ), child: PrettyQrView(qrImage: qrImage), ), ), const SizedBox(height: 12), Text( 'Sobald das andere Gerät den Code gescannt hat, tippe auf „Gescannt bestätigen".', textAlign: TextAlign.center, style: TextStyle(color: pt.fgDim, fontSize: 12), ), const SizedBox(height: 8), ], ), ); } catch (_) { return Padding( padding: const EdgeInsets.only(bottom: 16), child: _StatusCard( pt: pt, icon: Icons.error_outline_rounded, color: pt.danger, text: 'QR-Code konnte nicht generiert werden.', ), ); } } } class _SasEmojiContent extends StatelessWidget { final PyramidTheme pt; final KeyVerification verification; // ignore: library_private_types_in_public_api final VoidCallback onConfirm; final VoidCallback onReject; const _SasEmojiContent({ required this.pt, required this.verification, required this.onConfirm, required this.onReject, }); @override Widget build(BuildContext context) { final emojis = verification.sasEmojis; return Padding( padding: const EdgeInsets.only(bottom: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Vergleiche die Emojis auf beiden Geräten', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13), ), const SizedBox(height: 20), if (emojis.isNotEmpty) Wrap( spacing: 12, runSpacing: 12, alignment: WrapAlignment.center, children: emojis.map((e) => _SasEmojiChip(emoji: e, pt: pt)).toList(), ) else Text('Keine Emojis verfügbar.', style: TextStyle(color: pt.fgDim, fontSize: 13)), const SizedBox(height: 24), Text( 'Stimmen alle Emojis überein?', style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), ), const SizedBox(height: 16), Row( children: [ Expanded( child: OutlinedButton.icon( style: OutlinedButton.styleFrom( foregroundColor: pt.danger, side: BorderSide(color: pt.danger.withAlpha(120)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(vertical: 12), ), onPressed: onReject, icon: const Icon(Icons.close_rounded, size: 16), label: const Text('Stimmt nicht überein'), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: PyramidColors.success, foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(vertical: 12), ), onPressed: onConfirm, icon: const Icon(Icons.check_rounded, size: 16), label: const Text('Stimmt überein'), ), ), ], ), const SizedBox(height: 8), ], ), ); } } class _SasEmojiChip extends StatelessWidget { final KeyVerificationEmoji emoji; final PyramidTheme pt; const _SasEmojiChip({required this.emoji, required this.pt}); @override Widget build(BuildContext context) { return Container( width: 68, padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(10), border: Border.all(color: pt.border), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(emoji.emoji, style: const TextStyle(fontSize: 26)), const SizedBox(height: 4), Text( emoji.name, style: TextStyle(color: pt.fgMuted, fontSize: 10), textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ), ); } } // ───────────────────────────────────────────────────────────────────────────── // ENCRYPTION // ───────────────────────────────────────────────────────────────────────────── enum _EncState { idle, loading, needsKey, unlocking, done, error } enum _RecoveryStep { loading, showKey, confirmKey, applying, done, error } class _EncryptionSection extends ConsumerStatefulWidget { final PyramidTheme pt; final WidgetRef ref; const _EncryptionSection({required this.pt, required this.ref}); @override ConsumerState<_EncryptionSection> createState() => _EncryptionSectionState(); } class _EncryptionSectionState extends ConsumerState<_EncryptionSection> { _EncState _state = _EncState.idle; Bootstrap? _bootstrap; final _keyCtrl = TextEditingController(); String? _error; // Status loaded from encryption object bool? _crossSigningEnabled; bool? _crossSigningCached; bool? _keyBackupEnabled; bool _statusLoaded = false; // Key file export / import bool _exportLoading = false; bool _importLoading = false; String? _keyFileMsg; bool _keyFileMsgIsError = false; bool _diagUploading = false; String? _diagMsg; bool _diagMsgIsError = false; @override void initState() { super.initState(); _loadStatus(); } @override void dispose() { _keyCtrl.dispose(); super.dispose(); } Future _loadStatus() async { try { final client = await ref.read(matrixClientProvider.future); final enc = client.encryption; if (enc == null) return; final cached = await enc.crossSigning.isCached().catchError((_) => false); if (!mounted) return; setState(() { _crossSigningEnabled = enc.crossSigning.enabled; _crossSigningCached = cached; _keyBackupEnabled = enc.keyManager.enabled; _statusLoaded = true; }); } catch (_) {} } Future _start() async { setState(() { _state = _EncState.loading; _error = null; }); try { final client = await ref.read(matrixClientProvider.future); if (client.encryption == null) { setState(() { _state = _EncState.error; _error = 'Verschlüsselung nicht verfügbar.'; }); return; } final enc = client.encryption!; final cached = await enc.keyManager.isCached().catchError((_) => false); if (cached) { await enc.keyManager.loadAllKeys().catchError((_) {}); if (mounted) setState(() { _state = _EncState.done; _error = null; }); await _loadStatus(); return; } await client.roomsLoading; await client.accountDataLoading; await client.userDeviceKeysLoading; final completer = Completer(); _bootstrap = enc.bootstrap(onUpdate: (bs) { if (!mounted) return; switch (bs.state) { case BootstrapState.askWipeSsss: WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(false)); case BootstrapState.askBadSsss: WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); case BootstrapState.askUseExistingSsss: WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(true)); case BootstrapState.openExistingSsss: if (!completer.isCompleted) completer.complete(true); case BootstrapState.askNewSsss: if (!completer.isCompleted) completer.complete(false); case BootstrapState.error: if (!completer.isCompleted) completer.complete(false); if (mounted) setState(() { _state = _EncState.error; _error = 'Bootstrap-Fehler.'; }); default: break; } }); final needsKey = await completer.future .timeout(const Duration(seconds: 30), onTimeout: () => false); if (!mounted) return; setState(() => _state = needsKey ? _EncState.needsKey : _EncState.done); await _loadStatus(); } catch (e) { if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); } } Future _unlock() async { final bs = _bootstrap; if (bs == null || bs.newSsssKey == null) return; final key = _keyCtrl.text.trim(); if (key.isEmpty) { setState(() => _error = 'Bitte Wiederherstellungsschlüssel eingeben.'); return; } setState(() { _state = _EncState.unlocking; _error = null; }); try { await bs.newSsssKey!.unlock(keyOrPassphrase: key); await bs.openExistingSsss(); final client = await ref.read(matrixClientProvider.future); if (bs.encryption.crossSigning.enabled) { await client.encryption!.crossSigning.selfSign(recoveryKey: key); } await client.encryption!.keyManager.loadAllKeys(); if (mounted) setState(() => _state = _EncState.done); await _loadStatus(); } on InvalidPassphraseException catch (_) { if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Falscher Schlüssel.'; }); } on FormatException catch (_) { if (mounted) setState(() { _state = _EncState.needsKey; _error = 'Ungültiges Format.'; }); } catch (e) { if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); } } Future _resetSecurity() async { final confirm = await showDialog( context: context, builder: (ctx) { final pt = widget.pt; return AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), title: Text('Sicherheit zurücksetzen?', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), content: Text( 'Dies löscht deine aktuellen Cross-Signing-Schlüssel und den Online-Schlüssel-Backup. Nicht gesicherte Sitzungen können Nachrichten möglicherweise nicht mehr entschlüsseln.', style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => Navigator.pop(ctx, true), child: const Text('Zurücksetzen'), ), ], ); }, ); if (confirm != true || !mounted) return; setState(() { _state = _EncState.loading; _error = null; }); try { final client = await ref.read(matrixClientProvider.future); final enc = client.encryption; if (enc == null) throw Exception('Verschlüsselung nicht verfügbar'); await client.roomsLoading; await client.accountDataLoading; await client.userDeviceKeysLoading; final completer = Completer(); enc.bootstrap(onUpdate: (bs) { if (!mounted) return; switch (bs.state) { case BootstrapState.askWipeSsss: WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeSsss(true)); case BootstrapState.askBadSsss: WidgetsBinding.instance.addPostFrameCallback((_) => bs.ignoreBadSecrets(true)); case BootstrapState.askUseExistingSsss: WidgetsBinding.instance.addPostFrameCallback((_) => bs.useExistingSsss(false)); case BootstrapState.askNewSsss: WidgetsBinding.instance.addPostFrameCallback((_) => bs.newSsss()); case BootstrapState.askWipeCrossSigning: WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeCrossSigning(true)); case BootstrapState.askSetupCrossSigning: WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupCrossSigning( setupMasterKey: true, setupSelfSigningKey: true, setupUserSigningKey: true)); case BootstrapState.askWipeOnlineKeyBackup: WidgetsBinding.instance.addPostFrameCallback((_) => bs.wipeOnlineKeyBackup(true)); case BootstrapState.askSetupOnlineKeyBackup: WidgetsBinding.instance.addPostFrameCallback((_) => bs.askSetupOnlineKeyBackup(true)); case BootstrapState.done: if (!completer.isCompleted) completer.complete(); case BootstrapState.error: if (!completer.isCompleted) completer.completeError('Bootstrap-Fehler'); default: break; } }); await completer.future.timeout(const Duration(seconds: 60)); if (mounted) setState(() => _state = _EncState.done); await _loadStatus(); } catch (e) { if (mounted) setState(() { _state = _EncState.error; _error = e.toString().split('\n').first; }); } } Future _showPassphraseDialog({required bool forExport}) { return showDialog( context: context, builder: (ctx) => _PassphraseDialog(pt: widget.pt, forExport: forExport), ); } Future _exportKeys() async { final passphrase = await _showPassphraseDialog(forExport: true); if (passphrase == null || !mounted) return; setState(() { _exportLoading = true; _keyFileMsg = null; }); try { final client = await ref.read(matrixClientProvider.future); final db = client.database; final stored = await db.getAllInboundGroupSessions(); final sessions = >[]; for (final s in stored) { try { final content = jsonDecode(s.content) as Map; final sessionKey = content['session_key'] as String?; if (sessionKey == null || sessionKey.isEmpty) continue; final claimedKeys = s.senderClaimedKeys.isNotEmpty ? (jsonDecode(s.senderClaimedKeys) as Map) .map((k, v) => MapEntry(k, v.toString())) : {}; sessions.add({ 'algorithm': 'm.megolm.v1.aes-sha2', 'forwarding_curve25519_key_chain': [], 'room_id': s.roomId, 'sender_key': s.senderKey, 'sender_claimed_keys': claimedKeys, 'session_id': s.sessionId, 'session_key': sessionKey, }); } catch (_) { continue; } } if (sessions.isEmpty) throw Exception('Keine exportierbaren Sitzungsschlüssel gefunden.'); final encrypted = await compute(_encryptMegolmKeys, { 'passphrase': passphrase, 'sessions': sessions, }); final dir = await getApplicationDocumentsDirectory(); final now = DateTime.now(); final stamp = '${now.year}' '${now.month.toString().padLeft(2, '0')}' '${now.day.toString().padLeft(2, '0')}'; final file = File('${dir.path}/pyramid_keys_$stamp.txt'); await file.writeAsString(encrypted); if (mounted) setState(() { _exportLoading = false; _keyFileMsgIsError = false; _keyFileMsg = '${sessions.length} Schlüssel exportiert → ${file.path}'; }); } catch (e) { if (mounted) setState(() { _exportLoading = false; _keyFileMsgIsError = true; _keyFileMsg = 'Export fehlgeschlagen: ${e.toString().split('\n').first}'; }); } } Future _uploadDiagnostics() async { setState(() { _diagUploading = true; _diagMsg = null; }); try { final notifier = ref.read(e2eeDiagnosticsProvider.notifier); final msg = await notifier.upload( serverUrl: 'https://dashboard.steggi-matrix.work', token: 'J5laxTz5Zte9oRB5x8QsyYRQ6b0hAuXI', ); if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = false; _diagMsg = msg; }); } catch (e) { if (mounted) setState(() { _diagUploading = false; _diagMsgIsError = true; _diagMsg = e.toString(); }); } } Future _importKeys() async { final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['txt', 'key', 'keys'], allowMultiple: false, ); if (result == null || result.files.isEmpty || result.files.first.path == null) return; if (!mounted) return; final passphrase = await _showPassphraseDialog(forExport: false); if (passphrase == null || !mounted) return; setState(() { _importLoading = true; _keyFileMsg = null; }); try { final fileContent = await File(result.files.first.path!).readAsString(); final trimmed = fileContent.trim(); if (!trimmed.startsWith(_kMegolmHeader) || !trimmed.endsWith(_kMegolmFooter)) { throw Exception('Ungültiges Dateiformat – kein Megolm-Schlüsseldatei-Header.'); } final lines = trimmed.split('\n'); // Strip \r so Windows line endings (\r\n) don't corrupt the base64 string. final b64 = lines.skip(1).take(lines.length - 2).join('').replaceAll(RegExp(r'\s'), ''); final padded = b64 + '=' * ((4 - b64.length % 4) % 4); final dataBytes = base64.decode(padded); final sessions = await compute(_decryptMegolmKeys, { 'passphrase': passphrase, 'data': dataBytes.toList(), }); final client = await ref.read(matrixClientProvider.future); final km = client.encryption?.keyManager; if (km == null) throw Exception('Verschlüsselung nicht initialisiert.'); var imported = 0; for (final raw in sessions) { try { final session = raw as Map; final roomId = session['room_id'] as String?; final sessionId = session['session_id'] as String?; final senderKey = session['sender_key'] as String?; if (roomId == null || sessionId == null || senderKey == null) continue; final claimedKeys = (session['sender_claimed_keys'] as Map?) ?.map((k, v) => MapEntry(k.toString(), v.toString())) ?? {}; await km.setInboundGroupSession( roomId, sessionId, senderKey, Map.from(session), forwarded: true, senderClaimedKeys: claimedKeys, ); imported++; } catch (_) { continue; } } if (mounted) setState(() { _importLoading = false; _keyFileMsgIsError = false; _keyFileMsg = '$imported von ${sessions.length} Schlüssel importiert.'; }); } catch (e) { if (mounted) setState(() { _importLoading = false; _keyFileMsgIsError = true; _keyFileMsg = 'Import fehlgeschlagen: ${e.toString().split('\n').first}'; }); } } Future _changeRecoveryKey() async { final changed = await showDialog( context: context, barrierDismissible: false, builder: (ctx) => _ChangeRecoveryKeyDialog(pt: widget.pt), ); if (changed == true && mounted) { await _loadStatus(); } } @override Widget build(BuildContext context) { final pt = widget.pt; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle( title: 'Verschlüsselung', subtitle: 'Ende-zu-Ende-Verschlüsselung & Sicherheit verwalten.', pt: pt), // ── Status overview ─────────────────────────────────────────────── if (_statusLoaded) ...[ _SettingsGroup(title: 'Sicherheitsstatus', pt: pt, children: [ _SettingsRow( title: 'Cross-Signing', desc: _crossSigningEnabled == true ? (_crossSigningCached == true ? 'Eingerichtet & verifiziert' : 'Eingerichtet, Schlüssel nicht im Cache') : 'Nicht eingerichtet', pt: pt, control: Icon( _crossSigningEnabled == true && _crossSigningCached == true ? Icons.verified_rounded : (_crossSigningEnabled == true ? Icons.warning_amber_rounded : Icons.cancel_outlined), size: 16, color: _crossSigningEnabled == true && _crossSigningCached == true ? PyramidColors.success : (_crossSigningEnabled == true ? pt.away : pt.danger), ), ), _Divider(pt: pt), _SettingsRow( title: 'Online-Schlüsselbackup', desc: _keyBackupEnabled == true ? 'Aktiv – Sitzungsschlüssel gesichert' : 'Nicht aktiviert', pt: pt, control: Icon( _keyBackupEnabled == true ? Icons.cloud_done_rounded : Icons.cloud_off_rounded, size: 16, color: _keyBackupEnabled == true ? PyramidColors.success : pt.fgDim, ), ), ]), const SizedBox(height: 20), _FingerprintGroup(pt: pt), const SizedBox(height: 20), ], // ── Key recovery / unlock ───────────────────────────────────────── _SettingsGroup(title: 'Wiederherstellung', pt: pt, children: [ _SettingsRow( title: 'Schlüssel wiederherstellen', desc: 'Nachrichten mit dem Wiederherstellungsschlüssel entschlüsseln', pt: pt, showArrow: true, onTap: _state == _EncState.loading ? null : _start, ), _Divider(pt: pt), _SettingsRow( title: 'Wiederherstellungsschlüssel ändern', desc: 'Neuen Schlüssel generieren, notieren und aktivieren', pt: pt, showArrow: true, onTap: _state == _EncState.loading ? null : _changeRecoveryKey, ), _Divider(pt: pt), _SettingsRow( title: 'Sicherheit neu einrichten', desc: 'Cross-Signing & Backup zurücksetzen und neu aufsetzen', pt: pt, showArrow: true, destructive: true, onTap: _state == _EncState.loading ? null : _resetSecurity, ), ]), const SizedBox(height: 20), // ── Key file export / import ─────────────────────────────────────── _SettingsGroup(title: 'Schlüsseldatei', pt: pt, children: [ _SettingsRow( title: 'Schlüssel exportieren', desc: 'Sitzungsschlüssel als verschlüsselte Datei sichern (kompatibel mit Element)', pt: pt, showArrow: !_exportLoading, control: _exportLoading ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2), ) : null, onTap: (_exportLoading || _importLoading) ? null : _exportKeys, ), _Divider(pt: pt), _SettingsRow( title: 'Schlüssel importieren', desc: 'Sitzungsschlüssel aus exportierter Datei laden', pt: pt, showArrow: !_importLoading, control: _importLoading ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2), ) : null, onTap: (_exportLoading || _importLoading) ? null : _importKeys, ), ]), if (_keyFileMsg != null) ...[ const SizedBox(height: 12), _StatusCard( pt: pt, icon: _keyFileMsgIsError ? Icons.error_outline_rounded : Icons.check_circle_outline_rounded, color: _keyFileMsgIsError ? pt.danger : PyramidColors.success, text: _keyFileMsg!, ), ], const SizedBox(height: 20), // ── E2EE Diagnostics ────────────────────────────────────────────── Consumer(builder: (context, ref, _) { final count = ref.watch(e2eeDiagnosticsProvider).length; return _SettingsGroup(title: 'Diagnose', pt: pt, children: [ _SettingsRow( title: 'Entschlüsselungsfehler hochladen', desc: count == 0 ? 'Keine Fehler gesammelt' : '$count Fehler gesammelt – zum Server hochladen', pt: pt, showArrow: !_diagUploading && count > 0, control: _diagUploading ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2), ) : count > 0 ? Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: pt.danger.withAlpha(30), borderRadius: BorderRadius.circular(10), ), child: Text('$count', style: TextStyle(color: pt.danger, fontSize: 12)), ) : null, onTap: (_diagUploading || count == 0) ? null : _uploadDiagnostics, ), ]); }), if (_diagMsg != null) ...[ const SizedBox(height: 12), _StatusCard( pt: pt, icon: _diagMsgIsError ? Icons.error_outline_rounded : Icons.cloud_done_rounded, color: _diagMsgIsError ? pt.danger : PyramidColors.success, text: _diagMsg!, ), ], const SizedBox(height: 20), // ── Inline state UI ─────────────────────────────────────────────── if (_state == _EncState.loading) const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive())), if (_state == _EncState.done) ...[ _StatusCard( pt: pt, icon: Icons.check_circle_outline_rounded, color: PyramidColors.success, text: 'Alle Schlüssel importiert. Nachrichten werden entschlüsselt.'), const SizedBox(height: 12), ], if (_state == _EncState.needsKey || _state == _EncState.unlocking) ...[ _StatusCard( pt: pt, icon: Icons.lock_outline_rounded, color: pt.accent, text: 'Wiederherstellungsschlüssel erforderlich.'), const SizedBox(height: 16), TextField( controller: _keyCtrl, readOnly: _state == _EncState.unlocking, autofocus: true, autocorrect: false, style: TextStyle(color: pt.fg, fontSize: 13, fontFamily: 'monospace'), decoration: InputDecoration( hintText: 'EsXX XXXX XXXX XXXX …', hintStyle: TextStyle(color: pt.fgDim, fontSize: 12), labelText: 'Wiederherstellungsschlüssel', labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), errorText: _error, errorMaxLines: 2, filled: true, fillColor: pt.bg2, prefixIcon: Icon(Icons.vpn_key_outlined, color: pt.fgDim, size: 18), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), isDense: true, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), ), cursorColor: pt.accent, onSubmitted: (_) => _unlock(), ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: _PrimaryBtn( label: _state == _EncState.unlocking ? 'Entschlüssele…' : 'Nachrichten entschlüsseln', icon: Icons.lock_open_outlined, pt: pt, loading: _state == _EncState.unlocking, onPressed: _unlock, ), ), const SizedBox(height: 12), ], if (_state == _EncState.error) ...[ _StatusCard( pt: pt, icon: Icons.error_outline_rounded, color: PyramidColors.danger, text: _error ?? 'Unbekannter Fehler.'), const SizedBox(height: 12), OutlinedButton.icon( style: OutlinedButton.styleFrom( foregroundColor: pt.fg, side: BorderSide(color: pt.border), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: _start, icon: const Icon(Icons.refresh_rounded, size: 16), label: const Text('Erneut versuchen'), ), ], if (_state == _EncState.idle && !_statusLoaded) _StatusCard( pt: pt, icon: Icons.shield_outlined, color: pt.fgMuted, text: 'Lade Sicherheitsstatus…'), ], ), ); } } // ───────────────────────────────────────────────────────────────────────────── // CHANGE RECOVERY KEY DIALOG // ───────────────────────────────────────────────────────────────────────────── class _ChangeRecoveryKeyDialog extends ConsumerStatefulWidget { final PyramidTheme pt; const _ChangeRecoveryKeyDialog({required this.pt}); @override ConsumerState<_ChangeRecoveryKeyDialog> createState() => _ChangeRecoveryKeyDialogState(); } class _ChangeRecoveryKeyDialogState extends ConsumerState<_ChangeRecoveryKeyDialog> { _RecoveryStep _step = _RecoveryStep.loading; String? _generatedKey; String? _error; bool _copied = false; // Completer signals when bootstrap reaches `done` final _doneCompleter = Completer(); @override void initState() { super.initState(); _startBootstrap(); } Future _startBootstrap() async { try { final client = await ref.read(matrixClientProvider.future); final enc = client.encryption; if (enc == null) throw Exception('Verschlüsselung nicht verfügbar.'); await client.roomsLoading; await client.accountDataLoading; await client.userDeviceKeysLoading; enc.bootstrap(onUpdate: (bs) { if (!mounted) return; _onBootstrapUpdate(bs); }); } catch (e) { if (mounted) { setState(() { _step = _RecoveryStep.error; _error = e.toString().split('\n').first; }); } } } // After newSsss() calls checkCrossSigning(), the bootstrap immediately // transitions to askWipeCrossSigning (or askSetupCrossSigning). All post- // newSsss states must therefore auto-advance unconditionally — guarding on // _step would leave them stuck. void _onBootstrapUpdate(Bootstrap bs) { switch (bs.state) { case BootstrapState.askWipeSsss: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askWipeSsss) bs.wipeSsss(true); }); case BootstrapState.askBadSsss: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askBadSsss) bs.ignoreBadSecrets(true); }); case BootstrapState.askUseExistingSsss: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askUseExistingSsss) bs.useExistingSsss(false); }); case BootstrapState.askUnlockSsss: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askUnlockSsss) bs.unlockedSsss(); }); case BootstrapState.askNewSsss: // Only generate once if (_step == _RecoveryStep.loading) { WidgetsBinding.instance.addPostFrameCallback((_) async { if (bs.state != BootstrapState.askNewSsss) return; try { await bs.newSsss(); // newSsss() internally calls checkCrossSigning() which already // transitions to askWipeCrossSigning; capture key after it returns. final key = bs.newSsssKey?.recoveryKey; if (mounted) setState(() { _generatedKey = key; _step = _RecoveryStep.showKey; }); } catch (e) { if (mounted) setState(() { _step = _RecoveryStep.error; _error = e.toString().split('\n').first; }); } }); } case BootstrapState.askWipeCrossSigning: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askWipeCrossSigning) bs.wipeCrossSigning(false); }); case BootstrapState.askSetupCrossSigning: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askSetupCrossSigning) { bs.askSetupCrossSigning( setupMasterKey: true, setupSelfSigningKey: true, setupUserSigningKey: true, ); } }); case BootstrapState.askWipeOnlineKeyBackup: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askWipeOnlineKeyBackup) bs.wipeOnlineKeyBackup(true); }); case BootstrapState.askSetupOnlineKeyBackup: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askSetupOnlineKeyBackup) bs.askSetupOnlineKeyBackup(true); }); case BootstrapState.done: if (!_doneCompleter.isCompleted) _doneCompleter.complete(); // Only auto-advance to done if user already clicked through (applying). // If still on showKey/confirmKey, the user must finish reading first. if (mounted && _step == _RecoveryStep.applying) { setState(() => _step = _RecoveryStep.done); } case BootstrapState.error: if (!_doneCompleter.isCompleted) _doneCompleter.completeError('Bootstrap-Fehler'); if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Fehler bei der Schlüsselgenerierung.'; }); default: break; } } // Called when user taps "Schlüssel gespeichert – weiter" on the showKey screen. void _onKeySaved() { setState(() => _step = _RecoveryStep.confirmKey); } // Called when user submits the correct key confirmation. // Bootstrap may still be running (cross-signing + key backup); wait for done. Future _onKeyConfirmed() async { setState(() => _step = _RecoveryStep.applying); try { await _doneCompleter.future.timeout(const Duration(seconds: 90)); if (mounted) setState(() => _step = _RecoveryStep.done); } catch (e) { if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Timeout: Bootstrap abgebrochen.'; }); } } @override Widget build(BuildContext context) { final pt = widget.pt; final canClose = _step != _RecoveryStep.loading && _step != _RecoveryStep.applying && _step != _RecoveryStep.confirmKey; return Dialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border), ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440, minWidth: 320), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 16, 12, 12), child: Row( children: [ Icon(Icons.key_rounded, size: 18, color: pt.accent), const SizedBox(width: 8), Expanded( child: Text( 'Wiederherstellungsschlüssel', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), ), ), if (canClose) IconButton( icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), onPressed: () => Navigator.of(context).pop(_step == _RecoveryStep.done), padding: EdgeInsets.zero, constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), ), ], ), ), Divider(color: pt.border, height: 1), Padding( padding: const EdgeInsets.all(20), child: _buildBody(pt), ), ], ), ), ); } Widget _buildBody(PyramidTheme pt) { return switch (_step) { _RecoveryStep.loading => const Center( child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive()), ), _RecoveryStep.showKey => _ShowKeyBody( pt: pt, generatedKey: _generatedKey ?? '', copied: _copied, onCopy: () { Clipboard.setData(ClipboardData(text: _generatedKey ?? '')); setState(() => _copied = true); }, onKeySaved: _onKeySaved, ), _RecoveryStep.confirmKey => _ConfirmKeyBody( pt: pt, expectedKey: _generatedKey ?? '', onConfirmed: _onKeyConfirmed, ), _RecoveryStep.applying => Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator.adaptive(), const SizedBox(height: 16), Text( 'Schlüssel wird aktiviert…\nCross-Signing und Backup werden eingerichtet.', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), ), ], ), _RecoveryStep.done => Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52), const SizedBox(height: 12), Text( 'Wiederherstellungsschlüssel wurde erfolgreich geändert.', textAlign: TextAlign.center, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500), ), const SizedBox(height: 6), Text( 'Bewahre deinen Schlüssel sicher auf – du brauchst ihn bei Geräteverlust.', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.4), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: () => Navigator.of(context).pop(true), child: const Text('Fertig'), ), ), ], ), _RecoveryStep.error => Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48), const SizedBox(height: 12), Text( _error ?? 'Ein unbekannter Fehler ist aufgetreten.', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: OutlinedButton( style: OutlinedButton.styleFrom( foregroundColor: pt.fg, side: BorderSide(color: pt.border), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: () => Navigator.of(context).pop(false), child: const Text('Schließen'), ), ), ], ), }; } } class _ShowKeyBody extends StatelessWidget { final PyramidTheme pt; final String generatedKey; final bool copied; final VoidCallback onCopy; final VoidCallback onKeySaved; const _ShowKeyBody({ required this.pt, required this.generatedKey, required this.copied, required this.onCopy, required this.onKeySaved, }); @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Dein neuer Wiederherstellungsschlüssel', style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Text( 'Speichere diesen Schlüssel sicher – du brauchst ihn, wenn du den Zugang zu allen Geräten verlierst.', style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), ), const SizedBox(height: 16), Container( decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(8), border: Border.all(color: pt.border), ), child: Row( children: [ Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: SelectableText( generatedKey, style: TextStyle( color: pt.accent, fontSize: 13, fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6, ), ), ), ), IconButton( onPressed: onCopy, icon: Icon( copied ? Icons.check_rounded : Icons.copy_rounded, size: 18, color: copied ? PyramidColors.success : pt.fgDim, ), tooltip: copied ? 'Kopiert!' : 'In Zwischenablage kopieren', ), ], ), ), const SizedBox(height: 12), Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.orange.withAlpha(28), borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.orange.withAlpha(80)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.warning_amber_rounded, size: 16, color: Colors.orange.shade700), const SizedBox(width: 8), Expanded( child: Text( 'Schreibe diesen Schlüssel auf oder speichere ihn in einem Passwort-Manager. Er kann nicht wiederhergestellt werden.', style: TextStyle(color: Colors.orange.shade800, fontSize: 12, height: 1.4), ), ), ], ), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: onKeySaved, child: const Text('Schlüssel gespeichert – weiter'), ), ), ], ); } } class _ConfirmKeyBody extends StatefulWidget { final PyramidTheme pt; final String expectedKey; final VoidCallback onConfirmed; const _ConfirmKeyBody({ required this.pt, required this.expectedKey, required this.onConfirmed, }); @override State<_ConfirmKeyBody> createState() => _ConfirmKeyBodyState(); } class _ConfirmKeyBodyState extends State<_ConfirmKeyBody> { final _controller = TextEditingController(); bool _mismatch = false; @override void dispose() { _controller.dispose(); super.dispose(); } void _submit() { final entered = _controller.text.trim(); if (entered == widget.expectedKey) { widget.onConfirmed(); } else { setState(() => _mismatch = true); } } @override Widget build(BuildContext context) { final pt = widget.pt; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Schlüssel bestätigen', style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Text( 'Gib deinen neuen Wiederherstellungsschlüssel zur Bestätigung ein.', style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), ), const SizedBox(height: 16), TextField( controller: _controller, autofocus: true, style: TextStyle( color: pt.fg, fontSize: 13, fontFamily: 'monospace', letterSpacing: 0.4, ), decoration: InputDecoration( hintText: 'EsTX XXXX XXXX …', hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), filled: true, fillColor: pt.bg3, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.border), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.accent, width: 1.5), ), errorText: _mismatch ? 'Schlüssel stimmt nicht überein.' : null, ), onChanged: (_) { if (_mismatch) setState(() => _mismatch = false); }, onSubmitted: (_) => _submit(), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: _submit, child: const Text('Aktivieren'), ), ), ], ); } } // ───────────────────────────────────────────────────────────────────────────── // FINGERPRINT GROUP // ───────────────────────────────────────────────────────────────────────────── class _FingerprintGroup extends ConsumerWidget { final PyramidTheme pt; const _FingerprintGroup({required this.pt}); static String _fmt(String key) { final clean = key.replaceAll(RegExp(r'\s'), ''); final groups = []; for (var i = 0; i < clean.length; i += 4) { groups.add(clean.substring(i, (i + 4).clamp(0, clean.length))); } return groups.join(' '); } @override Widget build(BuildContext context, WidgetRef ref) { final clientAsync = ref.watch(matrixClientProvider); final client = clientAsync.valueOrNull; if (client == null) return const SizedBox.shrink(); final fingerprint = _fmt(client.fingerprintKey); final deviceId = client.deviceID ?? ''; return _SettingsGroup(title: 'Dieses Gerät', pt: pt, children: [ _SettingsRow( title: 'Geräte-ID', desc: deviceId, pt: pt, control: IconButton( icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), tooltip: 'Kopieren', padding: EdgeInsets.zero, constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), onPressed: () => Clipboard.setData(ClipboardData(text: deviceId)), ), ), _Divider(pt: pt), Padding( padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text('Ed25519-Fingerabdruck', style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)), const Spacer(), IconButton( icon: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), tooltip: 'Fingerabdruck kopieren', padding: EdgeInsets.zero, constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), onPressed: () => Clipboard.setData(ClipboardData(text: client.fingerprintKey)), ), ], ), const SizedBox(height: 4), SelectableText( fingerprint, style: TextStyle( color: pt.fgMuted, fontSize: 11, fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6, ), ), ], ), ), ]); } } // ───────────────────────────────────────────────────────────────────────────── // CHANGE PASSWORD DIALOG // ───────────────────────────────────────────────────────────────────────────── class _ChangePasswordDialog extends ConsumerStatefulWidget { final PyramidTheme pt; const _ChangePasswordDialog({required this.pt}); @override ConsumerState<_ChangePasswordDialog> createState() => _ChangePasswordDialogState(); } class _ChangePasswordDialogState extends ConsumerState<_ChangePasswordDialog> { final _oldCtrl = TextEditingController(); final _newCtrl = TextEditingController(); final _confirmCtrl = TextEditingController(); bool _oldVisible = false; bool _newVisible = false; bool _loading = false; String? _error; bool _done = false; @override void dispose() { _oldCtrl.dispose(); _newCtrl.dispose(); _confirmCtrl.dispose(); super.dispose(); } Future _submit() async { final oldPw = _oldCtrl.text; final newPw = _newCtrl.text; final confirm = _confirmCtrl.text; if (oldPw.isEmpty || newPw.isEmpty) { setState(() => _error = 'Bitte alle Felder ausfüllen.'); return; } if (newPw != confirm) { setState(() => _error = 'Die neuen Passwörter stimmen nicht überein.'); return; } if (newPw.length < 8) { setState(() => _error = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.'); return; } setState(() { _loading = true; _error = null; }); try { final client = await ref.read(matrixClientProvider.future); await client.changePassword(newPw, oldPassword: oldPw); if (mounted) setState(() { _loading = false; _done = true; }); } on MatrixException catch (e) { if (mounted) setState(() { _loading = false; _error = e.errorMessage; }); } catch (e) { if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; }); } } @override Widget build(BuildContext context) { final pt = widget.pt; return Dialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420, minWidth: 300), child: Padding( padding: const EdgeInsets.all(24), child: _done ? _buildDone(pt) : _buildForm(pt), ), ), ); } Widget _buildDone(PyramidTheme pt) => Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52), const SizedBox(height: 12), Text('Passwort geändert', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), const SizedBox(height: 6), Text('Dein Passwort wurde erfolgreich aktualisiert.', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)), const SizedBox(height: 20), SizedBox(width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => Navigator.of(context).pop(), child: const Text('Schließen'), )), ], ); Widget _buildForm(PyramidTheme pt) => Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(Icons.lock_outline_rounded, size: 18, color: pt.accent), const SizedBox(width: 8), Text('Passwort ändern', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), const Spacer(), IconButton( icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), onPressed: () => Navigator.of(context).pop(), padding: EdgeInsets.zero, constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), ), ]), const SizedBox(height: 20), _PwField(ctrl: _oldCtrl, label: 'Aktuelles Passwort', pt: pt, visible: _oldVisible, onToggle: () => setState(() => _oldVisible = !_oldVisible)), const SizedBox(height: 12), _PwField(ctrl: _newCtrl, label: 'Neues Passwort', pt: pt, visible: _newVisible, onToggle: () => setState(() => _newVisible = !_newVisible)), const SizedBox(height: 12), _PwField(ctrl: _confirmCtrl, label: 'Neues Passwort bestätigen', pt: pt, visible: _newVisible, onToggle: null, onSubmitted: (_) => _submit()), if (_error != null) ...[ const SizedBox(height: 10), _StatusCard(pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!), ], const SizedBox(height: 20), SizedBox(width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: _loading ? null : _submit, child: _loading ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2)) : const Text('Passwort ändern'), )), ], ); } class _PwField extends StatelessWidget { final TextEditingController ctrl; final String label; final PyramidTheme pt; final bool visible; final VoidCallback? onToggle; final ValueChanged? onSubmitted; const _PwField({ required this.ctrl, required this.label, required this.pt, required this.visible, required this.onToggle, this.onSubmitted, }); @override Widget build(BuildContext context) => TextField( controller: ctrl, obscureText: !visible, onSubmitted: onSubmitted, style: TextStyle(color: pt.fg, fontSize: 13), decoration: InputDecoration( labelText: label, labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), filled: true, fillColor: pt.bg3, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), suffixIcon: onToggle != null ? IconButton( icon: Icon(visible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim), onPressed: onToggle, ) : null, ), cursorColor: pt.accent, ); } // ───────────────────────────────────────────────────────────────────────────── // DELETE ACCOUNT DIALOG // ───────────────────────────────────────────────────────────────────────────── class _DeleteAccountDialog extends ConsumerStatefulWidget { final PyramidTheme pt; const _DeleteAccountDialog({required this.pt}); @override ConsumerState<_DeleteAccountDialog> createState() => _DeleteAccountDialogState(); } enum _DeleteStep { confirm, auth, deleting, error } class _DeleteAccountDialogState extends ConsumerState<_DeleteAccountDialog> { _DeleteStep _step = _DeleteStep.confirm; final _pwCtrl = TextEditingController(); bool _pwVisible = false; bool _eraseData = true; String? _error; @override void dispose() { _pwCtrl.dispose(); super.dispose(); } Future _proceed() async { setState(() { _step = _DeleteStep.deleting; _error = null; }); final client = await ref.read(matrixClientProvider.future); try { await client.deactivateAccount(erase: _eraseData); } on MatrixException catch (e) { if (e.requireAdditionalAuthentication && mounted) { // UIA required — try with password try { await client.deactivateAccount( erase: _eraseData, auth: AuthenticationData( type: AuthenticationTypes.password, session: e.session, additionalFields: { 'identifier': {'type': 'm.id.user', 'user': client.userID}, 'password': _pwCtrl.text, }, ), ); } on MatrixException catch (e2) { if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.errorMessage; }); return; } catch (e2) { if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.toString().split('\n').first; }); return; } } else { if (mounted) setState(() { _step = _DeleteStep.error; _error = e.errorMessage; }); return; } } catch (e) { if (mounted) setState(() { _step = _DeleteStep.error; _error = e.toString().split('\n').first; }); return; } // Konto wurde gelöscht — ausloggen try { await client.logout(); } catch (_) {} } @override Widget build(BuildContext context) { final pt = widget.pt; return Dialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440, minWidth: 300), child: Padding( padding: const EdgeInsets.all(24), child: switch (_step) { _DeleteStep.confirm => _buildConfirm(pt), _DeleteStep.auth => _buildAuth(pt), _DeleteStep.deleting => const Center( child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()), ), _DeleteStep.error => _buildError(pt), }, ), ), ); } Widget _buildConfirm(PyramidTheme pt) => Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(Icons.warning_rounded, size: 20, color: pt.danger), const SizedBox(width: 8), Text('Konto löschen', style: TextStyle(color: pt.danger, fontSize: 15, fontWeight: FontWeight.w700)), const Spacer(), IconButton( icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), onPressed: () => Navigator.of(context).pop(), padding: EdgeInsets.zero, constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), ), ]), const SizedBox(height: 16), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: pt.danger.withAlpha(20), borderRadius: BorderRadius.circular(8), border: Border.all(color: pt.danger.withAlpha(60)), ), child: Text( 'Diese Aktion ist unwiderruflich. Dein Konto, alle Sitzungen und ' 'deine Mitgliedschaft in Räumen werden dauerhaft gelöscht.', style: TextStyle(color: pt.fg, fontSize: 13, height: 1.5), ), ), const SizedBox(height: 16), GestureDetector( onTap: () => setState(() => _eraseData = !_eraseData), child: Row(children: [ SizedBox( width: 18, height: 18, child: Checkbox( value: _eraseData, onChanged: (v) => setState(() => _eraseData = v ?? true), activeColor: pt.accent, side: BorderSide(color: pt.border), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), const SizedBox(width: 8), Expanded(child: Text('Nachrichten und Daten vom Server löschen', style: TextStyle(color: pt.fg, fontSize: 13))), ]), ), const SizedBox(height: 20), Row(children: [ Expanded(child: OutlinedButton( style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => Navigator.of(context).pop(), child: const Text('Abbrechen'), )), const SizedBox(width: 12), Expanded(child: ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => setState(() => _step = _DeleteStep.auth), child: const Text('Weiter'), )), ]), ], ); Widget _buildAuth(PyramidTheme pt) => Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(Icons.lock_outline_rounded, size: 18, color: pt.danger), const SizedBox(width: 8), Text('Konto löschen bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)), ]), const SizedBox(height: 16), Text('Gib dein Passwort ein, um die Löschung zu bestätigen.', style: TextStyle(color: pt.fgMuted, fontSize: 13)), const SizedBox(height: 16), TextField( controller: _pwCtrl, autofocus: true, obscureText: !_pwVisible, style: TextStyle(color: pt.fg, fontSize: 13), decoration: InputDecoration( labelText: 'Passwort', labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), filled: true, fillColor: pt.bg3, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.danger)), suffixIcon: IconButton( icon: Icon(_pwVisible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim), onPressed: () => setState(() => _pwVisible = !_pwVisible), ), ), cursorColor: pt.danger, onSubmitted: (_) => _proceed(), ), const SizedBox(height: 20), Row(children: [ Expanded(child: OutlinedButton( style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => setState(() => _step = _DeleteStep.confirm), child: const Text('Zurück'), )), const SizedBox(width: 12), Expanded(child: ElevatedButton( style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: _proceed, child: const Text('Konto endgültig löschen'), )), ]), ], ); Widget _buildError(PyramidTheme pt) => Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline_rounded, color: pt.danger, size: 48), const SizedBox(height: 12), Text(_error ?? 'Ein Fehler ist aufgetreten.', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)), const SizedBox(height: 20), SizedBox(width: double.infinity, child: OutlinedButton( style: OutlinedButton.styleFrom(foregroundColor: pt.fg, side: BorderSide(color: pt.border), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => Navigator.of(context).pop(), child: const Text('Schließen'), )), ], ); } // ───────────────────────────────────────────────────────────────────────────── // ABOUT // ───────────────────────────────────────────────────────────────────────────── class _AboutSection extends ConsumerWidget { final PyramidTheme pt; const _AboutSection({required this.pt}); @override Widget build(BuildContext context, WidgetRef ref) { final clientAsync = ref.watch(matrixClientProvider); final client = clientAsync.valueOrNull; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle(title: 'Über Pyramid', subtitle: 'Version & Konto-Infos.', pt: pt), _SettingsGroup(title: 'Version', pt: pt, children: [ _SettingsRow( title: 'Pyramid', desc: '0.1.0 · Flutter · Matrix SDK', pt: pt, control: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: pt.accentSoft, borderRadius: BorderRadius.circular(6)), child: Text('Beta', style: TextStyle( color: pt.accent, fontSize: 10, fontWeight: FontWeight.w700)), )), _Divider(pt: pt), _SettingsRow( title: 'Matrix SDK', desc: 'matrix-dart-sdk ^6.2.0', pt: pt), ]), const SizedBox(height: 20), _UpdatesSettingsGroup(pt: pt), const SizedBox(height: 20), _NotificationsSettingsGroup(pt: pt), const SizedBox(height: 20), if (client != null) _SettingsGroup(title: 'Konto', pt: pt, children: [ _SettingsRow( title: 'Matrix-ID', desc: client.userID ?? '', pt: pt, control: GestureDetector( onTap: () => Clipboard.setData(ClipboardData(text: client.userID ?? '')), child: Tooltip( message: 'Kopieren', child: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), ), ), ), _Divider(pt: pt), _SettingsRow( title: 'Homeserver', desc: client.homeserver?.toString() ?? '', pt: pt, ), _Divider(pt: pt), _SettingsRow( title: 'Gerät', desc: client.deviceID ?? '', pt: pt, control: GestureDetector( onTap: () => Clipboard.setData(ClipboardData(text: client.deviceID ?? '')), child: Tooltip( message: 'Kopieren', child: Icon(Icons.copy_rounded, size: 14, color: pt.fgDim), ), ), ), ]), const SizedBox(height: 20), _SettingsGroup(title: 'Konto-Aktionen', pt: pt, children: [ _SettingsRow( title: 'Abmelden', desc: 'Auf diesem Gerät ausloggen', pt: pt, destructive: true, showArrow: true, onTap: () => _confirmLogout(context, ref), ), ]), ], ), ); } Future _confirmLogout(BuildContext context, WidgetRef ref) async { final confirm = await showDialog( context: context, builder: (ctx) => AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)), title: Text('Abmelden?', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)), content: Text( 'Du wirst auf diesem Gerät abgemeldet. Deine Nachrichten bleiben erhalten.', 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) { final client = await ref.read(matrixClientProvider.future); await client.logout().catchError((_) {}); } } } // ───────────────────────────────────────────────────────────────────────────── // NOTIFICATIONS SETTINGS GROUP // ───────────────────────────────────────────────────────────────────────────── class _NotificationsSettingsGroup extends ConsumerStatefulWidget { final PyramidTheme pt; const _NotificationsSettingsGroup({required this.pt}); @override ConsumerState<_NotificationsSettingsGroup> createState() => _NotificationsSettingsGroupState(); } class _NotificationsSettingsGroupState extends ConsumerState<_NotificationsSettingsGroup> { bool _running = false; String? _result; bool _retryRunning = false; String? _retryResult; Future _runDiag() async { setState(() { _running = true; _result = null; }); final client = await ref.read(matrixClientProvider.future); final diag = await fcmDiagnostics(client); if (mounted) setState(() { _running = false; _result = diag; }); } Future _runRetry() async { setState(() { _retryRunning = true; _retryResult = null; }); final client = await ref.read(matrixClientProvider.future); final result = await retryPusherRegistration(client); if (mounted) setState(() { _retryRunning = false; _retryResult = result; }); } @override Widget build(BuildContext context) { final pt = widget.pt; if (!Platform.isAndroid) return const SizedBox.shrink(); return _SettingsGroup(title: 'Push-Benachrichtigungen', pt: pt, children: [ _SettingsRow( title: 'Akku-Optimierung deaktivieren', desc: 'Samsung / andere OEMs unterdrücken sonst Benachrichtigungen bei geschlossener App.', pt: pt, showArrow: true, onTap: openBatteryOptimizationSettings, control: Icon(Icons.battery_saver_outlined, size: 16, color: pt.fgDim), ), _Divider(pt: pt), _SettingsRow( title: 'FCM Diagnose', desc: _result ?? 'Prüft ob FCM-Token & Pusher korrekt registriert sind.', pt: pt, onTap: _running ? null : _runDiag, control: _running ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent), ) : Icon(Icons.bug_report_outlined, size: 16, color: pt.fgDim), ), _Divider(pt: pt), _SettingsRow( title: 'Pusher neu registrieren', desc: _retryResult ?? 'Pusher beim Homeserver erneut anmelden.', pt: pt, onTap: _retryRunning ? null : _runRetry, control: _retryRunning ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent), ) : Icon(Icons.sync_outlined, size: 16, color: pt.fgDim), ), ]); } } // ───────────────────────────────────────────────────────────────────────────── // UPDATE SETTINGS GROUP // ───────────────────────────────────────────────────────────────────────────── class _UpdatesSettingsGroup extends ConsumerStatefulWidget { final PyramidTheme pt; const _UpdatesSettingsGroup({required this.pt}); @override ConsumerState<_UpdatesSettingsGroup> createState() => _UpdatesSettingsGroupState(); } class _UpdatesSettingsGroupState extends ConsumerState<_UpdatesSettingsGroup> { bool _checking = false; Future _checkNow() async { setState(() => _checking = true); await resetUpdateCheckTimer(); ref.invalidate(updateInfoProvider); // Give the async provider a moment to resolve before resetting the spinner. await Future.delayed(const Duration(seconds: 2)); if (mounted) setState(() => _checking = false); } @override Widget build(BuildContext context) { final pt = widget.pt; final updateAsync = ref.watch(updateInfoProvider); return _SettingsGroup(title: 'Updates', pt: pt, children: [ updateAsync.when( loading: () => _SettingsRow( title: 'Nach Updates suchen…', desc: 'Gitea Releases wird geprüft', pt: pt, control: SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: pt.accent, ), ), ), error: (e, _) => _SettingsRow( title: 'Update-Prüfung fehlgeschlagen', desc: 'Keine Verbindung oder Gitea nicht erreichbar (nur im Heimnetz).', pt: pt, control: _checking ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: pt.accent), ) : GestureDetector( onTap: _checkNow, child: Tooltip( message: 'Erneut prüfen', child: Icon(Icons.refresh_rounded, size: 16, color: pt.fgDim), ), ), ), data: (info) { if (info == null) { return _SettingsRow( title: 'Pyramid ist aktuell', desc: 'Du verwendest die neueste Version.', pt: pt, onTap: _checking ? null : _checkNow, control: _checking ? SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: pt.accent), ) : Icon(Icons.refresh_rounded, size: 16, color: pt.fgDim), ); } // New version available return Column( children: [ _SettingsRow( title: 'Update verfügbar: ${info.tagName}', desc: info.releaseName, pt: pt, showArrow: true, onTap: () async { if (info.canDownload) { await showUpdateDownloadDialog(context, info); } else { final uri = Uri.parse(info.htmlUrl); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } } }, control: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: pt.accent, borderRadius: BorderRadius.circular(6)), child: Text( 'Neu', style: TextStyle( color: pt.accentFg, fontSize: 10, fontWeight: FontWeight.w700), ), ), ), _Divider(pt: pt), _SettingsRow( title: 'Diese Version überspringen', desc: '${info.tagName} nicht mehr anzeigen', pt: pt, onTap: () async { await skipUpdateVersion(info.tagName); ref.invalidate(updateInfoProvider); }, ), ], ); }, ), ]); } } // ───────────────────────────────────────────────────────────────────────────── // Passphrase dialog (for key export/import) // ───────────────────────────────────────────────────────────────────────────── class _PassphraseDialog extends StatefulWidget { final PyramidTheme pt; final bool forExport; const _PassphraseDialog({required this.pt, required this.forExport}); @override State<_PassphraseDialog> createState() => _PassphraseDialogState(); } class _PassphraseDialogState extends State<_PassphraseDialog> { final _ctrl1 = TextEditingController(); final _ctrl2 = TextEditingController(); bool _obscure = true; String? _error; @override void dispose() { _ctrl1.dispose(); _ctrl2.dispose(); super.dispose(); } void _submit() { final pass = _ctrl1.text.trim(); if (pass.isEmpty) { setState(() => _error = 'Bitte ein Passwort eingeben.'); return; } if (widget.forExport && pass != _ctrl2.text.trim()) { setState(() => _error = 'Passwörter stimmen nicht überein.'); return; } Navigator.pop(context, pass); } InputDecoration _inputDeco(String label, PyramidTheme pt) => InputDecoration( labelText: label, labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13), errorText: _error, filled: true, fillColor: pt.bg3, prefixIcon: Icon(Icons.lock_outline_rounded, color: pt.fgDim, size: 18), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)), isDense: true, errorMaxLines: 2, suffixIcon: IconButton( icon: Icon( _obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18, color: pt.fgDim, ), onPressed: () => setState(() => _obscure = !_obscure), ), ); @override Widget build(BuildContext context) { final pt = widget.pt; return AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border), ), title: Text( widget.forExport ? 'Schlüssel exportieren' : 'Schlüssel importieren', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700), ), content: SizedBox( width: 340, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.forExport ? 'Wähle ein Passwort zum Verschlüsseln der Schlüsseldatei.' : 'Gib das Passwort ein, mit dem die Datei verschlüsselt wurde.', style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), ), const SizedBox(height: 16), TextField( controller: _ctrl1, obscureText: _obscure, autofocus: true, style: TextStyle(color: pt.fg, fontSize: 13), decoration: _inputDeco('Passwort', pt).copyWith( // Only show error on confirm field if export, else here errorText: widget.forExport ? null : _error, ), cursorColor: pt.accent, onSubmitted: widget.forExport ? null : (_) => _submit(), ), if (widget.forExport) ...[ const SizedBox(height: 12), TextField( controller: _ctrl2, obscureText: _obscure, style: TextStyle(color: pt.fg, fontSize: 13), decoration: _inputDeco('Passwort bestätigen', pt), cursorColor: pt.accent, onSubmitted: (_) => _submit(), ), ], ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: _submit, child: Text(widget.forExport ? 'Exportieren' : 'Importieren'), ), ], ); } } // ───────────────────────────────────────────────────────────────────────────── // Shared small widgets // ───────────────────────────────────────────────────────────────────────────── class _StatusCard extends StatelessWidget { final PyramidTheme pt; final IconData icon; final Color color; final String text; const _StatusCard( {required this.pt, required this.icon, required this.color, required this.text}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(8), border: Border.all(color: pt.border), ), child: Row( children: [ Icon(icon, color: color, size: 18), const SizedBox(width: 10), Expanded(child: Text(text, style: TextStyle(color: pt.fgMuted, fontSize: 13))), ], ), ); } }