💄 Better call UI

This commit is contained in:
2025-12-28 00:40:20 +08:00
parent d910d837eb
commit 200cf3ec80
9 changed files with 370 additions and 238 deletions

View File

@@ -16,6 +16,8 @@ import 'package:island/talker.dart';
part 'call.g.dart'; part 'call.g.dart';
part 'call.freezed.dart'; part 'call.freezed.dart';
enum ViewMode { grid, stage }
String formatDuration(Duration duration) { String formatDuration(Duration duration) {
String negativeSign = duration.isNegative ? '-' : ''; String negativeSign = duration.isNegative ? '-' : '';
String twoDigits(int n) => n.toString().padLeft(2, "0"); String twoDigits(int n) => n.toString().padLeft(2, "0");
@@ -33,6 +35,7 @@ sealed class CallState with _$CallState {
required bool isScreenSharing, required bool isScreenSharing,
required bool isSpeakerphone, required bool isSpeakerphone,
@Default(Duration(seconds: 0)) Duration duration, @Default(Duration(seconds: 0)) Duration duration,
@Default(ViewMode.grid) ViewMode viewMode,
String? error, String? error,
}) = _CallState; }) = _CallState;
} }
@@ -84,6 +87,7 @@ class CallNotifier extends _$CallNotifier {
isCameraEnabled: false, isCameraEnabled: false,
isScreenSharing: false, isScreenSharing: false,
isSpeakerphone: true, isSpeakerphone: true,
viewMode: ViewMode.grid,
); );
} }
@@ -258,8 +262,8 @@ class CallNotifier extends _$CallNotifier {
duration: Duration( duration: Duration(
milliseconds: milliseconds:
(DateTime.now().millisecondsSinceEpoch - (DateTime.now().millisecondsSinceEpoch -
(ongoingCall?.createdAt.millisecondsSinceEpoch ?? (ongoingCall?.createdAt.millisecondsSinceEpoch ??
DateTime.now().millisecondsSinceEpoch)), DateTime.now().millisecondsSinceEpoch)),
), ),
); );
}); });
@@ -418,6 +422,14 @@ class CallNotifier extends _$CallNotifier {
return participantsVolumes[live.remoteParticipant.sid] ?? 1; return participantsVolumes[live.remoteParticipant.sid] ?? 1;
} }
void toggleViewMode() {
state = state.copyWith(
viewMode: state.viewMode == ViewMode.grid
? ViewMode.stage
: ViewMode.grid,
);
}
void dispose() { void dispose() {
state = state.copyWith( state = state.copyWith(
error: null, error: null,

View File

@@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$CallState implements DiagnosticableTreeMixin { mixin _$CallState implements DiagnosticableTreeMixin {
bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; bool get isSpeakerphone; Duration get duration; String? get error; bool get isConnected; bool get isMicrophoneEnabled; bool get isCameraEnabled; bool get isScreenSharing; bool get isSpeakerphone; Duration get duration; ViewMode get viewMode; String? get error;
/// Create a copy of CallState /// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -26,21 +26,21 @@ $CallStateCopyWith<CallState> get copyWith => _$CallStateCopyWithImpl<CallState>
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties properties
..add(DiagnosticsProperty('type', 'CallState')) ..add(DiagnosticsProperty('type', 'CallState'))
..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('error', error)); ..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('viewMode', viewMode))..add(DiagnosticsProperty('error', error));
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(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.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.viewMode, viewMode) || other.viewMode == viewMode)&&(identical(other.error, error) || other.error == error));
} }
@override @override
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,error); int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,viewMode,error);
@override @override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, error: $error)'; return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, viewMode: $viewMode, error: $error)';
} }
@@ -51,7 +51,7 @@ abstract mixin class $CallStateCopyWith<$Res> {
factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl; factory $CallStateCopyWith(CallState value, $Res Function(CallState) _then) = _$CallStateCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error
}); });
@@ -68,7 +68,7 @@ class _$CallStateCopyWithImpl<$Res>
/// Create a copy of CallState /// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? error = freezed,}) { @pragma('vm:prefer-inline') @override $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? viewMode = null,Object? error = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
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,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable
@@ -76,7 +76,8 @@ as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCam
as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable
as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable
as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable as Duration,viewMode: null == viewMode ? _self.viewMode : viewMode // ignore: cast_nullable_to_non_nullable
as ViewMode,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?, as String?,
)); ));
} }
@@ -159,10 +160,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _CallState() when $default != null: case _CallState() when $default != null:
return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);case _: return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.viewMode,_that.error);case _:
return orElse(); return orElse();
} }
@@ -180,10 +181,10 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _CallState(): case _CallState():
return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);} return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.viewMode,_that.error);}
} }
/// A variant of `when` that fallback to returning `null` /// A variant of `when` that fallback to returning `null`
/// ///
@@ -197,10 +198,10 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _CallState() when $default != null: case _CallState() when $default != null:
return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.error);case _: return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnabled,_that.isScreenSharing,_that.isSpeakerphone,_that.duration,_that.viewMode,_that.error);case _:
return null; return null;
} }
@@ -212,7 +213,7 @@ return $default(_that.isConnected,_that.isMicrophoneEnabled,_that.isCameraEnable
class _CallState with DiagnosticableTreeMixin implements CallState { class _CallState with DiagnosticableTreeMixin implements CallState {
const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, required this.isSpeakerphone, this.duration = const Duration(seconds: 0), this.error}); const _CallState({required this.isConnected, required this.isMicrophoneEnabled, required this.isCameraEnabled, required this.isScreenSharing, required this.isSpeakerphone, this.duration = const Duration(seconds: 0), this.viewMode = ViewMode.grid, this.error});
@override final bool isConnected; @override final bool isConnected;
@@ -221,6 +222,7 @@ class _CallState with DiagnosticableTreeMixin implements CallState {
@override final bool isScreenSharing; @override final bool isScreenSharing;
@override final bool isSpeakerphone; @override final bool isSpeakerphone;
@override@JsonKey() final Duration duration; @override@JsonKey() final Duration duration;
@override@JsonKey() final ViewMode viewMode;
@override final String? error; @override final String? error;
/// Create a copy of CallState /// Create a copy of CallState
@@ -234,21 +236,21 @@ _$CallStateCopyWith<_CallState> get copyWith => __$CallStateCopyWithImpl<_CallSt
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties properties
..add(DiagnosticsProperty('type', 'CallState')) ..add(DiagnosticsProperty('type', 'CallState'))
..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('error', error)); ..add(DiagnosticsProperty('isConnected', isConnected))..add(DiagnosticsProperty('isMicrophoneEnabled', isMicrophoneEnabled))..add(DiagnosticsProperty('isCameraEnabled', isCameraEnabled))..add(DiagnosticsProperty('isScreenSharing', isScreenSharing))..add(DiagnosticsProperty('isSpeakerphone', isSpeakerphone))..add(DiagnosticsProperty('duration', duration))..add(DiagnosticsProperty('viewMode', viewMode))..add(DiagnosticsProperty('error', error));
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _CallState&&(identical(other.isConnected, isConnected) || other.isConnected == isConnected)&&(identical(other.isMicrophoneEnabled, isMicrophoneEnabled) || other.isMicrophoneEnabled == isMicrophoneEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&(identical(other.isScreenSharing, isScreenSharing) || other.isScreenSharing == isScreenSharing)&&(identical(other.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(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.isSpeakerphone, isSpeakerphone) || other.isSpeakerphone == isSpeakerphone)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.viewMode, viewMode) || other.viewMode == viewMode)&&(identical(other.error, error) || other.error == error));
} }
@override @override
int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,error); int get hashCode => Object.hash(runtimeType,isConnected,isMicrophoneEnabled,isCameraEnabled,isScreenSharing,isSpeakerphone,duration,viewMode,error);
@override @override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, error: $error)'; return 'CallState(isConnected: $isConnected, isMicrophoneEnabled: $isMicrophoneEnabled, isCameraEnabled: $isCameraEnabled, isScreenSharing: $isScreenSharing, isSpeakerphone: $isSpeakerphone, duration: $duration, viewMode: $viewMode, error: $error)';
} }
@@ -259,7 +261,7 @@ abstract mixin class _$CallStateCopyWith<$Res> implements $CallStateCopyWith<$Re
factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl; factory _$CallStateCopyWith(_CallState value, $Res Function(_CallState) _then) = __$CallStateCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, String? error bool isConnected, bool isMicrophoneEnabled, bool isCameraEnabled, bool isScreenSharing, bool isSpeakerphone, Duration duration, ViewMode viewMode, String? error
}); });
@@ -276,7 +278,7 @@ class __$CallStateCopyWithImpl<$Res>
/// Create a copy of CallState /// Create a copy of CallState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? error = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? isConnected = null,Object? isMicrophoneEnabled = null,Object? isCameraEnabled = null,Object? isScreenSharing = null,Object? isSpeakerphone = null,Object? duration = null,Object? viewMode = null,Object? error = freezed,}) {
return _then(_CallState( return _then(_CallState(
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,isMicrophoneEnabled: null == isMicrophoneEnabled ? _self.isMicrophoneEnabled : isMicrophoneEnabled // ignore: cast_nullable_to_non_nullable
@@ -284,7 +286,8 @@ as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCam
as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable as bool,isScreenSharing: null == isScreenSharing ? _self.isScreenSharing : isScreenSharing // ignore: cast_nullable_to_non_nullable
as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable as bool,isSpeakerphone: null == isSpeakerphone ? _self.isSpeakerphone : isSpeakerphone // ignore: cast_nullable_to_non_nullable
as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable as bool,duration: null == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable as Duration,viewMode: null == viewMode ? _self.viewMode : viewMode // ignore: cast_nullable_to_non_nullable
as ViewMode,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
as String?, as String?,
)); ));
} }

View File

@@ -41,7 +41,7 @@ final class CallNotifierProvider
} }
} }
String _$callNotifierHash() => r'ef4e3e9c9d411cf9dce1ceb456a3b866b2c87db3'; String _$callNotifierHash() => r'40bd884d3918b8e817329589c921774ab3c62ea2';
abstract class _$CallNotifier extends $Notifier<CallState> { abstract class _$CallNotifier extends $Notifier<CallState> {
CallState build(); CallState build();

View File

@@ -50,7 +50,7 @@ final class MessagesNotifierProvider
} }
} }
String _$messagesNotifierHash() => r'284625cff963ff26375d50d7202a33184e810fcb'; String _$messagesNotifierHash() => r'c7e2cd7f5b8673af88f5076814393dbfbd0d43c5';
final class MessagesNotifierFamily extends $Family final class MessagesNotifierFamily extends $Family
with with

View File

@@ -69,11 +69,11 @@ class CallScreen extends HookConsumerWidget {
callState.isConnected callState.isConnected
? formatDuration(callState.duration) ? formatDuration(callState.duration)
: (switch (callNotifier.room?.connectionState) { : (switch (callNotifier.room?.connectionState) {
ConnectionState.connected => 'connected', ConnectionState.connected => 'connected',
ConnectionState.connecting => 'connecting', ConnectionState.connecting => 'connecting',
ConnectionState.reconnecting => 'reconnecting', ConnectionState.reconnecting => 'reconnecting',
_ => 'disconnected', _ => 'disconnected',
}).tr(), }).tr(),
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
], ],
@@ -92,40 +92,40 @@ class CallScreen extends HookConsumerWidget {
), ),
], ],
), ),
body: body: callState.error != null
callState.error != null ? Center(
? Center( child: ConstrainedBox(
child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 320),
constraints: const BoxConstraints(maxWidth: 320), child: Column(
child: Column( children: [
children: [ const Icon(Symbols.error_outline, size: 48),
const Icon(Symbols.error_outline, size: 48), const Gap(4),
const Gap(4), Text(
Text( callState.error!,
callState.error!, textAlign: TextAlign.center,
textAlign: TextAlign.center, style: const TextStyle(color: Color(0xFF757575)),
style: const TextStyle(color: Color(0xFF757575)), ),
), const Gap(8),
const Gap(8), TextButton(
TextButton( onPressed: () {
onPressed: () { callNotifier.disconnect();
callNotifier.disconnect(); callNotifier.dispose();
callNotifier.dispose(); callNotifier.joinRoom(room);
callNotifier.joinRoom(room); },
}, child: Text('retry').tr(),
child: Text('retry').tr(), ),
), ],
],
),
), ),
)
: Column(
children: [
Expanded(child: CallContent()),
CallControlsBar(),
Gap(MediaQuery.of(context).padding.bottom + 16),
],
), ),
)
: Column(
children: [
const SizedBox(width: double.infinity),
Expanded(child: CallContent()),
CallControlsBar(popOnLeaves: true),
Gap(MediaQuery.of(context).padding.bottom + 16),
],
),
); );
} }
} }

View File

@@ -1,12 +1,89 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/chat/call.dart'; import 'package:island/pods/chat/call.dart';
import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:styled_widget/styled_widget.dart';
class CallStageView extends HookConsumerWidget {
final List<CallParticipantLive> participants;
final double? outerMaxHeight;
const CallStageView({
super.key,
required this.participants,
this.outerMaxHeight,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final focusedIndex = useState<int>(0);
final focusedParticipant = participants[focusedIndex.value];
final otherParticipants = participants
.where((p) => p != focusedParticipant)
.toList();
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Focused participant (takes most space)
LayoutBuilder(
builder: (context, constraints) {
// Calculate dynamic width based on available space
final maxWidth = constraints.maxWidth * 0.8;
final maxHeight = (outerMaxHeight ?? constraints.maxHeight) * 0.6;
return Container(
constraints: BoxConstraints(
maxWidth: maxWidth,
maxHeight: maxHeight,
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: CallParticipantTile(
live: focusedParticipant,
allTiles: true,
),
),
);
},
),
// Horizontal list of other participants
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final participant in otherParticipants)
Padding(
padding: const EdgeInsets.all(8),
child: SizedBox(
width: 180,
child: GestureDetector(
onTapDown: (_) {
final newIndex = participants.indexOf(participant);
focusedIndex.value = newIndex;
},
child: CallParticipantTile(
live: participant,
radius: 32,
allTiles: true,
),
),
),
),
],
),
),
],
);
}
}
class CallContent extends HookConsumerWidget { class CallContent extends HookConsumerWidget {
const CallContent({super.key}); final double? outerMaxHeight;
const CallContent({super.key, this.outerMaxHeight});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -34,7 +111,7 @@ class CallContent extends HookConsumerWidget {
); );
if (allAudioOnly) { if (allAudioOnly) {
// Audio-only: show avatars in a compact row // Audio-only: show avatars in a compact row with animated containers
return Center( return Center(
child: SingleChildScrollView( child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@@ -45,36 +122,49 @@ class CallContent extends HookConsumerWidget {
runSpacing: 8, runSpacing: 8,
children: [ children: [
for (final live in participants) for (final live in participants)
SpeakingRippleAvatar( Padding(
live: live, padding: const EdgeInsets.all(8),
size: 72, child: SpeakingRippleAvatar(live: live, size: 72),
).padding(horizontal: 4), ),
], ],
), ),
), ),
); );
} }
// Show all participants in a responsive grid if (callState.viewMode == ViewMode.stage) {
return LayoutBuilder( // Stage: allow user to select a participant to focus, show others below
builder: (context, constraints) { return CallStageView(
// Calculate width for responsive 2-column layout participants: participants,
final itemWidth = (constraints.maxWidth / 2) - 16; outerMaxHeight: outerMaxHeight,
);
} else {
// Grid: show all participants in a responsive grid
return LayoutBuilder(
builder: (context, constraints) {
// Calculate width for responsive 2-column layout
final itemWidth = (constraints.maxWidth / 2) - 16;
return Wrap( return SingleChildScrollView(
alignment: WrapAlignment.center, child: Wrap(
runAlignment: WrapAlignment.center, alignment: WrapAlignment.center,
spacing: 8, runAlignment: WrapAlignment.center,
runSpacing: 8, spacing: 8,
children: [ runSpacing: 8,
for (final participant in participants) children: [
SizedBox( for (final participant in participants)
width: itemWidth, SizedBox(
child: CallParticipantTile(live: participant), width: itemWidth,
), child: CallParticipantTile(
], live: participant,
); allTiles: true,
}, ),
); ),
],
),
);
},
);
}
} }
} }

View File

@@ -21,7 +21,12 @@ import 'package:livekit_client/livekit_client.dart';
class CallControlsBar extends HookConsumerWidget { class CallControlsBar extends HookConsumerWidget {
final bool isCompact; final bool isCompact;
const CallControlsBar({super.key, this.isCompact = false}); final bool popOnLeaves;
const CallControlsBar({
super.key,
this.isCompact = false,
this.popOnLeaves = false,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -41,91 +46,97 @@ class CallControlsBar extends HookConsumerWidget {
_buildCircularButtonWithDropdown( _buildCircularButtonWithDropdown(
context: context, context: context,
ref: ref, ref: ref,
icon: icon: callState.isCameraEnabled
callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off, ? Symbols.videocam
: Symbols.videocam_off,
onPressed: () => callNotifier.toggleCamera(), onPressed: () => callNotifier.toggleCamera(),
backgroundColor: const Color(0xFF424242), backgroundColor: const Color(0xFF424242),
hasDropdown: true, hasDropdown: true,
deviceType: 'videoinput', deviceType: 'videoinput',
), ),
_buildCircularButton( _buildCircularButton(
icon: icon: callState.isScreenSharing
callState.isScreenSharing ? Symbols.stop_screen_share
? Icons.stop_screen_share : Symbols.screen_share,
: Icons.screen_share,
onPressed: () => callNotifier.toggleScreenShare(context), onPressed: () => callNotifier.toggleScreenShare(context),
backgroundColor: const Color(0xFF424242), backgroundColor: const Color(0xFF424242),
), ),
_buildCircularButtonWithDropdown( _buildCircularButtonWithDropdown(
context: context, context: context,
ref: ref, ref: ref,
icon: callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off, icon: callState.isMicrophoneEnabled ? Symbols.mic : Symbols.mic_off,
onPressed: () => callNotifier.toggleMicrophone(), onPressed: () => callNotifier.toggleMicrophone(),
backgroundColor: const Color(0xFF424242), backgroundColor: const Color(0xFF424242),
hasDropdown: true, hasDropdown: true,
deviceType: 'audioinput', deviceType: 'audioinput',
), ),
_buildCircularButton( _buildCircularButton(
icon: icon: callState.isSpeakerphone
callState.isSpeakerphone ? Symbols.mobile_speaker
? Symbols.mobile_speaker : Symbols.ear_sound,
: Symbols.ear_sound,
onPressed: () => callNotifier.toggleSpeakerphone(), onPressed: () => callNotifier.toggleSpeakerphone(),
backgroundColor: const Color(0xFF424242), backgroundColor: const Color(0xFF424242),
), ),
_buildCircularButton(
icon: callState.viewMode == ViewMode.grid
? Symbols.grid_view
: Symbols.view_list,
onPressed: () => callNotifier.toggleViewMode(),
backgroundColor: const Color(0xFF424242),
),
_buildCircularButton( _buildCircularButton(
icon: Icons.call_end, icon: Icons.call_end,
onPressed: onPressed: () => showModalBottomSheet(
() => showModalBottomSheet( context: context,
context: context, isScrollControlled: true,
isScrollControlled: true, useRootNavigator: true,
useRootNavigator: true, builder: (innerContext) => Column(
builder: mainAxisSize: MainAxisSize.min,
(innerContext) => Column( children: [
mainAxisSize: MainAxisSize.min, const Gap(24),
children: [ ListTile(
const Gap(24), leading: const Icon(Symbols.logout, fill: 1),
ListTile( title: Text('callLeave').tr(),
leading: const Icon(Symbols.logout, fill: 1), onTap: () {
title: Text('callLeave').tr(), callNotifier.disconnect();
onTap: () { if (popOnLeaves) {
callNotifier.disconnect(); if (Navigator.of(context).canPop()) {
if (Navigator.of(context).canPop()) { Navigator.of(context).pop();
Navigator.of(context).pop(); }
} Navigator.of(innerContext).pop();
Navigator.of(innerContext).pop(); }
}, },
), ),
ListTile( ListTile(
leading: const Icon(Symbols.call_end, fill: 1), leading: const Icon(Symbols.call_end, fill: 1),
iconColor: Colors.red, iconColor: Colors.red,
title: Text('callEnd').tr(), title: Text('callEnd').tr(),
onTap: () async { onTap: () async {
callNotifier.disconnect(); callNotifier.disconnect();
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
try { try {
showLoadingModal(context); showLoadingModal(context);
await apiClient.delete( await apiClient.delete(
'/sphere/chat/realtime/${callNotifier.roomId}', '/sphere/chat/realtime/${callNotifier.roomId}',
); );
callNotifier.dispose(); callNotifier.dispose();
if (context.mounted) { if (context.mounted && popOnLeaves) {
if (Navigator.of(context).canPop()) { if (Navigator.of(context).canPop()) {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
Navigator.of(innerContext).pop(); Navigator.of(innerContext).pop();
} }
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
} finally { } finally {
if (context.mounted) hideLoadingModal(context); if (context.mounted) hideLoadingModal(context);
} }
}, },
), ),
Gap(MediaQuery.of(context).padding.bottom), Gap(MediaQuery.of(context).padding.bottom),
], ],
), ),
), ),
backgroundColor: const Color(0xFFE53E3E), backgroundColor: const Color(0xFFE53E3E),
iconColor: Colors.white, iconColor: Colors.white,
), ),
@@ -185,12 +196,11 @@ class CallControlsBar extends HookConsumerWidget {
bottom: 0, bottom: 0,
right: isCompact ? 0 : -4, right: isCompact ? 0 : -4,
child: Material( child: Material(
color: color: Colors
Colors .transparent, // Make Material transparent to show underlying color
.transparent, // Make Material transparent to show underlying color
child: InkWell( child: InkWell(
onTap: onTap: () =>
() => _showDeviceSelectionDialog(context, ref, deviceType), _showDeviceSelectionDialog(context, ref, deviceType),
borderRadius: BorderRadius.circular((isCompact ? 16 : 24) / 2), borderRadius: BorderRadius.circular((isCompact ? 16 : 24) / 2),
child: Container( child: Container(
width: isCompact ? 16 : 24, width: isCompact ? 16 : 24,
@@ -232,10 +242,9 @@ class CallControlsBar extends HookConsumerWidget {
context: context, context: context,
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
return SheetScaffold( return SheetScaffold(
titleText: titleText: deviceType == 'videoinput'
deviceType == 'videoinput' ? 'selectCamera'.tr()
? 'selectCamera'.tr() : 'selectMicrophone'.tr(),
: 'selectMicrophone'.tr(),
child: ListView.builder( child: ListView.builder(
itemCount: devices.length, itemCount: devices.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -434,30 +443,23 @@ class CallOverlayBar extends HookConsumerWidget {
) { ) {
final lastSpeaker = final lastSpeaker =
callNotifier.participants callNotifier.participants
.where( .where((element) => element.remoteParticipant.lastSpokeAt != null)
(element) => element.remoteParticipant.lastSpokeAt != null, .isEmpty
) ? callNotifier.participants.firstOrNull
.isEmpty : callNotifier.participants
? callNotifier.participants.firstOrNull .where((element) => element.remoteParticipant.lastSpokeAt != null)
: callNotifier.participants .fold(
.where( callNotifier.participants.firstOrNull,
(element) => element.remoteParticipant.lastSpokeAt != null, (value, element) =>
) element.remoteParticipant.lastSpokeAt != null &&
.fold( (value?.remoteParticipant.lastSpokeAt == null ||
callNotifier.participants.firstOrNull, element.remoteParticipant.lastSpokeAt!.compareTo(
(value, element) => value!.remoteParticipant.lastSpokeAt!,
element.remoteParticipant.lastSpokeAt != null && ) >
(value?.remoteParticipant.lastSpokeAt == null || 0)
element.remoteParticipant.lastSpokeAt! ? element
.compareTo( : value,
value! );
.remoteParticipant
.lastSpokeAt!,
) >
0)
? element
: value,
);
if (lastSpeaker == null) { if (lastSpeaker == null) {
return const SizedBox.shrink(key: ValueKey('active_waiting')); return const SizedBox.shrink(key: ValueKey('active_waiting'));
@@ -488,16 +490,15 @@ class CallOverlayBar extends HookConsumerWidget {
openColor: Theme.of(context).scaffoldBackgroundColor, openColor: Theme.of(context).scaffoldBackgroundColor,
middleColor: Theme.of(context).scaffoldBackgroundColor, middleColor: Theme.of(context).scaffoldBackgroundColor,
openBuilder: (context, action) => CallScreen(room: room), openBuilder: (context, action) => CallScreen(room: room),
closedBuilder: closedBuilder: (context, openContainer) => IconButton(
(context, openContainer) => IconButton( visualDensity: const VisualDensity(
visualDensity: const VisualDensity( horizontal: -4,
horizontal: -4, vertical: -4,
vertical: -4, ),
), icon: const Icon(Icons.fullscreen),
icon: const Icon(Icons.fullscreen), onPressed: openContainer,
onPressed: openContainer, tooltip: 'Full Screen',
tooltip: 'Full Screen', ),
),
), ),
IconButton( IconButton(
visualDensity: const VisualDensity( visualDensity: const VisualDensity(
@@ -512,10 +513,10 @@ class CallOverlayBar extends HookConsumerWidget {
).padding(horizontal: 12, vertical: 8), ).padding(horizontal: 12, vertical: 8),
// Video Preview // Video Preview
Container( Container(
height: 200, height: 320,
width: double.infinity, width: double.infinity,
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const CallContent(), child: const CallContent(outerMaxHeight: 320),
), ),
const CallControlsBar( const CallControlsBar(
isCompact: true, isCompact: true,
@@ -540,11 +541,10 @@ class CallOverlayBar extends HookConsumerWidget {
SizedBox( SizedBox(
width: 40, width: 40,
height: 40, height: 40,
child: child: SpeakingRippleAvatar(
SpeakingRippleAvatar( live: lastSpeaker,
live: lastSpeaker, size: 36,
size: 36, ).center(),
).center(),
), ),
const Gap(8), const Gap(8),
Column( Column(

View File

@@ -83,24 +83,21 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
alignment: Alignment.center, alignment: Alignment.center,
decoration: const BoxDecoration(shape: BoxShape.circle), decoration: const BoxDecoration(shape: BoxShape.circle),
child: account.when( child: account.when(
data: data: (value) => CallParticipantGestureDetector(
(value) => CallParticipantGestureDetector( participant: live,
participant: live, child: ProfilePictureWidget(
child: ProfilePictureWidget( file: value.profile.picture,
file: value.profile.picture, radius: size / 2,
radius: size / 2, ),
), ),
), error: (_, _) => CircleAvatar(
error: radius: size / 2,
(_, _) => CircleAvatar( child: const Icon(Symbols.question_mark),
radius: size / 2, ),
child: const Icon(Symbols.person_remove), loading: () => CircleAvatar(
), radius: size / 2,
loading: child: CircularProgressIndicator(),
() => CircleAvatar( ),
radius: size / 2,
child: CircularProgressIndicator(),
),
), ),
), ),
if (live.remoteParticipant.isMuted) if (live.remoteParticipant.isMuted)
@@ -130,12 +127,20 @@ class SpeakingRippleAvatar extends HookConsumerWidget {
class CallParticipantTile extends HookConsumerWidget { class CallParticipantTile extends HookConsumerWidget {
final CallParticipantLive live; final CallParticipantLive live;
final bool allTiles;
final double radius;
const CallParticipantTile({super.key, required this.live}); const CallParticipantTile({
super.key,
required this.live,
this.allTiles = false,
this.radius = 48,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final userInfo = ref.watch(accountProvider(live.participant.name)); final userInfo = ref.watch(accountProvider(live.participant.name));
final account = ref.watch(accountProvider(live.participant.identity));
final hasVideo = final hasVideo =
live.hasVideo && live.hasVideo &&
@@ -143,7 +148,7 @@ class CallParticipantTile extends HookConsumerWidget {
.where((pub) => pub.track != null && pub.kind == TrackType.VIDEO) .where((pub) => pub.track != null && pub.kind == TrackType.VIDEO)
.isNotEmpty; .isNotEmpty;
if (hasVideo) { if (hasVideo || allTiles) {
return Padding( return Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: LayoutBuilder( child: LayoutBuilder(
@@ -166,12 +171,11 @@ class CallParticipantTile extends HookConsumerWidget {
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: color: isSpeaking
isSpeaking ? Colors.green.withOpacity(
? Colors.green.withOpacity( 0.5 + 0.5 * audioLevel.clamp(0.0, 1.0),
0.5 + 0.5 * audioLevel.clamp(0.0, 1.0), )
) : Theme.of(context).colorScheme.outlineVariant,
: Theme.of(context).colorScheme.outlineVariant,
width: isSpeaking ? 4 : 1, width: isSpeaking ? 4 : 1,
), ),
), ),
@@ -182,14 +186,37 @@ class CallParticipantTile extends HookConsumerWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
VideoTrackRenderer( if (hasVideo)
live.remoteParticipant.trackPublications.values VideoTrackRenderer(
.where((track) => track.kind == TrackType.VIDEO) live.remoteParticipant.trackPublications.values
.first .where(
.track (track) => track.kind == TrackType.VIDEO,
as VideoTrack, )
renderMode: VideoRenderMode.platformView, .first
), .track
as VideoTrack,
renderMode: VideoRenderMode.platformView,
)
else
Center(
child: account.when(
data: (value) => CallParticipantGestureDetector(
participant: live,
child: ProfilePictureWidget(
file: value.profile.picture,
radius: radius,
),
),
error: (_, _) => CircleAvatar(
radius: radius,
child: const Icon(Symbols.question_mark),
),
loading: () => CircleAvatar(
radius: radius,
child: CircularProgressIndicator(),
),
),
),
Positioned( Positioned(
left: 8, left: 8,
bottom: 8, bottom: 8,

View File

@@ -58,7 +58,7 @@ final class RepliesNotifierProvider
} }
} }
String _$repliesNotifierHash() => r'2fa51bc3b8cc640e68fa316f61d00f8a0a3740ed'; String _$repliesNotifierHash() => r'fcaea9b502b1d713a8084da022a03e86d67acc1a';
final class RepliesNotifierFamily extends $Family final class RepliesNotifierFamily extends $Family
with with