6128953e28
Vorbereitung fuer Schnitt-Doc Schritt 3 (verification-Modul), ohne den Verschluesselungs-heiligen Code anzufassen: megolm_crypto (Export/Import- Krypto-Helfer), encryption_section (Enums + _EncryptionSection), recovery_key_dialogs (_ChangeRecoveryKeyDialog/_ShowKeyBody/_ConfirmKeyBody), fingerprint_passphrase (_FingerprintGroup/_PassphraseDialog). Rekonstruktion zeichenidentisch (1468 Code-Zeilen, ordinaler Vergleich), analyze sauber, 29 Tests gruen, App-Start ok (auth_log 17:43:14 loggedIn). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
660 lines
26 KiB
Dart
660 lines
26 KiB
Dart
part of '../../settings_modal.dart';
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
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',
|
||
// Eng begrenzter Diagnose-Token (haengt nur an den Diagnose-Log an,
|
||
// KEINE Admin-Rechte). Bewusst NICHT der Matrix-Server-Admin-Token –
|
||
// der darf niemals im Client landen. Passendes Gegenstueck in
|
||
// /home/steggi/matrix/server.py (DIAG_TOKEN) auf dem Pi.
|
||
token: 'pyr-diag-6AyELgODRv-4rF4dcau3kSa5YbgZqUcn',
|
||
);
|
||
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…'),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|