Files
pyramid/lib/layout/app_shell.dart
T
Bernd Steckmeister 3c7eb9ddca refactor: M2-Call-Pilot – Module call_signaling + voice_channel mit Fassaden (Entscheidung 2)
- CallSignalingService/callSignalingProvider und VoiceChannelService/
  voiceChannelProvider als einzige oeffentliche Schnittstellen
- voip_manager.dart / livekit_call_manager.dart in ihre Module verschoben,
  Logik unangetastet (nur Klassen-Koepfe, @override, Imports, Provider-Namen)
- Alle Konsumenten entkoppelt; Kompositions-Punkt matrix_client.dart als
  dokumentierte Ausnahme. analyze sauber, 29 Tests gruen, Windows-Start ok.
  Call-Klick-/Geraetetest steht aus (PROGRESS.md)
2026-07-06 12:42:59 +02:00

1075 lines
36 KiB
Dart

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/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<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell>
with WidgetsBindingObserver {
double _sidebarWidth = 260;
bool _bootstrapTriggered = false;
// Flankenerkennung für die Auto-Switch-Listener (siehe build).
bool _wasVoipConnected = false;
bool _wasLiveKitActive = false;
StreamSubscription<KeyVerification>? _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<void>(
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<double> 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<Widget> _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.',
),
);
}
}
}