import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.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:pyramid/core/app_state.dart'; import 'package:pyramid/core/matrix_client.dart'; import 'package:pyramid/core/theme.dart'; import 'package:pyramid/features/auth/bootstrap_dialog.dart'; import 'package:pyramid/features/call_ui/voice_channel.dart'; import 'package:pyramid/features/call_signaling/call_signaling_service.dart'; import 'package:pyramid/features/voice_channel/voice_channel_service.dart'; import 'package:pyramid/features/chat/chat_view.dart'; import 'package:pyramid/features/members/members_panel.dart'; import 'package:pyramid/features/rooms/rooms_panel.dart'; import 'package:pyramid/features/spaces/spaces_rail.dart'; import 'package:pyramid/widgets/search_modal.dart'; import 'package:pyramid/widgets/settings_modal.dart'; import 'package:pyramid/widgets/share_target_dialog.dart'; import 'package:pyramid/core/notification_service.dart'; import 'package:pyramid/widgets/update_banner.dart'; class AppShell extends ConsumerStatefulWidget { const AppShell({super.key}); @override ConsumerState createState() => _AppShellState(); } class _AppShellState extends ConsumerState with WidgetsBindingObserver { double _sidebarWidth = 260; bool _bootstrapTriggered = false; // Flankenerkennung für die Auto-Switch-Listener (siehe build). bool _wasVoipConnected = false; bool _wasLiveKitActive = false; StreamSubscription? _verificationSub; Timer? _presenceTimer; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback((_) { _triggerBootstrap(); _subscribeVerification(); initNotifications(ref); _startPresenceHeartbeat(); }); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _verificationSub?.cancel(); _presenceTimer?.cancel(); super.dispose(); } /// Sends `setPresence(online)` every 55 seconds while the app is active. /// This prevents the server from marking us offline due to inactivity timeout. void _startPresenceHeartbeat() async { _presenceTimer?.cancel(); final client = await ref.read(matrixClientProvider.future); if (!mounted) return; _sendPresence(client, true); _presenceTimer = Timer.periodic(const Duration(seconds: 55), (_) { _sendPresence(client, true); }); } void _sendPresence(Client client, bool online) { client .setPresence( client.userID!, online ? PresenceType.online : PresenceType.unavailable, ) .catchError((_) {}); } @override void didChangeAppLifecycleState(AppLifecycleState state) { super.didChangeAppLifecycleState(state); // Update focus state immediately — used by notification suppression logic. ref.read(windowFocusedProvider.notifier).state = state == AppLifecycleState.resumed; ref.read(matrixClientProvider.future).then((client) { if (!mounted) return; if (state == AppLifecycleState.resumed) { _sendPresence(client, true); // Restart heartbeat timer after coming back from background. _presenceTimer?.cancel(); _presenceTimer = Timer.periodic(const Duration(seconds: 55), (_) { _sendPresence(client, true); }); // Advance the notification watch time so catch-up events from the initial // sync (already shown by the FCM background handler) are not shown again. resetNotificationStartTime(); // Send any inline reply stored by the background notification handler. checkPendingReply(ref); } else if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { _presenceTimer?.cancel(); _sendPresence(client, false); } }).catchError((_) {}); } void _triggerBootstrap() async { if (_bootstrapTriggered) return; final client = await ref.read(matrixClientProvider.future); if (client.encryption?.crossSigning.enabled != true) { if (mounted) { setState(() => _bootstrapTriggered = true); showBootstrapDialog(context, client); } } } void _subscribeVerification() async { final client = await ref.read(matrixClientProvider.future); if (!mounted) return; _verificationSub = client.onKeyVerificationRequest.stream.listen((request) { if (!mounted) return; // Only show dialog if it's in askAccept state (incoming request not yet handled) if (request.state == KeyVerificationState.askAccept) { showDialog( context: context, barrierDismissible: false, builder: (ctx) => _IncomingVerificationDialog( request: request, pt: PyramidTheme.of(context), ), ); } }); } void onSidebarResize(double delta) { setState(() { _sidebarWidth = (_sidebarWidth + delta).clamp(200.0, 500.0); }); } void onCloseModal() { ref.read(activeModalProvider.notifier).state = ModalKind.none; } @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); // Activate notification watcher (no-op after first call) ref.watch(notificationWatcherProvider); // Auto-switch to voice view when call is connected — but only on the // transition into the connected state. The provider feuert bei jedem // notifyListeners (u.a. Render-Timer im Sekundentakt); ohne Flankenerkennung // würde der Nutzer permanent zurück in die Call-UI gezwungen und könnte // sie nie minimieren. ref.listen(callSignalingProvider, (prev, next) { final wasConnected = _wasVoipConnected; final isConnected = next.currentCall != null && next.currentCall!.state == CallState.kConnected; _wasVoipConnected = isConnected; if (isConnected && !wasConnected) { if (ref.read(viewModeProvider) != ViewMode.voice) { ref.read(viewModeProvider.notifier).state = ViewMode.voice; } } }); ref.listen(voiceChannelProvider, (prev, next) { // Only auto-switch to voice view for video/audio calls, NOT voice channels // (voice channels should stay in background with a mini bar) final wasActive = _wasLiveKitActive; final isActiveCall = next.isActive && !next.isConnecting && !next.isVoiceChannel; _wasLiveKitActive = isActiveCall; if (isActiveCall && !wasActive) { if (ref.read(viewModeProvider) != ViewMode.voice) { ref.read(viewModeProvider.notifier).state = ViewMode.voice; } } }); // "Teilen nach Pyramid" (Android): geteilte Inhalte → Raum-Picker zeigen. ref.listen(pendingShareProvider, (prev, next) { if (next != null && !next.isEmpty) { ref.read(pendingShareProvider.notifier).state = null; ShareTargetDialog.show(context, ref, next); } }); final sidebarWidth = _sidebarWidth; final railCollapsed = ref.watch(railCollapsedProvider); final activeRoomId = ref.watch(activeRoomIdProvider); final viewMode = ref.watch(viewModeProvider); final activeModal = ref.watch(activeModalProvider); final membersOpen = ref.watch(membersPanelOpenProvider); final innerPt = PyramidTheme.of(context); final voip = ref.watch(callSignalingProvider); final size = MediaQuery.sizeOf(context); final isMobile = size.width < 900 || size.height < 500; // Auto-close members panel if screen gets too small if (!isMobile && size.width < 1100 && membersOpen) { WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(membersPanelOpenProvider.notifier).state = false; }); } if (isMobile) { return _MobileShell( pt: innerPt, activeRoomId: activeRoomId, viewMode: viewMode, activeModal: activeModal, membersOpen: membersOpen, onCloseModal: onCloseModal, ); } return Scaffold( backgroundColor: pt.bg1, body: PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (activeModal != ModalKind.none) { onCloseModal(); } }, child: Column( children: [ const UpdateBanner(), const _NotificationBlockedBanner(), Expanded(child: Stack( children: [ Row( children: [ // Spaces rail AnimatedContainer( duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, width: railCollapsed ? 0 : 72, clipBehavior: Clip.hardEdge, decoration: const BoxDecoration(), child: const SpacesRail(), ), // Rooms panel with resizable divider SizedBox( width: sidebarWidth.clamp(200.0, size.width * 0.4), child: const RoomsPanel(), ), _ResizeDivider( pt: innerPt, onDrag: onSidebarResize, ), // Main content Expanded( child: viewMode == ViewMode.voice ? const VoiceChannelView() : activeRoomId != null ? ChatView(roomId: activeRoomId) : const NoChatSelected(), ), // Members panel — only if enough space (min 1100px total width) if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice && size.width >= 1100) MembersPanel(roomId: activeRoomId), ], ), // Incoming Call Popup for Desktop if (voip.currentCall != null && voip.currentCall!.state == CallState.kRinging) _IncomingCallOverlay(call: voip.currentCall!, voip: voip, pt: pt), // Modal overlays if (activeModal == ModalKind.settings) SettingsModal(onClose: onCloseModal), if (activeModal == ModalKind.search) SearchModal(onClose: onCloseModal), // Members panel as overlay if screen is narrow but requested if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice && size.width < 1100) ...[ Positioned.fill( child: GestureDetector( onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false, child: Container(color: Colors.black.withAlpha(110)), ), ), Positioned( right: 0, top: 0, bottom: 0, child: Container( decoration: BoxDecoration( boxShadow: [BoxShadow(color: Colors.black45, blurRadius: 16)], ), child: MembersPanel( roomId: activeRoomId, onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false, ), ), ), ], ], )), // end Stack + Expanded ], ), // end Column ), ); } } class _MobileShell extends ConsumerWidget { final PyramidTheme pt; final String? activeRoomId; final ViewMode viewMode; final ModalKind activeModal; final bool membersOpen; final VoidCallback onCloseModal; const _MobileShell({ required this.pt, required this.activeRoomId, required this.viewMode, required this.activeModal, required this.membersOpen, required this.onCloseModal, }); @override Widget build(BuildContext context, WidgetRef ref) { final voip = ref.watch(callSignalingProvider); Widget body; if (viewMode == ViewMode.voice) { body = const VoiceChannelView(); } else if (activeRoomId != null) { body = ChatView(roomId: activeRoomId!); } else { body = const RoomsPanel(); } return PopScope( canPop: activeRoomId == null && viewMode == ViewMode.chat && !membersOpen, onPopInvokedWithResult: (didPop, _) { if (!didPop) { if (membersOpen) { ref.read(membersPanelOpenProvider.notifier).state = false; } else if (viewMode == ViewMode.voice) { ref.read(viewModeProvider.notifier).state = ViewMode.chat; } else if (activeRoomId != null) { ref.read(activeRoomIdProvider.notifier).state = null; } } }, child: Scaffold( backgroundColor: pt.bg0, drawer: Drawer( width: 80, backgroundColor: pt.bg0, child: const SpacesRail(), ), body: Stack( children: [ body, if (membersOpen && activeRoomId != null && viewMode != ViewMode.voice) ...[ // Tap-outside scrim closes the panel Positioned.fill( child: GestureDetector( onTap: () => ref.read(membersPanelOpenProvider.notifier).state = false, child: Container(color: Colors.black.withAlpha(110)), ), ), Positioned( right: 0, top: 0, bottom: 0, child: Builder(builder: (ctx) { final w = MediaQuery.sizeOf(ctx).width; return MembersPanel( roomId: activeRoomId!, width: (w * 0.82).clamp(240.0, 320.0), onClose: () => ref.read(membersPanelOpenProvider.notifier).state = false, ); }), ), ], if (voip.currentCall != null && voip.currentCall!.state == CallState.kRinging) _IncomingCallOverlay(call: voip.currentCall!, voip: voip, pt: pt), if (activeModal == ModalKind.settings) SettingsModal(onClose: onCloseModal), if (activeModal == ModalKind.search) SearchModal(onClose: onCloseModal), const Positioned( top: 0, left: 0, right: 0, child: _NotificationBlockedBanner(), ), ], ), ), ); } } class _NotificationBlockedBanner extends ConsumerWidget { const _NotificationBlockedBanner(); @override Widget build(BuildContext context, WidgetRef ref) { final blocked = ref.watch(notificationsBlockedProvider); if (!blocked) return const SizedBox.shrink(); return Material( color: Colors.transparent, child: Container( color: const Color(0xFFF5A623), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( children: [ const Icon(Icons.notifications_off, size: 16, color: Colors.white), const SizedBox(width: 8), Expanded( child: Text( 'Benachrichtigungen sind deaktiviert', style: TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w500), ), ), TextButton( onPressed: () { openNotificationSettings(); ref.read(notificationsBlockedProvider.notifier).state = false; }, style: TextButton.styleFrom( foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: const Text('Einstellungen', style: TextStyle(fontSize: 12, decoration: TextDecoration.underline)), ), IconButton( icon: const Icon(Icons.close, size: 14, color: Colors.white), padding: const EdgeInsets.all(4), constraints: const BoxConstraints(), onPressed: () => ref.read(notificationsBlockedProvider.notifier).state = false, ), ], ), ), ); } } class _IncomingCallOverlay extends ConsumerWidget { final CallSession call; final CallSignalingService voip; final PyramidTheme pt; const _IncomingCallOverlay({ required this.call, required this.voip, required this.pt, }); @override Widget build(BuildContext context, WidgetRef ref) { return Container( color: Colors.black.withAlpha(180), child: Center( child: Container( width: 320, padding: const EdgeInsets.all(32), decoration: BoxDecoration( color: pt.bg2, borderRadius: BorderRadius.circular(pt.rXl), border: Border.all(color: pt.border), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 80, height: 80, decoration: BoxDecoration( color: pt.accent, shape: BoxShape.circle, ), child: const Icon(Icons.person_rounded, size: 40, color: Colors.white), ), const SizedBox(height: 24), Text( 'Eingehender Anruf', style: TextStyle(color: pt.fgDim, fontSize: 13), ), const SizedBox(height: 8), Text( call.room.getLocalizedDisplayname(), style: TextStyle(color: pt.fg, fontSize: 18, fontWeight: FontWeight.w700), textAlign: TextAlign.center, ), const SizedBox(height: 32), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ FloatingActionButton( heroTag: 'reject_call', onPressed: () => voip.safeAction(() async => await call.reject()), backgroundColor: pt.danger, child: const Icon(Icons.call_end_rounded, color: Colors.white), ), FloatingActionButton( heroTag: 'answer_call', onPressed: () { ref.read(viewModeProvider.notifier).state = ViewMode.voice; voip.safeAction(() async => await call.answer()); }, backgroundColor: pt.online, child: const Icon(Icons.call_rounded, color: Colors.white), ), ], ), ], ), ), ), ); } } class _ResizeDivider extends StatefulWidget { final PyramidTheme pt; final ValueChanged onDrag; const _ResizeDivider({required this.pt, required this.onDrag}); @override State<_ResizeDivider> createState() => _ResizeDividerState(); } class _ResizeDividerState extends State<_ResizeDivider> { bool _hovered = false; @override Widget build(BuildContext context) { return MouseRegion( cursor: SystemMouseCursors.resizeColumn, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( behavior: HitTestBehavior.translucent, onHorizontalDragUpdate: (d) => widget.onDrag(d.delta.dx), child: AnimatedContainer( duration: const Duration(milliseconds: 150), width: 6, color: _hovered ? widget.pt.accent.withAlpha(80) : widget.pt.border, ), ), ); } } class NoChatSelected extends StatelessWidget { const NoChatSelected({super.key}); @override Widget build(BuildContext context) { final pt = PyramidTheme.of(context); return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.chat_bubble_outline_rounded, size: 64, color: pt.fgDim.withAlpha(50)), const SizedBox(height: 16), Text( 'Select a chat to start messaging', style: TextStyle(color: pt.fgMuted, fontSize: 15), ), ], ), ); } } // ─── Incoming Key Verification Dialog ─────────────────────────────────────── class _IncomingVerificationDialog extends StatefulWidget { final KeyVerification request; final PyramidTheme pt; const _IncomingVerificationDialog({ required this.request, required this.pt, }); @override State<_IncomingVerificationDialog> createState() => _IncomingVerificationDialogState(); } class _IncomingVerificationDialogState extends State<_IncomingVerificationDialog> { late KeyVerificationState _state; @override void initState() { super.initState(); _state = widget.request.state; widget.request.onUpdate = () { if (mounted) setState(() => _state = widget.request.state); }; } @override void dispose() { if (!widget.request.isDone) { widget.request.cancel('m.user').catchError((_) {}); } super.dispose(); } @override Widget build(BuildContext context) { final pt = widget.pt; final req = widget.request; return AlertDialog( backgroundColor: pt.bg2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), 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( 'Verifikationsanfrage', style: TextStyle(color: pt.fg, fontSize: 16, fontWeight: FontWeight.w700), ), ), ], ), content: SizedBox( width: 380, child: _buildContent(pt, req), ), actions: _buildActions(pt, req), ); } Widget _buildContent(PyramidTheme pt, KeyVerification req) { return switch (_state) { KeyVerificationState.askAccept => Padding( padding: const EdgeInsets.only(bottom: 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _VerifInfoRow( icon: Icons.devices_rounded, label: (req.deviceId?.isNotEmpty == true) ? req.deviceId! : 'Unbekanntes Gerät', pt: pt, ), const SizedBox(height: 6), _VerifInfoRow( icon: Icons.person_outline_rounded, label: req.userId, pt: pt, ), const SizedBox(height: 12), Text( 'Dieses Gerät möchte sich mit deinem Konto verifizieren.', style: TextStyle(color: pt.fgMuted, fontSize: 13, height: 1.5), ), ], ), ), KeyVerificationState.waitingAccept => Padding( padding: const EdgeInsets.only(bottom: 16), child: Column(mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator.adaptive(), const SizedBox(height: 16), Text('Bereite Verifikation vor…', style: TextStyle(color: pt.fgMuted, fontSize: 13)), ]), ), KeyVerificationState.askChoice => Padding( padding: const EdgeInsets.only(bottom: 8), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Wähle eine Verifikationsmethode:', 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: () => req.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: () => req.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 => _QrCodeDisplay(pt: pt, request: req), KeyVerificationState.showQRSuccess => Padding( padding: const EdgeInsets.only(bottom: 16), child: _InfoCard( pt: pt, icon: Icons.verified_rounded, color: const Color(0xFF22C55E), text: 'QR-Code erfolgreich gescannt. Verifikation abgeschlossen.'), ), KeyVerificationState.askSas => _SasView(request: req, pt: pt, onClose: () => Navigator.pop(context)), 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 der anderen Seite…', style: TextStyle(color: pt.fgMuted, fontSize: 13)), ]), ), KeyVerificationState.done => Padding( padding: const EdgeInsets.only(bottom: 16), child: _InfoCard( pt: pt, icon: Icons.verified_rounded, color: const Color(0xFF22C55E), text: 'Gerät erfolgreich verifiziert.'), ), _ => Padding( padding: const EdgeInsets.only(bottom: 16), child: _InfoCard( pt: pt, icon: Icons.error_outline_rounded, color: pt.danger, text: 'Verifizierung fehlgeschlagen oder abgebrochen.'), ), }; } List _buildActions(PyramidTheme pt, KeyVerification req) { if (_state == KeyVerificationState.askAccept) { return [ TextButton( onPressed: () { req.cancel('m.user').catchError((_) {}); Navigator.pop(context); }, child: Text('Ablehnen', style: TextStyle(color: pt.danger)), ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: pt.accent, foregroundColor: pt.accentFg, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), onPressed: () => req.acceptVerification().catchError((_) {}), child: const Text('Annehmen'), ), ]; } if (_state == KeyVerificationState.confirmQRScan) { return [ TextButton( onPressed: () { req.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: () => req.acceptQRScanConfirmation().catchError((_) {}), child: const Text('Gescannt bestätigen'), ), ]; } if (_state == KeyVerificationState.done || _state == KeyVerificationState.showQRSuccess || _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.askSas || _state == KeyVerificationState.askChoice) return []; return [ TextButton( onPressed: () { req.cancel('m.user').catchError((_) {}); Navigator.pop(context); }, child: Text('Abbrechen', style: TextStyle(color: pt.fgMuted)), ), ]; } } class _SasView extends StatelessWidget { final KeyVerification request; final PyramidTheme pt; final VoidCallback onClose; const _SasView({required this.request, required this.pt, required this.onClose}); @override Widget build(BuildContext context) { final emojis = request.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: 10, runSpacing: 10, alignment: WrapAlignment.center, children: emojis.map((e) => _EmojiChip(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: () { request.rejectSas().catchError((_) {}); onClose(); }, icon: const Icon(Icons.close_rounded, size: 16), label: const Text('Stimmt nicht'), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF22C55E), foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(vertical: 12), ), onPressed: () => request.acceptSas().catchError((_) {}), icon: const Icon(Icons.check_rounded, size: 16), label: const Text('Stimmt überein'), ), ), ], ), const SizedBox(height: 8), ], ), ); } } class _EmojiChip extends StatelessWidget { final KeyVerificationEmoji emoji; final PyramidTheme pt; const _EmojiChip({required this.emoji, required this.pt}); @override Widget build(BuildContext context) { return Container( width: 64, 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: 24)), const SizedBox(height: 4), Text( emoji.name, style: TextStyle(color: pt.fgMuted, fontSize: 9), textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ), ); } } class _VerifInfoRow extends StatelessWidget { final IconData icon; final String label; final PyramidTheme pt; const _VerifInfoRow({required this.icon, required this.label, required this.pt}); @override Widget build(BuildContext context) { return Row( children: [ Icon(icon, size: 14, color: pt.fgDim), const SizedBox(width: 8), Expanded( child: Text( label, style: TextStyle(color: pt.fg, fontSize: 12, fontFamily: 'monospace'), overflow: TextOverflow.ellipsis, ), ), ], ); } } class _InfoCard extends StatelessWidget { final PyramidTheme pt; final IconData icon; final Color color; final String text; const _InfoCard({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))), ], ), ); } } class _QrCodeDisplay extends StatelessWidget { final PyramidTheme pt; final KeyVerification request; const _QrCodeDisplay({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: _InfoCard( pt: pt, icon: Icons.error_outline_rounded, color: pt.danger, text: 'QR-Code konnte nicht generiert werden.', ), ); } } }