- Calls keep going with the app in the background: a foreground service (CallService.kt, driven from lib/matrix/call_service.dart) declares the microphone, the camera while it's on and the screen while it's shared. Its notification (CallStyle) shows the call's length, opens the call and hangs up. Swiping the app away ends the call. - Screen sharing on Android: consent, then media projection on the service, then LiveKit publishes a screen-share track. A shared screen replaces the sharer's camera on their tile, shown whole and unmirrored; stopping it from Android's controls stops the share. - Read receipts per thread (MSC3771): an open thread sends receipts with thread_id, "Read" under replies comes from receipts in the thread, and threads with unread replies get a dot on "N replies" and in the list. Not tested on a device yet. README updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
5.1 KiB
Dart
165 lines
5.1 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:matrix/encryption.dart';
|
|
import 'package:matrix/matrix.dart';
|
|
|
|
import 'matrix/calls.dart';
|
|
import 'matrix/notifications.dart';
|
|
import 'screens/call_screen.dart';
|
|
import 'screens/home_screen.dart';
|
|
import 'screens/login_screen.dart';
|
|
import 'screens/room_screen.dart';
|
|
import 'state.dart';
|
|
import 'theme.dart';
|
|
import 'widgets/uia.dart';
|
|
import 'widgets/verification_sheet.dart';
|
|
|
|
final navigatorKey = GlobalKey<NavigatorState>();
|
|
final messengerKey = GlobalKey<ScaffoldMessengerState>();
|
|
|
|
class WarpApp extends ConsumerWidget {
|
|
const WarpApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final loginState = ref.watch(loginStateProvider);
|
|
final signedIn = loginState.value == LoginState.loggedIn;
|
|
// Signing in from the sign-up screen, or out from settings, leaves those
|
|
// screens on top of the new home: close them.
|
|
ref.listen(loginStateProvider, (prev, next) {
|
|
if (prev?.value != next.value) {
|
|
navigatorKey.currentState?.popUntil((route) => route.isFirst);
|
|
}
|
|
});
|
|
return MaterialApp(
|
|
title: 'Warp',
|
|
navigatorKey: navigatorKey,
|
|
scaffoldMessengerKey: messengerKey,
|
|
debugShowCheckedModeBanner: false,
|
|
theme: warpTheme(Brightness.light),
|
|
darkTheme: warpTheme(Brightness.dark),
|
|
themeMode: ref.watch(themeModeProvider),
|
|
home: loginState.isLoading
|
|
? const Scaffold()
|
|
: signedIn
|
|
? const _SignedIn(key: ValueKey('signed-in'))
|
|
: const LoginScreen(key: ValueKey('signed-out')),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Everything that runs only while signed in: the notification listener,
|
|
/// prompts the SDK raises (a password for an account change, an incoming
|
|
/// verification request) and opening rooms from notifications.
|
|
class _SignedIn extends ConsumerStatefulWidget {
|
|
const _SignedIn({super.key});
|
|
|
|
@override
|
|
ConsumerState<_SignedIn> createState() => _SignedInState();
|
|
}
|
|
|
|
class _SignedInState extends ConsumerState<_SignedIn>
|
|
with WidgetsBindingObserver {
|
|
final _subs = <StreamSubscription>[];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
final client = ref.read(clientProvider);
|
|
_subs
|
|
..add(Notifications.watch(client))
|
|
..add(Notifications.opened.listen(_openRoom))
|
|
..add(client.onUiaRequest.stream.listen(_onUia))
|
|
..add(client.onKeyVerificationRequest.stream.listen(_onVerification));
|
|
final calls = ref.read(callsProvider);
|
|
calls.addListener(_onCalls);
|
|
_subs.add(calls.openRequests.listen((_) => _openCall()));
|
|
_subs.add(
|
|
calls.errors.stream.listen(
|
|
(message) => messengerKey.currentState?.showSnackBar(
|
|
SnackBar(content: Text(message)),
|
|
),
|
|
),
|
|
);
|
|
Notifications.launchRoomId().then((roomId) {
|
|
if (roomId != null) _openRoom(roomId);
|
|
});
|
|
Notifications.requestPermission();
|
|
}
|
|
|
|
IncomingCall? _shownCall;
|
|
|
|
/// Shows the incoming call card when a call starts ringing.
|
|
void _onCalls() {
|
|
final calls = ref.read(callsProvider);
|
|
final call = calls.incoming;
|
|
if (call == null || call == _shownCall) return;
|
|
_shownCall = call;
|
|
final context = navigatorKey.currentContext;
|
|
final navigator = navigatorKey.currentState;
|
|
if (context == null || navigator == null) return;
|
|
if (!Notifications.foreground) {
|
|
Notifications.show(
|
|
roomId: call.roomId,
|
|
title: call.callerName,
|
|
body: call.video ? 'Video call' : 'Voice call',
|
|
);
|
|
}
|
|
showIncomingCall(context, calls, navigator);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
ref.read(callsProvider).removeListener(_onCalls);
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
for (final sub in _subs) {
|
|
sub.cancel();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
// The sync loop keeps running in the background for as long as Android
|
|
// lets the process live; pushes cover the time after that.
|
|
Notifications.foreground = state == AppLifecycleState.resumed;
|
|
}
|
|
|
|
void _openRoom(String roomId) {
|
|
final nav = navigatorKey.currentState;
|
|
if (nav == null) return;
|
|
nav.popUntil((route) => route.isFirst);
|
|
nav.push(RoomScreen.route(roomId));
|
|
}
|
|
|
|
/// Shows the call screen, unless it is already on top.
|
|
void _openCall() {
|
|
final nav = navigatorKey.currentState;
|
|
if (nav == null || !ref.read(callsProvider).inCall) return;
|
|
var onTop = false;
|
|
nav.popUntil((route) {
|
|
onTop = route.settings.name == 'call';
|
|
return true;
|
|
});
|
|
if (!onTop) nav.push(CallScreen.route());
|
|
}
|
|
|
|
void _onUia(UiaRequest request) {
|
|
final context = navigatorKey.currentContext;
|
|
if (context == null) return request.cancel();
|
|
handleUia(context, ref.read(clientProvider), request);
|
|
}
|
|
|
|
void _onVerification(KeyVerification request) {
|
|
final context = navigatorKey.currentContext;
|
|
if (context == null) return;
|
|
showVerificationSheet(context, request);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => const HomeScreen();
|
|
}
|