Better chat overlay

This commit is contained in:
2025-05-27 02:35:08 +08:00
parent fd587270da
commit 688f035f85
14 changed files with 525 additions and 409 deletions

View File

@ -13,8 +13,6 @@ import 'package:island/services/responsive.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/widgets/chat/call_overlay.dart';
import 'package:island/pods/call.dart';
class WindowScaffold extends HookConsumerWidget {
final Widget child;
@ -152,22 +150,8 @@ class AppScaffold extends StatelessWidget {
noBackground
? Colors.transparent
: Theme.of(context).scaffoldBackgroundColor,
body: Stack(
children: [
SizedBox.expand(
child:
noBackground
? content
: AppBackground(isRoot: true, child: content),
),
Positioned(
left: 16,
right: 16,
bottom: 8,
child: const _GlobalCallOverlay(),
),
],
),
body:
noBackground ? content : AppBackground(isRoot: true, child: content),
appBar: appBar,
bottomNavigationBar: bottomNavigationBar,
bottomSheet: bottomSheet,
@ -206,23 +190,6 @@ class PageBackButton extends StatelessWidget {
const kAppBackgroundImagePath = 'island_app_background';
/// Global call overlay bar (appears when in a call but not on the call screen)
class _GlobalCallOverlay extends HookConsumerWidget {
const _GlobalCallOverlay();
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callNotifierProvider);
// Find current route name
final modalRoute = ModalRoute.of(context);
final isOnCallScreen = modalRoute?.settings.name?.contains('call') ?? false;
// You may want to store roomId in callState for more robust navigation
if (callState.isConnected && !isOnCallScreen) {
return CallOverlayBar();
}
return const SizedBox.shrink();
}
}
final backgroundImageFileProvider = FutureProvider<File?>((ref) async {
if (kIsWeb) return null;
final dir = await getApplicationSupportDirectory();

View File

@ -1,10 +1,94 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/call.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/route.gr.dart';
import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:styled_widget/styled_widget.dart';
class CallControlsBar extends HookConsumerWidget {
const CallControlsBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.read(callNotifierProvider.notifier);
final userInfo = ref.watch(userInfoProvider);
final actionButtonStyle = ButtonStyle(
minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
);
return Card(
margin: const EdgeInsets.only(left: 12, right: 12, top: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Builder(
builder: (context) {
if (callNotifier.localParticipant == null) {
return CircularProgressIndicator().center();
}
return SizedBox(
width: 40,
height: 40,
child:
SpeakingRippleAvatar(
isSpeaking:
callNotifier.localParticipant!.isSpeaking,
audioLevel:
callNotifier.localParticipant!.audioLevel,
pictureId: userInfo.value?.profile.pictureId,
size: 36,
).center(),
);
},
),
],
),
),
IconButton(
icon: Icon(
callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
),
onPressed: () {
callNotifier.toggleMicrophone();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
),
onPressed: () {
callNotifier.toggleCamera();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isScreenSharing
? Icons.stop_screen_share
: Icons.screen_share,
),
onPressed: () {
callNotifier.toggleScreenShare();
},
style: actionButtonStyle,
),
],
).padding(all: 16),
);
}
}
/// A floating bar that appears when user is in a call but not on the call screen.
class CallOverlayBar extends HookConsumerWidget {
const CallOverlayBar({super.key});
@ -15,48 +99,123 @@ class CallOverlayBar extends HookConsumerWidget {
// Only show if connected and not on the call screen
if (!callState.isConnected) return const SizedBox.shrink();
return Positioned(
left: 16,
right: 16,
bottom: 32,
child: GestureDetector(
onTap: () {
if (callNotifier.roomId == null) return;
context.router.push(CallRoute(roomId: callNotifier.roomId!));
},
child: Material(
elevation: 8,
borderRadius: BorderRadius.circular(24),
color: Theme.of(context).colorScheme.primary,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.call, color: Colors.white),
const SizedBox(width: 12),
const Text(
'In call',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
final lastSpeaker =
callNotifier.participants
.where(
(element) => element.remoteParticipant.lastSpokeAt != null,
)
.isEmpty
? callNotifier.participants.first
: callNotifier.participants
.where(
(element) => element.remoteParticipant.lastSpokeAt != null,
)
.fold(
callNotifier.participants.first,
(value, element) =>
element.remoteParticipant.lastSpokeAt != null &&
(value.remoteParticipant.lastSpokeAt == null ||
element.remoteParticipant.lastSpokeAt!
.compareTo(
value
.remoteParticipant
.lastSpokeAt!,
) >
0)
? element
: value,
);
final actionButtonStyle = ButtonStyle(
minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
);
return GestureDetector(
child: Card(
margin: EdgeInsets.zero,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
children: [
Builder(
builder: (context) {
if (callNotifier.localParticipant == null) {
return CircularProgressIndicator().center();
}
return SizedBox(
width: 40,
height: 40,
child:
SpeakingRippleAvatar(
isSpeaking: lastSpeaker.isSpeaking,
audioLevel:
lastSpeaker.remoteParticipant.audioLevel,
pictureId:
lastSpeaker
.participant
.profile
?.account
.profile
.pictureId,
size: 36,
).center(),
);
},
),
const Gap(8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
lastSpeaker.participant.profile?.account.nick ??
'unknown'.tr(),
).bold(),
Text(
formatDuration(callState.duration),
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
const Icon(
Icons.arrow_forward_ios,
color: Colors.white,
size: 18,
),
],
],
),
],
),
),
),
),
IconButton(
icon: Icon(
callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
),
onPressed: () {
callNotifier.toggleMicrophone();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
),
onPressed: () {
callNotifier.toggleCamera();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
callState.isScreenSharing
? Icons.stop_screen_share
: Icons.screen_share,
),
onPressed: () {
callNotifier.toggleScreenShare();
},
style: actionButtonStyle,
),
],
).padding(all: 16),
),
onTap: () {
context.router.push(CallRoute(roomId: callNotifier.roomId!));
},
);
}
}

View File

@ -4,6 +4,7 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/database/message.dart';
import 'package:island/models/chat.dart';
import 'package:island/pods/call.dart';
import 'package:island/screens/chat/room.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_file_collection.dart';
@ -429,14 +430,6 @@ class _MessageContentCall extends StatelessWidget {
@override
Widget build(BuildContext context) {
String formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
return '${hours == 0 ? '' : '$hours hours '}'
'${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [