✨ Better chat overlay
This commit is contained in:
parent
fd587270da
commit
688f035f85
@ -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"
|
||||
}
|
||||
|
@ -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<void> 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();
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ T _$identity<T>(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<CallState> get copyWith => _$CallStateCopyWithImpl<CallState>
|
||||
|
||||
@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?,
|
||||
));
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ part of 'call.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$callNotifierHash() => r'5512070f943d98e999d97549c73e4d5f6e7b3ddd';
|
||||
String _$callNotifierHash() => r'0ae2e8ba21f145c80e1e65cf4fd15a7add17da78';
|
||||
|
||||
/// See also [CallNotifier].
|
||||
@ProviderFor(CallNotifier)
|
||||
|
@ -52,6 +52,7 @@ class WebSocketService {
|
||||
Future<void> connect(Ref ref) async {
|
||||
_ref = ref;
|
||||
|
||||
_statusStreamController.sink.add(WebSocketState.connecting());
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final atk = await getFreshAtk(
|
||||
ref.watch(tokenPairProvider),
|
||||
|
@ -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),
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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<String>('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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -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);
|
||||
ref.read(chatSummaryProvider.future).then((summary) {
|
||||
if ((summary[room.id]?.unreadCount ?? 0) > 0) {
|
||||
await ref
|
||||
.read(chatSummaryProvider.notifier)
|
||||
.clearUnreadCount(room.id);
|
||||
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,7 +337,9 @@ class ChatListScreen extends HookConsumerWidget {
|
||||
},
|
||||
child: const Icon(Symbols.add),
|
||||
),
|
||||
body: Column(
|
||||
body: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
@ -354,7 +359,10 @@ class ChatListScreen extends HookConsumerWidget {
|
||||
ref.invalidate(chatroomsJoinedProvider);
|
||||
}),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
padding:
|
||||
callState.isConnected
|
||||
? EdgeInsets.only(bottom: 96)
|
||||
: EdgeInsets.zero,
|
||||
itemCount:
|
||||
items
|
||||
.where(
|
||||
@ -362,7 +370,8 @@ class ChatListScreen extends HookConsumerWidget {
|
||||
selectedTab.value == 0 ||
|
||||
(selectedTab.value == 1 &&
|
||||
item.type == 1) ||
|
||||
(selectedTab.value == 2 && item.type != 1),
|
||||
(selectedTab.value == 2 &&
|
||||
item.type != 1),
|
||||
)
|
||||
.length,
|
||||
itemBuilder: (context, index) {
|
||||
@ -388,14 +397,17 @@ class ChatListScreen extends HookConsumerWidget {
|
||||
ChatRoomRoute(id: item.id),
|
||||
);
|
||||
} else {
|
||||
context.router.push(ChatRoomRoute(id: item.id));
|
||||
context.router.push(
|
||||
ChatRoomRoute(id: item.id),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading:
|
||||
() => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, stack) => ResponseErrorWidget(
|
||||
error: error,
|
||||
@ -407,6 +419,14 @@ class ChatListScreen extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: const CallOverlayBar().padding(horizontal: 16, vertical: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,7 +530,9 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
body: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: messages.when(
|
||||
@ -536,7 +543,8 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
: SuperListView.builder(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
controller: scrollController,
|
||||
reverse: true, // Show newest messages at the bottom
|
||||
reverse:
|
||||
true, // Show newest messages at the bottom
|
||||
itemCount: messageList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
@ -546,7 +554,8 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
: null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.senderId !=
|
||||
message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
@ -594,7 +603,8 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
}
|
||||
},
|
||||
progress:
|
||||
attachmentProgress.value[message.id],
|
||||
attachmentProgress.value[message
|
||||
.id],
|
||||
showAvatar: isLastInGroup,
|
||||
),
|
||||
loading:
|
||||
@ -609,7 +619,8 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading:
|
||||
() => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, _) => ResponseErrorWidget(
|
||||
error: error,
|
||||
@ -674,6 +685,14 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: CallOverlayBar().padding(horizontal: 8, top: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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<File?>((ref) async {
|
||||
if (kIsWeb) return null;
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
|
@ -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(
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: () {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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: [
|
||||
|
Loading…
x
Reference in New Issue
Block a user