// Version: 1.1.0 | Created: 2026-04-01 // IncomingCallOverlay — full-screen overlay shown when an m.call.invite // arrives. Displays caller name/avatar, and Accept / Decline buttons. import 'package:cached_network_image/cached_network_image.dart'; 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 '../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 ConsumerWidget { const _IncomingCallOverlay({required this.call}); final IncomingCall call; @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); return Positioned.fill( child: Material( color: Colors.black.withAlpha(220), child: SafeArea( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ // Caller avatar _CallerAvatar(call: call), 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: () { // Dismiss the overlay by navigating away; the repository // stream will emit null on the next event cycle. }, ), _CallActionButton( icon: call.isVideo ? Icons.videocam : Icons.call, label: 'Accept', colour: Colors.green, onTap: () { context.push( '/calls/${Uri.encodeComponent(call.roomId)}', ); }, ), ], ), ], ), ), ), ); } } class _CallerAvatar extends StatelessWidget { const _CallerAvatar({required this.call}); final IncomingCall call; @override Widget build(BuildContext context) { final initials = call.callerDisplayName.isNotEmpty ? call.callerDisplayName[0].toUpperCase() : '?'; if (call.callerAvatarUrl != null) { return CircleAvatar( radius: 56, backgroundImage: CachedNetworkImageProvider(call.callerAvatarUrl!), ); } return CircleAvatar( radius: 56, backgroundColor: Theme.of(context).colorScheme.primary.withAlpha(77), child: Text( initials, style: const TextStyle( fontSize: 40, color: Colors.white, fontWeight: FontWeight.bold, ), ), ); } } 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), ), ], ), ); } }