part of '../../settings_modal.dart'; class _ChangeRecoveryKeyDialog extends ConsumerStatefulWidget { final PyramidTheme pt; const _ChangeRecoveryKeyDialog({required this.pt}); @override ConsumerState<_ChangeRecoveryKeyDialog> createState() => _ChangeRecoveryKeyDialogState(); } class _ChangeRecoveryKeyDialogState extends ConsumerState<_ChangeRecoveryKeyDialog> { _RecoveryStep _step = _RecoveryStep.loading; String? _generatedKey; String? _error; bool _copied = false; // Completer signals when bootstrap reaches `done` final _doneCompleter = Completer(); @override void initState() { super.initState(); _startBootstrap(); } Future _startBootstrap() async { try { final client = await ref.read(matrixClientProvider.future); final enc = client.encryption; if (enc == null) throw Exception('Verschlüsselung nicht verfügbar.'); await client.roomsLoading; await client.accountDataLoading; await client.userDeviceKeysLoading; enc.bootstrap(onUpdate: (bs) { if (!mounted) return; _onBootstrapUpdate(bs); }); } catch (e) { if (mounted) { setState(() { _step = _RecoveryStep.error; _error = e.toString().split('\n').first; }); } } } // After newSsss() calls checkCrossSigning(), the bootstrap immediately // transitions to askWipeCrossSigning (or askSetupCrossSigning). All post- // newSsss states must therefore auto-advance unconditionally — guarding on // _step would leave them stuck. void _onBootstrapUpdate(Bootstrap bs) { switch (bs.state) { case BootstrapState.askWipeSsss: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askWipeSsss) bs.wipeSsss(true); }); case BootstrapState.askBadSsss: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askBadSsss) bs.ignoreBadSecrets(true); }); case BootstrapState.askUseExistingSsss: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askUseExistingSsss) bs.useExistingSsss(false); }); case BootstrapState.askUnlockSsss: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askUnlockSsss) bs.unlockedSsss(); }); case BootstrapState.askNewSsss: // Only generate once if (_step == _RecoveryStep.loading) { WidgetsBinding.instance.addPostFrameCallback((_) async { if (bs.state != BootstrapState.askNewSsss) return; try { await bs.newSsss(); // newSsss() internally calls checkCrossSigning() which already // transitions to askWipeCrossSigning; capture key after it returns. final key = bs.newSsssKey?.recoveryKey; if (mounted) setState(() { _generatedKey = key; _step = _RecoveryStep.showKey; }); } catch (e) { if (mounted) setState(() { _step = _RecoveryStep.error; _error = e.toString().split('\n').first; }); } }); } case BootstrapState.askWipeCrossSigning: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askWipeCrossSigning) bs.wipeCrossSigning(false); }); case BootstrapState.askSetupCrossSigning: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askSetupCrossSigning) { bs.askSetupCrossSigning( setupMasterKey: true, setupSelfSigningKey: true, setupUserSigningKey: true, ); } }); case BootstrapState.askWipeOnlineKeyBackup: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askWipeOnlineKeyBackup) bs.wipeOnlineKeyBackup(true); }); case BootstrapState.askSetupOnlineKeyBackup: WidgetsBinding.instance.addPostFrameCallback((_) { if (bs.state == BootstrapState.askSetupOnlineKeyBackup) bs.askSetupOnlineKeyBackup(true); }); case BootstrapState.done: if (!_doneCompleter.isCompleted) _doneCompleter.complete(); // Only auto-advance to done if user already clicked through (applying). // If still on showKey/confirmKey, the user must finish reading first. if (mounted && _step == _RecoveryStep.applying) { setState(() => _step = _RecoveryStep.done); } case BootstrapState.error: if (!_doneCompleter.isCompleted) _doneCompleter.completeError('Bootstrap-Fehler'); if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Fehler bei der Schlüsselgenerierung.'; }); default: break; } } // Called when user taps "Schlüssel gespeichert – weiter" on the showKey screen. void _onKeySaved() { setState(() => _step = _RecoveryStep.confirmKey); } // Called when user submits the correct key confirmation. // Bootstrap may still be running (cross-signing + key backup); wait for done. Future _onKeyConfirmed() async { setState(() => _step = _RecoveryStep.applying); try { await _doneCompleter.future.timeout(const Duration(seconds: 90)); if (mounted) setState(() => _step = _RecoveryStep.done); } catch (e) { if (mounted) setState(() { _step = _RecoveryStep.error; _error = 'Timeout: Bootstrap abgebrochen.'; }); } } @override Widget build(BuildContext context) { final pt = widget.pt; final canClose = _step != _RecoveryStep.loading && _step != _RecoveryStep.applying && _step != _RecoveryStep.confirmKey; return Dialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(pt.rXl), side: BorderSide(color: pt.border), ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 440, minWidth: 320), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 16, 12, 12), child: Row( children: [ Icon(Icons.key_rounded, size: 18, color: pt.accent), const SizedBox(width: 8), Expanded( child: Text( 'Wiederherstellungsschlüssel', style: TextStyle(color: pt.fg, fontSize: 15, fontWeight: FontWeight.w600), ), ), if (canClose) IconButton( icon: Icon(Icons.close_rounded, color: pt.fgDim, size: 16), onPressed: () => Navigator.of(context).pop(_step == _RecoveryStep.done), padding: EdgeInsets.zero, constraints: const BoxConstraints(maxWidth: 28, maxHeight: 28), ), ], ), ), Divider(color: pt.border, height: 1), Padding( padding: const EdgeInsets.all(20), child: _buildBody(pt), ), ], ), ), ); } Widget _buildBody(PyramidTheme pt) { return switch (_step) { _RecoveryStep.loading => const Center( child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator.adaptive()), ), _RecoveryStep.showKey => _ShowKeyBody( pt: pt, generatedKey: _generatedKey ?? '', copied: _copied, onCopy: () { Clipboard.setData(ClipboardData(text: _generatedKey ?? '')); setState(() => _copied = true); }, onKeySaved: _onKeySaved, ), _RecoveryStep.confirmKey => _ConfirmKeyBody( pt: pt, expectedKey: _generatedKey ?? '', onConfirmed: _onKeyConfirmed, ), _RecoveryStep.applying => Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator.adaptive(), const SizedBox(height: 16), Text( 'Schlüssel wird aktiviert…\nCross-Signing und Backup werden eingerichtet.', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), ), ], ), _RecoveryStep.done => Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check_circle_outline_rounded, color: PyramidColors.success, size: 52), const SizedBox(height: 12), Text( 'Wiederherstellungsschlüssel wurde erfolgreich geändert.', textAlign: TextAlign.center, style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w500), ), const SizedBox(height: 6), Text( 'Bewahre deinen Schlüssel sicher auf – du brauchst ihn bei Geräteverlust.', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.4), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: () => Navigator.of(context).pop(true), child: const Text('Fertig'), ), ), ], ), _RecoveryStep.error => Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline_rounded, color: PyramidColors.danger, size: 48), const SizedBox(height: 12), Text( _error ?? 'Ein unbekannter Fehler ist aufgetreten.', textAlign: TextAlign.center, style: TextStyle(color: pt.fgMuted, fontSize: 13), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: OutlinedButton( style: OutlinedButton.styleFrom( foregroundColor: pt.fg, side: BorderSide(color: pt.border), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: () => Navigator.of(context).pop(false), child: const Text('Schließen'), ), ), ], ), }; } } class _ShowKeyBody extends StatelessWidget { final PyramidTheme pt; final String generatedKey; final bool copied; final VoidCallback onCopy; final VoidCallback onKeySaved; const _ShowKeyBody({ required this.pt, required this.generatedKey, required this.copied, required this.onCopy, required this.onKeySaved, }); @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Dein neuer Wiederherstellungsschlüssel', style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Text( 'Speichere diesen Schlüssel sicher – du brauchst ihn, wenn du den Zugang zu allen Geräten verlierst.', style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), ), const SizedBox(height: 16), Container( decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(8), border: Border.all(color: pt.border), ), child: Row( children: [ Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: SelectableText( generatedKey, style: TextStyle( color: pt.accent, fontSize: 13, fontFamily: 'monospace', letterSpacing: 0.5, height: 1.6, ), ), ), ), IconButton( onPressed: onCopy, icon: Icon( copied ? Icons.check_rounded : Icons.copy_rounded, size: 18, color: copied ? PyramidColors.success : pt.fgDim, ), tooltip: copied ? 'Kopiert!' : 'In Zwischenablage kopieren', ), ], ), ), const SizedBox(height: 12), Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.orange.withAlpha(28), borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.orange.withAlpha(80)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.warning_amber_rounded, size: 16, color: Colors.orange.shade700), const SizedBox(width: 8), Expanded( child: Text( 'Schreibe diesen Schlüssel auf oder speichere ihn in einem Passwort-Manager. Er kann nicht wiederhergestellt werden.', style: TextStyle(color: Colors.orange.shade800, fontSize: 12, height: 1.4), ), ), ], ), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: onKeySaved, child: const Text('Schlüssel gespeichert – weiter'), ), ), ], ); } } class _ConfirmKeyBody extends StatefulWidget { final PyramidTheme pt; final String expectedKey; final VoidCallback onConfirmed; const _ConfirmKeyBody({ required this.pt, required this.expectedKey, required this.onConfirmed, }); @override State<_ConfirmKeyBody> createState() => _ConfirmKeyBodyState(); } class _ConfirmKeyBodyState extends State<_ConfirmKeyBody> { final _controller = TextEditingController(); bool _mismatch = false; @override void dispose() { _controller.dispose(); super.dispose(); } void _submit() { final entered = _controller.text.trim(); if (entered == widget.expectedKey) { widget.onConfirmed(); } else { setState(() => _mismatch = true); } } @override Widget build(BuildContext context) { final pt = widget.pt; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Schlüssel bestätigen', style: TextStyle(color: pt.fg, fontSize: 14, fontWeight: FontWeight.w600), ), const SizedBox(height: 8), Text( 'Gib deinen neuen Wiederherstellungsschlüssel zur Bestätigung ein.', style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), ), const SizedBox(height: 16), TextField( controller: _controller, autofocus: true, style: TextStyle( color: pt.fg, fontSize: 13, fontFamily: 'monospace', letterSpacing: 0.4, ), decoration: InputDecoration( hintText: 'EsTX XXXX XXXX …', hintStyle: TextStyle(color: pt.fgDim, fontSize: 13), filled: true, fillColor: pt.bg3, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: pt.border), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.border), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: _mismatch ? PyramidColors.danger : pt.accent, width: 1.5), ), errorText: _mismatch ? 'Schlüssel stimmt nicht überein.' : null, ), onChanged: (_) { if (_mismatch) setState(() => _mismatch = false); }, onSubmitted: (_) => _submit(), ), const SizedBox(height: 20), SizedBox( width: double.infinity, child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), onPressed: _submit, child: const Text('Aktivieren'), ), ), ], ); } }