99f10c5210
Dart 3 erlaubt mehrere Wildcard-Parameter mit demselben Namen "_" in einem Scope, daher sind __ / ___ für ungenutzte Callback-Parameter (pageBuilder, errorBuilder etc.) unnötig. Rein mechanische Änderung, keine Verhaltensänderung. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5515 lines
207 KiB
Dart
5515 lines
207 KiB
Dart
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<String, dynamic> 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<dynamic>} → encrypted String
|
||
String _encryptMegolmKeys(Map<String, dynamic> args) {
|
||
final pass = args['passphrase'] as String;
|
||
final sessions = args['sessions'] as List<dynamic>;
|
||
|
||
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<int>} → List<dynamic> sessions
|
||
List<dynamic> _decryptMegolmKeys(Map<String, dynamic> args) {
|
||
final pass = args['passphrase'] as String;
|
||
final raw = Uint8List.fromList(args['data'] as List<int>);
|
||
|
||
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<dynamic>;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class SettingsModal extends ConsumerStatefulWidget {
|
||
final VoidCallback onClose;
|
||
const SettingsModal({super.key, required this.onClose});
|
||
|
||
@override
|
||
ConsumerState<SettingsModal> createState() => _SettingsModalState();
|
||
}
|
||
|
||
class _SettingsModalState extends ConsumerState<SettingsModal> {
|
||
// 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<void> _logout() async {
|
||
final pt = widget.pt;
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
|
||
title: Text('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<Widget> children;
|
||
|
||
const _SettingsGroup({required this.title, required this.pt, required this.children});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title.toUpperCase(),
|
||
style: TextStyle(
|
||
color: pt.fgDim,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
letterSpacing: 0.04 * 11),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Container(
|
||
decoration: BoxDecoration(
|
||
color: pt.bg2,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: pt.border),
|
||
),
|
||
child: Column(children: children),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SettingsRow extends StatelessWidget {
|
||
final String title;
|
||
final String? desc;
|
||
final PyramidTheme pt;
|
||
final Widget? control;
|
||
final VoidCallback? onTap;
|
||
final bool destructive;
|
||
final bool showArrow;
|
||
|
||
const _SettingsRow({
|
||
required this.title,
|
||
required this.pt,
|
||
this.desc,
|
||
this.control,
|
||
this.onTap,
|
||
this.destructive = false,
|
||
this.showArrow = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
Widget content = Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: TextStyle(
|
||
color: destructive ? pt.danger : pt.fg,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
if (desc != null) ...[
|
||
const SizedBox(height: 2),
|
||
Text(desc!, style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
?control,
|
||
if (showArrow)
|
||
Icon(Icons.chevron_right_rounded, size: 16, color: pt.fgDim),
|
||
],
|
||
),
|
||
);
|
||
|
||
if (onTap != null) {
|
||
return Material(
|
||
color: Colors.transparent,
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(10),
|
||
child: content,
|
||
),
|
||
);
|
||
}
|
||
return content;
|
||
}
|
||
}
|
||
|
||
class _Divider extends StatelessWidget {
|
||
final PyramidTheme pt;
|
||
const _Divider({required this.pt});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(height: 1, color: pt.border, margin: const EdgeInsets.only(left: 14));
|
||
}
|
||
}
|
||
|
||
class _Toggle extends StatelessWidget {
|
||
final bool value;
|
||
final ValueChanged<bool> onChanged;
|
||
final PyramidTheme pt;
|
||
const _Toggle({required this.value, required this.onChanged, required this.pt});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GestureDetector(
|
||
onTap: () => onChanged(!value),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
width: 42,
|
||
height: 24,
|
||
decoration: BoxDecoration(
|
||
color: value ? pt.accent : pt.bg3,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: value ? pt.accent : pt.borderStrong),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(2),
|
||
child: AnimatedAlign(
|
||
duration: const Duration(milliseconds: 200),
|
||
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
||
child: Container(
|
||
width: 18,
|
||
height: 18,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
shape: BoxShape.circle,
|
||
boxShadow: [BoxShadow(color: Colors.black.withAlpha(60), blurRadius: 4)],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SegControl extends StatelessWidget {
|
||
final List<String> options;
|
||
final int selected;
|
||
final PyramidTheme pt;
|
||
final ValueChanged<int> onSelect;
|
||
|
||
const _SegControl(
|
||
{required this.options, required this.selected, required this.pt, required this.onSelect});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: pt.bg3,
|
||
borderRadius: BorderRadius.circular(pt.rSm),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: options.asMap().entries.map((e) {
|
||
final isActive = e.key == selected;
|
||
return GestureDetector(
|
||
onTap: () => onSelect(e.key),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 150),
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: isActive ? pt.accent : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(pt.rSm - 2),
|
||
),
|
||
child: Text(
|
||
e.value,
|
||
style: TextStyle(
|
||
color: isActive ? pt.accentFg : pt.fgMuted,
|
||
fontSize: 13,
|
||
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PrimaryBtn extends StatelessWidget {
|
||
final String label;
|
||
final IconData? icon;
|
||
final VoidCallback? onPressed;
|
||
final PyramidTheme pt;
|
||
final bool loading;
|
||
|
||
const _PrimaryBtn({
|
||
required this.label,
|
||
required this.pt,
|
||
this.icon,
|
||
this.onPressed,
|
||
this.loading = false,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final bg = pt.accent;
|
||
final fg = pt.accentFg;
|
||
return ElevatedButton.icon(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: bg,
|
||
foregroundColor: fg,
|
||
disabledBackgroundColor: bg.withAlpha(100),
|
||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
elevation: 0,
|
||
),
|
||
onPressed: loading ? null : onPressed,
|
||
icon: loading
|
||
? SizedBox(
|
||
width: 14, height: 14, child: CircularProgressIndicator(color: fg, strokeWidth: 2))
|
||
: (icon != null ? Icon(icon, size: 16) : const SizedBox.shrink()),
|
||
label: Text(label),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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<void> _loadProfile() async {
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
if (!mounted) return;
|
||
|
||
// 1) SOFORT aus dem lokalen Cache füllen — kein Warten auf den Server.
|
||
_nameCtrl.text = client.userID ?? '';
|
||
for (final room in client.rooms) {
|
||
final u = room.unsafeGetUserFromMemoryOrFallback(client.userID!);
|
||
if (u.displayName != null || u.avatarUrl != null) {
|
||
if (u.displayName != null) _nameCtrl.text = u.displayName!;
|
||
_avatarUri = u.avatarUrl;
|
||
break;
|
||
}
|
||
}
|
||
try {
|
||
final bannerData = client.accountData['com.pyramid.profile.banner'];
|
||
_bannerMxcUri = bannerData?.content['url'] as String?;
|
||
} catch (_) {}
|
||
setState(() {});
|
||
|
||
// 2) Server-Daten PARALLEL nachladen, UI aktualisiert sich pro Antwort.
|
||
client
|
||
.getProfileFromUserId(client.userID!)
|
||
.then((profile) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
if (profile.displayName != null) _nameCtrl.text = profile.displayName!;
|
||
_avatarUri = profile.avatarUrl ?? _avatarUri;
|
||
});
|
||
}).catchError((_) {});
|
||
|
||
() async {
|
||
try {
|
||
final apiUri = client.homeserver!.replace(
|
||
path:
|
||
'/_matrix/client/v3/profile/${Uri.encodeComponent(client.userID!)}',
|
||
);
|
||
final resp = await client.httpClient.get(
|
||
apiUri,
|
||
headers: {'Authorization': 'Bearer ${client.accessToken}'},
|
||
);
|
||
if (resp.statusCode == 200 && mounted) {
|
||
final data = jsonDecode(resp.body) as Map<String, dynamic>;
|
||
final url = data['io.pyramid.profile.banner_url'] as String?;
|
||
if (url != null) setState(() => _bannerMxcUri = url);
|
||
}
|
||
} catch (_) {}
|
||
}();
|
||
}
|
||
|
||
Future<void> _pickAvatar() async {
|
||
final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true);
|
||
final file = result?.files.first;
|
||
if (file == null) return;
|
||
final bytes = file.bytes ?? await file.xFile.readAsBytes();
|
||
setState(() => _pendingAvatar = bytes);
|
||
}
|
||
|
||
Future<void> _pickBanner() async {
|
||
final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true);
|
||
final file = result?.files.first;
|
||
if (file == null) return;
|
||
final raw = file.bytes ?? await file.xFile.readAsBytes();
|
||
if (!mounted) return;
|
||
final cropped = await showDialog<Uint8List>(
|
||
context: context,
|
||
builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 3.0),
|
||
);
|
||
if (cropped == null || !mounted) return;
|
||
setState(() => _pendingBanner = cropped);
|
||
}
|
||
|
||
Future<void> _pickServerBanner() async {
|
||
final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true);
|
||
final file = result?.files.first;
|
||
if (file == null) return;
|
||
final raw = file.bytes ?? await file.xFile.readAsBytes();
|
||
if (!mounted) return;
|
||
final cropped = await showDialog<Uint8List>(
|
||
context: context,
|
||
builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4),
|
||
);
|
||
if (cropped == null || !mounted) return;
|
||
setState(() => _serverBannerSaving = true);
|
||
try {
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
final uri = await client.uploadContent(cropped, filename: 'server_banner.png', contentType: 'image/png');
|
||
await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), uri.toString());
|
||
if (mounted) setState(() { _pendingServerBanner = cropped; _serverBannerSaving = false; });
|
||
} catch (_) {
|
||
if (mounted) setState(() => _serverBannerSaving = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _removeServerBanner() async {
|
||
final client = ref.read(matrixClientProvider).valueOrNull;
|
||
if (client == null) return;
|
||
await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), null);
|
||
if (mounted) setState(() => _pendingServerBanner = null);
|
||
}
|
||
|
||
Future<void> _save() async {
|
||
setState(() { _saving = true; _error = null; _saved = false; });
|
||
try {
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
final name = _nameCtrl.text.trim();
|
||
if (name.isNotEmpty) {
|
||
await client.setProfileField(
|
||
client.userID!,
|
||
'displayname',
|
||
{'displayname': name},
|
||
);
|
||
}
|
||
if (_pendingAvatar != null) {
|
||
await client.setAvatar(MatrixFile(
|
||
bytes: _pendingAvatar!,
|
||
name: 'avatar.jpg',
|
||
mimeType: 'image/jpeg',
|
||
));
|
||
setState(() => _pendingAvatar = null);
|
||
}
|
||
if (_pendingBanner != null) {
|
||
final uri = await client.uploadContent(
|
||
_pendingBanner!, filename: 'banner.png', contentType: 'image/png');
|
||
final uriStr = uri.toString();
|
||
// Store as a profile custom field so other users can see it
|
||
await client.setProfileField(
|
||
client.userID!,
|
||
'io.pyramid.profile.banner_url',
|
||
{'io.pyramid.profile.banner_url': uriStr},
|
||
);
|
||
// Also keep in account data as legacy fallback
|
||
await client.setAccountData(client.userID!, 'com.pyramid.profile.banner',
|
||
{'url': uriStr});
|
||
setState(() { _bannerMxcUri = uriStr; _pendingBanner = null; });
|
||
}
|
||
final status = _statusCtrl.text.trim();
|
||
await client.setPresence(
|
||
client.userID!,
|
||
PresenceType.online,
|
||
statusMsg: status.isEmpty ? null : status,
|
||
);
|
||
if (mounted) setState(() { _saving = false; _saved = true; });
|
||
await Future.delayed(const Duration(seconds: 2));
|
||
if (mounted) setState(() => _saved = false);
|
||
} catch (e) {
|
||
if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; });
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
final clientAsync = ref.watch(matrixClientProvider);
|
||
final client = clientAsync.valueOrNull;
|
||
|
||
return SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_SectionTitle(
|
||
title: 'Mein Profil',
|
||
subtitle: 'Wie du in Pyramid erscheinst.',
|
||
pt: pt),
|
||
// Banner
|
||
GestureDetector(
|
||
onTap: _pickBanner,
|
||
child: Stack(
|
||
children: [
|
||
Container(
|
||
height: 100,
|
||
decoration: BoxDecoration(
|
||
color: pt.bg3,
|
||
borderRadius: BorderRadius.circular(pt.rBase),
|
||
border: Border.all(color: pt.border),
|
||
),
|
||
clipBehavior: Clip.hardEdge,
|
||
child: _pendingBanner != null
|
||
? Image.memory(_pendingBanner!, fit: BoxFit.cover, width: double.infinity)
|
||
: (client != null && _bannerMxcUri != null
|
||
? MxcImage(
|
||
mxcUri: _bannerMxcUri!,
|
||
client: client,
|
||
fit: BoxFit.cover,
|
||
width: double.infinity,
|
||
)
|
||
: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.add_photo_alternate_outlined, size: 28, color: pt.fgDim),
|
||
const SizedBox(height: 4),
|
||
Text('Profilbanner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||
],
|
||
),
|
||
)),
|
||
),
|
||
Positioned(
|
||
right: 8,
|
||
bottom: 8,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: pt.bg0.withAlpha(200),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: pt.border),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted),
|
||
const SizedBox(width: 4),
|
||
Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
// Avatar row
|
||
Row(
|
||
children: [
|
||
GestureDetector(
|
||
onTap: _pickAvatar,
|
||
child: Stack(
|
||
children: [
|
||
Container(
|
||
width: 72,
|
||
height: 72,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: pt.border, width: 2),
|
||
),
|
||
child: ClipOval(
|
||
child: _pendingAvatar != null
|
||
? Image.memory(_pendingAvatar!, fit: BoxFit.cover)
|
||
: (client != null && _avatarUri != null
|
||
? MxcAvatar(
|
||
mxcUri: _avatarUri,
|
||
client: client,
|
||
size: 72,
|
||
placeholder: (_) => _AvatarFallback(
|
||
name: _nameCtrl.text, size: 72, pt: pt),
|
||
)
|
||
: _AvatarFallback(
|
||
name: _nameCtrl.text, size: 72, pt: pt)),
|
||
),
|
||
),
|
||
Positioned(
|
||
right: 0,
|
||
bottom: 0,
|
||
child: Container(
|
||
width: 24,
|
||
height: 24,
|
||
decoration: BoxDecoration(
|
||
color: pt.accent,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: pt.bg1, width: 2),
|
||
),
|
||
child: Icon(Icons.edit_rounded, size: 12, color: pt.accentFg),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 20),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
_nameCtrl.text.isNotEmpty ? _nameCtrl.text : (client?.userID ?? ''),
|
||
style: TextStyle(
|
||
color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(height: 2),
|
||
SelectableText(
|
||
client?.userID ?? '',
|
||
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||
),
|
||
const SizedBox(height: 6),
|
||
if (_pendingAvatar != null)
|
||
Container(
|
||
padding:
|
||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: pt.accent.withAlpha(40),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text('Neues Bild ausgewählt',
|
||
style: TextStyle(color: pt.accent, fontSize: 11)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
_SettingsGroup(title: 'Name & Status', pt: pt, children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 4),
|
||
child: _PrefTextField(
|
||
controller: _nameCtrl,
|
||
label: 'Anzeigename',
|
||
pt: pt,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(14, 4, 14, 14),
|
||
child: _PrefTextField(
|
||
controller: _statusCtrl,
|
||
label: 'Statusnachricht (optional)',
|
||
pt: pt,
|
||
maxLength: 120,
|
||
),
|
||
),
|
||
]),
|
||
const SizedBox(height: 20),
|
||
if (_error != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
child: _StatusCard(
|
||
pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!),
|
||
),
|
||
if (_saved)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
child: _StatusCard(
|
||
pt: pt,
|
||
color: PyramidColors.success,
|
||
icon: Icons.check_circle_outline_rounded,
|
||
text: 'Profil gespeichert.'),
|
||
),
|
||
_PrimaryBtn(
|
||
label: _saving ? 'Speichern…' : 'Speichern',
|
||
icon: Icons.save_outlined,
|
||
pt: pt,
|
||
loading: _saving,
|
||
onPressed: _save,
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
// ── Server-Banner ─────────────────────────────────────────────────
|
||
_SectionTitle(
|
||
title: 'Server-Darstellung',
|
||
subtitle: 'Banner für ${client?.homeserver?.host ?? 'diesen Server'}',
|
||
pt: pt,
|
||
),
|
||
Consumer(builder: (context, ref, _) {
|
||
final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {};
|
||
final homeserver = client?.homeserver.toString();
|
||
final existingMxc = homeserver != null ? serverBanners[homeserver] : null;
|
||
final hasBanner = _pendingServerBanner != null || existingMxc != null;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
GestureDetector(
|
||
onTap: _serverBannerSaving ? null : _pickServerBanner,
|
||
child: Container(
|
||
height: 90,
|
||
decoration: BoxDecoration(
|
||
color: pt.bg3,
|
||
borderRadius: BorderRadius.circular(pt.rBase),
|
||
border: Border.all(color: pt.border),
|
||
),
|
||
clipBehavior: Clip.hardEdge,
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
if (_pendingServerBanner != null)
|
||
Image.memory(_pendingServerBanner!, fit: BoxFit.cover,
|
||
errorBuilder: (_, _, _) => const SizedBox.shrink())
|
||
else if (existingMxc != null && client != null)
|
||
MxcImage(mxcUri: existingMxc, client: client, fit: BoxFit.cover)
|
||
else
|
||
Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.add_photo_alternate_outlined, size: 24, color: pt.fgDim),
|
||
const SizedBox(height: 4),
|
||
Text('Server-Banner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||
],
|
||
),
|
||
),
|
||
if (_serverBannerSaving)
|
||
Container(
|
||
color: Colors.black38,
|
||
child: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||
),
|
||
if (hasBanner)
|
||
Positioned(
|
||
right: 8,
|
||
bottom: 8,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: pt.bg0.withAlpha(200),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: pt.border),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted),
|
||
const SizedBox(width: 4),
|
||
Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (hasBanner) ...[
|
||
const SizedBox(height: 8),
|
||
GestureDetector(
|
||
onTap: _removeServerBanner,
|
||
child: Text(
|
||
'Banner entfernen',
|
||
style: TextStyle(color: pt.danger, fontSize: 12),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}),
|
||
|
||
const SizedBox(height: 24),
|
||
_SettingsGroup(title: 'Konto', pt: pt, children: [
|
||
_SettingsRow(
|
||
title: 'Passwort ändern',
|
||
desc: 'Aktuelles Passwort zum Ändern erforderlich',
|
||
pt: pt,
|
||
showArrow: true,
|
||
onTap: _changePassword,
|
||
),
|
||
]),
|
||
const SizedBox(height: 12),
|
||
_SettingsGroup(title: 'Gefahrenbereich', pt: pt, children: [
|
||
_SettingsRow(
|
||
title: 'Konto löschen',
|
||
desc: 'Konto dauerhaft deaktivieren – nicht rückgängig zu machen',
|
||
pt: pt,
|
||
showArrow: true,
|
||
destructive: true,
|
||
onTap: _deleteAccount,
|
||
),
|
||
]),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _changePassword() async {
|
||
await showDialog<void>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (ctx) => _ChangePasswordDialog(pt: widget.pt),
|
||
);
|
||
}
|
||
|
||
Future<void> _deleteAccount() async {
|
||
await showDialog<void>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (ctx) => _DeleteAccountDialog(pt: widget.pt),
|
||
);
|
||
}
|
||
}
|
||
|
||
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<MediaDeviceInfo> _audioInputs = [];
|
||
List<MediaDeviceInfo> _audioOutputs = [];
|
||
List<MediaDeviceInfo> _videoInputs = [];
|
||
String? _selectedMic;
|
||
String? _selectedSpeaker;
|
||
String? _selectedCamera;
|
||
double _outputVolume = 1.0;
|
||
bool _loading = true;
|
||
String? _loadError;
|
||
|
||
static const _kMic = 'voice_mic_device';
|
||
static const _kSpeaker = 'voice_speaker_device';
|
||
static const _kCamera = 'voice_camera_device';
|
||
static const _kOutputVolume = 'voice_output_volume';
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_init();
|
||
}
|
||
|
||
Future<void> _init() async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
List<MediaDeviceInfo> devices = [];
|
||
String? loadError;
|
||
try {
|
||
devices = await navigator.mediaDevices.enumerateDevices();
|
||
} catch (_) {
|
||
devices = [];
|
||
}
|
||
// Windows (und teils Android) liefert eine LEERE Liste ohne Fehler,
|
||
// solange in diesem Prozess noch nie ein Media-Stream geöffnet wurde.
|
||
// Deshalb nicht nur bei Exception, sondern auch bei leerer Liste einmal
|
||
// kurz getUserMedia anfordern und erneut enumerieren — und zwar SOLANGE
|
||
// der Stream noch offen ist (nach stop() melden manche Systeme wieder 0).
|
||
final hasAudioInput =
|
||
devices.any((d) => d.kind?.toLowerCase() == 'audioinput');
|
||
if (devices.isEmpty || !hasAudioInput) {
|
||
try {
|
||
final stream = await navigator.mediaDevices
|
||
.getUserMedia({'audio': true, 'video': false});
|
||
try {
|
||
devices = await navigator.mediaDevices.enumerateDevices();
|
||
} finally {
|
||
for (final t in stream.getTracks()) {
|
||
t.stop();
|
||
}
|
||
await stream.dispose();
|
||
}
|
||
if (devices.isEmpty) {
|
||
// Bekanntes libwebrtc-Problem auf manchen Windows-Systemen: das
|
||
// Audio-Device-Module meldet 0 Geräte, obwohl Aufnahme/Wiedergabe
|
||
// über das Standardgerät einwandfrei funktionieren.
|
||
loadError =
|
||
'Die Audiotreiber melden keine Geräteliste an WebRTC '
|
||
'(bekanntes libwebrtc-Problem auf manchen Windows-Systemen). '
|
||
'Anrufe funktionieren trotzdem — es wird das Windows-Standardgerät verwendet.';
|
||
}
|
||
} catch (e2) {
|
||
loadError = 'getUserMedia: ${e2.toString().split('\n').first}';
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_audioInputs = devices.where((d) => d.kind?.toLowerCase() == 'audioinput').toList();
|
||
_audioOutputs = devices.where((d) => d.kind?.toLowerCase() == 'audiooutput').toList();
|
||
_videoInputs = devices.where((d) => d.kind?.toLowerCase() == 'videoinput').toList();
|
||
_selectedMic = prefs.getString(_kMic);
|
||
_selectedSpeaker = prefs.getString(_kSpeaker);
|
||
_selectedCamera = prefs.getString(_kCamera);
|
||
_outputVolume = prefs.getDouble(_kOutputVolume) ?? 1.0;
|
||
_loadError = loadError;
|
||
_loading = false;
|
||
});
|
||
}
|
||
|
||
Future<void> _setOutputVolume(double v) async {
|
||
setState(() => _outputVolume = v);
|
||
final prefs = await SharedPreferences.getInstance();
|
||
await prefs.setDouble(_kOutputVolume, v);
|
||
// Live auf laufende Calls anwenden.
|
||
LiveKitCallManager.instance.setOutputVolume(v).catchError((_) {});
|
||
try {
|
||
PyramidVoipManager.instance.applyOutputVolume(v).catchError((_) {});
|
||
} catch (_) {} // Instanz existiert evtl. noch nicht
|
||
}
|
||
|
||
Future<void> _save(String key, String? value) async {
|
||
final prefs = await SharedPreferences.getInstance();
|
||
if (value == null || value.isEmpty) {
|
||
await prefs.remove(key);
|
||
} else {
|
||
await prefs.setString(key, value);
|
||
}
|
||
}
|
||
|
||
Widget _deviceDropdown({
|
||
required List<MediaDeviceInfo> devices,
|
||
required String? selected,
|
||
required String placeholder,
|
||
required IconData icon,
|
||
required ValueChanged<String?> onChanged,
|
||
}) {
|
||
final pt = widget.pt;
|
||
if (devices.isEmpty) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: pt.bg3,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: pt.border),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 13, color: pt.fgDim),
|
||
const SizedBox(width: 6),
|
||
Text('Kein Gerät', style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
final validSelected = devices.any((d) => d.deviceId == selected) ? selected : null;
|
||
|
||
return ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 220),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
decoration: BoxDecoration(
|
||
color: pt.bg3,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: pt.border),
|
||
),
|
||
child: DropdownButtonHideUnderline(
|
||
child: DropdownButton<String?>(
|
||
value: validSelected,
|
||
hint: Row(children: [
|
||
Icon(icon, size: 13, color: pt.fgDim),
|
||
const SizedBox(width: 6),
|
||
Text(placeholder, style: TextStyle(color: pt.fgDim, fontSize: 12)),
|
||
]),
|
||
dropdownColor: pt.bg2,
|
||
iconEnabledColor: pt.fgDim,
|
||
isDense: true,
|
||
style: TextStyle(color: pt.fg, fontSize: 12),
|
||
onChanged: onChanged,
|
||
items: devices.map((d) {
|
||
final label = d.label.isNotEmpty ? d.label : 'Gerät ${d.deviceId.substring(0, 6)}';
|
||
return DropdownMenuItem<String?>(
|
||
value: d.deviceId,
|
||
child: Text(label, overflow: TextOverflow.ellipsis),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
return SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_SectionTitle(
|
||
title: 'Sprache & Video',
|
||
subtitle: 'Mikrofon, Kamera und Anrufeinstellungen.',
|
||
pt: pt),
|
||
if (_loading)
|
||
const Center(child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()))
|
||
else ...[
|
||
if (_loadError != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: pt.bg2,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: pt.border),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(children: [
|
||
Icon(Icons.warning_amber_rounded, size: 16, color: pt.away),
|
||
const SizedBox(width: 8),
|
||
Text('Geräteliste nicht verfügbar',
|
||
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
|
||
]),
|
||
const SizedBox(height: 6),
|
||
Text(_loadError!, style: TextStyle(color: pt.fgDim, fontSize: 11)),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
'Mikrofon und Lautsprecher legst du in diesem Fall über die '
|
||
'Windows-Soundeinstellungen (Standardgerät) fest. '
|
||
'Falls dort alles stimmt: Windows-Einstellungen → Datenschutz → Mikrofon prüfen.',
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 11),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
_SettingsGroup(title: 'Geräte', pt: pt, children: [
|
||
_SettingsRow(
|
||
title: 'Mikrofon',
|
||
desc: _audioInputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioInputs.length} Gerät(e) gefunden',
|
||
pt: pt,
|
||
control: _deviceDropdown(
|
||
devices: _audioInputs,
|
||
selected: _selectedMic,
|
||
placeholder: 'Standard',
|
||
icon: Icons.mic_rounded,
|
||
onChanged: (v) {
|
||
setState(() => _selectedMic = v);
|
||
_save(_kMic, v);
|
||
},
|
||
),
|
||
),
|
||
_Divider(pt: pt),
|
||
_SettingsRow(
|
||
title: 'Lautsprecher',
|
||
desc: _audioOutputs.isEmpty ? 'Kein Gerät gefunden' : '${_audioOutputs.length} Gerät(e) gefunden',
|
||
pt: pt,
|
||
control: _deviceDropdown(
|
||
devices: _audioOutputs,
|
||
selected: _selectedSpeaker,
|
||
placeholder: 'Standard',
|
||
icon: Icons.volume_up_rounded,
|
||
onChanged: (v) {
|
||
setState(() => _selectedSpeaker = v);
|
||
_save(_kSpeaker, v);
|
||
},
|
||
),
|
||
),
|
||
_Divider(pt: pt),
|
||
_SettingsRow(
|
||
title: 'Kamera',
|
||
desc: _videoInputs.isEmpty ? 'Kein Gerät gefunden' : '${_videoInputs.length} Gerät(e) gefunden',
|
||
pt: pt,
|
||
control: _deviceDropdown(
|
||
devices: _videoInputs,
|
||
selected: _selectedCamera,
|
||
placeholder: 'Standard',
|
||
icon: Icons.videocam_rounded,
|
||
onChanged: (v) {
|
||
setState(() => _selectedCamera = v);
|
||
_save(_kCamera, v);
|
||
},
|
||
),
|
||
),
|
||
]),
|
||
const SizedBox(height: 16),
|
||
_SettingsGroup(title: 'Pegel', pt: pt, children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(Icons.volume_up_rounded, size: 15, color: pt.fgMuted),
|
||
const SizedBox(width: 8),
|
||
Text('Ausgabelautstärke',
|
||
style: TextStyle(
|
||
color: pt.fg,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500)),
|
||
const Spacer(),
|
||
Text('${(_outputVolume * 100).round()} %',
|
||
style: TextStyle(
|
||
color: pt.fgMuted,
|
||
fontSize: 12,
|
||
fontFamily: 'monospace')),
|
||
],
|
||
),
|
||
SliderTheme(
|
||
data: SliderThemeData(
|
||
activeTrackColor: pt.accent,
|
||
inactiveTrackColor: pt.bg3,
|
||
thumbColor: pt.accent,
|
||
overlayColor: pt.accent.withAlpha(30),
|
||
trackHeight: 3,
|
||
thumbShape: const RoundSliderThumbShape(
|
||
enabledThumbRadius: 7),
|
||
),
|
||
child: Slider(
|
||
value: _outputVolume.clamp(0.0, 1.5),
|
||
min: 0.0,
|
||
max: 1.5,
|
||
divisions: 30,
|
||
onChanged: _setOutputVolume,
|
||
),
|
||
),
|
||
Text(
|
||
'Lautstärke der anderen Teilnehmer in Calls. '
|
||
'Wirkt sofort auf laufende Anrufe.',
|
||
style: TextStyle(color: pt.fgDim, fontSize: 11),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
]),
|
||
const SizedBox(height: 16),
|
||
_TurnUsageCard(pt: pt),
|
||
const SizedBox(height: 12),
|
||
TextButton.icon(
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: pt.fgMuted,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
),
|
||
onPressed: () { setState(() { _loading = true; _loadError = null; }); _init(); },
|
||
icon: const Icon(Icons.refresh_rounded, size: 14),
|
||
label: const Text('Geräte neu laden', style: TextStyle(fontSize: 12)),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 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<String, dynamic>? _status;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
}
|
||
|
||
Future<void> _load() async {
|
||
try {
|
||
final resp = await http
|
||
.get(Uri.parse(
|
||
'https://steggi-matrix.work/.well-known/pyramid/turn-status.json'))
|
||
.timeout(const Duration(seconds: 5));
|
||
if (resp.statusCode == 200 && mounted) {
|
||
setState(
|
||
() => _status = jsonDecode(resp.body) as Map<String, dynamic>);
|
||
}
|
||
} catch (_) {
|
||
// Endpoint (noch) nicht vorhanden — Karte einfach nicht anzeigen.
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final s = _status;
|
||
if (s == null) return const SizedBox.shrink();
|
||
final pt = widget.pt;
|
||
final used = (s['egress_gb'] as num?)?.toDouble() ?? 0;
|
||
final limit = (s['limit_gb'] as num?)?.toDouble() ?? 990;
|
||
final pct = limit > 0 ? (used / limit).clamp(0.0, 1.0) : 0.0;
|
||
final active = s['turn_active'] != false;
|
||
final barColor = !active
|
||
? pt.danger
|
||
: pct > 0.8
|
||
? pt.away
|
||
: pt.accent;
|
||
|
||
return _SettingsGroup(title: 'Call-Relay (TURN)', pt: pt, children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(children: [
|
||
Icon(active ? Icons.cloud_done_rounded : Icons.cloud_off_rounded,
|
||
size: 15, color: active ? pt.online : pt.danger),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
active
|
||
? 'Relay aktiv'
|
||
: 'Relay deaktiviert (Limit erreicht)',
|
||
style: TextStyle(
|
||
color: pt.fg,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500)),
|
||
const Spacer(),
|
||
Text(
|
||
'${used.toStringAsFixed(1)} / ${limit.toStringAsFixed(0)} GB',
|
||
style: TextStyle(
|
||
color: pt.fgMuted,
|
||
fontSize: 12,
|
||
fontFamily: 'monospace')),
|
||
]),
|
||
const SizedBox(height: 8),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(99),
|
||
child: LinearProgressIndicator(
|
||
value: pct,
|
||
minHeight: 5,
|
||
backgroundColor: pt.bg3,
|
||
color: barColor,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
active
|
||
? 'Cloudflare-TURN-Traffic diesen Monat (Free Tier: ${limit.toStringAsFixed(0)} GB). '
|
||
'Bei Erreichen des Limits laufen Calls bis Monatsende ohne Relay weiter.'
|
||
: 'Monatslimit erreicht — Calls nutzen bis Monatsende nur direkte Verbindungen (STUN).',
|
||
style: TextStyle(color: pt.fgDim, fontSize: 11),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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<String> _ignoredUsers = [];
|
||
final Set<String> _unblocking = {};
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadIgnoredUsers();
|
||
}
|
||
|
||
Future<void> _loadIgnoredUsers() async {
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
if (mounted) setState(() => _ignoredUsers = List.from(client.ignoredUsers));
|
||
}
|
||
|
||
Future<void> _unblockUser(String userId) async {
|
||
setState(() => _unblocking.add(userId));
|
||
try {
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
await client.unignoreUser(userId);
|
||
await _loadIgnoredUsers();
|
||
} catch (e) {
|
||
// ignore — list will be stale but not crash
|
||
} finally {
|
||
if (mounted) setState(() => _unblocking.remove(userId));
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
final presence = ref.watch(privacyPresenceProvider);
|
||
final readReceipts = ref.watch(privacyReadReceiptsProvider);
|
||
final typing = ref.watch(privacyTypingProvider);
|
||
|
||
return SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_SectionTitle(
|
||
title: 'Datenschutz',
|
||
subtitle: 'Steuere, was andere über dich erfahren.',
|
||
pt: pt),
|
||
_SettingsGroup(title: 'Sichtbarkeit', pt: pt, children: [
|
||
_SettingsRow(
|
||
title: 'Online-Status anzeigen',
|
||
desc: 'Anderen anzeigen, wenn du aktiv bist',
|
||
pt: pt,
|
||
control: _Toggle(
|
||
value: presence,
|
||
pt: pt,
|
||
onChanged: (v) async {
|
||
ref.read(privacyPresenceProvider.notifier).set(v);
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
await client.setPresence(
|
||
client.userID!,
|
||
v ? PresenceType.online : PresenceType.offline,
|
||
).catchError((_) {});
|
||
},
|
||
),
|
||
),
|
||
_Divider(pt: pt),
|
||
_SettingsRow(
|
||
title: 'Lesebestätigungen senden',
|
||
desc: 'Anderen zeigen, dass du ihre Nachrichten gelesen hast',
|
||
pt: pt,
|
||
control: _Toggle(
|
||
value: readReceipts,
|
||
pt: pt,
|
||
onChanged: (v) => ref.read(privacyReadReceiptsProvider.notifier).set(v),
|
||
),
|
||
),
|
||
_Divider(pt: pt),
|
||
_SettingsRow(
|
||
title: 'Tipp-Indikator senden',
|
||
desc: '„schreibt…" Anzeige aktivieren',
|
||
pt: pt,
|
||
control: _Toggle(
|
||
value: typing,
|
||
pt: pt,
|
||
onChanged: (v) => ref.read(privacyTypingProvider.notifier).set(v),
|
||
),
|
||
),
|
||
]),
|
||
const SizedBox(height: 20),
|
||
// ── Blocked users ──────────────────────────────────────────────────
|
||
_SettingsGroup(title: 'Blockierte Nutzer (${_ignoredUsers.length})', pt: pt, children: [
|
||
if (_ignoredUsers.isEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Text('Keine blockierten Nutzer.',
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||
)
|
||
else
|
||
..._ignoredUsers.asMap().entries.map((entry) {
|
||
final i = entry.key;
|
||
final userId = entry.value;
|
||
final isUnblocking = _unblocking.contains(userId);
|
||
return Column(children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||
child: Row(children: [
|
||
Expanded(
|
||
child: Text(userId,
|
||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||
overflow: TextOverflow.ellipsis),
|
||
),
|
||
if (isUnblocking)
|
||
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
|
||
else
|
||
TextButton(
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: pt.accent,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
minimumSize: Size.zero,
|
||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||
),
|
||
onPressed: () => _unblockUser(userId),
|
||
child: const Text('Entblocken', style: TextStyle(fontSize: 12)),
|
||
),
|
||
]),
|
||
),
|
||
if (i < _ignoredUsers.length - 1) _Divider(pt: pt),
|
||
]);
|
||
}),
|
||
]),
|
||
const SizedBox(height: 20),
|
||
Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: pt.accentSoft,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: pt.accent.withAlpha(60)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.shield_outlined, size: 16, color: pt.accent),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
'Alle Nachrichten sind Ende-zu-Ende-verschlüsselt.',
|
||
style: TextStyle(color: pt.fg, fontSize: 12),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// SESSIONS
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class _SessionsSection extends ConsumerStatefulWidget {
|
||
final PyramidTheme pt;
|
||
const _SessionsSection({required this.pt});
|
||
|
||
@override
|
||
ConsumerState<_SessionsSection> createState() => _SessionsSectionState();
|
||
}
|
||
|
||
class _SessionsSectionState extends ConsumerState<_SessionsSection> {
|
||
List<DeviceKeys> _devices = [];
|
||
bool _loading = true;
|
||
String? _currentDeviceId;
|
||
final Set<String> _revoking = {};
|
||
bool _logoutAllLoading = false;
|
||
String? _error;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
}
|
||
|
||
Future<void> _load() async {
|
||
setState(() { _loading = true; _error = null; });
|
||
try {
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
_currentDeviceId = client.deviceID;
|
||
await client.updateUserDeviceKeys(additionalUsers: {client.userID!});
|
||
final keys = client.userDeviceKeys[client.userID];
|
||
if (mounted) {
|
||
setState(() {
|
||
_devices = keys?.deviceKeys.values.toList() ?? [];
|
||
_loading = false;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_loading = false;
|
||
_error = e.toString().split('\n').first;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _revokeDevice(String deviceId) async {
|
||
setState(() => _revoking.add(deviceId));
|
||
try {
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
await client.deleteDevice(deviceId);
|
||
await _load();
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_revoking.remove(deviceId);
|
||
_error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}';
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _renameCurrentDevice(BuildContext context) async {
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
final currentName = _devices
|
||
.where((d) => d.deviceId == _currentDeviceId)
|
||
.map((d) => d.unsigned?['device_display_name'] as String? ?? d.deviceId ?? '')
|
||
.firstOrNull ?? '';
|
||
|
||
final ctrl = TextEditingController(text: currentName);
|
||
final pt = widget.pt;
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl),
|
||
side: BorderSide(color: pt.border)),
|
||
title: Text('Gerät umbenennen',
|
||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||
content: TextField(
|
||
controller: ctrl,
|
||
autofocus: true,
|
||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||
decoration: InputDecoration(
|
||
labelText: 'Gerätename',
|
||
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||
filled: true, fillColor: pt.bg3,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
|
||
),
|
||
cursorColor: pt.accent,
|
||
),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
|
||
ElevatedButton(
|
||
style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: const Text('Umbenennen'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
ctrl.dispose();
|
||
if (confirmed != true || _currentDeviceId == null) return;
|
||
try {
|
||
await client.updateDevice(_currentDeviceId!, displayName: ctrl.text.trim());
|
||
await _load();
|
||
} catch (e) {
|
||
if (mounted) setState(() => _error = 'Umbenennen fehlgeschlagen: ${e.toString().split('\n').first}');
|
||
}
|
||
}
|
||
|
||
Future<void> _logoutAllOtherDevices(BuildContext context) async {
|
||
final pt = widget.pt;
|
||
final otherIds = _devices
|
||
.where((d) => d.deviceId != _currentDeviceId && d.deviceId != null)
|
||
.map((d) => d.deviceId!)
|
||
.toList();
|
||
if (otherIds.isEmpty) return;
|
||
|
||
final confirm = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
|
||
title: Text('Alle anderen Sitzungen abmelden?',
|
||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||
content: Text(
|
||
'${otherIds.length} Gerät${otherIds.length == 1 ? '' : 'e'} werden abgemeldet. Nicht gespeicherte Nachrichten auf diesen Geräten können verloren gehen.',
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5),
|
||
),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
|
||
ElevatedButton(
|
||
style: ElevatedButton.styleFrom(backgroundColor: pt.danger, foregroundColor: Colors.white, elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: const Text('Alle abmelden'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirm != true || !mounted) return;
|
||
setState(() { _logoutAllLoading = true; _error = null; });
|
||
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
try {
|
||
await client.deleteDevices(otherIds);
|
||
} on MatrixException catch (e) {
|
||
if (e.requireAdditionalAuthentication && mounted) {
|
||
final password = await _askPassword(context, pt);
|
||
if (password == null || !mounted) { setState(() => _logoutAllLoading = false); return; }
|
||
try {
|
||
await client.deleteDevices(otherIds, auth: AuthenticationData(
|
||
type: AuthenticationTypes.password,
|
||
session: e.session,
|
||
additionalFields: {
|
||
'identifier': {'type': 'm.id.user', 'user': client.userID},
|
||
'password': password,
|
||
},
|
||
));
|
||
} catch (e2) {
|
||
if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e2.toString().split('\n').first}'; });
|
||
return;
|
||
}
|
||
} else {
|
||
if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; });
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
if (mounted) setState(() { _logoutAllLoading = false; _error = 'Abmelden fehlgeschlagen: ${e.toString().split('\n').first}'; });
|
||
return;
|
||
}
|
||
await _load();
|
||
if (mounted) setState(() => _logoutAllLoading = false);
|
||
}
|
||
|
||
Future<String?> _askPassword(BuildContext context, PyramidTheme pt) {
|
||
final ctrl = TextEditingController();
|
||
return showDialog<String>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
|
||
title: Text('Passwort bestätigen', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w700)),
|
||
content: TextField(
|
||
controller: ctrl,
|
||
autofocus: true,
|
||
obscureText: true,
|
||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||
decoration: InputDecoration(
|
||
labelText: 'Passwort',
|
||
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||
filled: true, fillColor: pt.bg3,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
|
||
),
|
||
cursorColor: pt.accent,
|
||
onSubmitted: (_) => Navigator.pop(ctx, ctrl.text),
|
||
),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(ctx, null), child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted))),
|
||
ElevatedButton(
|
||
style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||
onPressed: () => Navigator.pop(ctx, ctrl.text),
|
||
child: const Text('Bestätigen'),
|
||
),
|
||
],
|
||
),
|
||
).then((v) { ctrl.dispose(); return v; });
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
|
||
return SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_SectionTitle(
|
||
title: 'Sitzungen',
|
||
subtitle: 'Geräte, auf denen du angemeldet bist.',
|
||
pt: pt),
|
||
if (_loading)
|
||
const Center(
|
||
child: Padding(padding: EdgeInsets.all(32), child: CircularProgressIndicator.adaptive()))
|
||
else if (_error != null)
|
||
_StatusCard(
|
||
pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!)
|
||
else ...[
|
||
_SettingsGroup(
|
||
title: 'Aktive Sitzungen (${_devices.length})',
|
||
pt: pt,
|
||
children: _devices.isEmpty
|
||
? [
|
||
Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Text('Keine Sitzungen gefunden.',
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||
)
|
||
]
|
||
: _devices.asMap().entries.map((entry) {
|
||
final i = entry.key;
|
||
final device = entry.value;
|
||
final isCurrent = device.deviceId == _currentDeviceId;
|
||
final isRevoking = _revoking.contains(device.deviceId);
|
||
final isVerified = device.verified;
|
||
final displayName = device.unsigned?['device_display_name'] as String? ??
|
||
device.deviceId ??
|
||
'Unbekanntes Gerät';
|
||
|
||
return Column(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 14, vertical: 12),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: isCurrent
|
||
? pt.accentSoft
|
||
: pt.bg3,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Icon(
|
||
Icons.devices_rounded,
|
||
size: 18,
|
||
color: isCurrent ? pt.accent : pt.fgMuted,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
displayName,
|
||
style: TextStyle(
|
||
color: pt.fg,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
),
|
||
if (isCurrent) ...[
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 6, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: pt.accentSoft,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: Text('Dieses Gerät',
|
||
style: TextStyle(
|
||
color: pt.accent,
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w600)),
|
||
),
|
||
const SizedBox(width: 4),
|
||
GestureDetector(
|
||
onTap: () => _renameCurrentDevice(context),
|
||
child: Tooltip(
|
||
message: 'Gerät umbenennen',
|
||
child: Icon(Icons.edit_rounded, size: 13, color: pt.fgDim),
|
||
),
|
||
),
|
||
],
|
||
if (isVerified == true && !isCurrent)
|
||
Icon(Icons.verified_rounded,
|
||
size: 14, color: PyramidColors.success),
|
||
if (isVerified == false && !isCurrent)
|
||
Icon(Icons.warning_amber_rounded,
|
||
size: 14, color: pt.away),
|
||
],
|
||
),
|
||
const SizedBox(height: 2),
|
||
SelectableText(
|
||
device.deviceId ?? '',
|
||
style: TextStyle(
|
||
color: pt.fgDim,
|
||
fontSize: 11,
|
||
fontFamily: 'monospace'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (!isCurrent) ...[
|
||
const SizedBox(width: 8),
|
||
if (isVerified == false)
|
||
GestureDetector(
|
||
onTap: () => _verifyDevice(context, device, displayName),
|
||
child: Tooltip(
|
||
message: 'Verifizieren',
|
||
child: Icon(Icons.verified_user_outlined,
|
||
size: 16, color: pt.accent),
|
||
),
|
||
),
|
||
const SizedBox(width: 6),
|
||
if (isRevoking)
|
||
SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2, color: pt.danger),
|
||
)
|
||
else
|
||
GestureDetector(
|
||
onTap: () => _confirmRevoke(context, device, displayName),
|
||
child: Tooltip(
|
||
message: 'Abmelden',
|
||
child: Icon(Icons.logout_rounded,
|
||
size: 16, color: pt.fgDim),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
if (i < _devices.length - 1) _Divider(pt: pt),
|
||
],
|
||
);
|
||
}).toList(),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
TextButton.icon(
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: pt.fgMuted,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
),
|
||
onPressed: _load,
|
||
icon: const Icon(Icons.refresh_rounded, size: 14),
|
||
label: const Text('Aktualisieren', style: TextStyle(fontSize: 12)),
|
||
),
|
||
const Spacer(),
|
||
if (_logoutAllLoading)
|
||
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
|
||
else if (_devices.where((d) => d.deviceId != _currentDeviceId).isNotEmpty)
|
||
TextButton.icon(
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: pt.danger,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
),
|
||
onPressed: () => _logoutAllOtherDevices(context),
|
||
icon: const Icon(Icons.logout_rounded, size: 14),
|
||
label: const Text('Alle anderen abmelden', style: TextStyle(fontSize: 12)),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _verifyDevice(
|
||
BuildContext context, DeviceKeys device, String displayName) async {
|
||
await showDialog<void>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (ctx) => _SasVerificationDialog(
|
||
pt: widget.pt,
|
||
deviceName: displayName,
|
||
device: device,
|
||
),
|
||
);
|
||
await _load();
|
||
}
|
||
|
||
Future<void> _confirmRevoke(
|
||
BuildContext context, DeviceKeys device, String displayName) async {
|
||
final confirm = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) {
|
||
final pt = widget.pt;
|
||
return AlertDialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl),
|
||
side: BorderSide(color: pt.border)),
|
||
title: Text('Gerät abmelden?',
|
||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||
content: Text(
|
||
'"$displayName" wird abgemeldet und alle lokalen Schlüssel werden gelöscht.',
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
|
||
),
|
||
ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: pt.danger,
|
||
foregroundColor: Colors.white,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: const Text('Abmelden'),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
if (confirm == true && device.deviceId != null) {
|
||
await _revokeDevice(device.deviceId!);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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<void> _startVerification() async {
|
||
try {
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
final deviceId = widget.device.deviceId;
|
||
if (deviceId == null) throw Exception('Keine Geräte-ID');
|
||
|
||
final deviceKeys = client.userDeviceKeys[client.userID]?.deviceKeys[deviceId];
|
||
if (deviceKeys == null) throw Exception('Gerät nicht gefunden');
|
||
|
||
final verification = await deviceKeys.startVerification();
|
||
_verification = verification;
|
||
|
||
verification.onUpdate = () {
|
||
if (!mounted) return;
|
||
setState(() => _state = verification.state);
|
||
};
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
_state = verification.state;
|
||
_starting = false;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_error = e.toString().split('\n').first;
|
||
_starting = false;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
|
||
return AlertDialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl),
|
||
side: BorderSide(color: pt.border)),
|
||
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
|
||
title: Row(
|
||
children: [
|
||
Icon(Icons.verified_user_outlined, size: 20, color: pt.accent),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
'Gerät verifizieren',
|
||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
content: SizedBox(
|
||
width: 380,
|
||
child: _buildContent(pt),
|
||
),
|
||
actions: _buildActions(pt),
|
||
);
|
||
}
|
||
|
||
Widget _buildContent(PyramidTheme pt) {
|
||
if (_error != null) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
child: _StatusCard(
|
||
pt: pt,
|
||
icon: Icons.error_outline_rounded,
|
||
color: pt.danger,
|
||
text: _error!),
|
||
);
|
||
}
|
||
|
||
if (_starting) {
|
||
return const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 24),
|
||
child: Center(child: CircularProgressIndicator.adaptive()),
|
||
);
|
||
}
|
||
|
||
final v = _verification;
|
||
|
||
return switch (_state) {
|
||
KeyVerificationState.waitingAccept || KeyVerificationState.askAccept => Padding(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const CircularProgressIndicator.adaptive(),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'Warte auf Bestätigung von „${widget.deviceName}"…',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Öffne Pyramid auf dem anderen Gerät und bestätige die Verifikationsanfrage.',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
KeyVerificationState.askChoice => Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'Das andere Gerät hat die Anfrage akzeptiert.\nWähle eine Verifikationsmethode:',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||
),
|
||
const SizedBox(height: 16),
|
||
ElevatedButton.icon(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: pt.accent,
|
||
foregroundColor: pt.accentFg,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
),
|
||
onPressed: () => v?.continueVerification('m.sas.v1').catchError((_) {}),
|
||
icon: const Icon(Icons.tag_faces_rounded, size: 18),
|
||
label: const Text('Emoji-Verifikation'),
|
||
),
|
||
const SizedBox(height: 8),
|
||
ElevatedButton.icon(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: pt.bg3,
|
||
foregroundColor: pt.fg,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
),
|
||
onPressed: () => v?.continueVerification('m.qr_code.show.v1').catchError((_) {}),
|
||
icon: const Icon(Icons.qr_code_rounded, size: 18),
|
||
label: const Text('QR-Code anzeigen'),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
),
|
||
KeyVerificationState.confirmQRScan => _OutgoingQrCodeDisplay(
|
||
pt: pt,
|
||
request: v,
|
||
),
|
||
KeyVerificationState.showQRSuccess => Padding(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
child: _StatusCard(
|
||
pt: pt,
|
||
icon: Icons.verified_rounded,
|
||
color: PyramidColors.success,
|
||
text: 'QR-Code erfolgreich gescannt. Verifikation abgeschlossen.'),
|
||
),
|
||
KeyVerificationState.askSas => _SasEmojiContent(
|
||
pt: pt,
|
||
verification: v!,
|
||
onConfirm: () => v.acceptSas().catchError((_) {}),
|
||
onReject: () {
|
||
v.rejectSas().catchError((_) {});
|
||
Navigator.of(context).pop();
|
||
},
|
||
),
|
||
KeyVerificationState.waitingSas => Padding(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const CircularProgressIndicator.adaptive(),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'Warte auf Bestätigung des anderen Geräts…',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
KeyVerificationState.done => Padding(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
child: _StatusCard(
|
||
pt: pt,
|
||
icon: Icons.verified_rounded,
|
||
color: PyramidColors.success,
|
||
text: '„${widget.deviceName}" wurde erfolgreich verifiziert.'),
|
||
),
|
||
_ => Padding(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
child: _StatusCard(
|
||
pt: pt,
|
||
icon: Icons.error_outline_rounded,
|
||
color: pt.danger,
|
||
text: 'Verifizierung fehlgeschlagen oder abgebrochen.'),
|
||
),
|
||
};
|
||
}
|
||
|
||
List<Widget> _buildActions(PyramidTheme pt) {
|
||
if (_state == KeyVerificationState.done ||
|
||
_state == KeyVerificationState.showQRSuccess ||
|
||
_error != null ||
|
||
_state == KeyVerificationState.error) {
|
||
return [
|
||
ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: pt.accent,
|
||
foregroundColor: pt.accentFg,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||
onPressed: () => Navigator.pop(context),
|
||
child: const Text('Schließen'),
|
||
),
|
||
];
|
||
}
|
||
if (_state == KeyVerificationState.confirmQRScan) {
|
||
return [
|
||
TextButton(
|
||
onPressed: () {
|
||
_verification?.cancel('m.user').catchError((_) {});
|
||
Navigator.pop(context);
|
||
},
|
||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
|
||
),
|
||
ElevatedButton(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: const Color(0xFF22C55E),
|
||
foregroundColor: Colors.white,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||
onPressed: () => _verification?.acceptQRScanConfirmation().catchError((_) {}),
|
||
child: const Text('Gescannt bestätigen'),
|
||
),
|
||
];
|
||
}
|
||
if (_state == KeyVerificationState.askSas || _state == KeyVerificationState.askChoice) {
|
||
return [];
|
||
}
|
||
return [
|
||
TextButton(
|
||
onPressed: () {
|
||
_verification?.cancel('m.user').catchError((_) {});
|
||
Navigator.pop(context);
|
||
},
|
||
child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)),
|
||
),
|
||
];
|
||
}
|
||
}
|
||
|
||
class _OutgoingQrCodeDisplay extends StatelessWidget {
|
||
final PyramidTheme pt;
|
||
final KeyVerification? request;
|
||
|
||
const _OutgoingQrCodeDisplay({required this.pt, required this.request});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final qrCode = request?.qrCode;
|
||
if (qrCode == null) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const CircularProgressIndicator.adaptive(),
|
||
const SizedBox(height: 12),
|
||
Text('QR-Code wird generiert…', style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
try {
|
||
final rawBytes = Uint8List.fromList(qrCode.qrDataRawBytes.toList());
|
||
final qr = QrCode.fromUint8List(data: rawBytes, errorCorrectLevel: QrErrorCorrectLevel.L);
|
||
final qrImage = QrImage(qr);
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'Scanne diesen QR-Code mit dem anderen Gerät',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Center(
|
||
child: Container(
|
||
width: 220,
|
||
height: 220,
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: PrettyQrView(qrImage: qrImage),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
'Sobald das andere Gerät den Code gescannt hat, tippe auf „Gescannt bestätigen".',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgDim, fontSize: 12),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
);
|
||
} catch (_) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 16),
|
||
child: _StatusCard(
|
||
pt: pt,
|
||
icon: Icons.error_outline_rounded,
|
||
color: pt.danger,
|
||
text: 'QR-Code konnte nicht generiert werden.',
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
class _SasEmojiContent extends StatelessWidget {
|
||
final PyramidTheme pt;
|
||
final KeyVerification verification; // ignore: library_private_types_in_public_api
|
||
final VoidCallback onConfirm;
|
||
final VoidCallback onReject;
|
||
|
||
const _SasEmojiContent({
|
||
required this.pt,
|
||
required this.verification,
|
||
required this.onConfirm,
|
||
required this.onReject,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final emojis = verification.sasEmojis;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'Vergleiche die Emojis auf beiden Geräten',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||
),
|
||
const SizedBox(height: 20),
|
||
if (emojis.isNotEmpty)
|
||
Wrap(
|
||
spacing: 12,
|
||
runSpacing: 12,
|
||
alignment: WrapAlignment.center,
|
||
children: emojis.map((e) => _SasEmojiChip(emoji: e, pt: pt)).toList(),
|
||
)
|
||
else
|
||
Text('Keine Emojis verfügbar.', style: TextStyle(color: pt.fgDim, fontSize: 13)),
|
||
const SizedBox(height: 24),
|
||
Text(
|
||
'Stimmen alle Emojis überein?',
|
||
style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: OutlinedButton.icon(
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: pt.danger,
|
||
side: BorderSide(color: pt.danger.withAlpha(120)),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
),
|
||
onPressed: onReject,
|
||
icon: const Icon(Icons.close_rounded, size: 16),
|
||
label: const Text('Stimmt nicht überein'),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: ElevatedButton.icon(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: PyramidColors.success,
|
||
foregroundColor: Colors.white,
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
),
|
||
onPressed: onConfirm,
|
||
icon: const Icon(Icons.check_rounded, size: 16),
|
||
label: const Text('Stimmt überein'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SasEmojiChip extends StatelessWidget {
|
||
final KeyVerificationEmoji emoji;
|
||
final PyramidTheme pt;
|
||
|
||
const _SasEmojiChip({required this.emoji, required this.pt});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: 68,
|
||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||
decoration: BoxDecoration(
|
||
color: pt.bg3,
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: pt.border),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(emoji.emoji, style: const TextStyle(fontSize: 26)),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
emoji.name,
|
||
style: TextStyle(color: pt.fgMuted, fontSize: 10),
|
||
textAlign: TextAlign.center,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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<void> _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<void> _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<bool>();
|
||
_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<void> _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<void> _resetSecurity() async {
|
||
final confirm = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) {
|
||
final pt = widget.pt;
|
||
return AlertDialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl),
|
||
side: BorderSide(color: pt.border)),
|
||
title: Text('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<void>();
|
||
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<String?> _showPassphraseDialog({required bool forExport}) {
|
||
return showDialog<String>(
|
||
context: context,
|
||
builder: (ctx) => _PassphraseDialog(pt: widget.pt, forExport: forExport),
|
||
);
|
||
}
|
||
|
||
Future<void> _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 = <Map<String, dynamic>>[];
|
||
|
||
for (final s in stored) {
|
||
try {
|
||
final content = jsonDecode(s.content) as Map<String, dynamic>;
|
||
final sessionKey = content['session_key'] as String?;
|
||
if (sessionKey == null || sessionKey.isEmpty) continue;
|
||
|
||
final claimedKeys = s.senderClaimedKeys.isNotEmpty
|
||
? (jsonDecode(s.senderClaimedKeys) as Map<String, dynamic>)
|
||
.map((k, v) => MapEntry(k, v.toString()))
|
||
: <String, String>{};
|
||
|
||
sessions.add({
|
||
'algorithm': 'm.megolm.v1.aes-sha2',
|
||
'forwarding_curve25519_key_chain': <dynamic>[],
|
||
'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<void> _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<void> _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<String, dynamic>;
|
||
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())) ??
|
||
<String, String>{};
|
||
|
||
await km.setInboundGroupSession(
|
||
roomId, sessionId, senderKey,
|
||
Map<String, dynamic>.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<void> _changeRecoveryKey() async {
|
||
final changed = await showDialog<bool>(
|
||
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<void>();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_startBootstrap();
|
||
}
|
||
|
||
Future<void> _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<void> _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 = <String>[];
|
||
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<void> _submit() async {
|
||
final oldPw = _oldCtrl.text;
|
||
final newPw = _newCtrl.text;
|
||
final confirm = _confirmCtrl.text;
|
||
if (oldPw.isEmpty || newPw.isEmpty) {
|
||
setState(() => _error = 'Bitte alle Felder ausfüllen.');
|
||
return;
|
||
}
|
||
if (newPw != confirm) {
|
||
setState(() => _error = 'Die neuen Passwörter stimmen nicht überein.');
|
||
return;
|
||
}
|
||
if (newPw.length < 8) {
|
||
setState(() => _error = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.');
|
||
return;
|
||
}
|
||
setState(() { _loading = true; _error = null; });
|
||
try {
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
await client.changePassword(newPw, oldPassword: oldPw);
|
||
if (mounted) setState(() { _loading = false; _done = true; });
|
||
} on MatrixException catch (e) {
|
||
if (mounted) setState(() { _loading = false; _error = e.errorMessage; });
|
||
} catch (e) {
|
||
if (mounted) setState(() { _loading = false; _error = e.toString().split('\n').first; });
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
return Dialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border)),
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 420, minWidth: 300),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: _done ? _buildDone(pt) : _buildForm(pt),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildDone(PyramidTheme pt) => Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52),
|
||
const SizedBox(height: 12),
|
||
Text('Passwort geändert', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 6),
|
||
Text('Dein Passwort wurde erfolgreich aktualisiert.',
|
||
textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13)),
|
||
const SizedBox(height: 20),
|
||
SizedBox(width: double.infinity, child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Schließen'),
|
||
)),
|
||
],
|
||
);
|
||
|
||
Widget _buildForm(PyramidTheme pt) => Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(children: [
|
||
Icon(Icons.lock_outline_rounded, size: 18, color: pt.accent),
|
||
const SizedBox(width: 8),
|
||
Text('Passwort ändern', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600)),
|
||
const Spacer(),
|
||
IconButton(
|
||
icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16),
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28),
|
||
),
|
||
]),
|
||
const SizedBox(height: 20),
|
||
_PwField(ctrl: _oldCtrl, label: 'Aktuelles Passwort', pt: pt,
|
||
visible: _oldVisible, onToggle: () => setState(() => _oldVisible = !_oldVisible)),
|
||
const SizedBox(height: 12),
|
||
_PwField(ctrl: _newCtrl, label: 'Neues Passwort', pt: pt,
|
||
visible: _newVisible, onToggle: () => setState(() => _newVisible = !_newVisible)),
|
||
const SizedBox(height: 12),
|
||
_PwField(ctrl: _confirmCtrl, label: 'Neues Passwort bestätigen', pt: pt,
|
||
visible: _newVisible, onToggle: null,
|
||
onSubmitted: (_) => _submit()),
|
||
if (_error != null) ...[
|
||
const SizedBox(height: 10),
|
||
_StatusCard(pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!),
|
||
],
|
||
const SizedBox(height: 20),
|
||
SizedBox(width: double.infinity, child: ElevatedButton(
|
||
style: ElevatedButton.styleFrom(backgroundColor: pt.accent, foregroundColor: pt.accentFg,
|
||
elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))),
|
||
onPressed: _loading ? null : _submit,
|
||
child: _loading
|
||
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator.adaptive(strokeWidth: 2))
|
||
: const Text('Passwort ändern'),
|
||
)),
|
||
],
|
||
);
|
||
}
|
||
|
||
class _PwField extends StatelessWidget {
|
||
final TextEditingController ctrl;
|
||
final String label;
|
||
final PyramidTheme pt;
|
||
final bool visible;
|
||
final VoidCallback? onToggle;
|
||
final ValueChanged<String>? onSubmitted;
|
||
|
||
const _PwField({
|
||
required this.ctrl, required this.label, required this.pt,
|
||
required this.visible, required this.onToggle, this.onSubmitted,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => TextField(
|
||
controller: ctrl,
|
||
obscureText: !visible,
|
||
onSubmitted: onSubmitted,
|
||
style: TextStyle(color: pt.fg, fontSize: 13),
|
||
decoration: InputDecoration(
|
||
labelText: label,
|
||
labelStyle: TextStyle(color: pt.fgMuted, fontSize: 13),
|
||
filled: true, fillColor: pt.bg3,
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border)),
|
||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.accent)),
|
||
suffixIcon: onToggle != null
|
||
? IconButton(
|
||
icon: Icon(visible ? Icons.visibility_off_rounded : Icons.visibility_rounded, size: 16, color: pt.fgDim),
|
||
onPressed: onToggle,
|
||
)
|
||
: null,
|
||
),
|
||
cursorColor: pt.accent,
|
||
);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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<void> _proceed() async {
|
||
setState(() { _step = _DeleteStep.deleting; _error = null; });
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
try {
|
||
await client.deactivateAccount(erase: _eraseData);
|
||
} on MatrixException catch (e) {
|
||
if (e.requireAdditionalAuthentication && mounted) {
|
||
// UIA required — try with password
|
||
try {
|
||
await client.deactivateAccount(
|
||
erase: _eraseData,
|
||
auth: AuthenticationData(
|
||
type: AuthenticationTypes.password,
|
||
session: e.session,
|
||
additionalFields: {
|
||
'identifier': {'type': 'm.id.user', 'user': client.userID},
|
||
'password': _pwCtrl.text,
|
||
},
|
||
),
|
||
);
|
||
} on MatrixException catch (e2) {
|
||
if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.errorMessage; });
|
||
return;
|
||
} catch (e2) {
|
||
if (mounted) setState(() { _step = _DeleteStep.error; _error = e2.toString().split('\n').first; });
|
||
return;
|
||
}
|
||
} else {
|
||
if (mounted) setState(() { _step = _DeleteStep.error; _error = e.errorMessage; });
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
if (mounted) setState(() { _step = _DeleteStep.error; _error = e.toString().split('\n').first; });
|
||
return;
|
||
}
|
||
// Konto wurde gelöscht — ausloggen
|
||
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<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
|
||
final confirm = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: pt.bg2,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(pt.rXl),
|
||
side: BorderSide(color: pt.border)),
|
||
title: Text('Abmelden?',
|
||
style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700)),
|
||
content: Text(
|
||
'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<void> _runDiag() async {
|
||
setState(() { _running = true; _result = null; });
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
final diag = await fcmDiagnostics(client);
|
||
if (mounted) setState(() { _running = false; _result = diag; });
|
||
}
|
||
|
||
Future<void> _runRetry() async {
|
||
setState(() { _retryRunning = true; _retryResult = null; });
|
||
final client = await ref.read(matrixClientProvider.future);
|
||
final result = await retryPusherRegistration(client);
|
||
if (mounted) setState(() { _retryRunning = false; _retryResult = result; });
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
if (!Platform.isAndroid) return const SizedBox.shrink();
|
||
return _SettingsGroup(title: 'Push-Benachrichtigungen', pt: pt, children: [
|
||
_SettingsRow(
|
||
title: 'Akku-Optimierung deaktivieren',
|
||
desc: 'Samsung / andere OEMs unterdrücken sonst Benachrichtigungen bei geschlossener App.',
|
||
pt: pt,
|
||
showArrow: true,
|
||
onTap: openBatteryOptimizationSettings,
|
||
control: Icon(Icons.battery_saver_outlined, size: 16, color: pt.fgDim),
|
||
),
|
||
_Divider(pt: pt),
|
||
_SettingsRow(
|
||
title: 'FCM Diagnose',
|
||
desc: _result ?? 'Prüft ob FCM-Token & Pusher korrekt registriert sind.',
|
||
pt: pt,
|
||
onTap: _running ? null : _runDiag,
|
||
control: _running
|
||
? SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent),
|
||
)
|
||
: Icon(Icons.bug_report_outlined, size: 16, color: pt.fgDim),
|
||
),
|
||
_Divider(pt: pt),
|
||
_SettingsRow(
|
||
title: 'Pusher neu registrieren',
|
||
desc: _retryResult ?? 'Pusher beim Homeserver erneut anmelden.',
|
||
pt: pt,
|
||
onTap: _retryRunning ? null : _runRetry,
|
||
control: _retryRunning
|
||
? SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(strokeWidth: 2, color: pt.accent),
|
||
)
|
||
: Icon(Icons.sync_outlined, size: 16, color: pt.fgDim),
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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<void> _checkNow() async {
|
||
setState(() => _checking = true);
|
||
await resetUpdateCheckTimer();
|
||
ref.invalidate(updateInfoProvider);
|
||
// Give the async provider a moment to resolve before resetting the spinner.
|
||
await Future<void>.delayed(const Duration(seconds: 2));
|
||
if (mounted) setState(() => _checking = false);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pt = widget.pt;
|
||
final updateAsync = ref.watch(updateInfoProvider);
|
||
|
||
return _SettingsGroup(title: 'Updates', pt: pt, children: [
|
||
updateAsync.when(
|
||
loading: () => _SettingsRow(
|
||
title: 'Nach Updates suchen…',
|
||
desc: 'Gitea Releases wird geprüft',
|
||
pt: pt,
|
||
control: SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: pt.accent,
|
||
),
|
||
),
|
||
),
|
||
error: (e, _) => _SettingsRow(
|
||
title: 'Update-Prüfung fehlgeschlagen',
|
||
desc: 'Keine Verbindung oder Gitea nicht erreichbar (nur im Heimnetz).',
|
||
pt: pt,
|
||
control: _checking
|
||
? SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2, color: pt.accent),
|
||
)
|
||
: GestureDetector(
|
||
onTap: _checkNow,
|
||
child: Tooltip(
|
||
message: 'Erneut prüfen',
|
||
child: Icon(Icons.refresh_rounded,
|
||
size: 16, color: pt.fgDim),
|
||
),
|
||
),
|
||
),
|
||
data: (info) {
|
||
if (info == null) {
|
||
return _SettingsRow(
|
||
title: 'Pyramid ist aktuell',
|
||
desc: 'Du verwendest die neueste Version.',
|
||
pt: pt,
|
||
onTap: _checking ? null : _checkNow,
|
||
control: _checking
|
||
? SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2, color: pt.accent),
|
||
)
|
||
: Icon(Icons.refresh_rounded, size: 16, color: pt.fgDim),
|
||
);
|
||
}
|
||
// New version available
|
||
return Column(
|
||
children: [
|
||
_SettingsRow(
|
||
title: 'Update verfügbar: ${info.tagName}',
|
||
desc: info.releaseName,
|
||
pt: pt,
|
||
showArrow: true,
|
||
onTap: () async {
|
||
if (info.canDownload) {
|
||
await showUpdateDownloadDialog(context, info);
|
||
} else {
|
||
final uri = Uri.parse(info.htmlUrl);
|
||
if (await canLaunchUrl(uri)) {
|
||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||
}
|
||
}
|
||
},
|
||
control: Container(
|
||
padding:
|
||
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: pt.accent,
|
||
borderRadius: BorderRadius.circular(6)),
|
||
child: Text(
|
||
'Neu',
|
||
style: TextStyle(
|
||
color: pt.accentFg,
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w700),
|
||
),
|
||
),
|
||
),
|
||
_Divider(pt: pt),
|
||
_SettingsRow(
|
||
title: 'Diese Version überspringen',
|
||
desc: '${info.tagName} nicht mehr anzeigen',
|
||
pt: pt,
|
||
onTap: () async {
|
||
await skipUpdateVersion(info.tagName);
|
||
ref.invalidate(updateInfoProvider);
|
||
},
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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))),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|