diff --git a/ios/Podfile b/ios/Podfile
index c4cb422..4929876 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -41,5 +41,9 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
+ target.build_configurations.each do |config|
+ # Workaround for https://github.com/flutter/flutter/issues/64502
+ config.build_settings['ONLY_ACTIVE_ARCH'] = 'YES' # <= this line
+ end
end
end
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 137c6de..35617c5 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -26,6 +26,16 @@
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
+ NSCalendarsUsageDescription
+ Grant access to Calander help us to shows Solar Calander with your own events.
+ NSCameraUsageDescription
+ Grant access to Camera will allow Solian take photo or video for your post.
+ NSMicrophoneUsageDescription
+ Grant access to Microphone will allow Solian record audio for your post.
+ NSPhotoLibraryAddUsageDescription
+ Grant access to Photo Library will allow Solian download photo to album for you.
+ NSPhotoLibraryUsageDescription
+ Grant access to Photo Library will allow Solian upload photo or video for your post.
UIApplicationSupportsIndirectInputEvents
UIBackgroundModes
@@ -33,11 +43,14 @@
fetch
audio
remote-notification
+ voip
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
Main
+ UIStatusBarHidden
+
UISupportedInterfaceOrientations
UIInterfaceOrientationPortrait
@@ -51,17 +64,5 @@
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
- NSCalendarsUsageDescription
- Grant access to Calander help us to shows Solar Calander with your own events.
- NSCameraUsageDescription
- Grant access to Camera will allow Solian take photo or video for your post.
- NSMicrophoneUsageDescription
- Grant access to Microphone will allow Solian record audio for your post.
- NSPhotoLibraryAddUsageDescription
- Grant access to Photo Library will allow Solian download photo to album for you.
- NSPhotoLibraryUsageDescription
- Grant access to Photo Library will allow Solian upload photo or video for your post.
- UIStatusBarHidden
-
diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart
index 836f56d..551f2c8 100644
--- a/lib/firebase_options.dart
+++ b/lib/firebase_options.dart
@@ -85,4 +85,5 @@ class DefaultFirebaseOptions {
storageBucket: 'solian-0x001.firebasestorage.app',
measurementId: 'G-JD1YEG9D6F',
);
-}
+
+}
\ No newline at end of file
diff --git a/lib/main.dart b/lib/main.dart
index d4353d8..f364c11 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -19,6 +19,7 @@ import 'package:island/pods/websocket.dart';
import 'package:island/route.dart';
import 'package:island/screens/auth/tabs.dart';
import 'package:island/services/notify.dart';
+import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:relative_time/relative_time.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -28,11 +29,21 @@ import 'package:flutter_native_splash/flutter_native_splash.dart';
void main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
+ log(
+ "[SplashScreen] Keeping the flash screen to loading other resources...",
+ );
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
}
- await EasyLocalization.ensureInitialized();
- await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
+ try {
+ await EasyLocalization.ensureInitialized();
+ await Firebase.initializeApp(
+ options: DefaultFirebaseOptions.currentPlatform,
+ );
+ log("[SplashScreen] Firebase is ready!");
+ } catch (err) {
+ showErrorAlert(err);
+ }
final prefs = await SharedPreferences.getInstance();
@@ -43,6 +54,7 @@ void main() async {
appWindow.size = initialSize;
appWindow.alignment = Alignment.center;
appWindow.show();
+ log("[SplashScreen] Desktop window is ready!");
});
}
@@ -52,10 +64,12 @@ void main() async {
if (imagePickerImplementation is ImagePickerAndroid) {
imagePickerImplementation.useAndroidPhotoPicker = true;
}
+ log("[SplashScreen] Android image picker is ready!");
}
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
FlutterNativeSplash.remove();
+ log("[SplashScreen] Now hiding the splash screen...");
}
runApp(
diff --git a/lib/models/chat.dart b/lib/models/chat.dart
index ac5bd4a..d6733ae 100644
--- a/lib/models/chat.dart
+++ b/lib/models/chat.dart
@@ -146,12 +146,27 @@ sealed class ChatRealtimeJoinResponse with _$ChatRealtimeJoinResponse {
required String callId,
required String roomName,
required bool isAdmin,
+ required List participants,
}) = _ChatRealtimeJoinResponse;
factory ChatRealtimeJoinResponse.fromJson(Map json) =>
_$ChatRealtimeJoinResponseFromJson(json);
}
+@freezed
+sealed class CallParticipant with _$CallParticipant {
+ const factory CallParticipant({
+ required String identity,
+ required String name,
+ required DateTime joinedAt,
+ required String? accountId,
+ required SnChatMember? profile,
+ }) = _CallParticipant;
+
+ factory CallParticipant.fromJson(Map json) =>
+ _$CallParticipantFromJson(json);
+}
+
@freezed
sealed class SnRealtimeCall with _$SnRealtimeCall {
const factory SnRealtimeCall({
diff --git a/lib/models/chat.freezed.dart b/lib/models/chat.freezed.dart
index 7df3096..5eb124b 100644
--- a/lib/models/chat.freezed.dart
+++ b/lib/models/chat.freezed.dart
@@ -1342,7 +1342,7 @@ as DateTime,
/// @nodoc
mixin _$ChatRealtimeJoinResponse {
- String get provider; String get endpoint; String get token; String get callId; String get roomName; bool get isAdmin;
+ String get provider; String get endpoint; String get token; String get callId; String get roomName; bool get isAdmin; List get participants;
/// Create a copy of ChatRealtimeJoinResponse
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -1355,16 +1355,16 @@ $ChatRealtimeJoinResponseCopyWith get copyWith => _$Ch
@override
bool operator ==(Object other) {
- return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatRealtimeJoinResponse&&(identical(other.provider, provider) || other.provider == provider)&&(identical(other.endpoint, endpoint) || other.endpoint == endpoint)&&(identical(other.token, token) || other.token == token)&&(identical(other.callId, callId) || other.callId == callId)&&(identical(other.roomName, roomName) || other.roomName == roomName)&&(identical(other.isAdmin, isAdmin) || other.isAdmin == isAdmin));
+ return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatRealtimeJoinResponse&&(identical(other.provider, provider) || other.provider == provider)&&(identical(other.endpoint, endpoint) || other.endpoint == endpoint)&&(identical(other.token, token) || other.token == token)&&(identical(other.callId, callId) || other.callId == callId)&&(identical(other.roomName, roomName) || other.roomName == roomName)&&(identical(other.isAdmin, isAdmin) || other.isAdmin == isAdmin)&&const DeepCollectionEquality().equals(other.participants, participants));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
-int get hashCode => Object.hash(runtimeType,provider,endpoint,token,callId,roomName,isAdmin);
+int get hashCode => Object.hash(runtimeType,provider,endpoint,token,callId,roomName,isAdmin,const DeepCollectionEquality().hash(participants));
@override
String toString() {
- return 'ChatRealtimeJoinResponse(provider: $provider, endpoint: $endpoint, token: $token, callId: $callId, roomName: $roomName, isAdmin: $isAdmin)';
+ return 'ChatRealtimeJoinResponse(provider: $provider, endpoint: $endpoint, token: $token, callId: $callId, roomName: $roomName, isAdmin: $isAdmin, participants: $participants)';
}
@@ -1375,7 +1375,7 @@ abstract mixin class $ChatRealtimeJoinResponseCopyWith<$Res> {
factory $ChatRealtimeJoinResponseCopyWith(ChatRealtimeJoinResponse value, $Res Function(ChatRealtimeJoinResponse) _then) = _$ChatRealtimeJoinResponseCopyWithImpl;
@useResult
$Res call({
- String provider, String endpoint, String token, String callId, String roomName, bool isAdmin
+ String provider, String endpoint, String token, String callId, String roomName, bool isAdmin, List participants
});
@@ -1392,7 +1392,7 @@ class _$ChatRealtimeJoinResponseCopyWithImpl<$Res>
/// Create a copy of ChatRealtimeJoinResponse
/// with the given fields replaced by the non-null parameter values.
-@pragma('vm:prefer-inline') @override $Res call({Object? provider = null,Object? endpoint = null,Object? token = null,Object? callId = null,Object? roomName = null,Object? isAdmin = null,}) {
+@pragma('vm:prefer-inline') @override $Res call({Object? provider = null,Object? endpoint = null,Object? token = null,Object? callId = null,Object? roomName = null,Object? isAdmin = null,Object? participants = null,}) {
return _then(_self.copyWith(
provider: null == provider ? _self.provider : provider // ignore: cast_nullable_to_non_nullable
as String,endpoint: null == endpoint ? _self.endpoint : endpoint // ignore: cast_nullable_to_non_nullable
@@ -1400,7 +1400,8 @@ as String,token: null == token ? _self.token : token // ignore: cast_nullable_to
as String,callId: null == callId ? _self.callId : callId // ignore: cast_nullable_to_non_nullable
as String,roomName: null == roomName ? _self.roomName : roomName // ignore: cast_nullable_to_non_nullable
as String,isAdmin: null == isAdmin ? _self.isAdmin : isAdmin // ignore: cast_nullable_to_non_nullable
-as bool,
+as bool,participants: null == participants ? _self.participants : participants // ignore: cast_nullable_to_non_nullable
+as List,
));
}
@@ -1411,7 +1412,7 @@ as bool,
@JsonSerializable()
class _ChatRealtimeJoinResponse implements ChatRealtimeJoinResponse {
- const _ChatRealtimeJoinResponse({required this.provider, required this.endpoint, required this.token, required this.callId, required this.roomName, required this.isAdmin});
+ const _ChatRealtimeJoinResponse({required this.provider, required this.endpoint, required this.token, required this.callId, required this.roomName, required this.isAdmin, required final List participants}): _participants = participants;
factory _ChatRealtimeJoinResponse.fromJson(Map json) => _$ChatRealtimeJoinResponseFromJson(json);
@override final String provider;
@@ -1420,6 +1421,13 @@ class _ChatRealtimeJoinResponse implements ChatRealtimeJoinResponse {
@override final String callId;
@override final String roomName;
@override final bool isAdmin;
+ final List _participants;
+@override List get participants {
+ if (_participants is EqualUnmodifiableListView) return _participants;
+ // ignore: implicit_dynamic_type
+ return EqualUnmodifiableListView(_participants);
+}
+
/// Create a copy of ChatRealtimeJoinResponse
/// with the given fields replaced by the non-null parameter values.
@@ -1434,16 +1442,16 @@ Map toJson() {
@override
bool operator ==(Object other) {
- return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatRealtimeJoinResponse&&(identical(other.provider, provider) || other.provider == provider)&&(identical(other.endpoint, endpoint) || other.endpoint == endpoint)&&(identical(other.token, token) || other.token == token)&&(identical(other.callId, callId) || other.callId == callId)&&(identical(other.roomName, roomName) || other.roomName == roomName)&&(identical(other.isAdmin, isAdmin) || other.isAdmin == isAdmin));
+ return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatRealtimeJoinResponse&&(identical(other.provider, provider) || other.provider == provider)&&(identical(other.endpoint, endpoint) || other.endpoint == endpoint)&&(identical(other.token, token) || other.token == token)&&(identical(other.callId, callId) || other.callId == callId)&&(identical(other.roomName, roomName) || other.roomName == roomName)&&(identical(other.isAdmin, isAdmin) || other.isAdmin == isAdmin)&&const DeepCollectionEquality().equals(other._participants, _participants));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
-int get hashCode => Object.hash(runtimeType,provider,endpoint,token,callId,roomName,isAdmin);
+int get hashCode => Object.hash(runtimeType,provider,endpoint,token,callId,roomName,isAdmin,const DeepCollectionEquality().hash(_participants));
@override
String toString() {
- return 'ChatRealtimeJoinResponse(provider: $provider, endpoint: $endpoint, token: $token, callId: $callId, roomName: $roomName, isAdmin: $isAdmin)';
+ return 'ChatRealtimeJoinResponse(provider: $provider, endpoint: $endpoint, token: $token, callId: $callId, roomName: $roomName, isAdmin: $isAdmin, participants: $participants)';
}
@@ -1454,7 +1462,7 @@ abstract mixin class _$ChatRealtimeJoinResponseCopyWith<$Res> implements $ChatRe
factory _$ChatRealtimeJoinResponseCopyWith(_ChatRealtimeJoinResponse value, $Res Function(_ChatRealtimeJoinResponse) _then) = __$ChatRealtimeJoinResponseCopyWithImpl;
@override @useResult
$Res call({
- String provider, String endpoint, String token, String callId, String roomName, bool isAdmin
+ String provider, String endpoint, String token, String callId, String roomName, bool isAdmin, List participants
});
@@ -1471,7 +1479,7 @@ class __$ChatRealtimeJoinResponseCopyWithImpl<$Res>
/// Create a copy of ChatRealtimeJoinResponse
/// with the given fields replaced by the non-null parameter values.
-@override @pragma('vm:prefer-inline') $Res call({Object? provider = null,Object? endpoint = null,Object? token = null,Object? callId = null,Object? roomName = null,Object? isAdmin = null,}) {
+@override @pragma('vm:prefer-inline') $Res call({Object? provider = null,Object? endpoint = null,Object? token = null,Object? callId = null,Object? roomName = null,Object? isAdmin = null,Object? participants = null,}) {
return _then(_ChatRealtimeJoinResponse(
provider: null == provider ? _self.provider : provider // ignore: cast_nullable_to_non_nullable
as String,endpoint: null == endpoint ? _self.endpoint : endpoint // ignore: cast_nullable_to_non_nullable
@@ -1479,7 +1487,8 @@ as String,token: null == token ? _self.token : token // ignore: cast_nullable_to
as String,callId: null == callId ? _self.callId : callId // ignore: cast_nullable_to_non_nullable
as String,roomName: null == roomName ? _self.roomName : roomName // ignore: cast_nullable_to_non_nullable
as String,isAdmin: null == isAdmin ? _self.isAdmin : isAdmin // ignore: cast_nullable_to_non_nullable
-as bool,
+as bool,participants: null == participants ? _self._participants : participants // ignore: cast_nullable_to_non_nullable
+as List,
));
}
@@ -1487,6 +1496,175 @@ as bool,
}
+/// @nodoc
+mixin _$CallParticipant {
+
+ String get identity; String get name; DateTime get joinedAt; String? get accountId; SnChatMember? get profile;
+/// Create a copy of CallParticipant
+/// with the given fields replaced by the non-null parameter values.
+@JsonKey(includeFromJson: false, includeToJson: false)
+@pragma('vm:prefer-inline')
+$CallParticipantCopyWith get copyWith => _$CallParticipantCopyWithImpl(this as CallParticipant, _$identity);
+
+ /// Serializes this CallParticipant to a JSON map.
+ 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));
+}
+
+@JsonKey(includeFromJson: false, includeToJson: false)
+@override
+int get hashCode => Object.hash(runtimeType,identity,name,joinedAt,accountId,profile);
+
+@override
+String toString() {
+ return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt, accountId: $accountId, profile: $profile)';
+}
+
+
+}
+
+/// @nodoc
+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
+});
+
+
+$SnChatMemberCopyWith<$Res>? get profile;
+
+}
+/// @nodoc
+class _$CallParticipantCopyWithImpl<$Res>
+ implements $CallParticipantCopyWith<$Res> {
+ _$CallParticipantCopyWithImpl(this._self, this._then);
+
+ final CallParticipant _self;
+ final $Res Function(CallParticipant) _then;
+
+/// 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,}) {
+ 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?,
+ ));
+}
+/// 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));
+ });
+}
+}
+
+
+/// @nodoc
+@JsonSerializable()
+
+class _CallParticipant implements CallParticipant {
+ const _CallParticipant({required this.identity, required this.name, required this.joinedAt, required this.accountId, required this.profile});
+ 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.
+@override @JsonKey(includeFromJson: false, includeToJson: false)
+@pragma('vm:prefer-inline')
+_$CallParticipantCopyWith<_CallParticipant> get copyWith => __$CallParticipantCopyWithImpl<_CallParticipant>(this, _$identity);
+
+@override
+Map toJson() {
+ return _$CallParticipantToJson(this, );
+}
+
+@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));
+}
+
+@JsonKey(includeFromJson: false, includeToJson: false)
+@override
+int get hashCode => Object.hash(runtimeType,identity,name,joinedAt,accountId,profile);
+
+@override
+String toString() {
+ return 'CallParticipant(identity: $identity, name: $name, joinedAt: $joinedAt, accountId: $accountId, profile: $profile)';
+}
+
+
+}
+
+/// @nodoc
+abstract mixin class _$CallParticipantCopyWith<$Res> implements $CallParticipantCopyWith<$Res> {
+ factory _$CallParticipantCopyWith(_CallParticipant value, $Res Function(_CallParticipant) _then) = __$CallParticipantCopyWithImpl;
+@override @useResult
+$Res call({
+ String identity, String name, DateTime joinedAt, String? accountId, SnChatMember? profile
+});
+
+
+@override $SnChatMemberCopyWith<$Res>? get profile;
+
+}
+/// @nodoc
+class __$CallParticipantCopyWithImpl<$Res>
+ implements _$CallParticipantCopyWith<$Res> {
+ __$CallParticipantCopyWithImpl(this._self, this._then);
+
+ final _CallParticipant _self;
+ final $Res Function(_CallParticipant) _then;
+
+/// 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,}) {
+ 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?,
+ ));
+}
+
+/// 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));
+ });
+}
+}
+
+
/// @nodoc
mixin _$SnRealtimeCall {
diff --git a/lib/models/chat.g.dart b/lib/models/chat.g.dart
index b8ef225..859177f 100644
--- a/lib/models/chat.g.dart
+++ b/lib/models/chat.g.dart
@@ -249,6 +249,10 @@ _ChatRealtimeJoinResponse _$ChatRealtimeJoinResponseFromJson(
callId: json['call_id'] as String,
roomName: json['room_name'] as String,
isAdmin: json['is_admin'] as bool,
+ participants:
+ (json['participants'] as List)
+ .map((e) => CallParticipant.fromJson(e as Map))
+ .toList(),
);
Map _$ChatRealtimeJoinResponseToJson(
@@ -260,8 +264,30 @@ Map _$ChatRealtimeJoinResponseToJson(
'call_id': instance.callId,
'room_name': instance.roomName,
'is_admin': instance.isAdmin,
+ 'participants': instance.participants.map((e) => e.toJson()).toList(),
};
+_CallParticipant _$CallParticipantFromJson(Map json) =>
+ _CallParticipant(
+ 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) =>
+ {
+ 'identity': instance.identity,
+ 'name': instance.name,
+ 'joined_at': instance.joinedAt.toIso8601String(),
+ 'account_id': instance.accountId,
+ 'profile': instance.profile?.toJson(),
+ };
+
_SnRealtimeCall _$SnRealtimeCallFromJson(Map json) =>
_SnRealtimeCall(
id: json['id'] as String,
diff --git a/lib/pods/call.dart b/lib/pods/call.dart
index b8ba3df..7b0b364 100644
--- a/lib/pods/call.dart
+++ b/lib/pods/call.dart
@@ -1,7 +1,12 @@
+import 'package:island/pods/userinfo.dart';
+import 'package:island/screens/chat/chat.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';
@@ -9,43 +14,244 @@ part 'call.freezed.dart';
@freezed
sealed class CallState with _$CallState {
const factory CallState({
- required bool isMuted,
required bool isConnected,
+ required bool isMicrophoneEnabled,
+ required bool isCameraEnabled,
+ required bool isScreenSharing,
String? error,
}) = _CallState;
}
+@freezed
+sealed class CallParticipantLive with _$CallParticipantLive {
+ const CallParticipantLive._();
+
+ const factory CallParticipantLive({
+ required CallParticipant participant,
+ required Participant remoteParticipant,
+ }) = _CallParticipantLive;
+
+ bool get isSpeaking => remoteParticipant.isSpeaking;
+ bool get isMuted => remoteParticipant.isMuted;
+ bool get isScreenSharing => remoteParticipant.isScreenShareEnabled();
+ bool get isScreenSharingWithAudio =>
+ remoteParticipant.isScreenShareAudioEnabled();
+
+ bool get hasVideo => remoteParticipant.hasVideo;
+ bool get hasAudio => remoteParticipant.hasAudio;
+}
+
@riverpod
class CallNotifier extends _$CallNotifier {
Room? _room;
LocalParticipant? _localParticipant;
- LocalAudioTrack? _localAudioTrack;
+ List _participants = [];
+ final Map _participantInfoByIdentity = {};
+ StreamSubscription? _wsSubscription;
+ EventsListener? _roomListener;
+
+ List get participants =>
+ List.unmodifiable(_participants);
+ LocalParticipant? get localParticipant => _localParticipant;
@override
CallState build() {
- return const CallState(isMuted: false, isConnected: false);
+ // Subscribe to websocket updates
+ _subscribeToParticipantsUpdate();
+ return const CallState(
+ isConnected: false,
+ isMicrophoneEnabled: true,
+ isCameraEnabled: false,
+ isScreenSharing: false,
+ );
}
+ 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();
+ _roomListener = _room!.createListener();
+ _room!.addListener(_onRoomChange);
+ _roomListener!
+ ..on((e) {
+ _refreshLiveParticipants();
+ })
+ ..on((e) {
+ _participants = [];
+ state = state.copyWith();
+ });
+ }
+
+ void _onRoomChange() {
+ _refreshLiveParticipants();
+ }
+
+ void _refreshLiveParticipants() {
+ if (_room == null) return;
+ final remoteParticipants = _room!.remoteParticipants;
+ _participants = [];
+ // Add local participant first if available
+ if (_localParticipant != null) {
+ final localInfo = _buildParticipant();
+ _participants.add(
+ CallParticipantLive(
+ participant: localInfo,
+ remoteParticipant: _localParticipant!,
+ ),
+ );
+ }
+ // Add remote participants
+ _participants.addAll(
+ remoteParticipants.values.map((remote) {
+ final match =
+ _participantInfoByIdentity[remote.identity] ??
+ CallParticipant(
+ identity: remote.identity,
+ name: remote.identity,
+ joinedAt: DateTime.now(),
+ accountId: null,
+ profile: null,
+ );
+ return CallParticipantLive(
+ participant: match,
+ remoteParticipant: remote,
+ );
+ }),
+ );
+ state = state.copyWith();
+ }
+
+ /// Builds the CallParticipant object for the local participant.
+ /// Optionally, pass [participants] if you want to prioritize info from the latest list.
+ CallParticipant _buildParticipant({List? participants}) {
+ if (_localParticipant == null) {
+ throw StateError('No local participant available');
+ }
+ // Prefer info from the latest participants list if available
+ if (participants != null) {
+ final idx = participants.indexWhere(
+ (p) => p.identity == _localParticipant!.identity,
+ );
+ 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,
+ );
+ }
+
+ void _updateLiveParticipants(List participants) {
+ // Update the info map for lookup
+ for (final p in participants) {
+ _participantInfoByIdentity[p.identity] = p;
+ }
+ if (_room == null) {
+ // Can't build live objects, just store empty
+ _participants = [];
+ state = state.copyWith();
+ return;
+ }
+ final remoteParticipants = _room!.remoteParticipants;
+ final remotes = remoteParticipants.values.toList();
+ _participants = [];
+ // Add local participant if present in the list
+ if (_localParticipant != null) {
+ final localInfo = _buildParticipant(participants: participants);
+ _participants.add(
+ CallParticipantLive(
+ participant: localInfo,
+ remoteParticipant: _localParticipant!,
+ ),
+ );
+ }
+ // Add remote participants
+ _participants.addAll(
+ participants.map((p) {
+ RemoteParticipant? remote;
+ for (final r in remotes) {
+ if (r.identity == p.identity) {
+ remote = r;
+ break;
+ }
+ }
+ if (_localParticipant != null &&
+ p.identity == _localParticipant!.identity) {
+ return null; // Already added local
+ }
+ return remote != null
+ ? CallParticipantLive(participant: p, remoteParticipant: remote)
+ : null;
+ }).whereType(),
+ );
+ state = state.copyWith();
+ }
+
+ String? _roomId;
+
Future joinRoom(String roomId) async {
+ _roomId = roomId;
try {
final apiClient = ref.read(apiClientProvider);
final response = await apiClient.get('/chat/realtime/$roomId/join');
if (response.statusCode == 200 && response.data != null) {
final data = response.data;
- final String endpoint = data['endpoint'];
- final String token = data['token'];
+ // Parse join response
+ final joinResponse = ChatRealtimeJoinResponse.fromJson(data);
+ final participants = joinResponse.participants;
+ final String endpoint = joinResponse.endpoint;
+ final String token = joinResponse.token;
// Connect to LiveKit
_room = Room();
- await _room!.connect(endpoint, token);
+
+ await _room!.connect(
+ endpoint,
+ token,
+ connectOptions: ConnectOptions(autoSubscribe: true),
+ roomOptions: RoomOptions(adaptiveStream: true, dynacast: true),
+ fastConnectOptions: FastConnectOptions(
+ microphone: TrackOption(enabled: true),
+ ),
+ );
_localParticipant = _room!.localParticipant;
- // Create local audio track and publish
- _localAudioTrack = await LocalAudioTrack.create();
- await _localParticipant!.publishAudioTrack(_localAudioTrack!);
+
+ _initRoomListeners();
+ _updateLiveParticipants(participants);
// Listen for connection updates
_room!.addListener(() {
state = state.copyWith(
isConnected: _room!.connectionState == ConnectionState.connected,
+ isMicrophoneEnabled: _localParticipant!.isMicrophoneEnabled(),
+ isCameraEnabled: _localParticipant!.isCameraEnabled(),
+ isScreenSharing: _localParticipant!.isScreenShareEnabled(),
);
});
state = state.copyWith(isConnected: true);
@@ -57,27 +263,55 @@ class CallNotifier extends _$CallNotifier {
}
}
- void toggleMute() {
- final newMuted = !state.isMuted;
- state = state.copyWith(isMuted: newMuted);
- if (_localAudioTrack != null) {
- if (newMuted) {
- _localAudioTrack!.mute();
+ Future toggleMicrophone() async {
+ if (_localParticipant != null) {
+ const autostop = true;
+ final target = !_localParticipant!.isMicrophoneEnabled();
+ state = state.copyWith(isMicrophoneEnabled: target);
+ if (target) {
+ await _localParticipant!.audioTrackPublications.firstOrNull?.unmute(
+ stopOnMute: autostop,
+ );
} else {
- _localAudioTrack!.unmute();
+ await _localParticipant!.audioTrackPublications.firstOrNull?.mute(
+ stopOnMute: autostop,
+ );
}
}
}
+ Future toggleCamera() async {
+ if (_localParticipant != null) {
+ final target = !_localParticipant!.isCameraEnabled();
+ state = state.copyWith(isCameraEnabled: target);
+ await _localParticipant!.setCameraEnabled(target);
+ }
+ }
+
+ Future toggleScreenShare() async {
+ if (_localParticipant != null) {
+ final target = !_localParticipant!.isScreenShareEnabled();
+ state = state.copyWith(isScreenSharing: target);
+ await _localParticipant!.setScreenShareEnabled(target);
+ }
+ }
+
Future disconnect() async {
if (_room != null) {
await _room!.disconnect();
- state = state.copyWith(isConnected: false);
+ state = state.copyWith(
+ isConnected: false,
+ isMicrophoneEnabled: false,
+ isCameraEnabled: false,
+ isScreenSharing: false,
+ );
}
}
void dispose() {
- _localAudioTrack?.dispose();
+ _wsSubscription?.cancel();
+ _roomListener?.dispose();
+ _room?.removeListener(_onRoomChange);
_room?.dispose();
}
}
diff --git a/lib/pods/call.freezed.dart b/lib/pods/call.freezed.dart
index 7e237ea..db900db 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 isMuted; bool get isConnected; String? get error;
+ bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; 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.isMuted, isMuted) || other.isMuted == isMuted)&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(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.error, error) || other.error == error));
}
@override
-int get hashCode => Object.hash(runtimeType,isMuted,isConnected,error);
+int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,error);
@override
String toString() {
- return 'CallState(isMuted: $isMuted, isConnected: $isConnected, error: $error)';
+ return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, error: $error)';
}
@@ -46,7 +46,7 @@ abstract mixin class $CallStateCopyWith<$Res> {
factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl;
@useResult
$Res call({
- bool isMuted, bool isConnected, String? error
+ bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, String? error
});
@@ -63,10 +63,12 @@ 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? isMuted = null,Object? isConnected = null,Object? error = freezed,}) {
+@pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? error = freezed,}) {
return _then(_self.copyWith(
-isMuted: null == isMuted ? _self.isMuted : isMuted // ignore: cast_nullable_to_non_nullable
-as bool,isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
+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 String?,
));
@@ -79,11 +81,13 @@ as String?,
class _CallState implements CallState {
- const _CallState({required this.isMuted, required this.isConnected, this.error});
+ const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, this.error});
-@override final bool isMuted;
@override final bool isConnected;
+@override final bool isMicrophoneEnabled;
+@override final bool isCameraEnabled;
+@override final bool isScreenSharing;
@override final String? error;
/// Create a copy of CallState
@@ -96,16 +100,16 @@ _$CallStateCopyWith<_CallState> get copyWith => __$CallStateCopyWithImpl<_CallSt
@override
bool operator ==(Object other) {
- return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isMuted, isMuted) || other.isMuted == isMuted)&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(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.error, error) || other.error == error));
}
@override
-int get hashCode => Object.hash(runtimeType,isMuted,isConnected,error);
+int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,error);
@override
String toString() {
- return 'CallState(isMuted: $isMuted, isConnected: $isConnected, error: $error)';
+ return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, error: $error)';
}
@@ -116,7 +120,7 @@ abstract mixin class _$CallStateCopyWith<$Res> implements $CallStateCopyWith<$Re
factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl;
@override @useResult
$Res call({
- bool isMuted, bool isConnected, String? error
+ bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, String? error
});
@@ -133,10 +137,12 @@ 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? isMuted = null,Object? isConnected = null,Object? error = freezed,}) {
+@override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? error = freezed,}) {
return _then(_CallState(
-isMuted: null == isMuted ? _self.isMuted : isMuted // ignore: cast_nullable_to_non_nullable
-as bool,isConnected: null == isConnected ? _self.isConnected : isConnected // ignore: cast_nullable_to_non_nullable
+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 String?,
));
@@ -145,4 +151,152 @@ as String?,
}
+/// @nodoc
+mixin _$CallParticipantLive {
+
+ CallParticipant get participant; Participant get remoteParticipant;
+/// Create a copy of CallParticipantLive
+/// with the given fields replaced by the non-null parameter values.
+@JsonKey(includeFromJson: false, includeToJson: false)
+@pragma('vm:prefer-inline')
+$CallParticipantLiveCopyWith get copyWith => _$CallParticipantLiveCopyWithImpl(this as CallParticipantLive, _$identity);
+
+
+
+@override
+bool operator ==(Object other) {
+ return identical(this, other) || (other.runtimeType == runtimeType&&other is CallParticipantLive&&(identical(other.participant, participant) || other.participant == participant)&&(identical(other.remoteParticipant, remoteParticipant) || other.remoteParticipant == remoteParticipant));
+}
+
+
+@override
+int get hashCode => Object.hash(runtimeType,participant,remoteParticipant);
+
+@override
+String toString() {
+ return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)';
+}
+
+
+}
+
+/// @nodoc
+abstract mixin class $CallParticipantLiveCopyWith<$Res> {
+ factory $CallParticipantLiveCopyWith(CallParticipantLive value, $Res Function(CallParticipantLive) _then) = _$CallParticipantLiveCopyWithImpl;
+@useResult
+$Res call({
+ CallParticipant participant, Participant remoteParticipant
+});
+
+
+$CallParticipantCopyWith<$Res> get participant;
+
+}
+/// @nodoc
+class _$CallParticipantLiveCopyWithImpl<$Res>
+ implements $CallParticipantLiveCopyWith<$Res> {
+ _$CallParticipantLiveCopyWithImpl(this._self, this._then);
+
+ final CallParticipantLive _self;
+ final $Res Function(CallParticipantLive) _then;
+
+/// Create a copy of CallParticipantLive
+/// with the given fields replaced by the non-null parameter values.
+@pragma('vm:prefer-inline') @override $Res call({Object? participant = null,Object? remoteParticipant = null,}) {
+ return _then(_self.copyWith(
+participant: null == participant ? _self.participant : participant // ignore: cast_nullable_to_non_nullable
+as CallParticipant,remoteParticipant: null == remoteParticipant ? _self.remoteParticipant : remoteParticipant // ignore: cast_nullable_to_non_nullable
+as Participant,
+ ));
+}
+/// Create a copy of CallParticipantLive
+/// with the given fields replaced by the non-null parameter values.
+@override
+@pragma('vm:prefer-inline')
+$CallParticipantCopyWith<$Res> get participant {
+
+ return $CallParticipantCopyWith<$Res>(_self.participant, (value) {
+ return _then(_self.copyWith(participant: value));
+ });
+}
+}
+
+
+/// @nodoc
+
+
+class _CallParticipantLive extends CallParticipantLive {
+ const _CallParticipantLive({required this.participant, required this.remoteParticipant}): super._();
+
+
+@override final CallParticipant participant;
+@override final Participant remoteParticipant;
+
+/// Create a copy of CallParticipantLive
+/// with the given fields replaced by the non-null parameter values.
+@override @JsonKey(includeFromJson: false, includeToJson: false)
+@pragma('vm:prefer-inline')
+_$CallParticipantLiveCopyWith<_CallParticipantLive> get copyWith => __$CallParticipantLiveCopyWithImpl<_CallParticipantLive>(this, _$identity);
+
+
+
+@override
+bool operator ==(Object other) {
+ return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallParticipantLive&&(identical(other.participant, participant) || other.participant == participant)&&(identical(other.remoteParticipant, remoteParticipant) || other.remoteParticipant == remoteParticipant));
+}
+
+
+@override
+int get hashCode => Object.hash(runtimeType,participant,remoteParticipant);
+
+@override
+String toString() {
+ return 'CallParticipantLive(participant: $participant, remoteParticipant: $remoteParticipant)';
+}
+
+
+}
+
+/// @nodoc
+abstract mixin class _$CallParticipantLiveCopyWith<$Res> implements $CallParticipantLiveCopyWith<$Res> {
+ factory _$CallParticipantLiveCopyWith(_CallParticipantLive value, $Res Function(_CallParticipantLive) _then) = __$CallParticipantLiveCopyWithImpl;
+@override @useResult
+$Res call({
+ CallParticipant participant, Participant remoteParticipant
+});
+
+
+@override $CallParticipantCopyWith<$Res> get participant;
+
+}
+/// @nodoc
+class __$CallParticipantLiveCopyWithImpl<$Res>
+ implements _$CallParticipantLiveCopyWith<$Res> {
+ __$CallParticipantLiveCopyWithImpl(this._self, this._then);
+
+ final _CallParticipantLive _self;
+ final $Res Function(_CallParticipantLive) _then;
+
+/// Create a copy of CallParticipantLive
+/// with the given fields replaced by the non-null parameter values.
+@override @pragma('vm:prefer-inline') $Res call({Object? participant = null,Object? remoteParticipant = null,}) {
+ return _then(_CallParticipantLive(
+participant: null == participant ? _self.participant : participant // ignore: cast_nullable_to_non_nullable
+as CallParticipant,remoteParticipant: null == remoteParticipant ? _self.remoteParticipant : remoteParticipant // ignore: cast_nullable_to_non_nullable
+as Participant,
+ ));
+}
+
+/// Create a copy of CallParticipantLive
+/// with the given fields replaced by the non-null parameter values.
+@override
+@pragma('vm:prefer-inline')
+$CallParticipantCopyWith<$Res> get participant {
+
+ return $CallParticipantCopyWith<$Res>(_self.participant, (value) {
+ return _then(_self.copyWith(participant: value));
+ });
+}
+}
+
// dart format on
diff --git a/lib/pods/call.g.dart b/lib/pods/call.g.dart
index 2c2c3c0..d5b41e4 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'c39e8d88673113bde0b14eb16cd9d86fa549e42c';
+String _$callNotifierHash() => r'5512070f943d98e999d97549c73e4d5f6e7b3ddd';
/// See also [CallNotifier].
@ProviderFor(CallNotifier)
diff --git a/lib/route.gr.dart b/lib/route.gr.dart
index 4ff5bbd..ec5ca83 100644
--- a/lib/route.gr.dart
+++ b/lib/route.gr.dart
@@ -62,8 +62,8 @@ class AccountProfileRoute extends _i27.PageRouteInfo {
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs(
- orElse: () =>
- AccountProfileRouteArgs(name: pathParams.getString('name')),
+ orElse:
+ () => AccountProfileRouteArgs(name: pathParams.getString('name')),
);
return _i1.AccountProfileScreen(key: args.key, name: args.name);
},
@@ -81,6 +81,16 @@ class AccountProfileRouteArgs {
String toString() {
return 'AccountProfileRouteArgs{key: $key, name: $name}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! AccountProfileRouteArgs) return false;
+ return key == other.key && name == other.name;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ name.hashCode;
}
/// generated route for
@@ -120,6 +130,16 @@ class AccountRouteArgs {
String toString() {
return 'AccountRouteArgs{key: $key, isAside: $isAside}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! AccountRouteArgs) return false;
+ return key == other.key && isAside == other.isAside;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ isAside.hashCode;
}
/// generated route for
@@ -193,6 +213,16 @@ class CallRouteArgs {
String toString() {
return 'CallRouteArgs{key: $key, roomId: $roomId}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! CallRouteArgs) return false;
+ return key == other.key && roomId == other.roomId;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ roomId.hashCode;
}
/// generated route for
@@ -234,6 +264,16 @@ class ChatDetailRouteArgs {
String toString() {
return 'ChatDetailRouteArgs{key: $key, id: $id}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! ChatDetailRouteArgs) return false;
+ return key == other.key && id == other.id;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ id.hashCode;
}
/// generated route for
@@ -273,6 +313,16 @@ class ChatListRouteArgs {
String toString() {
return 'ChatListRouteArgs{key: $key, isAside: $isAside}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! ChatListRouteArgs) return false;
+ return key == other.key && isAside == other.isAside;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ isAside.hashCode;
}
/// generated route for
@@ -314,6 +364,16 @@ class ChatRoomRouteArgs {
String toString() {
return 'ChatRoomRouteArgs{key: $key, id: $id}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! ChatRoomRouteArgs) return false;
+ return key == other.key && id == other.id;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ id.hashCode;
}
/// generated route for
@@ -385,6 +445,16 @@ class CreatorHubRouteArgs {
String toString() {
return 'CreatorHubRouteArgs{key: $key, isAside: $isAside}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! CreatorHubRouteArgs) return false;
+ return key == other.key && isAside == other.isAside;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ isAside.hashCode;
}
/// generated route for
@@ -439,6 +509,16 @@ class EditChatRouteArgs {
String toString() {
return 'EditChatRouteArgs{key: $key, id: $id}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! EditChatRouteArgs) return false;
+ return key == other.key && id == other.id;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ id.hashCode;
}
/// generated route for
@@ -480,6 +560,16 @@ class EditPublisherRouteArgs {
String toString() {
return 'EditPublisherRouteArgs{key: $key, name: $name}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! EditPublisherRouteArgs) return false;
+ return key == other.key && name == other.name;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ name.hashCode;
}
/// generated route for
@@ -521,6 +611,16 @@ class EditRealmRouteArgs {
String toString() {
return 'EditRealmRouteArgs{key: $key, slug: $slug}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! EditRealmRouteArgs) return false;
+ return key == other.key && slug == other.slug;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ slug.hashCode;
}
/// generated route for
@@ -550,10 +650,11 @@ class EditStickerPacksRoute
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs(
- orElse: () => EditStickerPacksRouteArgs(
- pubName: pathParams.getString('name'),
- packId: pathParams.optString('packId'),
- ),
+ orElse:
+ () => EditStickerPacksRouteArgs(
+ pubName: pathParams.getString('name'),
+ packId: pathParams.optString('packId'),
+ ),
);
return _i12.EditStickerPacksScreen(
key: args.key,
@@ -581,6 +682,18 @@ class EditStickerPacksRouteArgs {
String toString() {
return 'EditStickerPacksRouteArgs{key: $key, pubName: $pubName, packId: $packId}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! EditStickerPacksRouteArgs) return false;
+ return key == other.key &&
+ pubName == other.pubName &&
+ packId == other.packId;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ pubName.hashCode ^ packId.hashCode;
}
/// generated route for
@@ -605,10 +718,11 @@ class EditStickersRoute extends _i27.PageRouteInfo {
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs(
- orElse: () => EditStickersRouteArgs(
- packId: pathParams.getString('packId'),
- id: pathParams.optString('id'),
- ),
+ orElse:
+ () => EditStickersRouteArgs(
+ packId: pathParams.getString('packId'),
+ id: pathParams.optString('id'),
+ ),
);
return _i13.EditStickersScreen(
key: args.key,
@@ -636,6 +750,16 @@ class EditStickersRouteArgs {
String toString() {
return 'EditStickersRouteArgs{key: $key, packId: $packId, id: $id}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! EditStickersRouteArgs) return false;
+ return key == other.key && packId == other.packId && id == other.id;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ packId.hashCode ^ id.hashCode;
}
/// generated route for
@@ -659,8 +783,8 @@ class EventCalanderRoute extends _i27.PageRouteInfo {
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs(
- orElse: () =>
- EventCalanderRouteArgs(name: pathParams.getString('name')),
+ orElse:
+ () => EventCalanderRouteArgs(name: pathParams.getString('name')),
);
return _i14.EventCalanderScreen(key: args.key, name: args.name);
},
@@ -678,6 +802,16 @@ class EventCalanderRouteArgs {
String toString() {
return 'EventCalanderRouteArgs{key: $key, name: $name}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! EventCalanderRouteArgs) return false;
+ return key == other.key && name == other.name;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ name.hashCode;
}
/// generated route for
@@ -717,6 +851,16 @@ class ExploreRouteArgs {
String toString() {
return 'ExploreRouteArgs{key: $key, isAside: $isAside}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! ExploreRouteArgs) return false;
+ return key == other.key && isAside == other.isAside;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ isAside.hashCode;
}
/// generated route for
@@ -821,8 +965,9 @@ class NewStickerPacksRoute
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs(
- orElse: () =>
- NewStickerPacksRouteArgs(pubName: pathParams.getString('name')),
+ orElse:
+ () =>
+ NewStickerPacksRouteArgs(pubName: pathParams.getString('name')),
);
return _i12.NewStickerPacksScreen(key: args.key, pubName: args.pubName);
},
@@ -840,6 +985,16 @@ class NewStickerPacksRouteArgs {
String toString() {
return 'NewStickerPacksRouteArgs{key: $key, pubName: $pubName}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! NewStickerPacksRouteArgs) return false;
+ return key == other.key && pubName == other.pubName;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ pubName.hashCode;
}
/// generated route for
@@ -863,8 +1018,8 @@ class NewStickersRoute extends _i27.PageRouteInfo {
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs(
- orElse: () =>
- NewStickersRouteArgs(packId: pathParams.getString('packId')),
+ orElse:
+ () => NewStickersRouteArgs(packId: pathParams.getString('packId')),
);
return _i13.NewStickersScreen(key: args.key, packId: args.packId);
},
@@ -882,6 +1037,16 @@ class NewStickersRouteArgs {
String toString() {
return 'NewStickersRouteArgs{key: $key, packId: $packId}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! NewStickersRouteArgs) return false;
+ return key == other.key && packId == other.packId;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ packId.hashCode;
}
/// generated route for
@@ -940,6 +1105,16 @@ class PostComposeRouteArgs {
String toString() {
return 'PostComposeRouteArgs{key: $key, originalPost: $originalPost}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! PostComposeRouteArgs) return false;
+ return key == other.key && originalPost == other.originalPost;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ originalPost.hashCode;
}
/// generated route for
@@ -981,6 +1156,16 @@ class PostDetailRouteArgs {
String toString() {
return 'PostDetailRouteArgs{key: $key, id: $id}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! PostDetailRouteArgs) return false;
+ return key == other.key && id == other.id;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ id.hashCode;
}
/// generated route for
@@ -1022,6 +1207,16 @@ class PostEditRouteArgs {
String toString() {
return 'PostEditRouteArgs{key: $key, id: $id}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! PostEditRouteArgs) return false;
+ return key == other.key && id == other.id;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ id.hashCode;
}
/// generated route for
@@ -1046,8 +1241,8 @@ class PublisherProfileRoute
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs(
- orElse: () =>
- PublisherProfileRouteArgs(name: pathParams.getString('name')),
+ orElse:
+ () => PublisherProfileRouteArgs(name: pathParams.getString('name')),
);
return _i20.PublisherProfileScreen(key: args.key, name: args.name);
},
@@ -1065,6 +1260,16 @@ class PublisherProfileRouteArgs {
String toString() {
return 'PublisherProfileRouteArgs{key: $key, name: $name}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! PublisherProfileRouteArgs) return false;
+ return key == other.key && name == other.name;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ name.hashCode;
}
/// generated route for
@@ -1106,6 +1311,16 @@ class RealmDetailRouteArgs {
String toString() {
return 'RealmDetailRouteArgs{key: $key, slug: $slug}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! RealmDetailRouteArgs) return false;
+ return key == other.key && slug == other.slug;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ slug.hashCode;
}
/// generated route for
@@ -1179,10 +1394,11 @@ class StickerPackDetailRoute
builder: (data) {
final pathParams = data.inheritedPathParams;
final args = data.argsAs(
- orElse: () => StickerPackDetailRouteArgs(
- pubName: pathParams.getString('name'),
- id: pathParams.getString('packId'),
- ),
+ orElse:
+ () => StickerPackDetailRouteArgs(
+ pubName: pathParams.getString('name'),
+ id: pathParams.getString('packId'),
+ ),
);
return _i13.StickerPackDetailScreen(
key: args.key,
@@ -1210,6 +1426,16 @@ class StickerPackDetailRouteArgs {
String toString() {
return 'StickerPackDetailRouteArgs{key: $key, pubName: $pubName, id: $id}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! StickerPackDetailRouteArgs) return false;
+ return key == other.key && pubName == other.pubName && id == other.id;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ pubName.hashCode ^ id.hashCode;
}
/// generated route for
@@ -1251,6 +1477,16 @@ class StickersRouteArgs {
String toString() {
return 'StickersRouteArgs{key: $key, pubName: $pubName}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! StickersRouteArgs) return false;
+ return key == other.key && pubName == other.pubName;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ pubName.hashCode;
}
/// generated route for
@@ -1300,6 +1536,16 @@ class TabsNavigationWidgetArgs {
String toString() {
return 'TabsNavigationWidgetArgs{key: $key, child: $child, router: $router}';
}
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (other is! TabsNavigationWidgetArgs) return false;
+ return key == other.key && child == other.child && router == other.router;
+ }
+
+ @override
+ int get hashCode => key.hashCode ^ child.hashCode ^ router.hashCode;
}
/// generated route for
diff --git a/lib/screens/chat/call.dart b/lib/screens/chat/call.dart
index a131dc5..86c62e0 100644
--- a/lib/screens/chat/call.dart
+++ b/lib/screens/chat/call.dart
@@ -1,8 +1,18 @@
import 'package:auto_route/annotations.dart';
+import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
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_participant_tile.dart';
+import 'package:livekit_client/livekit_client.dart';
+import 'package:styled_widget/styled_widget.dart';
@RoutePage()
class CallScreen extends HookConsumerWidget {
@@ -11,6 +21,9 @@ 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);
@@ -19,25 +32,327 @@ class CallScreen extends HookConsumerWidget {
return null;
}, []);
- return Scaffold(
- appBar: AppBar(title: const Text('Audio Call')),
- body: Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
+ final actionButtonStyle = ButtonStyle(
+ minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
+ );
+
+ final viewMode = useState('grid');
+
+ return AppScaffold(
+ appBar: AppBar(
+ leading: PageBackButton(
+ onWillPop: () {
+ callNotifier.disconnect().then((_) {
+ callNotifier.dispose();
+ });
+ },
+ ),
+ title: Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
children: [
- if (callState.error != null)
- Text(callState.error!, style: const TextStyle(color: Colors.red)),
- IconButton(
- icon: Icon(callState.isMuted ? Icons.mic_off : Icons.mic),
- onPressed: callNotifier.toggleMute,
+ Text(
+ chatRoom.whenOrNull()?.name ?? 'loading'.tr(),
+ style: const TextStyle(fontSize: 16),
+ ),
+ Text(
+ callState.isConnected
+ ? Duration(
+ milliseconds:
+ (DateTime.now().millisecondsSinceEpoch -
+ (ongoingCall
+ .value
+ ?.createdAt
+ .millisecondsSinceEpoch ??
+ 0)),
+ ).toString()
+ : 'Connecting',
+ style: const TextStyle(fontSize: 14),
),
- if (callState.isConnected)
- const Text('Connected')
- else
- const CircularProgressIndicator(),
],
),
+ actions: [
+ Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ IconButton(
+ icon: Icon(Icons.grid_view),
+ tooltip: 'Grid View',
+ onPressed: () => viewMode.value = 'grid',
+ color:
+ viewMode.value == 'grid'
+ ? Theme.of(context).colorScheme.primary
+ : null,
+ ),
+ IconButton(
+ icon: Icon(Icons.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),
+ ),
+ )
+ : 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) {
+ if (!callState.isConnected) {
+ return const Center(
+ child: CircularProgressIndicator(),
+ );
+ }
+ if (callNotifier.participants.isEmpty) {
+ return const Center(
+ 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(
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Wrap(
+ crossAxisAlignment: WrapCrossAlignment.center,
+ alignment: WrapAlignment.center,
+ spacing: 8,
+ 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
+ .pictureId,
+ size: 72,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+ 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(),
+ ),
+ ],
+ ).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,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+ // 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,
+ ),
+ 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 d9b57f5..dbcec03 100644
--- a/lib/screens/chat/chat.dart
+++ b/lib/screens/chat/chat.dart
@@ -244,6 +244,7 @@ class ChatListScreen extends HookConsumerWidget {
Tab(
child: Text(
'chatTabAll'.tr(),
+ textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
@@ -252,6 +253,7 @@ class ChatListScreen extends HookConsumerWidget {
Tab(
child: Text(
'chatTabDirect'.tr(),
+ textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
@@ -260,6 +262,7 @@ class ChatListScreen extends HookConsumerWidget {
Tab(
child: Text(
'chatTabGroup'.tr(),
+ textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
diff --git a/lib/widgets/app_scaffold.dart b/lib/widgets/app_scaffold.dart
index 73fa9ea..3965e9b 100644
--- a/lib/widgets/app_scaffold.dart
+++ b/lib/widgets/app_scaffold.dart
@@ -179,12 +179,14 @@ class AppScaffold extends StatelessWidget {
class PageBackButton extends StatelessWidget {
final List? shadows;
- const PageBackButton({super.key, this.shadows});
+ final VoidCallback? onWillPop;
+ const PageBackButton({super.key, this.shadows, this.onWillPop});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () {
+ onWillPop?.call();
context.router.maybePop();
},
icon: Icon(
diff --git a/lib/widgets/chat/call_participant_tile.dart b/lib/widgets/chat/call_participant_tile.dart
new file mode 100644
index 0000000..87cb63e
--- /dev/null
+++ b/lib/widgets/chat/call_participant_tile.dart
@@ -0,0 +1,114 @@
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/material.dart';
+import 'package:island/pods/call.dart';
+import 'package:island/widgets/content/cloud_files.dart';
+import 'package:livekit_client/livekit_client.dart';
+
+class SpeakingRippleAvatar extends StatelessWidget {
+ final bool isSpeaking;
+ final double audioLevel;
+ final String? pictureId;
+ final double size;
+
+ const SpeakingRippleAvatar({
+ super.key,
+ required this.isSpeaking,
+ required this.audioLevel,
+ required this.pictureId,
+ this.size = 96,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ 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)
+ Container(
+ width: animatedRadius * 2,
+ height: animatedRadius * 2,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: Colors.green.withOpacity(0.75 + 0.25 * clampedLevel),
+ ),
+ ),
+ Container(
+ width: size,
+ height: size,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(shape: BoxShape.circle),
+ child: ProfilePictureWidget(fileId: pictureId, radius: size / 2),
+ ),
+ ],
+ );
+ },
+ );
+ }
+}
+
+class CallParticipantTile extends StatelessWidget {
+ final CallParticipantLive live;
+
+ const CallParticipantTile({super.key, required this.live});
+
+ @override
+ Widget build(BuildContext context) {
+ final hasVideo =
+ live.hasVideo &&
+ live.remoteParticipant.trackPublications.values
+ .where((pub) => pub.track != null && pub.kind == TrackType.VIDEO)
+ .isNotEmpty;
+ final audioLevel = live.remoteParticipant.audioLevel;
+
+ if (hasVideo) {
+ 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,
+ ),
+ ),
+ ),
+ Positioned(
+ left: 8,
+ right: 8,
+ bottom: 8,
+ child: Text(
+ live.participant.profile?.account.nick ??
+ '${'unknown'.tr()}\'s video',
+ textAlign: TextAlign.center,
+ style: const TextStyle(fontSize: 14, color: Colors.white),
+ ),
+ ),
+ ],
+ );
+ } else {
+ return SpeakingRippleAvatar(
+ isSpeaking: live.isSpeaking,
+ audioLevel: audioLevel,
+ pictureId: live.participant.profile?.account.profile.pictureId,
+ size: 84,
+ );
+ }
+ }
+}
diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index 98eb9be..a25f670 100644
--- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -15,7 +15,7 @@
@@ -31,7 +31,7 @@
@@ -66,7 +66,7 @@
@@ -83,7 +83,7 @@
diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements
index f8c8b25..5cdcb7b 100644
--- a/macos/Runner/DebugProfile.entitlements
+++ b/macos/Runner/DebugProfile.entitlements
@@ -8,6 +8,8 @@
com.apple.security.device.audio-input
+ com.apple.security.device.camera
+
com.apple.security.files.downloads.read-write
com.apple.security.files.user-selected.read-only
diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements
index 852fa1a..986d2a4 100644
--- a/macos/Runner/Release.entitlements
+++ b/macos/Runner/Release.entitlements
@@ -4,5 +4,17 @@
com.apple.security.app-sandbox
+ com.apple.security.device.audio-input
+
+ com.apple.security.device.camera
+
+ com.apple.security.files.downloads.read-write
+
+ com.apple.security.files.user-selected.read-only
+
+ com.apple.security.network.client
+
+ com.apple.security.network.server
+
diff --git a/pubspec.lock b/pubspec.lock
index d3a99fa..f60d709 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -77,18 +77,18 @@ packages:
dependency: "direct main"
description:
name: auto_route
- sha256: "89bc5d17d8c575399891194b8cd02b39f52a8512c730052f17ebe443cdcb9109"
+ sha256: eae18fcd3e3762eb6074a3560c0f411d1e36bd9f8d3eed9c15ed1c577e8d1815
url: "https://pub.dev"
source: hosted
- version: "10.0.1"
+ version: "10.1.0"
auto_route_generator:
dependency: "direct dev"
description:
name: auto_route_generator
- sha256: "8e622d26dc6be4bf496d47969e3e9ba555c3abcf2290da6abfa43cbd4f57fa52"
+ sha256: "9e3846fcbeacba5c362557328dd8c8fbc953b6a0cbc3395365e8d8f92eea29c4"
url: "https://pub.dev"
source: hosted
- version: "10.0.1"
+ version: "10.1.0"
avatar_stack:
dependency: "direct main"
description:
@@ -1225,10 +1225,10 @@ packages:
dependency: "direct main"
description:
name: material_symbols_icons
- sha256: d45b6c36c3effa8cb51b1afb8698107d5ff1f88fa4631428f34a8a01abc295d7
+ sha256: "7c50901b39d1ad645ee25d920aed008061e1fd541a897b4ebf2c01d966dbf16b"
url: "https://pub.dev"
source: hosted
- version: "4.2815.0"
+ version: "4.2815.1"
media_kit:
dependency: "direct main"
description: