3c7eb9ddca
- 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)
1075 lines
36 KiB
Dart
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.',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|