🧱 Realtime call infra
This commit is contained in:
@ -13,6 +13,8 @@ import 'package:island/services/responsive.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:island/widgets/chat/call_overlay.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
|
||||
class WindowScaffold extends HookConsumerWidget {
|
||||
final Widget child;
|
||||
@ -150,11 +152,16 @@ class AppScaffold extends StatelessWidget {
|
||||
noBackground
|
||||
? Colors.transparent
|
||||
: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: SizedBox.expand(
|
||||
child:
|
||||
noBackground
|
||||
? content
|
||||
: AppBackground(isRoot: true, child: content),
|
||||
body: Stack(
|
||||
children: [
|
||||
SizedBox.expand(
|
||||
child:
|
||||
noBackground
|
||||
? content
|
||||
: AppBackground(isRoot: true, child: content),
|
||||
),
|
||||
const _GlobalCallOverlay(),
|
||||
],
|
||||
),
|
||||
appBar: appBar,
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
@ -192,6 +199,28 @@ class PageBackButton extends StatelessWidget {
|
||||
|
||||
const kAppBackgroundImagePath = 'island_app_background';
|
||||
|
||||
/// Global call overlay bar (appears when in a call but not on the call screen)
|
||||
class _GlobalCallOverlay extends HookConsumerWidget {
|
||||
const _GlobalCallOverlay();
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final callState = ref.watch(callNotifierProvider);
|
||||
// Find current route name
|
||||
final modalRoute = ModalRoute.of(context);
|
||||
final isOnCallScreen = modalRoute?.settings.name?.contains('call') ?? false;
|
||||
// You may want to store roomId in callState for more robust navigation
|
||||
final roomId =
|
||||
(modalRoute?.settings.arguments is Map &&
|
||||
(modalRoute!.settings.arguments as Map).containsKey('roomId'))
|
||||
? (modalRoute.settings.arguments as Map)['roomId'] as String
|
||||
: null;
|
||||
if (callState.isConnected && !isOnCallScreen && roomId != null) {
|
||||
return CallOverlayBar(roomId: roomId);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
final backgroundImageFileProvider = FutureProvider<File?>((ref) async {
|
||||
if (kIsWeb) return null;
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
|
112
lib/widgets/chat/call_button.dart
Normal file
112
lib/widgets/chat/call_button.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
151
lib/widgets/chat/call_button.g.dart
Normal file
151
lib/widgets/chat/call_button.g.dart
Normal 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
|
53
lib/widgets/chat/call_overlay.dart
Normal file
53
lib/widgets/chat/call_overlay.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -42,11 +42,16 @@ class CheckInWidget extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final todayResult = ref.watch(checkInResultTodayProvider);
|
||||
|
||||
Future<void> checkIn() async {
|
||||
Future<void> checkIn({String? captchatTk}) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
try {
|
||||
await client.post('/accounts/me/check-in');
|
||||
await client.post(
|
||||
'/accounts/me/check-in',
|
||||
data: captchatTk == null ? null : jsonEncode(captchatTk),
|
||||
);
|
||||
ref.invalidate(checkInResultTodayProvider);
|
||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||
userNotifier.fetchUser();
|
||||
} catch (err) {
|
||||
if (err is DioException) {
|
||||
if (err.response?.statusCode == 423 && context.mounted) {
|
||||
@ -54,14 +59,7 @@ class CheckInWidget extends HookConsumerWidget {
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
|
||||
if (captchaTk == null) return;
|
||||
await client.post(
|
||||
'/accounts/me/check-in',
|
||||
data: jsonEncode(captchaTk),
|
||||
);
|
||||
ref.invalidate(checkInResultTodayProvider);
|
||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||
userNotifier.fetchUser();
|
||||
return;
|
||||
return await checkIn(captchatTk: captchaTk);
|
||||
}
|
||||
}
|
||||
showErrorAlert(err);
|
||||
@ -139,7 +137,7 @@ class CheckInWidget extends HookConsumerWidget {
|
||||
if (todayResult.valueOrNull == null) {
|
||||
checkIn();
|
||||
} else {
|
||||
context.router.push(MyselfEventCalendarRoute());
|
||||
context.router.push(EventCalanderRoute(name: 'me'));
|
||||
}
|
||||
},
|
||||
icon: AnimatedSwitcher(
|
||||
|
Reference in New Issue
Block a user