// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // IncomingCallOverlay — full-screen overlay shown when an m.call.invite // arrives. Displays caller name/avatar, and Accept / Decline buttons. import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../shared/widgets/matrix_avatar.dart'; import '../data/matrixrtc_repository.dart'; import '../domain/incoming_call.dart'; part 'incoming_call_overlay.g.dart'; // --------------------------------------------------------------------------- // Provider that surfaces the latest incoming call (or null when idle) // --------------------------------------------------------------------------- @riverpod Stream incomingCallStream(Ref ref) async* { yield null; // idle initial state final repo = ref.watch(matrixRtcRepositoryProvider); await for (final call in repo.incomingCallStream) { yield call; } } // --------------------------------------------------------------------------- // Widget // --------------------------------------------------------------------------- /// Wrap this around the top-level router widget to detect and display incoming /// calls. Listens to [incomingCallStreamProvider] and shows the overlay when /// a call arrives. class IncomingCallOverlayHost extends ConsumerWidget { const IncomingCallOverlayHost({super.key, required this.child}); final Widget child; @override Widget build(BuildContext context, WidgetRef ref) { final callAsync = ref.watch(incomingCallStreamProvider); return Stack( children: [ child, callAsync.when( loading: () => const SizedBox.shrink(), error: (_, __) => const SizedBox.shrink(), data: (call) { if (call == null) return const SizedBox.shrink(); return _IncomingCallOverlay(call: call); }, ), ], ); } } class _IncomingCallOverlay extends StatefulWidget { const _IncomingCallOverlay({required this.call}); final IncomingCall call; @override State<_IncomingCallOverlay> createState() => _IncomingCallOverlayState(); } class _IncomingCallOverlayState extends State<_IncomingCallOverlay> { bool _dismissed = false; @override Widget build(BuildContext context) { if (_dismissed) return const SizedBox.shrink(); final theme = Theme.of(context); final call = widget.call; return Positioned.fill( child: Material( color: Colors.black.withAlpha(220), child: SafeArea( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Caller avatar MatrixAvatar( name: call.callerDisplayName.isNotEmpty ? call.callerDisplayName : call.callerId, avatarUrl: call.callerAvatarUrl, radius: 56, ), const SizedBox(height: 24), // Caller name Text( call.callerDisplayName.isNotEmpty ? call.callerDisplayName : call.callerId, style: theme.textTheme.headlineMedium?.copyWith( color: Colors.white, fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), Text( call.isVideo ? 'Incoming video call' : 'Incoming voice call', style: TextStyle( color: Colors.white.withAlpha(179), fontSize: 16, ), ), const SizedBox(height: 64), // Accept / Decline Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _CallActionButton( icon: Icons.call_end, label: 'Decline', colour: Colors.red, onTap: () => setState(() => _dismissed = true), ), _CallActionButton( icon: call.isVideo ? Icons.videocam : Icons.call, label: 'Accept', colour: Colors.green, onTap: () { context.push( '/calls/${Uri.encodeComponent(call.roomId)}', ); }, ), ], ), ], ), ), ), ); } } class _CallActionButton extends StatelessWidget { const _CallActionButton({ required this.icon, required this.label, required this.colour, required this.onTap, }); final IconData icon; final String label; final Color colour; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 72, height: 72, decoration: BoxDecoration(color: colour, shape: BoxShape.circle), child: Icon(icon, color: Colors.white, size: 32), ), const SizedBox(height: 8), Text( label, style: const TextStyle(color: Colors.white, fontSize: 14), ), ], ), ); } }