🧱 Realtime call infra

This commit is contained in:
2025-05-25 17:40:52 +08:00
parent 9abc61a310
commit edf4ff1c5b
30 changed files with 1454 additions and 563 deletions

View File

@ -0,0 +1,112 @@
import 'package:auto_route/auto_route.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/call.dart';
import 'package:island/pods/network.dart';
import 'package:island/route.gr.dart';
import 'package:island/widgets/alert.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'call_button.g.dart';
@riverpod
Future<SnRealtimeCall?> ongoingCall(Ref ref, String roomId) async {
try {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/chat/realtime/$roomId');
return SnRealtimeCall.fromJson(resp.data);
} catch (e) {
if (e is DioException && e.response?.statusCode == 404) {
return null;
}
showErrorAlert(e);
return null;
}
}
class AudioCallButton extends HookConsumerWidget {
final String roomId;
const AudioCallButton({super.key, required this.roomId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ongoingCall = ref.watch(ongoingCallProvider(roomId));
final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.read(callNotifierProvider.notifier);
final isLoading = useState(false);
final apiClient = ref.watch(apiClientProvider);
Future<void> handleJoin() async {
isLoading.value = true;
try {
await apiClient.post('/chat/realtime/$roomId');
if (context.mounted) {
context.router.push(CallRoute(roomId: roomId));
}
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}
Future<void> handleEnd() async {
isLoading.value = true;
try {
await apiClient.delete('/chat/realtime/$roomId');
callNotifier.dispose(); // Clean up call resources
} catch (e) {
showErrorAlert(e);
} finally {
isLoading.value = false;
}
}
if (isLoading.value) {
return IconButton(
icon: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).appBarTheme.foregroundColor!,
padding: EdgeInsets.all(4),
),
),
onPressed: null,
);
}
if (callState.isConnected) {
// Show end call button if in call
return IconButton(
icon: const Icon(Icons.call_end),
tooltip: 'End Call',
onPressed: handleEnd,
);
}
if (ongoingCall.value != null) {
// There is an ongoing call, offer to join it directly
return IconButton(
icon: const Icon(Icons.call),
tooltip: 'Join Ongoing Call',
onPressed: () {
if (context.mounted) {
context.router.push(CallRoute(roomId: roomId));
}
},
);
}
// Show join/start call button
return IconButton(
icon: const Icon(Icons.call),
tooltip: 'Start/Join Call',
onPressed: handleJoin,
);
}
}

View File

@ -0,0 +1,151 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'call_button.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$ongoingCallHash() => r'd8a942e6695a7da702daeaa452464c16761ef6e7';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
/// See also [ongoingCall].
@ProviderFor(ongoingCall)
const ongoingCallProvider = OngoingCallFamily();
/// See also [ongoingCall].
class OngoingCallFamily extends Family<AsyncValue<SnRealtimeCall?>> {
/// See also [ongoingCall].
const OngoingCallFamily();
/// See also [ongoingCall].
OngoingCallProvider call(String roomId) {
return OngoingCallProvider(roomId);
}
@override
OngoingCallProvider getProviderOverride(
covariant OngoingCallProvider provider,
) {
return call(provider.roomId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'ongoingCallProvider';
}
/// See also [ongoingCall].
class OngoingCallProvider extends AutoDisposeFutureProvider<SnRealtimeCall?> {
/// See also [ongoingCall].
OngoingCallProvider(String roomId)
: this._internal(
(ref) => ongoingCall(ref as OngoingCallRef, roomId),
from: ongoingCallProvider,
name: r'ongoingCallProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$ongoingCallHash,
dependencies: OngoingCallFamily._dependencies,
allTransitiveDependencies: OngoingCallFamily._allTransitiveDependencies,
roomId: roomId,
);
OngoingCallProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.roomId,
}) : super.internal();
final String roomId;
@override
Override overrideWith(
FutureOr<SnRealtimeCall?> Function(OngoingCallRef provider) create,
) {
return ProviderOverride(
origin: this,
override: OngoingCallProvider._internal(
(ref) => create(ref as OngoingCallRef),
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
roomId: roomId,
),
);
}
@override
AutoDisposeFutureProviderElement<SnRealtimeCall?> createElement() {
return _OngoingCallProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is OngoingCallProvider && other.roomId == roomId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, roomId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin OngoingCallRef on AutoDisposeFutureProviderRef<SnRealtimeCall?> {
/// The parameter `roomId` of this provider.
String get roomId;
}
class _OngoingCallProviderElement
extends AutoDisposeFutureProviderElement<SnRealtimeCall?>
with OngoingCallRef {
_OngoingCallProviderElement(super.provider);
@override
String get roomId => (origin as OngoingCallProvider).roomId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -0,0 +1,53 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/call.dart';
import 'package:island/route.gr.dart';
/// A floating bar that appears when user is in a call but not on the call screen.
class CallOverlayBar extends HookConsumerWidget {
final String roomId;
const CallOverlayBar({super.key, required this.roomId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callNotifierProvider);
// Only show if connected and not on the call screen
if (!callState.isConnected) return const SizedBox.shrink();
return Positioned(
left: 16,
right: 16,
bottom: 32,
child: GestureDetector(
onTap: () {
context.router.push(CallRoute(roomId: roomId));
},
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(24),
color: Theme.of(context).colorScheme.primary,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.call, color: Colors.white),
const SizedBox(width: 12),
const Text(
'In call',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16),
),
],
),
const Icon(Icons.arrow_forward_ios, color: Colors.white, size: 18),
],
),
),
),
),
);
}
}

View File

@ -46,10 +46,6 @@ class MessageItem extends HookConsumerWidget {
isCurrentUser
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.5)
: Theme.of(context).colorScheme.surfaceContainer;
final linkColor = Color.alphaBlend(
Theme.of(context).colorScheme.primary,
containerColor,
);
final hasBackground =
ref.watch(backgroundImageFileProvider).valueOrNull != null;
@ -196,16 +192,8 @@ class MessageItem extends HookConsumerWidget {
textColor: textColor,
isReply: false,
).padding(vertical: 4),
if (remoteMessage.content?.isNotEmpty ?? false)
MarkdownTextContent(
content: remoteMessage.content!,
isSelectable: true,
linkStyle: TextStyle(color: linkColor),
textStyle: TextStyle(
color: textColor,
fontSize: 14,
),
),
if (_MessageItemContent.hasContent(remoteMessage))
_MessageItemContent(item: remoteMessage),
if (remoteMessage.attachments.isNotEmpty)
LayoutBuilder(
builder: (context, constraints) {
@ -360,7 +348,10 @@ class MessageQuoteWidget extends HookConsumerWidget {
: message.toRemoteMessage().forwardedMessageId!,
),
builder: (context, snapshot) {
if (snapshot.hasData) {
final remoteMessage =
snapshot.hasData ? snapshot.data!.toRemoteMessage() : null;
if (remoteMessage != null) {
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(8)),
child: Container(
@ -378,7 +369,7 @@ class MessageQuoteWidget extends HookConsumerWidget {
children: [
Icon(Symbols.reply, size: 16, color: textColor),
Text(
'Replying to ${snapshot.data!.toRemoteMessage().sender.account.nick}',
'Replying to ${remoteMessage.sender.account.nick}',
).textColor(textColor).bold(),
],
).padding(right: 8)
@ -389,16 +380,12 @@ class MessageQuoteWidget extends HookConsumerWidget {
children: [
Icon(Symbols.forward, size: 16, color: textColor),
Text(
'Forwarded from ${snapshot.data!.toRemoteMessage().sender.account.nick}',
'Forwarded from ${remoteMessage.sender.account.nick}',
).textColor(textColor).bold(),
],
).padding(right: 8),
if (snapshot.data!.toRemoteMessage().content?.isNotEmpty ??
false)
Text(
snapshot.data!.toRemoteMessage().content!,
style: TextStyle(color: textColor),
),
if (_MessageItemContent.hasContent(remoteMessage))
_MessageItemContent(item: remoteMessage),
],
),
),
@ -410,3 +397,62 @@ class MessageQuoteWidget extends HookConsumerWidget {
);
}
}
class _MessageItemContent extends StatelessWidget {
final SnChatMessage item;
const _MessageItemContent({required this.item});
@override
Widget build(BuildContext context) {
switch (item.type) {
case 'call.start':
case 'call.ended':
return _MessageContentCall(
isEnded: item.type == 'call.ended',
duration: item.meta['duration']?.toDouble(),
);
case 'text':
default:
return MarkdownTextContent(content: item.content!);
}
}
static bool hasContent(SnChatMessage item) {
return item.type == 'text' && (item.content?.isNotEmpty ?? false);
}
}
class _MessageContentCall extends StatelessWidget {
final bool isEnded;
final double? duration;
const _MessageContentCall({required this.isEnded, this.duration});
@override
Widget build(BuildContext context) {
String formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
return '${hours == 0 ? '' : '$hours hours '}'
'${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isEnded ? Symbols.call_end : Symbols.phone_in_talk,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
Gap(4),
Text(
isEnded
? 'Call ended after ${formatDuration(Duration(seconds: duration!.toInt()))}'
: 'Call started',
style: TextStyle(color: Theme.of(context).colorScheme.primary),
),
],
);
}
}