diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index cb86d57..124a510 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -278,5 +278,8 @@ "settingsHideBottomNav": "Hide Bottom Navigation", "settingsSoundEffects": "Sound Effects", "settingsAprilFoolFeatures": "April Fool Features", - "settingsEnterToSend": "Enter to Send" + "settingsEnterToSend": "Enter to Send", + "postTitle": "Title", + "postDescription": "Description", + "call": "Call" } diff --git a/lib/pods/call.dart b/lib/pods/call.dart index 88d0a50..46ca4c4 100644 --- a/lib/pods/call.dart +++ b/lib/pods/call.dart @@ -1,5 +1,6 @@ import 'package:island/pods/userinfo.dart'; import 'package:island/screens/chat/chat.dart'; +import 'package:island/widgets/chat/call_button.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'dart:async'; @@ -11,6 +12,14 @@ import 'package:island/pods/websocket.dart'; part 'call.g.dart'; part 'call.freezed.dart'; +String formatDuration(Duration duration) { + String negativeSign = duration.isNegative ? '-' : ''; + String twoDigits(int n) => n.toString().padLeft(2, "0"); + String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60).abs()); + String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60).abs()); + return "$negativeSign${twoDigits(duration.inHours)}:$twoDigitMinutes:$twoDigitSeconds"; +} + @freezed sealed class CallState with _$CallState { const factory CallState({ @@ -18,6 +27,7 @@ sealed class CallState with _$CallState { required bool isMicrophoneEnabled, required bool isCameraEnabled, required bool isScreenSharing, + @Default(Duration(seconds: 0)) Duration duration, String? error, }) = _CallState; } @@ -54,6 +64,8 @@ class CallNotifier extends _$CallNotifier { List.unmodifiable(_participants); LocalParticipant? get localParticipant => _localParticipant; + Timer? _durationTimer; + @override CallState build() { // Subscribe to websocket updates @@ -219,8 +231,16 @@ class CallNotifier extends _$CallNotifier { Future joinRoom(String roomId) async { _roomId = roomId; + if (_room != null) { + await _room!.disconnect(); + await _room!.dispose(); + _room = null; + _localParticipant = null; + _participants = []; + } try { final apiClient = ref.read(apiClientProvider); + final ongoingCall = await ref.read(ongoingCallProvider(roomId).future); final response = await apiClient.get('/chat/realtime/$roomId/join'); if (response.statusCode == 200 && response.data != null) { final data = response.data; @@ -229,6 +249,19 @@ class CallNotifier extends _$CallNotifier { final participants = joinResponse.participants; final String endpoint = joinResponse.endpoint; final String token = joinResponse.token; + + // Setup duration timer + _durationTimer?.cancel(); + _durationTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + state = state.copyWith( + duration: Duration( + milliseconds: + (DateTime.now().millisecondsSinceEpoch - + (ongoingCall?.createdAt.millisecondsSinceEpoch ?? 0)), + ), + ); + }); + // Connect to LiveKit _room = Room(); @@ -314,5 +347,6 @@ class CallNotifier extends _$CallNotifier { _roomListener?.dispose(); _room?.removeListener(_onRoomChange); _room?.dispose(); + _durationTimer?.cancel(); } } diff --git a/lib/pods/call.freezed.dart b/lib/pods/call.freezed.dart index db900db..c6a98cc 100644 --- a/lib/pods/call.freezed.dart +++ b/lib/pods/call.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$CallState { - bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; String? get error; + bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; Duration get duration; String? get error; /// Create a copy of CallState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -26,16 +26,16 @@ $CallStateCopyWith get copyWith => _$CallStateCopyWithImpl @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.error, error) || other.error == error)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error)); } @override -int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,error); +int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,duration,error); @override String toString() { - return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, error: $error)'; + return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, duration: $duration, error: $error)'; } @@ -46,7 +46,7 @@ abstract mixin class $CallStateCopyWith<$Res> { factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl; @useResult $Res call({ - bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, String? error + bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error }); @@ -63,13 +63,14 @@ class _$CallStateCopyWithImpl<$Res> /// Create a copy of CallState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? error = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? duration = null,Object? error = freezed,}) { return _then(_self.copyWith( isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable -as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable +as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable as String?, )); } @@ -81,13 +82,14 @@ as String?, class _CallState implements CallState { - const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, this.error}); + const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, this.duration = const Duration(seconds: 0), this.error}); @override final bool isConnected; @override final bool isMicrophoneEnabled; @override final bool isCameraEnabled; @override final bool isScreenSharing; +@override@JsonKey() final Duration duration; @override final String? error; /// Create a copy of CallState @@ -100,16 +102,16 @@ _$CallStateCopyWith<_CallState> get copyWith => __$CallStateCopyWithImpl<_CallSt @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.error, error) || other.error == error)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.error, error) || other.error == error)); } @override -int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,error); +int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,duration,error); @override String toString() { - return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, error: $error)'; + return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, duration: $duration, error: $error)'; } @@ -120,7 +122,7 @@ abstract mixin class _$CallStateCopyWith<$Res> implements $CallStateCopyWith<$Re factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl; @override @useResult $Res call({ - bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, String? error + bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, Duration duration, String? error }); @@ -137,13 +139,14 @@ class __$CallStateCopyWithImpl<$Res> /// Create a copy of CallState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? error = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? duration = null,Object? error = freezed,}) { return _then(_CallState( isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable as bool,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable -as bool,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable +as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable +as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable as String?, )); } diff --git a/lib/pods/call.g.dart b/lib/pods/call.g.dart index d5b41e4..7742b9b 100644 --- a/lib/pods/call.g.dart +++ b/lib/pods/call.g.dart @@ -6,7 +6,7 @@ part of 'call.dart'; // RiverpodGenerator // ************************************************************************** -String _$callNotifierHash() => r'5512070f943d98e999d97549c73e4d5f6e7b3ddd'; +String _$callNotifierHash() => r'0ae2e8ba21f145c80e1e65cf4fd15a7add17da78'; /// See also [CallNotifier]. @ProviderFor(CallNotifier) diff --git a/lib/pods/websocket.dart b/lib/pods/websocket.dart index 69ef172..b1662e4 100644 --- a/lib/pods/websocket.dart +++ b/lib/pods/websocket.dart @@ -52,6 +52,7 @@ class WebSocketService { Future connect(Ref ref) async { _ref = ref; + _statusStreamController.sink.add(WebSocketState.connecting()); final baseUrl = ref.watch(serverUrlProvider); final atk = await getFreshAtk( ref.watch(tokenPairProvider), diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 4c02c98..4badc2f 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -284,7 +284,7 @@ class _UnauthorizedAccountScreen extends StatelessWidget { @override Widget build(BuildContext context) { return AppScaffold( - appBar: AppBar(title: const Text('Account')), + appBar: AppBar(title: const Text('account').tr()), body: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 360), diff --git a/lib/screens/auth/tabs.dart b/lib/screens/auth/tabs.dart index 5b2f6a7..566da58 100644 --- a/lib/screens/auth/tabs.dart +++ b/lib/screens/auth/tabs.dart @@ -17,6 +17,7 @@ class TabNavigationObserver extends AutoRouterObserver { @override void didPush(Route route, Route? previousRoute) { + if (route is DialogRoute) return; Future(() { onChange(route.settings.name); }); @@ -24,6 +25,7 @@ class TabNavigationObserver extends AutoRouterObserver { @override void didPop(Route route, Route? previousRoute) { + if (route is DialogRoute) return; Future(() { onChange(previousRoute?.settings.name); }); diff --git a/lib/screens/chat/call.dart b/lib/screens/chat/call.dart index bfc567e..e6e46e7 100644 --- a/lib/screens/chat/call.dart +++ b/lib/screens/chat/call.dart @@ -5,11 +5,10 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/call.dart'; -import 'package:island/pods/userinfo.dart'; -import 'package:island/screens/chat/chat.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/chat/call_button.dart'; +import 'package:island/widgets/chat/call_overlay.dart'; import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -22,8 +21,6 @@ class CallScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final ongoingCall = ref.watch(ongoingCallProvider(roomId)); - final userInfo = ref.watch(userInfoProvider); - final chatRoom = ref.watch(chatroomProvider(roomId)); final callState = ref.watch(callNotifierProvider); final callNotifier = ref.read(callNotifierProvider.notifier); @@ -32,10 +29,6 @@ class CallScreen extends HookConsumerWidget { return null; }, []); - final actionButtonStyle = ButtonStyle( - minimumSize: const MaterialStatePropertyAll(Size(24, 24)), - ); - final viewMode = useState('grid'); return AppScaffold( @@ -74,20 +67,12 @@ class CallScreen extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( - chatRoom.whenOrNull()?.name ?? 'loading'.tr(), + ongoingCall.value?.room.name ?? 'call'.tr(), style: const TextStyle(fontSize: 16), ), Text( callState.isConnected - ? Duration( - milliseconds: - (DateTime.now().millisecondsSinceEpoch - - (ongoingCall - .value - ?.createdAt - .millisecondsSinceEpoch ?? - 0)), - ).toString() + ? formatDuration(callState.duration) : 'Connecting', style: const TextStyle(fontSize: 14), ), @@ -131,78 +116,6 @@ class CallScreen extends HookConsumerWidget { ) : Column( children: [ - Card( - margin: const EdgeInsets.only(left: 12, right: 12, top: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Row( - children: [ - Builder( - builder: (context) { - if (callNotifier.localParticipant == null) { - return CircularProgressIndicator().center(); - } - return SizedBox( - width: 40, - height: 40, - child: - SpeakingRippleAvatar( - isSpeaking: - callNotifier - .localParticipant! - .isSpeaking, - audioLevel: - callNotifier - .localParticipant! - .audioLevel, - pictureId: - userInfo.value?.profile.pictureId, - size: 36, - ).center(), - ); - }, - ), - ], - ), - ), - IconButton( - icon: Icon( - callState.isMicrophoneEnabled - ? Icons.mic - : Icons.mic_off, - ), - onPressed: () { - callNotifier.toggleMicrophone(); - }, - style: actionButtonStyle, - ), - IconButton( - icon: Icon( - callState.isCameraEnabled - ? Icons.videocam - : Icons.videocam_off, - ), - onPressed: () { - callNotifier.toggleCamera(); - }, - style: actionButtonStyle, - ), - IconButton( - icon: Icon( - callState.isScreenSharing - ? Icons.stop_screen_share - : Icons.screen_share, - ), - onPressed: () { - callNotifier.toggleScreenShare(); - }, - style: actionButtonStyle, - ), - ], - ).padding(all: 16), - ), Expanded( child: Builder( builder: (context) { @@ -374,6 +287,8 @@ class CallScreen extends HookConsumerWidget { }, ), ), + CallControlsBar(), + Gap(MediaQuery.of(context).padding.bottom + 16), ], ), ); diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index dbcec03..05aa681 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -11,6 +11,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:island/models/chat.dart'; import 'package:island/models/file.dart'; import 'package:island/models/realm.dart'; +import 'package:island/pods/call.dart'; import 'package:island/pods/chat_summary.dart'; import 'package:island/pods/config.dart'; import 'package:island/pods/network.dart'; @@ -21,6 +22,7 @@ import 'package:island/services/responsive.dart'; import 'package:island/widgets/account/account_picker.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/chat/call_overlay.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/realms/selection_dropdown.dart'; import 'package:island/widgets/response.dart'; @@ -147,12 +149,11 @@ class ChatRoomListTile extends HookConsumerWidget { subtitle: buildSubtitle(), onTap: () async { // Clear unread count if there are unread messages - final summary = await ref.read(chatSummaryProvider.future); - if ((summary[room.id]?.unreadCount ?? 0) > 0) { - await ref - .read(chatSummaryProvider.notifier) - .clearUnreadCount(room.id); - } + ref.read(chatSummaryProvider.future).then((summary) { + if ((summary[room.id]?.unreadCount ?? 0) > 0) { + ref.read(chatSummaryProvider.notifier).clearUnreadCount(room.id); + } + }); onTap?.call(); }, ); @@ -213,6 +214,8 @@ class ChatListScreen extends HookConsumerWidget { 0, ); // 0 for All, 1 for Direct Messages, 2 for Group Chats + final callState = ref.watch(callNotifierProvider); + useEffect(() { tabController.addListener(() { selectedTab.value = tabController.index; @@ -334,76 +337,93 @@ class ChatListScreen extends HookConsumerWidget { }, child: const Icon(Symbols.add), ), - body: Column( + body: Stack( children: [ - Consumer( - builder: (context, ref, _) { - final summaryState = ref.watch(chatSummaryProvider); - return summaryState.maybeWhen( - loading: () => const LinearProgressIndicator(), - orElse: () => const SizedBox.shrink(), - ); - }, - ), - Expanded( - child: chats.when( - data: - (items) => RefreshIndicator( - onRefresh: - () => Future.sync(() { - ref.invalidate(chatroomsJoinedProvider); - }), - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: - items - .where( - (item) => - selectedTab.value == 0 || - (selectedTab.value == 1 && - item.type == 1) || - (selectedTab.value == 2 && item.type != 1), - ) - .length, - itemBuilder: (context, index) { - final filteredItems = - items - .where( - (item) => - selectedTab.value == 0 || - (selectedTab.value == 1 && - item.type == 1) || - (selectedTab.value == 2 && - item.type != 1), - ) - .toList(); - final item = filteredItems[index]; - return ChatRoomListTile( - room: item, - isDirect: item.type == 1, - onTap: () { - if (context.router.topRoute.name == - ChatRoomRoute.name) { - context.router.replace( - ChatRoomRoute(id: item.id), - ); - } else { - context.router.push(ChatRoomRoute(id: item.id)); - } + Column( + children: [ + Consumer( + builder: (context, ref, _) { + final summaryState = ref.watch(chatSummaryProvider); + return summaryState.maybeWhen( + loading: () => const LinearProgressIndicator(), + orElse: () => const SizedBox.shrink(), + ); + }, + ), + Expanded( + child: chats.when( + data: + (items) => RefreshIndicator( + onRefresh: + () => Future.sync(() { + ref.invalidate(chatroomsJoinedProvider); + }), + child: ListView.builder( + padding: + callState.isConnected + ? EdgeInsets.only(bottom: 96) + : EdgeInsets.zero, + itemCount: + items + .where( + (item) => + selectedTab.value == 0 || + (selectedTab.value == 1 && + item.type == 1) || + (selectedTab.value == 2 && + item.type != 1), + ) + .length, + itemBuilder: (context, index) { + final filteredItems = + items + .where( + (item) => + selectedTab.value == 0 || + (selectedTab.value == 1 && + item.type == 1) || + (selectedTab.value == 2 && + item.type != 1), + ) + .toList(); + final item = filteredItems[index]; + return ChatRoomListTile( + room: item, + isDirect: item.type == 1, + onTap: () { + if (context.router.topRoute.name == + ChatRoomRoute.name) { + context.router.replace( + ChatRoomRoute(id: item.id), + ); + } else { + context.router.push( + ChatRoomRoute(id: item.id), + ); + } + }, + ); }, - ); - }, - ), - ), - loading: () => const Center(child: CircularProgressIndicator()), - error: - (error, stack) => ResponseErrorWidget( - error: error, - onRetry: () { - ref.invalidate(chatroomsJoinedProvider); - }, - ), - ), + ), + ), + loading: + () => const Center(child: CircularProgressIndicator()), + error: + (error, stack) => ResponseErrorWidget( + error: error, + onRetry: () { + ref.invalidate(chatroomsJoinedProvider); + }, + ), + ), + ), + ], + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: const CallOverlayBar().padding(horizontal: 16, vertical: 12), ), ], ), diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 7a937e1..24e9db8 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -22,6 +22,7 @@ import 'package:island/screens/posts/compose.dart'; import 'package:island/services/responsive.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; +import 'package:island/widgets/chat/call_overlay.dart'; import 'package:island/widgets/chat/message_item.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/response.dart'; @@ -352,6 +353,10 @@ class ChatRoomScreen extends HookConsumerWidget { if (message.chatRoomId != chatRoom.value?.id) return; switch (pkt.type) { case 'messages.new': + if (message.type.startsWith('call')) { + // Handle the ongoing call. + ref.invalidate(ongoingCallProvider(message.chatRoomId)); + } messagesNotifier.receiveMessage(message); // Send read receipt for new message sendReadReceipt(); @@ -525,152 +530,166 @@ class ChatRoomScreen extends HookConsumerWidget { const Gap(8), ], ), - body: Column( + body: Stack( children: [ - Expanded( - child: messages.when( - data: - (messageList) => - messageList.isEmpty - ? Center(child: Text('No messages yet'.tr())) - : SuperListView.builder( - padding: EdgeInsets.symmetric(vertical: 16), - controller: scrollController, - reverse: true, // Show newest messages at the bottom - itemCount: messageList.length, - itemBuilder: (context, index) { - final message = messageList[index]; - final nextMessage = - index < messageList.length - 1 - ? messageList[index + 1] - : null; - final isLastInGroup = - nextMessage == null || - nextMessage.senderId != message.senderId || - nextMessage.createdAt - .difference(message.createdAt) - .inMinutes - .abs() > - 3; + Column( + children: [ + Expanded( + child: messages.when( + data: + (messageList) => + messageList.isEmpty + ? Center(child: Text('No messages yet'.tr())) + : SuperListView.builder( + padding: EdgeInsets.symmetric(vertical: 16), + controller: scrollController, + reverse: + true, // Show newest messages at the bottom + itemCount: messageList.length, + itemBuilder: (context, index) { + final message = messageList[index]; + final nextMessage = + index < messageList.length - 1 + ? messageList[index + 1] + : null; + final isLastInGroup = + nextMessage == null || + nextMessage.senderId != + message.senderId || + nextMessage.createdAt + .difference(message.createdAt) + .inMinutes + .abs() > + 3; - return chatIdentity.when( - skipError: true, - data: - (identity) => MessageItem( - message: message, - isCurrentUser: - identity?.id == message.senderId, - onAction: (action) { - switch (action) { - case MessageItemAction.delete: - messagesNotifier.deleteMessage( - message.id, - ); - case MessageItemAction.edit: - messageEditingTo.value = - message.toRemoteMessage(); - messageController.text = - messageEditingTo - .value - ?.content ?? - ''; - attachments.value = - messageEditingTo - .value! - .attachments - .map( - (e) => - UniversalFile.fromAttachment( - e, - ), - ) - .toList(); - case MessageItemAction.forward: - messageForwardingTo.value = - message.toRemoteMessage(); - case MessageItemAction.reply: - messageReplyingTo.value = - message.toRemoteMessage(); - } - }, - progress: - attachmentProgress.value[message.id], - showAvatar: isLastInGroup, - ), - loading: - () => MessageItem( - message: message, - isCurrentUser: false, - onAction: null, - progress: null, - showAvatar: false, - ), - error: (_, __) => const SizedBox.shrink(), - ); - }, - ), - loading: () => const Center(child: CircularProgressIndicator()), - error: - (error, _) => ResponseErrorWidget( - error: error, - onRetry: () => messagesNotifier.loadInitial(), - ), - ), - ), - chatRoom.when( - data: - (room) => _ChatInput( - messageController: messageController, - chatRoom: room!, - onSend: sendMessage, - onClear: () { - if (messageEditingTo.value != null) { - attachments.value.clear(); - messageController.clear(); - } - messageEditingTo.value = null; - messageReplyingTo.value = null; - messageForwardingTo.value = null; - }, - messageEditingTo: messageEditingTo.value, - messageReplyingTo: messageReplyingTo.value, - messageForwardingTo: messageForwardingTo.value, - onPickFile: (bool isPhoto) { - if (isPhoto) { - pickPhotoMedia(); - } else { - pickVideoMedia(); - } - }, - attachments: attachments.value, - onUploadAttachment: (_) { - // not going to do anything, only upload when send the message - }, - onDeleteAttachment: (index) async { - final attachment = attachments.value[index]; - if (attachment.isOnCloud) { - final client = ref.watch(apiClientProvider); - await client.delete('/files/${attachment.data.id}'); - } - final clone = List.of(attachments.value); - clone.removeAt(index); - attachments.value = clone; - }, - onMoveAttachment: (idx, delta) { - if (idx + delta < 0 || - idx + delta >= attachments.value.length) { - return; - } - final clone = List.of(attachments.value); - clone.insert(idx + delta, clone.removeAt(idx)); - attachments.value = clone; - }, - onAttachmentsChanged: (newAttachments) { - attachments.value = newAttachments; - }, + return chatIdentity.when( + skipError: true, + data: + (identity) => MessageItem( + message: message, + isCurrentUser: + identity?.id == message.senderId, + onAction: (action) { + switch (action) { + case MessageItemAction.delete: + messagesNotifier.deleteMessage( + message.id, + ); + case MessageItemAction.edit: + messageEditingTo.value = + message.toRemoteMessage(); + messageController.text = + messageEditingTo + .value + ?.content ?? + ''; + attachments.value = + messageEditingTo + .value! + .attachments + .map( + (e) => + UniversalFile.fromAttachment( + e, + ), + ) + .toList(); + case MessageItemAction.forward: + messageForwardingTo.value = + message.toRemoteMessage(); + case MessageItemAction.reply: + messageReplyingTo.value = + message.toRemoteMessage(); + } + }, + progress: + attachmentProgress.value[message + .id], + showAvatar: isLastInGroup, + ), + loading: + () => MessageItem( + message: message, + isCurrentUser: false, + onAction: null, + progress: null, + showAvatar: false, + ), + error: (_, __) => const SizedBox.shrink(), + ); + }, + ), + loading: + () => const Center(child: CircularProgressIndicator()), + error: + (error, _) => ResponseErrorWidget( + error: error, + onRetry: () => messagesNotifier.loadInitial(), + ), ), - error: (_, __) => const SizedBox.shrink(), - loading: () => const SizedBox.shrink(), + ), + chatRoom.when( + data: + (room) => _ChatInput( + messageController: messageController, + chatRoom: room!, + onSend: sendMessage, + onClear: () { + if (messageEditingTo.value != null) { + attachments.value.clear(); + messageController.clear(); + } + messageEditingTo.value = null; + messageReplyingTo.value = null; + messageForwardingTo.value = null; + }, + messageEditingTo: messageEditingTo.value, + messageReplyingTo: messageReplyingTo.value, + messageForwardingTo: messageForwardingTo.value, + onPickFile: (bool isPhoto) { + if (isPhoto) { + pickPhotoMedia(); + } else { + pickVideoMedia(); + } + }, + attachments: attachments.value, + onUploadAttachment: (_) { + // not going to do anything, only upload when send the message + }, + onDeleteAttachment: (index) async { + final attachment = attachments.value[index]; + if (attachment.isOnCloud) { + final client = ref.watch(apiClientProvider); + await client.delete('/files/${attachment.data.id}'); + } + final clone = List.of(attachments.value); + clone.removeAt(index); + attachments.value = clone; + }, + onMoveAttachment: (idx, delta) { + if (idx + delta < 0 || + idx + delta >= attachments.value.length) { + return; + } + final clone = List.of(attachments.value); + clone.insert(idx + delta, clone.removeAt(idx)); + attachments.value = clone; + }, + onAttachmentsChanged: (newAttachments) { + attachments.value = newAttachments; + }, + ), + error: (_, __) => const SizedBox.shrink(), + loading: () => const SizedBox.shrink(), + ), + ], + ), + Positioned( + left: 0, + right: 0, + top: 0, + child: CallOverlayBar().padding(horizontal: 8, top: 12), ), ], ), diff --git a/lib/screens/posts/compose.dart b/lib/screens/posts/compose.dart index edf09bf..3f60e2a 100644 --- a/lib/screens/posts/compose.dart +++ b/lib/screens/posts/compose.dart @@ -316,7 +316,7 @@ class PostComposeScreen extends HookConsumerWidget { TextField( controller: titleController, decoration: InputDecoration.collapsed( - hintText: 'Title', + hintText: 'title'.tr(), ), style: TextStyle(fontSize: 16), onTapOutside: @@ -326,7 +326,7 @@ class PostComposeScreen extends HookConsumerWidget { TextField( controller: descriptionController, decoration: InputDecoration.collapsed( - hintText: 'Description', + hintText: 'description'.tr(), ), style: TextStyle(fontSize: 16), onTapOutside: diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart index 0c89b25..45d2907 100644 --- a/lib/widgets/app_scaffold.dart +++ b/lib/widgets/app_scaffold.dart @@ -13,8 +13,6 @@ 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; @@ -152,22 +150,8 @@ class AppScaffold extends StatelessWidget { noBackground ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor, - body: Stack( - children: [ - SizedBox.expand( - child: - noBackground - ? content - : AppBackground(isRoot: true, child: content), - ), - Positioned( - left: 16, - right: 16, - bottom: 8, - child: const _GlobalCallOverlay(), - ), - ], - ), + body: + noBackground ? content : AppBackground(isRoot: true, child: content), appBar: appBar, bottomNavigationBar: bottomNavigationBar, bottomSheet: bottomSheet, @@ -206,23 +190,6 @@ 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 - if (callState.isConnected && !isOnCallScreen) { - return CallOverlayBar(); - } - return const SizedBox.shrink(); - } -} - final backgroundImageFileProvider = FutureProvider((ref) async { if (kIsWeb) return null; final dir = await getApplicationSupportDirectory(); diff --git a/lib/widgets/chat/call_overlay.dart b/lib/widgets/chat/call_overlay.dart index 73319fd..e438504 100644 --- a/lib/widgets/chat/call_overlay.dart +++ b/lib/widgets/chat/call_overlay.dart @@ -1,10 +1,94 @@ import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/call.dart'; +import 'package:island/pods/userinfo.dart'; import 'package:island/route.gr.dart'; +import 'package:island/widgets/chat/call_participant_tile.dart'; +import 'package:styled_widget/styled_widget.dart'; + +class CallControlsBar extends HookConsumerWidget { + const CallControlsBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final callState = ref.watch(callNotifierProvider); + final callNotifier = ref.read(callNotifierProvider.notifier); + + final userInfo = ref.watch(userInfoProvider); + + final actionButtonStyle = ButtonStyle( + minimumSize: const MaterialStatePropertyAll(Size(24, 24)), + ); + + return Card( + margin: const EdgeInsets.only(left: 12, right: 12, top: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Row( + children: [ + Builder( + builder: (context) { + if (callNotifier.localParticipant == null) { + return CircularProgressIndicator().center(); + } + return SizedBox( + width: 40, + height: 40, + child: + SpeakingRippleAvatar( + isSpeaking: + callNotifier.localParticipant!.isSpeaking, + audioLevel: + callNotifier.localParticipant!.audioLevel, + pictureId: userInfo.value?.profile.pictureId, + size: 36, + ).center(), + ); + }, + ), + ], + ), + ), + IconButton( + icon: Icon( + callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off, + ), + onPressed: () { + callNotifier.toggleMicrophone(); + }, + style: actionButtonStyle, + ), + IconButton( + icon: Icon( + callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off, + ), + onPressed: () { + callNotifier.toggleCamera(); + }, + style: actionButtonStyle, + ), + IconButton( + icon: Icon( + callState.isScreenSharing + ? Icons.stop_screen_share + : Icons.screen_share, + ), + onPressed: () { + callNotifier.toggleScreenShare(); + }, + style: actionButtonStyle, + ), + ], + ).padding(all: 16), + ); + } +} -/// A floating bar that appears when user is in a call but not on the call screen. class CallOverlayBar extends HookConsumerWidget { const CallOverlayBar({super.key}); @@ -15,48 +99,123 @@ class CallOverlayBar extends HookConsumerWidget { // 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: () { - if (callNotifier.roomId == null) return; - context.router.push(CallRoute(roomId: callNotifier.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, + final lastSpeaker = + callNotifier.participants + .where( + (element) => element.remoteParticipant.lastSpokeAt != null, + ) + .isEmpty + ? callNotifier.participants.first + : callNotifier.participants + .where( + (element) => element.remoteParticipant.lastSpokeAt != null, + ) + .fold( + callNotifier.participants.first, + (value, element) => + element.remoteParticipant.lastSpokeAt != null && + (value.remoteParticipant.lastSpokeAt == null || + element.remoteParticipant.lastSpokeAt! + .compareTo( + value + .remoteParticipant + .lastSpokeAt!, + ) > + 0) + ? element + : value, + ); + + final actionButtonStyle = ButtonStyle( + minimumSize: const MaterialStatePropertyAll(Size(24, 24)), + ); + + return GestureDetector( + child: Card( + margin: EdgeInsets.zero, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Row( + children: [ + Builder( + builder: (context) { + if (callNotifier.localParticipant == null) { + return CircularProgressIndicator().center(); + } + return SizedBox( + width: 40, + height: 40, + child: + SpeakingRippleAvatar( + isSpeaking: lastSpeaker.isSpeaking, + audioLevel: + lastSpeaker.remoteParticipant.audioLevel, + pictureId: + lastSpeaker + .participant + .profile + ?.account + .profile + .pictureId, + size: 36, + ).center(), + ); + }, + ), + const Gap(8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + lastSpeaker.participant.profile?.account.nick ?? + 'unknown'.tr(), + ).bold(), + Text( + formatDuration(callState.duration), + style: Theme.of(context).textTheme.bodySmall, ), - ), - ], - ), - const Icon( - Icons.arrow_forward_ios, - color: Colors.white, - size: 18, - ), - ], + ], + ), + ], + ), ), - ), - ), + IconButton( + icon: Icon( + callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off, + ), + onPressed: () { + callNotifier.toggleMicrophone(); + }, + style: actionButtonStyle, + ), + IconButton( + icon: Icon( + callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off, + ), + onPressed: () { + callNotifier.toggleCamera(); + }, + style: actionButtonStyle, + ), + IconButton( + icon: Icon( + callState.isScreenSharing + ? Icons.stop_screen_share + : Icons.screen_share, + ), + onPressed: () { + callNotifier.toggleScreenShare(); + }, + style: actionButtonStyle, + ), + ], + ).padding(all: 16), ), + onTap: () { + context.router.push(CallRoute(roomId: callNotifier.roomId!)); + }, ); } } diff --git a/lib/widgets/chat/message_item.dart b/lib/widgets/chat/message_item.dart index 2ab228e..3fcb4c4 100644 --- a/lib/widgets/chat/message_item.dart +++ b/lib/widgets/chat/message_item.dart @@ -4,6 +4,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/database/message.dart'; import 'package:island/models/chat.dart'; +import 'package:island/pods/call.dart'; import 'package:island/screens/chat/room.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_file_collection.dart'; @@ -429,14 +430,6 @@ class _MessageContentCall extends StatelessWidget { @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: [