diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index b822609..218eb44 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -355,6 +355,8 @@ "postTitle": "Title", "postDescription": "Description", "call": "Call", + "callLeave": "Leave", + "callEnd": "End this call", "done": "Done", "loginResetPasswordSent": "Password reset link sent, please check your email inbox.", "accountDeletion": "Delete Account", @@ -712,7 +714,7 @@ "translating": "Translating", "translated": "Translated", "reactionThumbUp": "Thumbs Up", - "reactionThumbDown": "Thumbs Down", + "reactionThumbDown": "Thumbs Down", "reactionJustOkay": "Just Okay", "reactionCry": "Cry", "reactionConfuse": "Confused", @@ -721,5 +723,8 @@ "reactionAngry": "Angry", "reactionParty": "Party", "reactionPray": "Pray", - "reactionHeart": "Heart" + "reactionHeart": "Heart", + "selectMicrophone": "Select Microphone", + "selectCamera": "Select Camera", + "switchedTo": "Switched to {}" } diff --git a/lib/models/chat.dart b/lib/models/chat.dart index bc714b0..5281a11 100644 --- a/lib/models/chat.dart +++ b/lib/models/chat.dart @@ -162,8 +162,6 @@ sealed class CallParticipant with _$CallParticipant { required String identity, required String name, required DateTime joinedAt, - required String? accountId, - required SnChatMember? profile, }) = _CallParticipant; factory CallParticipant.fromJson(Map json) => diff --git a/lib/models/chat.freezed.dart b/lib/models/chat.freezed.dart index 003baa5..df59006 100644 --- a/lib/models/chat.freezed.dart +++ b/lib/models/chat.freezed.dart @@ -2498,7 +2498,7 @@ as List, /// @nodoc mixin _$CallParticipant { - String get identity; String get name; DateTime get joinedAt; String? get accountId; SnChatMember? get profile; + String get identity; String get name; DateTime get joinedAt; /// Create a copy of CallParticipant /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -2511,16 +2511,16 @@ $CallParticipantCopyWith get copyWith => _$CallParticipantCopyW @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.profile, profile) || other.profile == profile)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,identity,name,joinedAt,accountId,profile); +int get hashCode => Object.hash(runtimeType,identity,name,joinedAt); @override String toString() { - return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt, accountId: $accountId, profile: $profile)'; + return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt)'; } @@ -2531,11 +2531,11 @@ abstract mixin class $CallParticipantCopyWith<$Res> { factory $CallParticipantCopyWith(CallParticipant value, $Res Function(CallParticipant) _then) = _$CallParticipantCopyWithImpl; @useResult $Res call({ - String identity, String name, DateTime joinedAt, String? accountId, SnChatMember? profile + String identity, String name, DateTime joinedAt }); -$SnChatMemberCopyWith<$Res>? get profile; + } /// @nodoc @@ -2548,29 +2548,15 @@ class _$CallParticipantCopyWithImpl<$Res> /// Create a copy of CallParticipant /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? identity = null,Object? name = null,Object? joinedAt = null,Object? accountId = freezed,Object? profile = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? identity = null,Object? name = null,Object? joinedAt = null,}) { return _then(_self.copyWith( identity: null == identity ? _self.identity : identity // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable -as DateTime,accountId: freezed == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable -as String?,profile: freezed == profile ? _self.profile : profile // ignore: cast_nullable_to_non_nullable -as SnChatMember?, +as DateTime, )); } -/// Create a copy of CallParticipant -/// with the given fields replaced by the non-null parameter values. -@override -@pragma('vm:prefer-inline') -$SnChatMemberCopyWith<$Res>? get profile { - if (_self.profile == null) { - return null; - } - return $SnChatMemberCopyWith<$Res>(_self.profile!, (value) { - return _then(_self.copyWith(profile: value)); - }); -} } @@ -2649,10 +2635,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String identity, String name, DateTime joinedAt, String? accountId, SnChatMember? profile)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String identity, String name, DateTime joinedAt)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _CallParticipant() when $default != null: -return $default(_that.identity,_that.name,_that.joinedAt,_that.accountId,_that.profile);case _: +return $default(_that.identity,_that.name,_that.joinedAt);case _: return orElse(); } @@ -2670,10 +2656,10 @@ return $default(_that.identity,_that.name,_that.joinedAt,_that.accountId,_that.p /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String identity, String name, DateTime joinedAt, String? accountId, SnChatMember? profile) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String identity, String name, DateTime joinedAt) $default,) {final _that = this; switch (_that) { case _CallParticipant(): -return $default(_that.identity,_that.name,_that.joinedAt,_that.accountId,_that.profile);} +return $default(_that.identity,_that.name,_that.joinedAt);} } /// A variant of `when` that fallback to returning `null` /// @@ -2687,10 +2673,10 @@ return $default(_that.identity,_that.name,_that.joinedAt,_that.accountId,_that.p /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String identity, String name, DateTime joinedAt, String? accountId, SnChatMember? profile)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String identity, String name, DateTime joinedAt)? $default,) {final _that = this; switch (_that) { case _CallParticipant() when $default != null: -return $default(_that.identity,_that.name,_that.joinedAt,_that.accountId,_that.profile);case _: +return $default(_that.identity,_that.name,_that.joinedAt);case _: return null; } @@ -2702,14 +2688,12 @@ return $default(_that.identity,_that.name,_that.joinedAt,_that.accountId,_that.p @JsonSerializable() class _CallParticipant implements CallParticipant { - const _CallParticipant({required this.identity, required this.name, required this.joinedAt, required this.accountId, required this.profile}); + const _CallParticipant({required this.identity, required this.name, required this.joinedAt}); factory _CallParticipant.fromJson(Map json) => _$CallParticipantFromJson(json); @override final String identity; @override final String name; @override final DateTime joinedAt; -@override final String? accountId; -@override final SnChatMember? profile; /// Create a copy of CallParticipant /// with the given fields replaced by the non-null parameter values. @@ -2724,16 +2708,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.profile, profile) || other.profile == profile)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallParticipant&&(identical(other.identity, identity) || other.identity == identity)&&(identical(other.name, name) || other.name == name)&&(identical(other.joinedAt, joinedAt) || other.joinedAt == joinedAt)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,identity,name,joinedAt,accountId,profile); +int get hashCode => Object.hash(runtimeType,identity,name,joinedAt); @override String toString() { - return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt, accountId: $accountId, profile: $profile)'; + return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt)'; } @@ -2744,11 +2728,11 @@ abstract mixin class _$CallParticipantCopyWith<$Res> implements $CallParticipant factory _$CallParticipantCopyWith(_CallParticipant value, $Res Function(_CallParticipant) _then) = __$CallParticipantCopyWithImpl; @override @useResult $Res call({ - String identity, String name, DateTime joinedAt, String? accountId, SnChatMember? profile + String identity, String name, DateTime joinedAt }); -@override $SnChatMemberCopyWith<$Res>? get profile; + } /// @nodoc @@ -2761,30 +2745,16 @@ class __$CallParticipantCopyWithImpl<$Res> /// Create a copy of CallParticipant /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? identity = null,Object? name = null,Object? joinedAt = null,Object? accountId = freezed,Object? profile = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? identity = null,Object? name = null,Object? joinedAt = null,}) { return _then(_CallParticipant( identity: null == identity ? _self.identity : identity // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,joinedAt: null == joinedAt ? _self.joinedAt : joinedAt // ignore: cast_nullable_to_non_nullable -as DateTime,accountId: freezed == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable -as String?,profile: freezed == profile ? _self.profile : profile // ignore: cast_nullable_to_non_nullable -as SnChatMember?, +as DateTime, )); } -/// Create a copy of CallParticipant -/// with the given fields replaced by the non-null parameter values. -@override -@pragma('vm:prefer-inline') -$SnChatMemberCopyWith<$Res>? get profile { - if (_self.profile == null) { - return null; - } - return $SnChatMemberCopyWith<$Res>(_self.profile!, (value) { - return _then(_self.copyWith(profile: value)); - }); -} } diff --git a/lib/models/chat.g.dart b/lib/models/chat.g.dart index 1dca414..76281e2 100644 --- a/lib/models/chat.g.dart +++ b/lib/models/chat.g.dart @@ -285,11 +285,6 @@ _CallParticipant _$CallParticipantFromJson(Map json) => identity: json['identity'] as String, name: json['name'] as String, joinedAt: DateTime.parse(json['joined_at'] as String), - accountId: json['account_id'] as String?, - profile: - json['profile'] == null - ? null - : SnChatMember.fromJson(json['profile'] as Map), ); Map _$CallParticipantToJson(_CallParticipant instance) => @@ -297,8 +292,6 @@ Map _$CallParticipantToJson(_CallParticipant instance) => 'identity': instance.identity, 'name': instance.name, 'joined_at': instance.joinedAt.toIso8601String(), - 'account_id': instance.accountId, - 'profile': instance.profile?.toJson(), }; _SnRealtimeCall _$SnRealtimeCallFromJson(Map json) => diff --git a/lib/pods/call.dart b/lib/pods/call.dart index 9acae66..c561c65 100644 --- a/lib/pods/call.dart +++ b/lib/pods/call.dart @@ -1,13 +1,10 @@ -import 'package:island/pods/userinfo.dart'; -import 'package:island/screens/chat/chat.dart'; +import 'dart:async'; 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'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:island/pods/network.dart'; import 'package:island/models/chat.dart'; -import 'package:island/pods/websocket.dart'; part 'call.g.dart'; part 'call.freezed.dart'; @@ -42,7 +39,8 @@ sealed class CallParticipantLive with _$CallParticipantLive { }) = _CallParticipantLive; bool get isSpeaking => remoteParticipant.isSpeaking; - bool get isMuted => remoteParticipant.isMuted; + bool get isMuted => + remoteParticipant.isMuted || !remoteParticipant.isMicrophoneEnabled(); bool get isScreenSharing => remoteParticipant.isScreenShareEnabled(); bool get isScreenSharingWithAudio => remoteParticipant.isScreenShareAudioEnabled(); @@ -57,7 +55,6 @@ class CallNotifier extends _$CallNotifier { LocalParticipant? _localParticipant; List _participants = []; final Map _participantInfoByIdentity = {}; - StreamSubscription? _wsSubscription; EventsListener? _roomListener; List get participants => @@ -71,7 +68,6 @@ class CallNotifier extends _$CallNotifier { @override CallState build() { // Subscribe to websocket updates - _subscribeToParticipantsUpdate(); return const CallState( isConnected: false, isMicrophoneEnabled: true, @@ -80,27 +76,6 @@ class CallNotifier extends _$CallNotifier { ); } - void _subscribeToParticipantsUpdate() { - // Only subscribe once - if (_wsSubscription != null) return; - final ws = ref.read(websocketProvider); - _wsSubscription = ws.dataStream.listen((packet) { - if (packet.type == 'call.participants.update' && packet.data != null) { - final participantsData = packet.data!["participants"]; - if (participantsData is List) { - final parsed = - participantsData - .map( - (e) => - CallParticipant.fromJson(Map.from(e)), - ) - .toList(); - _updateLiveParticipants(parsed); - } - } - }); - } - void _initRoomListeners() { if (_room == null) return; _roomListener?.dispose(); @@ -143,8 +118,6 @@ class CallNotifier extends _$CallNotifier { identity: remote.identity, name: remote.identity, joinedAt: DateTime.now(), - accountId: null, - profile: null, ); return CallParticipantLive( participant: match, @@ -169,16 +142,12 @@ class CallNotifier extends _$CallNotifier { if (idx != -1) return participants[idx]; } - final userInfo = ref.read(userInfoProvider); - final roomIdentity = ref.read(chatroomIdentityProvider(_roomId)); // Otherwise, use info from the identity map or fallback to minimal return _participantInfoByIdentity[_localParticipant!.identity] ?? CallParticipant( identity: _localParticipant!.identity, name: _localParticipant!.identity, joinedAt: DateTime.now(), - accountId: userInfo.value?.id, - profile: roomIdentity.value, ); } @@ -205,6 +174,7 @@ class CallNotifier extends _$CallNotifier { remoteParticipant: _localParticipant!, ), ); + state = state.copyWith(); } // Add remote participants _participants.addAll( @@ -264,7 +234,8 @@ class CallNotifier extends _$CallNotifier { duration: Duration( milliseconds: (DateTime.now().millisecondsSinceEpoch - - (ongoingCall?.createdAt.millisecondsSinceEpoch ?? 0)), + (ongoingCall?.createdAt.millisecondsSinceEpoch ?? + DateTime.now().millisecondsSinceEpoch)), ), ); }); @@ -318,6 +289,7 @@ class CallNotifier extends _$CallNotifier { stopOnMute: autostop, ); } + state = state.copyWith(); } } @@ -326,6 +298,7 @@ class CallNotifier extends _$CallNotifier { final target = !_localParticipant!.isCameraEnabled(); state = state.copyWith(isCameraEnabled: target); await _localParticipant!.setCameraEnabled(target); + state = state.copyWith(); } } @@ -334,6 +307,7 @@ class CallNotifier extends _$CallNotifier { final target = !_localParticipant!.isScreenShareEnabled(); state = state.copyWith(isScreenSharing: target); await _localParticipant!.setScreenShareEnabled(target); + state = state.copyWith(); } } @@ -350,7 +324,13 @@ class CallNotifier extends _$CallNotifier { } void dispose() { - _wsSubscription?.cancel(); + state = state.copyWith( + error: null, + isConnected: false, + isMicrophoneEnabled: false, + isCameraEnabled: false, + isScreenSharing: false, + ); _roomListener?.dispose(); _room?.removeListener(_onRoomChange); _room?.dispose(); diff --git a/lib/pods/call.g.dart b/lib/pods/call.g.dart index 99e97bc..079a111 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'107174cd6cfab6bfafe44f8c4a72a67bcb93217b'; +String _$callNotifierHash() => r'e4312feadb5b34f186b5349a7ee8b671b842dafc'; /// See also [CallNotifier]. @ProviderFor(CallNotifier) diff --git a/lib/screens/chat/call.dart b/lib/screens/chat/call.dart index cfa3b3b..d67165c 100644 --- a/lib/screens/chat/call.dart +++ b/lib/screens/chat/call.dart @@ -4,7 +4,6 @@ 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/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'; @@ -21,14 +20,20 @@ class CallScreen extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final ongoingCall = ref.watch(ongoingCallProvider(roomId)); final callState = ref.watch(callNotifierProvider); - final callNotifier = ref.read(callNotifierProvider.notifier); + final callNotifier = ref.watch(callNotifierProvider.notifier); useEffect(() { callNotifier.joinRoom(roomId); return null; }, []); - final viewMode = useState('grid'); + final allAudioOnly = callNotifier.participants.every( + (p) => + !(p.hasVideo && + p.remoteParticipant.trackPublications.values.any( + (pub) => pub.track != null && pub.kind == TrackType.VIDEO, + )), + ); return AppScaffold( noBackground: false, @@ -50,39 +55,50 @@ class CallScreen extends HookConsumerWidget { ], ), actions: [ - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - icon: Icon(Symbols.grid_view), - tooltip: 'Grid View', - onPressed: () => viewMode.value = 'grid', - color: - viewMode.value == 'grid' - ? Theme.of(context).colorScheme.primary - : null, + if (!allAudioOnly) + SingleChildScrollView( + child: Row( + spacing: 4, + children: [ + for (final live in callNotifier.participants) + SpeakingRippleAvatar( + isSpeaking: live.isSpeaking, + isMuted: live.isMuted, + audioLevel: live.remoteParticipant.audioLevel, + identity: live.participant.identity, + size: 30, + ), + const Gap(8), + ], ), - IconButton( - icon: Icon(Symbols.view_agenda), - tooltip: 'Stage View', - onPressed: () => viewMode.value = 'stage', - color: - viewMode.value == 'stage' - ? Theme.of(context).colorScheme.primary - : null, - ), - ], - ), - const Gap(8), + ), ], ), body: callState.error != null ? Center( - child: Text( - callState.error!, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.red), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Column( + children: [ + const Icon(Symbols.error_outline, size: 48), + const Gap(4), + Text( + callState.error!, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF757575)), + ), + const Gap(8), + TextButton( + onPressed: () { + callNotifier.disconnect(); + callNotifier.dispose(); + callNotifier.joinRoom(roomId); + }, + child: Text('retry').tr(), + ), + ], + ), ), ) : Column( @@ -100,17 +116,8 @@ class CallScreen extends HookConsumerWidget { child: Text('No participants in call'), ); } + final participants = callNotifier.participants; - final allAudioOnly = participants.every( - (p) => - !(p.hasVideo && - p.remoteParticipant.trackPublications.values - .any( - (pub) => - pub.track != null && - pub.kind == TrackType.VIDEO, - )), - ); if (allAudioOnly) { // Audio-only: show avatars in a compact row return Center( @@ -123,138 +130,45 @@ class CallScreen extends HookConsumerWidget { runSpacing: 8, children: [ for (final live in participants) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - ), - child: SpeakingRippleAvatar( - isSpeaking: live.isSpeaking, - audioLevel: - live.remoteParticipant.audioLevel, - pictureId: - live - .participant - .profile - ?.account - .profile - .picture - ?.id, - size: 72, - ), - ), + SpeakingRippleAvatar( + isSpeaking: live.isSpeaking, + isMuted: live.isMuted, + audioLevel: + live.remoteParticipant.audioLevel, + identity: live.participant.identity, + size: 72, + ).padding(horizontal: 4), ], ), ), ); } - if (viewMode.value == 'stage') { - // Stage view: show main speaker(s) large, others in row - final mainSpeakers = - participants - .where( - (p) => p - .remoteParticipant - .trackPublications - .values - .any( - (pub) => - pub.track != null && - pub.kind == TrackType.VIDEO, - ), - ) - .toList(); - if (mainSpeakers.isEmpty && participants.isNotEmpty) { - mainSpeakers.add(participants.first); - } - final others = - participants - .where((p) => !mainSpeakers.contains(p)) - .toList(); - return Column( - children: [ - Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (final speaker in mainSpeakers) - Expanded( - child: - AspectRatio( - aspectRatio: 16 / 9, - child: Card( - margin: EdgeInsets.zero, - child: ClipRRect( - borderRadius: - BorderRadius.circular(8), - child: Column( - children: [ - CallParticipantTile( - live: speaker, - ), - ], - ), - ), - ), - ).center(), + + // Stage view: show main speaker(s) large, others in row + final mainSpeakers = + participants + .where( + (p) => p + .remoteParticipant + .trackPublications + .values + .any( + (pub) => + pub.track != null && + pub.kind == TrackType.VIDEO, ), - ], - ).padding(horizontal: 12), - ), - if (others.isNotEmpty) - SizedBox( - height: 100, - child: ListView( - scrollDirection: Axis.horizontal, - children: [ - for (final other in others) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - ), - child: CallParticipantTile( - live: other, - ), - ), - ], - ), - ), - ], - ); + ) + .toList(); + if (mainSpeakers.isEmpty && participants.isNotEmpty) { + mainSpeakers.add(participants.first); } - // Default: grid view - return GridView.builder( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: - isWidestScreen(context) - ? 4 - : isWiderScreen(context) - ? 3 - : 2, - childAspectRatio: 16 / 9, - crossAxisSpacing: 8, - mainAxisSpacing: 8, + return Column( + children: [ + for (final speaker in mainSpeakers) + Expanded( + child: CallParticipantTile(live: speaker), ), - itemCount: participants.length, - itemBuilder: (context, idx) { - final live = participants[idx]; - return AspectRatio( - aspectRatio: 16 / 9, - child: Card( - margin: EdgeInsets.zero, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Column( - children: [CallParticipantTile(live: live)], - ), - ), - ), - ).center(); - }, + ], ); }, ), diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index 21ea91b..2291cd5 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -21,7 +21,6 @@ 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/content/sheet.dart'; import 'package:island/widgets/realms/selection_dropdown.dart'; @@ -346,91 +345,79 @@ class ChatListScreen extends HookConsumerWidget { child: const Icon(Symbols.add), ), floatingActionButtonLocation: TabbedFabLocation(context), - body: Stack( + body: Column( children: [ - Column( - children: [ - Consumer( - builder: (context, ref, _) { - final summaryState = ref.watch(chatSummaryProvider); - return summaryState.maybeWhen( - loading: - () => const LinearProgressIndicator( - minHeight: 2, - borderRadius: BorderRadius.zero, - ), - orElse: () => const SizedBox.shrink(), - ); - }, - ), - Expanded( - child: chats.when( - data: - (items) => RefreshIndicator( - onRefresh: - () => Future.sync(() { - ref.invalidate(chatroomsJoinedProvider); - }), - child: ListView.builder( - padding: getTabbedPadding( - context, - bottom: callState.isConnected ? 96 : null, - ), - 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: () { - context.pushNamed( - 'chatRoom', - pathParameters: {'id': item.id}, - ); - }, + Consumer( + builder: (context, ref, _) { + final summaryState = ref.watch(chatSummaryProvider); + return summaryState.maybeWhen( + loading: + () => const LinearProgressIndicator( + minHeight: 2, + borderRadius: BorderRadius.zero, + ), + orElse: () => const SizedBox.shrink(), + ); + }, + ), + Expanded( + child: chats.when( + data: + (items) => RefreshIndicator( + onRefresh: + () => Future.sync(() { + ref.invalidate(chatroomsJoinedProvider); + }), + child: ListView.builder( + padding: getTabbedPadding( + context, + bottom: callState.isConnected ? 96 : null, + ), + 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: () { + context.pushNamed( + 'chatRoom', + pathParameters: {'id': item.id}, ); }, - ), - ), - loading: - () => const Center(child: CircularProgressIndicator()), - error: - (error, stack) => ResponseErrorWidget( - error: error, - onRetry: () { - ref.invalidate(chatroomsJoinedProvider); - }, - ), - ), - ), - ], - ), - Positioned( - left: 0, - right: 0, - bottom: getTabbedPadding(context).bottom + 8, - child: const CallOverlayBar().padding(horizontal: 16, vertical: 12), + ); + }, + ), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, stack) => ResponseErrorWidget( + error: error, + onRetry: () { + ref.invalidate(chatroomsJoinedProvider); + }, + ), + ), ), ], ), diff --git a/lib/widgets/account/account_pfc.dart b/lib/widgets/account/account_pfc.dart index e2919ed..307388b 100644 --- a/lib/widgets/account/account_pfc.dart +++ b/lib/widgets/account/account_pfc.dart @@ -167,6 +167,7 @@ Future showAccountProfileCard( offset: offset ?? Offset.zero, context: context, builder: (context) => AccountProfileCard(uname: uname), + alignment: Alignment.center, dimBackground: true, ); } diff --git a/lib/widgets/chat/call_overlay.dart b/lib/widgets/chat/call_overlay.dart index 23312e1..8d9f319 100644 --- a/lib/widgets/chat/call_overlay.dart +++ b/lib/widgets/chat/call_overlay.dart @@ -4,9 +4,11 @@ import 'package:go_router/go_router.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/call.dart'; +import 'package:island/pods/network.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:island/widgets/content/sheet.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:livekit_client/livekit_client.dart'; @@ -55,7 +57,49 @@ class CallControlsBar extends HookConsumerWidget { const Gap(16), _buildCircularButton( icon: Icons.call_end, - onPressed: () => callNotifier.disconnect(), + onPressed: + () => showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + builder: + (context) => ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Symbols.logout, fill: 1), + title: Text('callLeave').tr(), + onTap: () { + callNotifier.disconnect(); + Navigator.of(context).pop(); + }, + ), + ListTile( + leading: const Icon(Symbols.call_end, fill: 1), + iconColor: Colors.red, + title: Text('callEnd').tr(), + onTap: () async { + callNotifier.disconnect(); + final apiClient = ref.watch(apiClientProvider); + await apiClient.delete( + '/sphere/chat/realtime/${callNotifier.roomId}', + ); + callNotifier.dispose(); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + ), + Gap(MediaQuery.of(context).padding.bottom), + ], + ), + ), + ), backgroundColor: const Color(0xFFE53E3E), iconColor: Colors.white, ), @@ -279,7 +323,7 @@ class CallOverlayBar extends HookConsumerWidget { child: Card( margin: EdgeInsets.zero, child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Row( @@ -295,16 +339,10 @@ class CallOverlayBar extends HookConsumerWidget { child: SpeakingRippleAvatar( isSpeaking: lastSpeaker.isSpeaking, + isMuted: lastSpeaker.isMuted, audioLevel: lastSpeaker.remoteParticipant.audioLevel, - pictureId: - lastSpeaker - .participant - .profile - ?.account - .profile - .picture - ?.id, + identity: lastSpeaker.participant.identity, size: 36, ).center(), ); @@ -314,10 +352,7 @@ class CallOverlayBar extends HookConsumerWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - lastSpeaker.participant.profile?.account.nick ?? - 'unknown'.tr(), - ).bold(), + Text('@${lastSpeaker.participant.identity}').bold(), Text( formatDuration(callState.duration), style: Theme.of(context).textTheme.bodySmall, @@ -360,7 +395,10 @@ class CallOverlayBar extends HookConsumerWidget { ).padding(all: 16), ), onTap: () { - context.pushNamed('chatCall', pathParameters: {'id': callNotifier.roomId!}); + context.pushNamed( + 'chatCall', + pathParameters: {'id': callNotifier.roomId!}, + ); }, ); } diff --git a/lib/widgets/chat/call_participant_tile.dart b/lib/widgets/chat/call_participant_tile.dart index 3d1bffd..33fdca7 100644 --- a/lib/widgets/chat/call_participant_tile.dart +++ b/lib/widgets/chat/call_participant_tile.dart @@ -1,69 +1,118 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/call.dart'; +import 'package:island/screens/account/profile.dart'; +import 'package:island/widgets/account/account_pfc.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:livekit_client/livekit_client.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:styled_widget/styled_widget.dart'; -class SpeakingRippleAvatar extends StatelessWidget { +class SpeakingRippleAvatar extends HookConsumerWidget { final bool isSpeaking; + final bool isMuted; final double audioLevel; - final String? pictureId; + final String identity; final double size; const SpeakingRippleAvatar({ super.key, required this.isSpeaking, + required this.isMuted, required this.audioLevel, - required this.pictureId, + required this.identity, this.size = 96, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final account = ref.watch(accountProvider(identity)); + final avatarRadius = size / 2; final clampedLevel = audioLevel.clamp(0.0, 1.0); final rippleRadius = avatarRadius + clampedLevel * (size * 0.333); - return TweenAnimationBuilder( - tween: Tween( - begin: avatarRadius, - end: isSpeaking ? rippleRadius : avatarRadius, - ), - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - builder: (context, animatedRadius, child) { - return Stack( - alignment: Alignment.center, - children: [ - if (isSpeaking) + return SizedBox( + width: size + 8, + height: size + 8, + child: TweenAnimationBuilder( + tween: Tween( + begin: avatarRadius, + end: isSpeaking ? rippleRadius : avatarRadius, + ), + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + builder: (context, animatedRadius, child) { + return Stack( + alignment: Alignment.center, + children: [ + if (isSpeaking) + Container( + width: animatedRadius * 2, + height: animatedRadius * 2, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.green.withOpacity(0.75 + 0.25 * clampedLevel), + ), + ), Container( - width: animatedRadius * 2, - height: animatedRadius * 2, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.green.withOpacity(0.75 + 0.25 * clampedLevel), + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration(shape: BoxShape.circle), + child: account.when( + data: + (value) => AccountPfcGestureDetector( + uname: identity, + child: ProfilePictureWidget( + file: value.profile.picture, + radius: size / 2, + ), + ), + error: + (_, _) => CircleAvatar( + radius: size / 2, + child: const Icon(Symbols.person_remove), + ), + loading: + () => CircleAvatar( + radius: size / 2, + child: CircularProgressIndicator(), + ), ), ), - Container( - width: size, - height: size, - alignment: Alignment.center, - decoration: BoxDecoration(shape: BoxShape.circle), - child: ProfilePictureWidget(fileId: pictureId, radius: size / 2), - ), - ], - ); - }, + if (isMuted) + Positioned( + bottom: 4, + right: 4, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + child: const Icon( + Symbols.mic_off, + size: 14, + fill: 1, + ).padding(left: 1.5, top: 1.5), + ), + ), + ], + ); + }, + ), ); } } -class CallParticipantTile extends StatelessWidget { +class CallParticipantTile extends HookConsumerWidget { final CallParticipantLive live; const CallParticipantTile({super.key, required this.live}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final hasVideo = live.hasVideo && live.remoteParticipant.trackPublications.values @@ -75,18 +124,15 @@ class CallParticipantTile extends StatelessWidget { return Stack( fit: StackFit.loose, children: [ - Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: AspectRatio( - aspectRatio: 16 / 9, - child: VideoTrackRenderer( - live.remoteParticipant.trackPublications.values - .where((track) => track.kind == TrackType.VIDEO) - .first - .track - as VideoTrack, - renderMode: VideoRenderMode.platformView, - ), + AspectRatio( + aspectRatio: 16 / 9, + child: VideoTrackRenderer( + live.remoteParticipant.trackPublications.values + .where((track) => track.kind == TrackType.VIDEO) + .first + .track + as VideoTrack, + renderMode: VideoRenderMode.platformView, ), ), Positioned( @@ -94,10 +140,20 @@ class CallParticipantTile extends StatelessWidget { right: 8, bottom: 8, child: Text( - live.participant.profile?.account.nick ?? - '${'unknown'.tr()}\'s video', + '@${live.participant.name}', textAlign: TextAlign.center, - style: const TextStyle(fontSize: 14, color: Colors.white), + style: const TextStyle( + fontSize: 14, + color: Colors.white, + shadows: [ + BoxShadow( + color: Colors.black54, + offset: Offset(1, 1), + spreadRadius: 8, + blurRadius: 8, + ), + ], + ), ), ), ], @@ -105,8 +161,9 @@ class CallParticipantTile extends StatelessWidget { } else { return SpeakingRippleAvatar( isSpeaking: live.isSpeaking, + isMuted: live.isMuted, audioLevel: audioLevel, - pictureId: live.participant.profile?.account.profile.picture?.id, + identity: live.participant.identity, size: 84, ); } diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..ff65dfc --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,341 @@ +PODS: + - bitsdojo_window_macos (0.0.1): + - FlutterMacOS + - connectivity_plus (0.0.1): + - FlutterMacOS + - croppy (0.0.1): + - FlutterMacOS + - device_info_plus (0.0.1): + - FlutterMacOS + - file_picker (0.0.1): + - FlutterMacOS + - file_selector_macos (0.0.1): + - FlutterMacOS + - Firebase/CoreOnly (12.0.0): + - FirebaseCore (~> 12.0.0) + - Firebase/Messaging (12.0.0): + - Firebase/CoreOnly + - FirebaseMessaging (~> 12.0.0) + - firebase_core (4.0.0): + - Firebase/CoreOnly (~> 12.0.0) + - FlutterMacOS + - firebase_messaging (16.0.0): + - Firebase/CoreOnly (~> 12.0.0) + - Firebase/Messaging (~> 12.0.0) + - firebase_core + - FlutterMacOS + - FirebaseCore (12.0.0): + - FirebaseCoreInternal (~> 12.0.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (12.0.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (12.0.0): + - FirebaseCore (~> 12.0.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - PromisesObjC (~> 2.4) + - FirebaseMessaging (12.0.0): + - FirebaseCore (~> 12.0.0) + - FirebaseInstallations (~> 12.0.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - flutter_inappwebview_macos (0.0.1): + - FlutterMacOS + - OrderedSet (~> 6.0.3) + - flutter_platform_alert (0.0.1): + - FlutterMacOS + - flutter_secure_storage_macos (6.1.3): + - FlutterMacOS + - flutter_timezone (0.1.0): + - FlutterMacOS + - flutter_udid (0.0.1): + - FlutterMacOS + - SAMKeychain + - flutter_webrtc (1.0.0): + - FlutterMacOS + - WebRTC-SDK (= 137.7151.02) + - FlutterMacOS (1.0.0) + - gal (1.0.0): + - Flutter + - FlutterMacOS + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - irondash_engine_context (0.0.1): + - FlutterMacOS + - livekit_client (2.5.0): + - flutter_webrtc + - FlutterMacOS + - WebRTC-SDK (= 137.7151.02) + - local_auth_darwin (0.0.1): + - Flutter + - FlutterMacOS + - media_kit_libs_macos_video (1.0.4): + - FlutterMacOS + - media_kit_video (0.0.1): + - FlutterMacOS + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - OrderedSet (6.0.3) + - package_info_plus (0.0.1): + - FlutterMacOS + - pasteboard (0.0.1): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - PromisesObjC (2.4.0) + - record_macos (1.0.0): + - FlutterMacOS + - SAMKeychain (1.5.3) + - share_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sign_in_with_apple (0.0.1): + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - sqlite3 (3.50.3): + - sqlite3/common (= 3.50.3) + - sqlite3/common (3.50.3) + - sqlite3/dbstatvtab (3.50.3): + - sqlite3/common + - sqlite3/fts5 (3.50.3): + - sqlite3/common + - sqlite3/math (3.50.3): + - sqlite3/common + - sqlite3/perf-threadsafe (3.50.3): + - sqlite3/common + - sqlite3/rtree (3.50.3): + - sqlite3/common + - sqlite3/session (3.50.3): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (~> 3.50.3) + - sqlite3/dbstatvtab + - sqlite3/fts5 + - sqlite3/math + - sqlite3/perf-threadsafe + - sqlite3/rtree + - sqlite3/session + - super_native_extensions (0.0.1): + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - volume_controller (0.0.1): + - FlutterMacOS + - wakelock_plus (0.0.1): + - FlutterMacOS + - WebRTC-SDK (137.7151.02) + +DEPENDENCIES: + - bitsdojo_window_macos (from `Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos`) + - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) + - croppy (from `Flutter/ephemeral/.symlinks/plugins/croppy/macos`) + - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) + - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) + - firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`) + - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) + - flutter_platform_alert (from `Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos`) + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) + - flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`) + - flutter_udid (from `Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos`) + - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`) + - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`) + - livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`) + - local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`) + - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) + - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - pasteboard (from `Flutter/ephemeral/.symlinks/plugins/pasteboard/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sign_in_with_apple (from `Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) + - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - volume_controller (from `Flutter/ephemeral/.symlinks/plugins/volume_controller/macos`) + - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) + +SPEC REPOS: + trunk: + - Firebase + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - FirebaseMessaging + - GoogleDataTransport + - GoogleUtilities + - nanopb + - OrderedSet + - PromisesObjC + - SAMKeychain + - sqlite3 + - WebRTC-SDK + +EXTERNAL SOURCES: + bitsdojo_window_macos: + :path: Flutter/ephemeral/.symlinks/plugins/bitsdojo_window_macos/macos + connectivity_plus: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos + croppy: + :path: Flutter/ephemeral/.symlinks/plugins/croppy/macos + device_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos + file_picker: + :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + firebase_core: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos + firebase_messaging: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos + flutter_inappwebview_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos + flutter_platform_alert: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_platform_alert/macos + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos + flutter_timezone: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos + flutter_udid: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos + flutter_webrtc: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos + FlutterMacOS: + :path: Flutter/ephemeral + gal: + :path: Flutter/ephemeral/.symlinks/plugins/gal/darwin + irondash_engine_context: + :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos + livekit_client: + :path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos + local_auth_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin + media_kit_libs_macos_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos + media_kit_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + pasteboard: + :path: Flutter/ephemeral/.symlinks/plugins/pasteboard/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + record_macos: + :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sign_in_with_apple: + :path: Flutter/ephemeral/.symlinks/plugins/sign_in_with_apple/macos + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + sqlite3_flutter_libs: + :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin + super_native_extensions: + :path: Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + volume_controller: + :path: Flutter/ephemeral/.symlinks/plugins/volume_controller/macos + wakelock_plus: + :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos + +SPEC CHECKSUMS: + bitsdojo_window_macos: 7959fb0ca65a3ccda30095c181ecb856fae48ea9 + connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e + croppy: d9bfc8c02f3cd1851f669a421df298a474b78f43 + device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 + file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a + file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 + Firebase: 800d487043c0557d9faed71477a38d9aafb08a41 + firebase_core: eeea10f64026b68cd0bc3dee079ab4717e22909e + firebase_messaging: 5eefcd5bde556bfacdd9968e11c52f39032dfbe5 + FirebaseCore: 055f4ab117d5964158c833f3d5e7ec6d91648d4a + FirebaseCoreInternal: dedc28e569a4be85f38f3d6af1070a2e12018d55 + FirebaseInstallations: d4c7c958f99c8860d7fcece786314ae790e2f988 + FirebaseMessaging: af49f8d7c0a3d2a017d9302c80946f45a7777dde + flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d + flutter_platform_alert: 8fa7a7c21f95b26d08b4a3891936ca27e375f284 + flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 + flutter_timezone: d59eea86178cbd7943cd2431cc2eaa9850f935d8 + flutter_udid: d26e455e8c06174e6aff476e147defc6cae38495 + flutter_webrtc: 0d70bd8782c19bde286dc52f766eebbea26de201 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + gal: baecd024ebfd13c441269ca7404792a7152fde89 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba + livekit_client: 0b0515e03858b86a7c14cc7fd6f772331f6ee84c + local_auth_darwin: d2e8c53ef0c4f43c646462e3415432c4dab3ae19 + media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 + media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 + package_info_plus: f0052d280d17aa382b932f399edf32507174e870 + pasteboard: 278d8100149f940fb795d6b3a74f0720c890ecb7 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + record_macos: 295d70bd5fb47145df78df7b80e6697cd18403c0 + SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c + share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 + sign_in_with_apple: 6673c03c9e3643f6c8d33601943fbfa9ae99f94e + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + sqlite3: 83105acd294c9137c026e2da1931c30b4588ab81 + sqlite3_flutter_libs: 616267f2fca40e9c6af8c5d82324e05667040b6e + super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 + url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 + volume_controller: 5c068e6d085c80dadd33fc2c918d2114b775b3dd + wakelock_plus: 21ddc249ac4b8d018838dbdabd65c5976c308497 + WebRTC-SDK: d20de357dcbf7c9696b124b39f3ff62125107e4b + +PODFILE CHECKSUM: 346bfb2deb41d4a6ebd6f6799f92188bde2d246f + +COCOAPODS: 1.16.2