Files
warp/apps/mobile/lib/screens/call_screen.dart
T
tajniak81andClaude Opus 5.5 b8ddbaacf3 Mobile calls: keep call memberships in memory; answering keeps the call screen
Found testing calls between two phones on the Compose call stack:

- The famedly SDK keeps only "important" state in memory for a room that
  hasn't been opened, so call memberships of other rooms were invisible:
  no ring (it was cleared at once, the call looking empty), no room-list
  marker. org.matrix.msc3401.call.member is now important state.
- The incoming-call card closed itself with Navigator.pop when the ring
  cleared, which popped the call screen pushed above it on Answer. The
  buttons now close the card first, and it closes itself only while it
  is the top route.

Tested: a voice call in a room of two (ring, answer, both connected
through LiveKit), and a video call in an encrypted room (to-device media
keys, frame cryptors OK on both sides, each phone showing the other's
video). The web client sees a phone's membership and rings for its
notification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:08:01 +02:00

536 lines
16 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:livekit_client/livekit_client.dart' as lk;
import 'package:matrix/matrix.dart';
import '../matrix/calls.dart';
import '../state.dart';
import '../theme.dart';
import '../widgets/avatar.dart';
/// The call this device is in: the other person large and you in the
/// corner for a call of two, a grid of tiles for more; mute, camera, flip
/// and leave. "Calling…" while it rings the other person.
class CallScreen extends ConsumerStatefulWidget {
const CallScreen({super.key});
static Route<void> route() => MaterialPageRoute(
settings: const RouteSettings(name: 'call'),
builder: (_) => const CallScreen(),
);
@override
ConsumerState<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends ConsumerState<CallScreen> {
Timer? _clock;
@override
void initState() {
super.initState();
_clock = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_clock?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final calls = ref.watch(callsProvider);
final client = ref.watch(clientProvider);
return ListenableBuilder(
listenable: calls,
builder: (context, _) {
final roomId = calls.roomId;
if (roomId == null) {
// The call ended: leave the screen.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
});
return const Scaffold(backgroundColor: Colors.black);
}
final room = client.getRoomById(roomId);
final tiles = _tiles(calls, room);
final title = room?.getLocalizedDisplayname() ?? '';
return Scaffold(
backgroundColor: const Color(0xFF0F1213),
body: SafeArea(
child: Column(
children: [
_TopBar(title: title, calls: calls),
Expanded(
child: _Stage(tiles: tiles, calls: calls, room: room),
),
_Controls(calls: calls),
],
),
),
);
},
);
}
List<_Tile> _tiles(Calls calls, Room? room) {
final lkRoom = calls.livekit;
if (lkRoom == null) return [];
final speakers = lkRoom.activeSpeakers.map((p) => p.identity).toSet();
_Tile tile(lk.Participant p, bool local) {
final userId = _identityUser(p.identity);
final user = room?.unsafeGetUserFromMemoryOrFallback(userId);
final camera = p.getTrackPublicationBySource(lk.TrackSource.camera);
final screen = p.getTrackPublicationBySource(
lk.TrackSource.screenShareVideo,
);
final mic = p.getTrackPublicationBySource(lk.TrackSource.microphone);
lk.VideoTrack? video(lk.TrackPublication? pub) =>
pub != null && !pub.muted && pub.track is lk.VideoTrack
? pub.track as lk.VideoTrack
: null;
return _Tile(
identity: p.identity,
userId: userId,
name: user?.calcDisplayname() ?? userId,
avatar: user?.avatarUrl,
local: local,
video: video(screen) ?? video(camera),
micMuted: mic == null || mic.muted,
speaking: speakers.contains(p.identity),
);
}
return [
if (lkRoom.localParticipant != null) tile(lkRoom.localParticipant!, true),
for (final p in lkRoom.remoteParticipants.values) tile(p, false),
];
}
/// The user of a LiveKit identity, "@alice:example.org:DEVICE": the
/// device is after the last colon (a server name may have a port).
static String _identityUser(String identity) {
final i = identity.lastIndexOf(':');
return i > 0 ? identity.substring(0, i) : identity;
}
}
class _Tile {
const _Tile({
required this.identity,
required this.userId,
required this.name,
required this.avatar,
required this.local,
required this.video,
required this.micMuted,
required this.speaking,
});
final String identity;
final String userId;
final String name;
final Uri? avatar;
final bool local;
final lk.VideoTrack? video;
final bool micMuted;
final bool speaking;
}
class _TopBar extends StatelessWidget {
const _TopBar({required this.title, required this.calls});
final String title;
final Calls calls;
@override
Widget build(BuildContext context) {
final since = calls.joinedAt;
final status = switch (calls.phase) {
CallPhase.joining => 'Connecting…',
CallPhase.reconnecting => 'Reconnecting…',
_ when calls.ringingOut => 'Calling…',
_ when since != null => _length(DateTime.now().difference(since)),
_ => '',
};
return Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 16, 8),
child: Row(
children: [
IconButton(
tooltip: 'Back to the chat',
icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontFamily: fontDisplay,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
Text(
status,
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
),
if (calls.encrypted)
const Tooltip(
message: 'End-to-end encrypted',
child: Icon(Icons.lock_outline, color: Colors.white70, size: 18),
),
],
),
);
}
static String _length(Duration d) {
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return d.inHours > 0 ? '${d.inHours}:$m:$s' : '$m:$s';
}
}
class _Stage extends StatelessWidget {
const _Stage({required this.tiles, required this.calls, required this.room});
final List<_Tile> tiles;
final Calls calls;
final Room? room;
@override
Widget build(BuildContext context) {
if (tiles.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
final remote = tiles.where((t) => !t.local).toList();
final local = tiles.firstWhere((t) => t.local, orElse: () => tiles.first);
// Alone: ringing, or waiting for someone to come.
if (remote.isEmpty) {
return Stack(
children: [
Positioned.fill(child: _TileView(tile: local, large: true)),
if (calls.ringingOut)
const Align(
alignment: Alignment(0, 0.6),
child: Text(
'Ringing…',
style: TextStyle(color: Colors.white70, fontSize: 15),
),
),
],
);
}
// Two: the other person large, you in the corner.
if (remote.length == 1) {
return Stack(
children: [
Positioned.fill(child: _TileView(tile: remote.single, large: true)),
Positioned(
right: 16,
bottom: 16,
width: 110,
height: 160,
child: _TileView(tile: local),
),
],
);
}
// More: a grid.
final columns = tiles.length <= 4 ? 2 : 3;
return GridView.count(
padding: const EdgeInsets.all(8),
crossAxisCount: columns,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 3 / 4,
children: [for (final t in tiles) _TileView(tile: t)],
);
}
}
class _TileView extends StatelessWidget {
const _TileView({required this.tile, this.large = false});
final _Tile tile;
final bool large;
@override
Widget build(BuildContext context) {
final video = tile.video;
return ClipRRect(
borderRadius: BorderRadius.circular(large ? 0 : 14),
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF1B2022),
border: tile.speaking && !large
? Border.all(color: const Color(0xFF22C3A6), width: 3)
: null,
),
child: Stack(
children: [
Positioned.fill(
child: video != null
? lk.VideoTrackRenderer(
video,
fit: large
? lk.VideoViewFit.cover
: lk.VideoViewFit.cover,
mirrorMode: tile.local
? lk.VideoViewMirrorMode.mirror
: lk.VideoViewMirrorMode.off,
)
: Center(
child: Avatar(
name: tile.name,
seed: tile.userId,
mxc: tile.avatar,
size: large ? 112 : 56,
),
),
),
Positioned(
left: 8,
bottom: 8,
right: 8,
child: Row(
children: [
if (tile.micMuted)
const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(Icons.mic_off, color: Colors.white, size: 16),
),
Flexible(
child: Text(
tile.local ? 'You' : tile.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
shadows: [Shadow(blurRadius: 4)],
),
),
),
],
),
),
],
),
),
);
}
}
class _Controls extends StatelessWidget {
const _Controls({required this.calls});
final Calls calls;
@override
Widget build(BuildContext context) {
Widget round(
IconData icon,
String label,
VoidCallback? onTap, {
bool active = true,
Color? colour,
}) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Material(
color: colour ?? (active ? Colors.white24 : Colors.white),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox.square(
dimension: 60,
child: Icon(
icon,
color: colour != null || active ? Colors.white : Colors.black,
),
),
),
),
const SizedBox(height: 6),
Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
],
);
final connected = calls.phase == CallPhase.connected;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
round(
calls.micOn ? Icons.mic : Icons.mic_off,
calls.micOn ? 'Mute' : 'Unmute',
connected ? calls.toggleMic : null,
active: calls.micOn,
),
round(
calls.cameraOn ? Icons.videocam : Icons.videocam_off,
'Camera',
connected ? calls.toggleCamera : null,
active: calls.cameraOn,
),
if (calls.cameraOn)
round(
Icons.flip_camera_android,
'Flip',
connected ? calls.flipCamera : null,
),
round(
Icons.call_end,
'Leave',
calls.leave,
colour: const Color(0xFFD64545),
),
],
),
);
}
}
/// The incoming call card: who, from which room, answer (with or without
/// video) or decline.
Future<void> showIncomingCall(
BuildContext context,
Calls calls,
NavigatorState navigator,
) => showModalBottomSheet<void>(
context: context,
isDismissible: false,
enableDrag: false,
builder: (sheet) => ListenableBuilder(
listenable: calls,
builder: (context, _) {
final call = calls.incoming;
if (call == null) {
// Answered elsewhere, declined, or it stopped ringing.
// Only while the sheet is on top: after Answer, the call screen
// is, and popping would close it instead.
WidgetsBinding.instance.addPostFrameCallback((_) {
final route = ModalRoute.of(sheet);
if (route != null && route.isCurrent) Navigator.of(sheet).pop();
});
return const SizedBox(height: 200);
}
final c = context.warp;
final text = Theme.of(context).textTheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Avatar(
name: call.callerName,
seed: call.callerId,
mxc: call.callerAvatar,
size: 72,
),
const SizedBox(height: 12),
Text(call.callerName, style: text.titleLarge),
Text(
call.video ? 'Video call' : 'Voice call',
style: text.bodySmall,
),
if (call.roomName != call.callerName)
Text(call.roomName, style: text.bodySmall),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_RoundButton(
icon: Icons.call_end,
label: 'Decline',
colour: c.danger,
onTap: () {
Navigator.of(sheet).pop();
calls.decline();
},
),
if (call.video)
_RoundButton(
icon: Icons.call,
label: 'Voice only',
colour: c.tealStrong,
onTap: () {
Navigator.of(sheet).pop();
calls.answer(withVideo: false);
navigator.push(CallScreen.route());
},
),
_RoundButton(
icon: call.video ? Icons.videocam : Icons.call,
label: 'Answer',
colour: c.tealStrong,
onTap: () {
Navigator.of(sheet).pop();
calls.answer();
navigator.push(CallScreen.route());
},
),
],
),
],
),
),
);
},
),
);
class _RoundButton extends StatelessWidget {
const _RoundButton({
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) => Column(
children: [
Material(
color: colour,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox.square(
dimension: 64,
child: Icon(icon, color: Colors.white),
),
),
),
const SizedBox(height: 6),
Text(label, style: Theme.of(context).textTheme.labelMedium),
],
);
}