🎨 Use feature based folder structure
This commit is contained in:
117
lib/chat/chat_widgets/call_button.dart
Normal file
117
lib/chat/chat_widgets/call_button.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/chat/chat_pod/call.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'call_button.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<SnRealtimeCall?> ongoingCall(Ref ref, String roomId) async {
|
||||
if (roomId.isEmpty) return null;
|
||||
try {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final resp = await apiClient.get('/messager/chat/realtime/$roomId');
|
||||
return SnRealtimeCall.fromJson(resp.data);
|
||||
} catch (e) {
|
||||
if (e is DioException && e.response?.statusCode == 404) {
|
||||
return null;
|
||||
}
|
||||
showErrorAlert(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class AudioCallButton extends HookConsumerWidget {
|
||||
final SnChatRoom room;
|
||||
const AudioCallButton({super.key, required this.room});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final ongoingCall = ref.watch(ongoingCallProvider(room.id));
|
||||
final callState = ref.watch(callProvider);
|
||||
final callNotifier = ref.read(callProvider.notifier);
|
||||
final isLoading = useState(false);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
|
||||
Future<void> handleJoin() async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await apiClient.post('/messager/chat/realtime/${room.id}');
|
||||
ref.invalidate(ongoingCallProvider(room.id));
|
||||
// Just join the room, the overlay will handle the UI
|
||||
await callNotifier.joinRoom(room);
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleEnd() async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await apiClient.delete('/messager/chat/realtime/${room.id}');
|
||||
callNotifier.dispose(); // Clean up call resources
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading.value) {
|
||||
return IconButton(
|
||||
icon: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
padding: EdgeInsets.all(4),
|
||||
),
|
||||
),
|
||||
onPressed: null,
|
||||
);
|
||||
}
|
||||
|
||||
if (callState.isConnected) {
|
||||
// Show end call button if in call
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.call_end),
|
||||
tooltip: 'End Call',
|
||||
onPressed: handleEnd,
|
||||
);
|
||||
}
|
||||
|
||||
if (ongoingCall.value != null) {
|
||||
// There is an ongoing call, offer to join it directly
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.call),
|
||||
tooltip: 'Join Ongoing Call',
|
||||
onPressed: () async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await callNotifier.joinRoom(room);
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Show join/start call button
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.call),
|
||||
tooltip: 'Start Call',
|
||||
onPressed: handleJoin,
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/chat/chat_widgets/call_button.g.dart
Normal file
85
lib/chat/chat_widgets/call_button.g.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'call_button.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(ongoingCall)
|
||||
final ongoingCallProvider = OngoingCallFamily._();
|
||||
|
||||
final class OngoingCallProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<SnRealtimeCall?>,
|
||||
SnRealtimeCall?,
|
||||
FutureOr<SnRealtimeCall?>
|
||||
>
|
||||
with $FutureModifier<SnRealtimeCall?>, $FutureProvider<SnRealtimeCall?> {
|
||||
OngoingCallProvider._({
|
||||
required OngoingCallFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'ongoingCallProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$ongoingCallHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'ongoingCallProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<SnRealtimeCall?> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<SnRealtimeCall?> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return ongoingCall(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is OngoingCallProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$ongoingCallHash() => r'3d1efaaca2981ebf698e9241453dbf2b2f13bfe3';
|
||||
|
||||
final class OngoingCallFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<SnRealtimeCall?>, String> {
|
||||
OngoingCallFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'ongoingCallProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
OngoingCallProvider call(String roomId) =>
|
||||
OngoingCallProvider._(argument: roomId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'ongoingCallProvider';
|
||||
}
|
||||
170
lib/chat/chat_widgets/call_content.dart
Normal file
170
lib/chat/chat_widgets/call_content.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/chat/chat_pod/call.dart';
|
||||
import 'package:island/chat/chat_widgets/call_participant_tile.dart';
|
||||
import 'package:livekit_client/livekit_client.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 {
|
||||
final double? outerMaxHeight;
|
||||
const CallContent({super.key, this.outerMaxHeight});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final callState = ref.watch(callProvider);
|
||||
final callNotifier = ref.watch(callProvider.notifier);
|
||||
|
||||
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 &&
|
||||
!pub.muted &&
|
||||
!pub.isDisposed,
|
||||
)),
|
||||
);
|
||||
|
||||
if (allAudioOnly) {
|
||||
// Audio-only: show avatars in a compact row with animated containers
|
||||
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.all(8),
|
||||
child: SpeakingRippleAvatar(live: live, size: 72),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (callState.viewMode == ViewMode.stage) {
|
||||
// Stage: allow user to select a participant to focus, show others below
|
||||
return CallStageView(
|
||||
participants: participants,
|
||||
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 SingleChildScrollView(
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final participant in participants)
|
||||
SizedBox(
|
||||
width: itemWidth,
|
||||
child: CallParticipantTile(
|
||||
live: participant,
|
||||
allTiles: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
605
lib/chat/chat_widgets/call_overlay.dart
Normal file
605
lib/chat/chat_widgets/call_overlay.dart
Normal file
@@ -0,0 +1,605 @@
|
||||
import 'package:animations/animations.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/chat/chat_models/chat.dart';
|
||||
import 'package:island/chat/chat_pod/call.dart';
|
||||
import 'package:island/accounts/accounts_pod.dart';
|
||||
import 'package:island/chat/chat_widgets/call_button.dart';
|
||||
import 'package:island/chat/chat_widgets/call_content.dart';
|
||||
import 'package:island/chat/chat_widgets/call_participant_tile.dart';
|
||||
import 'package:island/chat/chat_widgets/call_screen.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
class CallControlsBar extends HookConsumerWidget {
|
||||
final bool isCompact;
|
||||
final bool popOnLeaves;
|
||||
const CallControlsBar({
|
||||
super.key,
|
||||
this.isCompact = false,
|
||||
this.popOnLeaves = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final callState = ref.watch(callProvider);
|
||||
final callNotifier = ref.read(callProvider.notifier);
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isCompact ? 12 : 20,
|
||||
vertical: isCompact ? 8 : 16,
|
||||
),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
runSpacing: isCompact ? 12 : 16,
|
||||
spacing: isCompact ? 12 : 16,
|
||||
children: [
|
||||
_buildCircularButtonWithDropdown(
|
||||
context: context,
|
||||
ref: ref,
|
||||
icon: callState.isCameraEnabled
|
||||
? Symbols.videocam
|
||||
: Symbols.videocam_off,
|
||||
onPressed: () => callNotifier.toggleCamera(),
|
||||
backgroundColor: const Color(0xFF424242),
|
||||
hasDropdown: true,
|
||||
deviceType: 'videoinput',
|
||||
),
|
||||
_buildCircularButton(
|
||||
icon: callState.isScreenSharing
|
||||
? Symbols.stop_screen_share
|
||||
: Symbols.screen_share,
|
||||
onPressed: () => callNotifier.toggleScreenShare(context),
|
||||
backgroundColor: const Color(0xFF424242),
|
||||
),
|
||||
_buildCircularButtonWithDropdown(
|
||||
context: context,
|
||||
ref: ref,
|
||||
icon: callState.isMicrophoneEnabled ? Symbols.mic : Symbols.mic_off,
|
||||
onPressed: () => callNotifier.toggleMicrophone(),
|
||||
backgroundColor: const Color(0xFF424242),
|
||||
hasDropdown: true,
|
||||
deviceType: 'audioinput',
|
||||
),
|
||||
_buildCircularButton(
|
||||
icon: callState.isSpeakerphone
|
||||
? Symbols.mobile_speaker
|
||||
: Symbols.ear_sound,
|
||||
onPressed: () => callNotifier.toggleSpeakerphone(),
|
||||
backgroundColor: const Color(0xFF424242),
|
||||
),
|
||||
_buildCircularButton(
|
||||
icon: callState.viewMode == ViewMode.grid
|
||||
? Symbols.grid_view
|
||||
: Symbols.view_list,
|
||||
onPressed: () => callNotifier.toggleViewMode(),
|
||||
backgroundColor: const Color(0xFF424242),
|
||||
),
|
||||
_buildCircularButton(
|
||||
icon: Icons.call_end,
|
||||
onPressed: () => showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (innerContext) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Gap(24),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.logout, fill: 1),
|
||||
title: Text('callLeave').tr(),
|
||||
onTap: () {
|
||||
callNotifier.disconnect();
|
||||
if (popOnLeaves) {
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
Navigator.of(innerContext).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.call_end, fill: 1),
|
||||
iconColor: Colors.red,
|
||||
title: Text('callEnd').tr(),
|
||||
onTap: () async {
|
||||
callNotifier.disconnect();
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
await apiClient.delete(
|
||||
'/messager/chat/realtime/${callNotifier.roomId}',
|
||||
);
|
||||
callNotifier.dispose();
|
||||
if (context.mounted && popOnLeaves) {
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
Navigator.of(innerContext).pop();
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
Gap(MediaQuery.of(context).padding.bottom),
|
||||
],
|
||||
),
|
||||
),
|
||||
backgroundColor: const Color(0xFFE53E3E),
|
||||
iconColor: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCircularButton({
|
||||
required IconData icon,
|
||||
required VoidCallback onPressed,
|
||||
required Color backgroundColor,
|
||||
Color? iconColor,
|
||||
}) {
|
||||
final size = isCompact ? 40.0 : 56.0;
|
||||
final iconSize = isCompact ? 20.0 : 24.0;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, color: iconColor ?? Colors.white, size: iconSize),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCircularButtonWithDropdown({
|
||||
required BuildContext context,
|
||||
required WidgetRef ref,
|
||||
required IconData icon,
|
||||
required VoidCallback onPressed,
|
||||
required Color backgroundColor,
|
||||
required bool hasDropdown,
|
||||
Color? iconColor,
|
||||
String? deviceType, // 'videoinput' or 'audioinput'
|
||||
}) {
|
||||
final size = isCompact ? 40.0 : 56.0;
|
||||
final iconSize = isCompact ? 20.0 : 24.0;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, color: iconColor ?? Colors.white, size: iconSize),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
),
|
||||
if (hasDropdown && deviceType != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: isCompact ? 0 : -4,
|
||||
child: Material(
|
||||
color: Colors
|
||||
.transparent, // Make Material transparent to show underlying color
|
||||
child: InkWell(
|
||||
onTap: () =>
|
||||
_showDeviceSelectionDialog(context, ref, deviceType),
|
||||
borderRadius: BorderRadius.circular((isCompact ? 16 : 24) / 2),
|
||||
child: Container(
|
||||
width: isCompact ? 16 : 24,
|
||||
height: isCompact ? 16 : 24,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor.withOpacity(0.8),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: Colors.white,
|
||||
size: isCompact ? 12 : 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showDeviceSelectionDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String deviceType,
|
||||
) async {
|
||||
try {
|
||||
final devices = await Hardware.instance.enumerateDevices(
|
||||
type: deviceType,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return SheetScaffold(
|
||||
titleText: deviceType == 'videoinput'
|
||||
? 'selectCamera'.tr()
|
||||
: 'selectMicrophone'.tr(),
|
||||
child: ListView.builder(
|
||||
itemCount: devices.length,
|
||||
itemBuilder: (context, index) {
|
||||
final device = devices[index];
|
||||
return ListTile(
|
||||
title: Text(
|
||||
device.label.isNotEmpty
|
||||
? device.label
|
||||
: '${'device'.tr()} ${index + 1}',
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
_switchDevice(context, ref, device, deviceType);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _switchDevice(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
MediaDevice device,
|
||||
String deviceType,
|
||||
) async {
|
||||
try {
|
||||
final callNotifier = ref.read(callProvider.notifier);
|
||||
|
||||
if (deviceType == 'videoinput') {
|
||||
// Switch camera device
|
||||
final localParticipant = callNotifier.room?.localParticipant;
|
||||
final videoTrack =
|
||||
localParticipant?.videoTrackPublications.firstOrNull?.track;
|
||||
|
||||
if (videoTrack is LocalVideoTrack) {
|
||||
await videoTrack.switchCamera(device.deviceId);
|
||||
}
|
||||
} else if (deviceType == 'audioinput') {
|
||||
// Switch microphone device
|
||||
final localParticipant = callNotifier.room?.localParticipant;
|
||||
final audioTrack =
|
||||
localParticipant?.audioTrackPublications.firstOrNull?.track;
|
||||
|
||||
if (audioTrack is LocalAudioTrack) {
|
||||
// For audio devices, we need to restart the track with new device
|
||||
await audioTrack.restartTrack(
|
||||
AudioCaptureOptions(deviceId: device.deviceId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
showSnackBar(
|
||||
'switchedTo'.tr(
|
||||
args: [device.label.isNotEmpty ? device.label : 'device'],
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CallOverlayBar extends HookConsumerWidget {
|
||||
final SnChatRoom room;
|
||||
const CallOverlayBar({super.key, required this.room});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Use selective watching to reduce rebuilds
|
||||
final isConnected = ref.watch(
|
||||
callProvider.select((state) => state.isConnected),
|
||||
);
|
||||
final duration = ref.watch(callProvider.select((state) => state.duration));
|
||||
final isMicrophoneEnabled = ref.watch(
|
||||
callProvider.select((state) => state.isMicrophoneEnabled),
|
||||
);
|
||||
final callNotifier = ref.read(callProvider.notifier);
|
||||
final ongoingCall = ref.watch(ongoingCallProvider(room.id));
|
||||
|
||||
// Memoize expensive computations
|
||||
final lastSpeaker = useMemoized(() {
|
||||
final participants = callNotifier.participants;
|
||||
if (participants.isEmpty) return null;
|
||||
|
||||
final speakers = participants.where(
|
||||
(element) => element.remoteParticipant.lastSpokeAt != null,
|
||||
);
|
||||
|
||||
if (speakers.isEmpty) return participants.first;
|
||||
|
||||
return speakers.fold<CallParticipantLive?>(null, (previous, current) {
|
||||
if (previous == null) return current;
|
||||
return current.remoteParticipant.lastSpokeAt!.compareTo(
|
||||
previous.remoteParticipant.lastSpokeAt!,
|
||||
) >
|
||||
0
|
||||
? current
|
||||
: previous;
|
||||
});
|
||||
}, [callNotifier.participants]);
|
||||
|
||||
final userInfo = ref.watch(userInfoProvider).value!;
|
||||
|
||||
// Memoize chat room name
|
||||
final chatRoomName = useMemoized(() {
|
||||
final room = callNotifier.chatRoom;
|
||||
if (room == null) return 'unnamed'.tr();
|
||||
return room.name ??
|
||||
(room.members ?? [])
|
||||
.where((element) => element.id != userInfo.id)
|
||||
.map((element) => element.account.nick)
|
||||
.first;
|
||||
}, [callNotifier.chatRoom, userInfo]);
|
||||
|
||||
// State for overlay mode: compact or preview
|
||||
// Default to true (preview mode) so user sees video immediately after joining
|
||||
final isExpanded = useState(true);
|
||||
|
||||
Widget child;
|
||||
if (isConnected) {
|
||||
child = _buildActiveCallOverlay(
|
||||
context,
|
||||
ref,
|
||||
duration,
|
||||
isMicrophoneEnabled,
|
||||
callNotifier,
|
||||
lastSpeaker,
|
||||
chatRoomName,
|
||||
isExpanded,
|
||||
);
|
||||
} else if (ongoingCall.value != null) {
|
||||
child = _buildJoinPrompt(context, ref);
|
||||
} else {
|
||||
child = const SizedBox.shrink(key: ValueKey('empty'));
|
||||
}
|
||||
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
layoutBuilder: (currentChild, previousChildren) {
|
||||
return Stack(
|
||||
alignment: Alignment.topCenter,
|
||||
children: <Widget>[...previousChildren, ?currentChild],
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJoinPrompt(BuildContext context, WidgetRef ref) {
|
||||
final isLoading = useState(false);
|
||||
|
||||
return Card(
|
||||
key: const ValueKey('join_prompt'),
|
||||
margin: EdgeInsets.zero,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.videocam,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Call in progress').bold(),
|
||||
Text('Tap to join', style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
if (isLoading.value)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
).padding(right: 8)
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: () async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
// Just join the room, don't navigate
|
||||
await ref.read(callProvider.notifier).joinRoom(room);
|
||||
} catch (e) {
|
||||
showErrorAlert(e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.call, size: 18),
|
||||
label: const Text('Join'),
|
||||
style: FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(all: 12),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActiveCallOverlay(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Duration duration,
|
||||
bool isMicrophoneEnabled,
|
||||
CallNotifier callNotifier,
|
||||
CallParticipantLive? lastSpeaker,
|
||||
String chatRoomName,
|
||||
ValueNotifier<bool> isExpanded,
|
||||
) {
|
||||
if (lastSpeaker == null) {
|
||||
return const SizedBox.shrink(key: ValueKey('active_waiting'));
|
||||
}
|
||||
|
||||
// Preview Mode (Expanded)
|
||||
if (isExpanded.value) {
|
||||
return Card(
|
||||
key: const ValueKey('active_expanded'),
|
||||
margin: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
const Gap(4),
|
||||
Text(chatRoomName),
|
||||
const Gap(4),
|
||||
Text(formatDuration(duration)).bold(),
|
||||
const Spacer(),
|
||||
OpenContainer(
|
||||
closedElevation: 0,
|
||||
closedColor: Colors.transparent,
|
||||
openColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
middleColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
openBuilder: (context, action) => CallScreen(room: room),
|
||||
closedBuilder: (context, openContainer) => IconButton(
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
icon: const Icon(Icons.fullscreen),
|
||||
onPressed: openContainer,
|
||||
tooltip: 'Full Screen',
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
icon: const Icon(Icons.expand_less),
|
||||
onPressed: () => isExpanded.value = false,
|
||||
tooltip: 'Collapse',
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12, vertical: 8),
|
||||
// Video Preview
|
||||
Container(
|
||||
height: 320,
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: const CallContent(outerMaxHeight: 320),
|
||||
),
|
||||
const CallControlsBar(
|
||||
isCompact: true,
|
||||
).padding(vertical: 8, horizontal: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Compact Mode
|
||||
return GestureDetector(
|
||||
key: const ValueKey('active_collapsed'),
|
||||
onTap: () => isExpanded.value = true,
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: SpeakingRippleAvatar(
|
||||
live: lastSpeaker,
|
||||
size: 36,
|
||||
).center(),
|
||||
),
|
||||
const Gap(8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('@${lastSpeaker.participant.identity}').bold(),
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
chatRoomName,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
formatDuration(duration),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
callNotifier.toggleMicrophone();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.expand_more),
|
||||
onPressed: () => isExpanded.value = true,
|
||||
tooltip: 'Expand',
|
||||
),
|
||||
],
|
||||
).padding(all: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
128
lib/chat/chat_widgets/call_participant_card.dart
Normal file
128
lib/chat/chat_widgets/call_participant_card.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_popup_card/flutter_popup_card.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_nameplate.dart';
|
||||
import 'package:island/chat/chat_pod/call.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class CallParticipantCard extends HookConsumerWidget {
|
||||
final CallParticipantLive live;
|
||||
const CallParticipantCard({super.key, required this.live});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final width = math
|
||||
.min(MediaQuery.of(context).size.width - 80, 360)
|
||||
.toDouble();
|
||||
final callNotifier = ref.watch(callProvider.notifier);
|
||||
|
||||
final volumeSliderValue = useState(callNotifier.getParticipantVolume(live));
|
||||
|
||||
return PopupCard(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Column(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Symbols.sound_detection_loud_sound, size: 16),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
max: 2,
|
||||
value: volumeSliderValue.value,
|
||||
onChanged: (value) {
|
||||
volumeSliderValue.value = value;
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
callNotifier.setParticipantVolume(live, value);
|
||||
},
|
||||
year2023: true,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: Text(
|
||||
'${(volumeSliderValue.value * 100).toStringAsFixed(0)}%',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Symbols.wifi, size: 16),
|
||||
const Gap(8),
|
||||
Text(switch (live.remoteParticipant.connectionQuality) {
|
||||
ConnectionQuality.excellent => 'Excellent',
|
||||
ConnectionQuality.good => 'Good',
|
||||
ConnectionQuality.poor => 'Bad',
|
||||
ConnectionQuality.lost => 'Lost',
|
||||
_ => 'Connecting',
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 20, top: 16),
|
||||
AccountNameplate(
|
||||
name: live.participant.identity,
|
||||
isOutlined: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CallParticipantRegion extends StatelessWidget {
|
||||
final CallParticipantLive participant;
|
||||
final Widget child;
|
||||
const CallParticipantRegion({
|
||||
super.key,
|
||||
required this.participant,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
child: child,
|
||||
onTapDown: (details) {
|
||||
showCallParticipantCard(
|
||||
context,
|
||||
participant,
|
||||
offset: details.localPosition,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showCallParticipantCard(
|
||||
BuildContext context,
|
||||
CallParticipantLive participant, {
|
||||
Offset? offset,
|
||||
}) async {
|
||||
await showPopupCard<void>(
|
||||
offset: offset ?? Offset.zero,
|
||||
context: context,
|
||||
builder: (context) => CallParticipantCard(live: participant),
|
||||
alignment: Alignment.center,
|
||||
dimBackground: true,
|
||||
);
|
||||
}
|
||||
265
lib/chat/chat_widgets/call_participant_tile.dart
Normal file
265
lib/chat/chat_widgets/call_participant_tile.dart
Normal file
@@ -0,0 +1,265 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/account/profile.dart';
|
||||
import 'package:island/chat/chat_pod/call.dart';
|
||||
import 'package:island/chat/chat_widgets/call_participant_card.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class SpeakingRipple extends StatelessWidget {
|
||||
final double size;
|
||||
final double audioLevel;
|
||||
final bool isSpeaking;
|
||||
final Widget child;
|
||||
|
||||
const SpeakingRipple({
|
||||
super.key,
|
||||
required this.size,
|
||||
required this.audioLevel,
|
||||
required this.isSpeaking,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@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 SizedBox(
|
||||
width: size + 8,
|
||||
height: size + 8,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
child!,
|
||||
],
|
||||
);
|
||||
},
|
||||
child: SizedBox(width: size, height: size, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpeakingRippleAvatar extends HookConsumerWidget {
|
||||
final CallParticipantLive live;
|
||||
final double size;
|
||||
|
||||
const SpeakingRippleAvatar({super.key, required this.live, this.size = 96});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final account = ref.watch(accountProvider(live.participant.identity));
|
||||
|
||||
return SpeakingRipple(
|
||||
size: size,
|
||||
audioLevel: live.remoteParticipant.audioLevel,
|
||||
isSpeaking: live.remoteParticipant.isSpeaking,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(shape: BoxShape.circle),
|
||||
child: account.when(
|
||||
data: (value) => CallParticipantRegion(
|
||||
participant: live,
|
||||
child: ProfilePictureWidget(
|
||||
file: value.profile.picture,
|
||||
radius: size / 2,
|
||||
),
|
||||
),
|
||||
error: (_, _) => CircleAvatar(
|
||||
radius: size / 2,
|
||||
child: const Icon(Symbols.question_mark),
|
||||
),
|
||||
loading: () => CircleAvatar(
|
||||
radius: size / 2,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (live.remoteParticipant.isMuted)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
child: const Icon(
|
||||
Symbols.mic_off,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CallParticipantTile extends HookConsumerWidget {
|
||||
final CallParticipantLive live;
|
||||
final bool allTiles;
|
||||
final double radius;
|
||||
|
||||
const CallParticipantTile({
|
||||
super.key,
|
||||
required this.live,
|
||||
this.allTiles = false,
|
||||
this.radius = 48,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userInfo = ref.watch(accountProvider(live.participant.name));
|
||||
final account = ref.watch(accountProvider(live.participant.identity));
|
||||
|
||||
final hasVideo =
|
||||
live.hasVideo &&
|
||||
live.remoteParticipant.trackPublications.values
|
||||
.where((pub) => pub.track != null && pub.kind == TrackType.VIDEO)
|
||||
.isNotEmpty;
|
||||
|
||||
if (hasVideo || allTiles) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Use the smaller dimension to determine the "size" for the ripple calculation
|
||||
// effectively making the ripple relative to the tile size.
|
||||
// However, for a rectangular video, we might want a different approach.
|
||||
// The user asked for "speaking ripple to the video as well".
|
||||
// If we use the extracted SpeakingRipple, it expects a size and assumes a circle.
|
||||
// We need to adapt it or create a rectangular version.
|
||||
// Given the "image" likely shows a rectangular video with rounded corners,
|
||||
// let's create a specific wrapper for the video tile that adds a border/glow when speaking.
|
||||
|
||||
final isSpeaking = live.remoteParticipant.isSpeaking;
|
||||
final audioLevel = live.remoteParticipant.audioLevel;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSpeaking
|
||||
? Colors.green.withOpacity(
|
||||
0.5 + 0.5 * audioLevel.clamp(0.0, 1.0),
|
||||
)
|
||||
: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: isSpeaking ? 4 : 1,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (hasVideo)
|
||||
VideoTrackRenderer(
|
||||
live.remoteParticipant.trackPublications.values
|
||||
.where(
|
||||
(track) => track.kind == TrackType.VIDEO,
|
||||
)
|
||||
.first
|
||||
.track
|
||||
as VideoTrack,
|
||||
renderMode: VideoRenderMode.platformView,
|
||||
)
|
||||
else
|
||||
Center(
|
||||
child: account.when(
|
||||
data: (value) => CallParticipantRegion(
|
||||
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(
|
||||
left: 8,
|
||||
bottom: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.6),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (live.remoteParticipant.isMuted)
|
||||
const Icon(
|
||||
Symbols.mic_off,
|
||||
size: 14,
|
||||
color: Colors.redAccent,
|
||||
).padding(right: 4),
|
||||
Text(
|
||||
userInfo.value?.nick ?? live.participant.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return SpeakingRippleAvatar(size: 84, live: live);
|
||||
}
|
||||
}
|
||||
}
|
||||
131
lib/chat/chat_widgets/call_screen.dart
Normal file
131
lib/chat/chat_widgets/call_screen.dart
Normal file
@@ -0,0 +1,131 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart' hide ConnectionState;
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/chat/chat_pod/call.dart';
|
||||
import 'package:island/chat/chat_widgets/call_button.dart';
|
||||
import 'package:island/chat/chat_widgets/call_content.dart';
|
||||
import 'package:island/chat/chat_widgets/call_overlay.dart';
|
||||
import 'package:island/chat/chat_widgets/call_participant_tile.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/shared/widgets/app_scaffold.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class CallScreen extends HookConsumerWidget {
|
||||
final SnChatRoom room;
|
||||
const CallScreen({super.key, required this.room});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final ongoingCall = ref.watch(ongoingCallProvider(room.id));
|
||||
final callState = ref.watch(callProvider);
|
||||
final callNotifier = ref.watch(callProvider.notifier);
|
||||
|
||||
useEffect(() {
|
||||
talker.info('[Call] Joining the call...');
|
||||
callNotifier.joinRoom(room).catchError((_) {
|
||||
showConfirmAlert(
|
||||
'Seems there already has a call connected, do you want override it?',
|
||||
'Call already connected',
|
||||
).then((value) {
|
||||
if (value != true) return;
|
||||
talker.info('[Call] Joining the call... with overrides');
|
||||
callNotifier.disconnect();
|
||||
callNotifier.dispose();
|
||||
callNotifier.joinRoom(room);
|
||||
});
|
||||
});
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
final allAudioOnly = callNotifier.participants.every(
|
||||
(p) =>
|
||||
!(p.hasVideo &&
|
||||
p.remoteParticipant.trackPublications.values.any(
|
||||
(pub) =>
|
||||
pub.track != null &&
|
||||
pub.kind == TrackType.VIDEO &&
|
||||
!pub.muted &&
|
||||
!pub.isDisposed,
|
||||
)),
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
leading: PageBackButton(),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
ongoingCall.value?.room.name ?? 'call'.tr(),
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
callState.isConnected
|
||||
? formatDuration(callState.duration)
|
||||
: (switch (callNotifier.room?.connectionState) {
|
||||
ConnectionState.connected => 'connected',
|
||||
ConnectionState.connecting => 'connecting',
|
||||
ConnectionState.reconnecting => 'reconnecting',
|
||||
_ => 'disconnected',
|
||||
}).tr(),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (!allAudioOnly)
|
||||
SingleChildScrollView(
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
for (final live in callNotifier.participants)
|
||||
SpeakingRippleAvatar(live: live, size: 30),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: callState.error != null
|
||||
? Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Symbols.error_outline, size: 48),
|
||||
const Gap(4),
|
||||
Text(
|
||||
callState.error!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Color(0xFF757575)),
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
callNotifier.disconnect();
|
||||
callNotifier.dispose();
|
||||
callNotifier.joinRoom(room);
|
||||
},
|
||||
child: Text('retry').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
const SizedBox(width: double.infinity),
|
||||
Expanded(child: CallContent()),
|
||||
CallControlsBar(popOnLeaves: true),
|
||||
Gap(MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
756
lib/chat/chat_widgets/chat_detail_screen.dart
Normal file
756
lib/chat/chat_widgets/chat_detail_screen.dart
Normal file
@@ -0,0 +1,756 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_pfc.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_picker.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/status.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/chat/chat_pod/chat_room.dart';
|
||||
import 'package:island/chat/chat_widgets/chat_room_form.dart';
|
||||
import 'package:island/chat/chat_widgets/chat_search_screen.dart';
|
||||
import 'package:island/core/database.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/pagination/pagination.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/shared/widgets/app_scaffold.dart';
|
||||
import 'package:island/shared/widgets/pagination_list.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
part 'chat_detail_screen.freezed.dart';
|
||||
part 'chat_detail_screen.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<int> totalMessagesCount(Ref ref, String roomId) async {
|
||||
final database = ref.watch(databaseProvider);
|
||||
return database.getTotalMessagesForRoom(roomId);
|
||||
}
|
||||
|
||||
class ChatDetailScreen extends HookConsumerWidget {
|
||||
final String id;
|
||||
const ChatDetailScreen({super.key, required this.id});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final roomState = ref.watch(chatRoomProvider(id));
|
||||
final roomIdentity = ref.watch(chatRoomIdentityProvider(id));
|
||||
final totalMessages = ref.watch(totalMessagesCountProvider(id));
|
||||
|
||||
// Local state for pinned status to provide immediate UI feedback
|
||||
final isPinned = useState<bool?>(null);
|
||||
|
||||
// Initialize pinned state from database
|
||||
useEffect(() {
|
||||
final db = ref.read(databaseProvider);
|
||||
(db.select(
|
||||
db.chatRooms,
|
||||
)..where((r) => r.id.equals(id))).getSingleOrNull().then((room) {
|
||||
isPinned.value = room?.isPinned ?? false;
|
||||
});
|
||||
return null;
|
||||
}, [id]);
|
||||
|
||||
const kNotifyLevelText = [
|
||||
'chatNotifyLevelAll',
|
||||
'chatNotifyLevelMention',
|
||||
'chatNotifyLevelNone',
|
||||
];
|
||||
|
||||
void setNotifyLevel(int level) async {
|
||||
try {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.patch(
|
||||
'/messager/chat/$id/members/me/notify',
|
||||
data: {'notify_level': level},
|
||||
);
|
||||
ref.invalidate(chatRoomIdentityProvider(id));
|
||||
if (context.mounted) {
|
||||
showSnackBar(
|
||||
'chatNotifyLevelUpdated'.tr(args: [kNotifyLevelText[level].tr()]),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
void setChatBreak(DateTime until) async {
|
||||
try {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.patch(
|
||||
'/messager/chat/$id/members/me/notify',
|
||||
data: {'break_until': until.toUtc().toIso8601String()},
|
||||
);
|
||||
ref.invalidate(chatRoomIdentityProvider(id));
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
void showNotifyLevelBottomSheet(SnChatMember identity) {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => SheetScaffold(
|
||||
height: 320,
|
||||
titleText: 'chatNotifyLevel'.tr(),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('chatNotifyLevelAll').tr(),
|
||||
subtitle: const Text('chatNotifyLevelDescription').tr(),
|
||||
leading: const Icon(Icons.notifications_active),
|
||||
selected: identity.notify == 0,
|
||||
onTap: () {
|
||||
setNotifyLevel(0);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('chatNotifyLevelMention').tr(),
|
||||
subtitle: const Text('chatNotifyLevelDescription').tr(),
|
||||
leading: const Icon(Icons.alternate_email),
|
||||
selected: identity.notify == 1,
|
||||
onTap: () {
|
||||
setNotifyLevel(1);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('chatNotifyLevelNone').tr(),
|
||||
subtitle: const Text('chatNotifyLevelDescription').tr(),
|
||||
leading: const Icon(Icons.notifications_off),
|
||||
selected: identity.notify == 2,
|
||||
onTap: () {
|
||||
setNotifyLevel(2);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showChatBreakDialog() {
|
||||
final now = DateTime.now();
|
||||
final durationController = TextEditingController();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('chatBreak').tr(),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('chatBreakDescription').tr(),
|
||||
const Gap(16),
|
||||
ListTile(
|
||||
title: const Text('chatBreakClearButton').tr(),
|
||||
subtitle: const Text('chatBreakClear').tr(),
|
||||
leading: const Icon(Icons.notifications_active),
|
||||
onTap: () {
|
||||
setChatBreak(now);
|
||||
Navigator.pop(context);
|
||||
if (context.mounted) {
|
||||
showSnackBar('chatBreakCleared'.tr());
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('chatBreak5m').tr(),
|
||||
subtitle: const Text(
|
||||
'chatBreakHour',
|
||||
).tr(args: ['chatBreak5m'.tr()]),
|
||||
leading: const Icon(Symbols.circle),
|
||||
onTap: () {
|
||||
setChatBreak(now.add(const Duration(minutes: 5)));
|
||||
Navigator.pop(context);
|
||||
if (context.mounted) {
|
||||
showSnackBar('chatBreakSet'.tr(args: ['5m']));
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('chatBreak10m').tr(),
|
||||
subtitle: const Text(
|
||||
'chatBreakHour',
|
||||
).tr(args: ['chatBreak10m'.tr()]),
|
||||
leading: const Icon(Symbols.circle),
|
||||
onTap: () {
|
||||
setChatBreak(now.add(const Duration(minutes: 10)));
|
||||
Navigator.pop(context);
|
||||
if (context.mounted) {
|
||||
showSnackBar('chatBreakSet'.tr(args: ['10m']));
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('chatBreak15m').tr(),
|
||||
subtitle: const Text(
|
||||
'chatBreakHour',
|
||||
).tr(args: ['chatBreak15m'.tr()]),
|
||||
leading: const Icon(Symbols.timer_3),
|
||||
onTap: () {
|
||||
setChatBreak(now.add(const Duration(minutes: 15)));
|
||||
Navigator.pop(context);
|
||||
if (context.mounted) {
|
||||
showSnackBar('chatBreakSet'.tr(args: ['15m']));
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('chatBreak30m').tr(),
|
||||
subtitle: const Text(
|
||||
'chatBreakHour',
|
||||
).tr(args: ['chatBreak30m'.tr()]),
|
||||
leading: const Icon(Symbols.timer),
|
||||
onTap: () {
|
||||
setChatBreak(now.add(const Duration(minutes: 30)));
|
||||
Navigator.pop(context);
|
||||
if (context.mounted) {
|
||||
showSnackBar('chatBreakSet'.tr(args: ['30m']));
|
||||
}
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
TextField(
|
||||
controller: durationController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'chatBreakCustomMinutes'.tr(),
|
||||
hintText: 'chatBreakEnterMinutes'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.check),
|
||||
onPressed: () {
|
||||
final minutes = int.tryParse(durationController.text);
|
||||
if (minutes != null && minutes > 0) {
|
||||
setChatBreak(now.add(Duration(minutes: minutes)));
|
||||
Navigator.pop(context);
|
||||
if (context.mounted) {
|
||||
showSnackBar(
|
||||
'chatBreakSet'.tr(args: ['${minutes}m']),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('cancel').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const iconShadow = Shadow(
|
||||
color: Colors.black54,
|
||||
blurRadius: 5.0,
|
||||
offset: Offset(1.0, 1.0),
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
body: roomState.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('errorGeneric'.tr(args: [error.toString()]))),
|
||||
data: (currentRoom) => CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 180,
|
||||
pinned: true,
|
||||
leading: PageBackButton(shadows: [iconShadow]),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background:
|
||||
(currentRoom!.type == 1 && currentRoom.background != null)
|
||||
? CloudImageWidget(file: currentRoom.background!)
|
||||
: (currentRoom.type == 1 &&
|
||||
currentRoom.members!.length == 1 &&
|
||||
currentRoom
|
||||
.members!
|
||||
.first
|
||||
.account
|
||||
.profile
|
||||
.background
|
||||
?.id !=
|
||||
null)
|
||||
? CloudImageWidget(
|
||||
file: currentRoom
|
||||
.members!
|
||||
.first
|
||||
.account
|
||||
.profile
|
||||
.background!,
|
||||
)
|
||||
: currentRoom.background != null
|
||||
? CloudImageWidget(
|
||||
file: currentRoom.background!,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
color: Theme.of(context).appBarTheme.backgroundColor,
|
||||
),
|
||||
title: Text(
|
||||
(currentRoom.type == 1 && currentRoom.name == null)
|
||||
? currentRoom.members!
|
||||
.map((e) => e.account.nick)
|
||||
.join(', ')
|
||||
: currentRoom.name!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor,
|
||||
shadows: [iconShadow],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.people, shadows: [iconShadow]),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => _ChatMemberListSheet(roomId: id),
|
||||
);
|
||||
},
|
||||
),
|
||||
_ChatRoomActionMenu(id: id, iconShadow: iconShadow),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
currentRoom.description ?? 'descriptionNone'.tr(),
|
||||
style: const TextStyle(fontSize: 16),
|
||||
).padding(all: 24),
|
||||
const Divider(height: 1),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Pin/Unpin Switch
|
||||
if (isPinned.value != null)
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: Icon(
|
||||
Symbols.push_pin,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: const Text('pinChatRoom').tr(),
|
||||
subtitle: const Text('pinChatRoomDescription').tr(),
|
||||
value: isPinned.value!,
|
||||
onChanged: (value) async {
|
||||
// Update local state immediately for instant UI feedback
|
||||
isPinned.value = value;
|
||||
final db = ref.read(databaseProvider);
|
||||
await db.toggleChatRoomPinned(id);
|
||||
// Re-verify the state from database in case of error
|
||||
final room = await (db.select(
|
||||
db.chatRooms,
|
||||
)..where((r) => r.id.equals(id))).getSingleOrNull();
|
||||
final actualPinned = room?.isPinned ?? false;
|
||||
if (actualPinned != value) {
|
||||
// Revert if database operation failed
|
||||
isPinned.value = actualPinned;
|
||||
}
|
||||
showSnackBar(
|
||||
value
|
||||
? 'chatRoomPinned'.tr()
|
||||
: 'chatRoomUnpinned'.tr(),
|
||||
);
|
||||
},
|
||||
),
|
||||
roomIdentity.when(
|
||||
data: (identity) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
leading: const Icon(Symbols.notifications),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: const Text('chatNotifyLevel').tr(),
|
||||
subtitle: Text(
|
||||
kNotifyLevelText[identity!.notify].tr(),
|
||||
),
|
||||
onTap: () => showNotifyLevelBottomSheet(identity),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
leading: const Icon(Icons.timer),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: const Text('chatBreak').tr(),
|
||||
subtitle:
|
||||
identity.breakUntil != null &&
|
||||
identity.breakUntil!.isAfter(
|
||||
DateTime.now(),
|
||||
)
|
||||
? Text(
|
||||
DateFormat(
|
||||
'yyyy-MM-dd HH:mm',
|
||||
).format(identity.breakUntil!),
|
||||
)
|
||||
: const Text('chatBreakNone').tr(),
|
||||
onTap: () => showChatBreakDialog(),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
leading: const Icon(Icons.search),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: const Text('searchMessages').tr(),
|
||||
subtitle: totalMessages.when(
|
||||
data: (count) => Text(
|
||||
'messagesCount'.tr(args: [count.toString()]),
|
||||
),
|
||||
loading: () =>
|
||||
const CircularProgressIndicator(),
|
||||
error: (err, stack) => Text(
|
||||
'errorGeneric'.tr(args: [err.toString()]),
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
final result = await context.pushNamed(
|
||||
'searchMessages',
|
||||
pathParameters: {'id': id},
|
||||
);
|
||||
if (result is SearchMessagesResult) {
|
||||
// Navigate back to room screen with message to jump to
|
||||
if (context.mounted) {
|
||||
context.pop(result);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatRoomActionMenu extends HookConsumerWidget {
|
||||
final String id;
|
||||
final Shadow iconShadow;
|
||||
|
||||
const _ChatRoomActionMenu({required this.id, required this.iconShadow});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chatIdentity = ref.watch(chatRoomIdentityProvider(id));
|
||||
final chatRoom = ref.watch(chatRoomProvider(id));
|
||||
|
||||
final isManagable =
|
||||
chatIdentity.value?.accountId == chatRoom.value?.accountId ||
|
||||
chatRoom.value?.type == 1;
|
||||
|
||||
return PopupMenuButton(
|
||||
icon: Icon(Icons.more_vert, shadows: [iconShadow]),
|
||||
itemBuilder: (context) => [
|
||||
if (isManagable)
|
||||
PopupMenuItem(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => EditChatScreen(id: id),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
// Invalidate to refresh room data after edit
|
||||
ref.invalidate(chatMemberListProvider(id));
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const Gap(12),
|
||||
const Text('editChatRoom').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isManagable)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const Gap(12),
|
||||
const Text(
|
||||
'deleteChatRoom',
|
||||
style: TextStyle(color: Colors.red),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
showConfirmAlert(
|
||||
'deleteChatRoomHint'.tr(),
|
||||
'deleteChatRoom'.tr(),
|
||||
isDanger: true,
|
||||
).then((confirm) async {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.delete('/messager/chat/$id');
|
||||
ref.invalidate(chatRoomJoinedProvider);
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
else
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.exit_to_app,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const Gap(12),
|
||||
Text(
|
||||
'leaveChatRoom',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
showConfirmAlert(
|
||||
'leaveChatRoomHint'.tr(),
|
||||
'leaveChatRoom'.tr(),
|
||||
).then((confirm) async {
|
||||
if (confirm) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.delete('/messager/chat/$id/members/me');
|
||||
ref.invalidate(chatRoomJoinedProvider);
|
||||
if (context.mounted) {
|
||||
context.pop();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class ChatRoomMemberState with _$ChatRoomMemberState {
|
||||
const factory ChatRoomMemberState({
|
||||
required List<SnChatMember> members,
|
||||
required bool isLoading,
|
||||
required int total,
|
||||
String? error,
|
||||
}) = _ChatRoomMemberState;
|
||||
}
|
||||
|
||||
final chatMemberListProvider = AsyncNotifierProvider.autoDispose.family(
|
||||
ChatMemberListNotifier.new,
|
||||
);
|
||||
|
||||
class ChatMemberListNotifier
|
||||
extends AsyncNotifier<PaginationState<SnChatMember>>
|
||||
with AsyncPaginationController<SnChatMember> {
|
||||
static const pageSize = 20;
|
||||
|
||||
final String arg;
|
||||
ChatMemberListNotifier(this.arg);
|
||||
|
||||
@override
|
||||
Future<List<SnChatMember>> fetch() async {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final response = await apiClient.get(
|
||||
'/messager/chat/$arg/members',
|
||||
queryParameters: {
|
||||
'offset': fetchedCount.toString(),
|
||||
'take': pageSize,
|
||||
'withStatus': true,
|
||||
},
|
||||
);
|
||||
|
||||
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
final members = response.data
|
||||
.map((e) => SnChatMember.fromJson(e))
|
||||
.cast<SnChatMember>()
|
||||
.toList();
|
||||
|
||||
return members;
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatMemberListSheet extends HookConsumerWidget {
|
||||
final String roomId;
|
||||
const _ChatMemberListSheet({required this.roomId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final memberState = ref.watch(chatMemberListProvider(roomId));
|
||||
final memberNotifier = ref.watch(chatMemberListProvider(roomId).notifier);
|
||||
|
||||
final roomIdentity = ref.watch(chatRoomIdentityProvider(roomId));
|
||||
final chatRoom = ref.watch(chatRoomProvider(roomId));
|
||||
|
||||
final isManagable =
|
||||
chatRoom.value?.accountId == roomIdentity.value?.accountId ||
|
||||
chatRoom.value?.type == 1;
|
||||
|
||||
Future<void> invitePerson() async {
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const AccountPickerSheet(),
|
||||
);
|
||||
if (result == null) return;
|
||||
try {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.post(
|
||||
'/messager/chat/invites/$roomId',
|
||||
data: {'related_user_id': result.id, 'role': 0},
|
||||
);
|
||||
memberNotifier.refresh();
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.8,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'members'.plural(memberState.value?.totalCount ?? 0),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.person_add),
|
||||
onPressed: invitePerson,
|
||||
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.refresh),
|
||||
onPressed: () {
|
||||
memberNotifier.refresh();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.close),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: PaginationList(
|
||||
provider: chatMemberListProvider(roomId),
|
||||
notifier: chatMemberListProvider(roomId).notifier,
|
||||
itemBuilder: (context, idx, member) {
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.only(left: 16, right: 12),
|
||||
leading: AccountPfcRegion(
|
||||
uname: member.account.name,
|
||||
child: ProfilePictureWidget(
|
||||
file: member.account.profile.picture,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Flexible(child: Text(member.account.nick)),
|
||||
if (member.status != null)
|
||||
AccountStatusLabel(
|
||||
status: member.status!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (member.joinedAt == null)
|
||||
const Icon(Symbols.pending_actions, size: 20),
|
||||
],
|
||||
),
|
||||
subtitle: Text("@${member.account.name}"),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isManagable)
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.delete),
|
||||
onPressed: () {
|
||||
showConfirmAlert(
|
||||
'removeChatMemberHint'.tr(),
|
||||
'removeChatMember'.tr(),
|
||||
).then((confirm) async {
|
||||
if (confirm != true) return;
|
||||
try {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.delete(
|
||||
'/messager/chat/$roomId/members/${member.accountId}',
|
||||
);
|
||||
// Refresh both providers
|
||||
memberNotifier.refresh();
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
280
lib/chat/chat_widgets/chat_detail_screen.freezed.dart
Normal file
280
lib/chat/chat_widgets/chat_detail_screen.freezed.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'chat_detail_screen.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$ChatRoomMemberState {
|
||||
|
||||
List<SnChatMember> get members; bool get isLoading; int get total; String? get error;
|
||||
/// Create a copy of ChatRoomMemberState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatRoomMemberStateCopyWith<ChatRoomMemberState> get copyWith => _$ChatRoomMemberStateCopyWithImpl<ChatRoomMemberState>(this as ChatRoomMemberState, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatRoomMemberState&&const DeepCollectionEquality().equals(other.members, members)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.total, total) || other.total == total)&&(identical(other.error, error) || other.error == error));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(members),isLoading,total,error);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatRoomMemberState(members: $members, isLoading: $isLoading, total: $total, error: $error)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $ChatRoomMemberStateCopyWith<$Res> {
|
||||
factory $ChatRoomMemberStateCopyWith(ChatRoomMemberState value, $Res Function(ChatRoomMemberState) _then) = _$ChatRoomMemberStateCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
List<SnChatMember> members, bool isLoading, int total, String? error
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class _$ChatRoomMemberStateCopyWithImpl<$Res>
|
||||
implements $ChatRoomMemberStateCopyWith<$Res> {
|
||||
_$ChatRoomMemberStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final ChatRoomMemberState _self;
|
||||
final $Res Function(ChatRoomMemberState) _then;
|
||||
|
||||
/// Create a copy of ChatRoomMemberState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? members = null,Object? isLoading = null,Object? total = null,Object? error = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
members: null == members ? _self.members : members // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnChatMember>,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
|
||||
as bool,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
|
||||
as int,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
/// Adds pattern-matching-related methods to [ChatRoomMemberState].
|
||||
extension ChatRoomMemberStatePatterns on ChatRoomMemberState {
|
||||
/// A variant of `map` that fallback to returning `orElse`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _ChatRoomMemberState value)? $default,{required TResult orElse(),}){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatRoomMemberState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// Callbacks receives the raw object, upcasted.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case final Subclass2 value:
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _ChatRoomMemberState value) $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatRoomMemberState():
|
||||
return $default(_that);}
|
||||
}
|
||||
/// A variant of `map` that fallback to returning `null`.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case final Subclass value:
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _ChatRoomMemberState value)? $default,){
|
||||
final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatRoomMemberState() when $default != null:
|
||||
return $default(_that);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
/// A variant of `when` that fallback to an `orElse` callback.
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return orElse();
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( List<SnChatMember> members, bool isLoading, int total, String? error)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatRoomMemberState() when $default != null:
|
||||
return $default(_that.members,_that.isLoading,_that.total,_that.error);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
}
|
||||
/// A `switch`-like method, using callbacks.
|
||||
///
|
||||
/// As opposed to `map`, this offers destructuring.
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case Subclass2(:final field2):
|
||||
/// return ...;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( List<SnChatMember> members, bool isLoading, int total, String? error) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatRoomMemberState():
|
||||
return $default(_that.members,_that.isLoading,_that.total,_that.error);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
/// It is equivalent to doing:
|
||||
/// ```dart
|
||||
/// switch (sealedClass) {
|
||||
/// case Subclass(:final field):
|
||||
/// return ...;
|
||||
/// case _:
|
||||
/// return null;
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( List<SnChatMember> members, bool isLoading, int total, String? error)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _ChatRoomMemberState() when $default != null:
|
||||
return $default(_that.members,_that.isLoading,_that.total,_that.error);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
|
||||
class _ChatRoomMemberState implements ChatRoomMemberState {
|
||||
const _ChatRoomMemberState({required final List<SnChatMember> members, required this.isLoading, required this.total, this.error}): _members = members;
|
||||
|
||||
|
||||
final List<SnChatMember> _members;
|
||||
@override List<SnChatMember> get members {
|
||||
if (_members is EqualUnmodifiableListView) return _members;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_members);
|
||||
}
|
||||
|
||||
@override final bool isLoading;
|
||||
@override final int total;
|
||||
@override final String? error;
|
||||
|
||||
/// Create a copy of ChatRoomMemberState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$ChatRoomMemberStateCopyWith<_ChatRoomMemberState> get copyWith => __$ChatRoomMemberStateCopyWithImpl<_ChatRoomMemberState>(this, _$identity);
|
||||
|
||||
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatRoomMemberState&&const DeepCollectionEquality().equals(other._members, _members)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)&&(identical(other.total, total) || other.total == total)&&(identical(other.error, error) || other.error == error));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_members),isLoading,total,error);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatRoomMemberState(members: $members, isLoading: $isLoading, total: $total, error: $error)';
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$ChatRoomMemberStateCopyWith<$Res> implements $ChatRoomMemberStateCopyWith<$Res> {
|
||||
factory _$ChatRoomMemberStateCopyWith(_ChatRoomMemberState value, $Res Function(_ChatRoomMemberState) _then) = __$ChatRoomMemberStateCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
List<SnChatMember> members, bool isLoading, int total, String? error
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
/// @nodoc
|
||||
class __$ChatRoomMemberStateCopyWithImpl<$Res>
|
||||
implements _$ChatRoomMemberStateCopyWith<$Res> {
|
||||
__$ChatRoomMemberStateCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _ChatRoomMemberState _self;
|
||||
final $Res Function(_ChatRoomMemberState) _then;
|
||||
|
||||
/// Create a copy of ChatRoomMemberState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? members = null,Object? isLoading = null,Object? total = null,Object? error = freezed,}) {
|
||||
return _then(_ChatRoomMemberState(
|
||||
members: null == members ? _self._members : members // ignore: cast_nullable_to_non_nullable
|
||||
as List<SnChatMember>,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable
|
||||
as bool,total: null == total ? _self.total : total // ignore: cast_nullable_to_non_nullable
|
||||
as int,error: freezed == error ? _self.error : error // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// dart format on
|
||||
80
lib/chat/chat_widgets/chat_detail_screen.g.dart
Normal file
80
lib/chat/chat_widgets/chat_detail_screen.g.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_detail_screen.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(totalMessagesCount)
|
||||
final totalMessagesCountProvider = TotalMessagesCountFamily._();
|
||||
|
||||
final class TotalMessagesCountProvider
|
||||
extends $FunctionalProvider<AsyncValue<int>, int, FutureOr<int>>
|
||||
with $FutureModifier<int>, $FutureProvider<int> {
|
||||
TotalMessagesCountProvider._({
|
||||
required TotalMessagesCountFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'totalMessagesCountProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$totalMessagesCountHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'totalMessagesCountProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<int> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<int> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return totalMessagesCount(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is TotalMessagesCountProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$totalMessagesCountHash() =>
|
||||
r'd55f1507aba2acdce5e468c1c2e15dba7640c571';
|
||||
|
||||
final class TotalMessagesCountFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<int>, String> {
|
||||
TotalMessagesCountFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'totalMessagesCountProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
TotalMessagesCountProvider call(String roomId) =>
|
||||
TotalMessagesCountProvider._(argument: roomId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'totalMessagesCountProvider';
|
||||
}
|
||||
1049
lib/chat/chat_widgets/chat_input.dart
Normal file
1049
lib/chat/chat_widgets/chat_input.dart
Normal file
File diff suppressed because it is too large
Load Diff
103
lib/chat/chat_widgets/chat_invites_sheet.dart
Normal file
103
lib/chat/chat_widgets/chat_invites_sheet.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/chat/chat_pod/chat_room.dart';
|
||||
import 'package:island/chat/chat_widgets/chat_room_list_tile.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/realms/realm/realms.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class ChatInvitesSheet extends HookConsumerWidget {
|
||||
const ChatInvitesSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final invites = ref.watch(chatroomInvitesProvider);
|
||||
|
||||
Future<void> acceptInvite(SnChatMember invite) async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post(
|
||||
'/messager/chat/invites/${invite.chatRoom!.id}/accept',
|
||||
);
|
||||
ref.invalidate(chatroomInvitesProvider);
|
||||
ref.invalidate(chatRoomJoinedProvider);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> declineInvite(SnChatMember invite) async {
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post(
|
||||
'/messager/chat/invites/${invite.chatRoom!.id}/decline',
|
||||
);
|
||||
ref.invalidate(chatroomInvitesProvider);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'invites'.tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.refresh),
|
||||
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
||||
onPressed: () {
|
||||
ref.invalidate(realmInvitesProvider);
|
||||
},
|
||||
),
|
||||
],
|
||||
child: invites.when(
|
||||
data: (items) => items.isEmpty
|
||||
? Center(
|
||||
child: Text('invitesEmpty', textAlign: TextAlign.center).tr(),
|
||||
)
|
||||
: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final invite = items[index];
|
||||
return ChatRoomListTile(
|
||||
room: invite.chatRoom!,
|
||||
isDirect: invite.chatRoom!.type == 1,
|
||||
subtitle: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
if (invite.chatRoom!.type == 1)
|
||||
Badge(
|
||||
label: const Text('directMessage').tr(),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.check),
|
||||
onPressed: () => acceptInvite(invite),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.close),
|
||||
onPressed: () => declineInvite(invite),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Center(child: Text('Error: $error')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
182
lib/chat/chat_widgets/chat_link_attachments.dart
Normal file
182
lib/chat/chat_widgets/chat_link_attachments.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
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/drive/drive_models/file.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/pagination/pagination.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/shared/widgets/pagination_list.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
final chatCloudFileListNotifierProvider = AsyncNotifierProvider.autoDispose(
|
||||
ChatCloudFileListNotifier.new,
|
||||
);
|
||||
|
||||
class ChatCloudFileListNotifier
|
||||
extends AsyncNotifier<PaginationState<SnCloudFile>>
|
||||
with AsyncPaginationController<SnCloudFile> {
|
||||
@override
|
||||
Future<List<SnCloudFile>> fetch() async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final take = 20;
|
||||
|
||||
final queryParameters = {'offset': fetchedCount, 'take': take};
|
||||
|
||||
final response = await client.get(
|
||||
'/drive/files/me',
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
|
||||
final List<SnCloudFile> items = (response.data as List)
|
||||
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
class ChatLinkAttachment extends HookConsumerWidget {
|
||||
const ChatLinkAttachment({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final idController = useTextEditingController();
|
||||
final errorMessage = useState<String?>(null);
|
||||
|
||||
return SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: 'linkAttachment'.tr(),
|
||||
child: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'attachmentsRecentUploads'.tr()),
|
||||
Tab(text: 'attachmentsManualInput'.tr()),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
PaginationList(
|
||||
provider: chatCloudFileListNotifierProvider,
|
||||
notifier: chatCloudFileListNotifierProvider.notifier,
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
itemBuilder: (context, index, item) {
|
||||
final itemType = item.mimeType?.split('/').firstOrNull;
|
||||
return ListTile(
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
width: 48,
|
||||
child: switch (itemType) {
|
||||
'image' => CloudImageWidget(file: item),
|
||||
'audio' => const Icon(
|
||||
Symbols.audio_file,
|
||||
fill: 1,
|
||||
).center(),
|
||||
'video' => const Icon(
|
||||
Symbols.video_file,
|
||||
fill: 1,
|
||||
).center(),
|
||||
_ => const Icon(
|
||||
Symbols.body_system,
|
||||
fill: 1,
|
||||
).center(),
|
||||
},
|
||||
),
|
||||
),
|
||||
title: item.name.isEmpty
|
||||
? Text('untitled').tr().italic()
|
||||
: Text(item.name),
|
||||
onTap: () {
|
||||
Navigator.pop(context, item);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: idController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fileId'.tr(),
|
||||
helperText: 'fileIdHint'.tr(),
|
||||
helperMaxLines: 3,
|
||||
errorText: errorMessage.value,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(16),
|
||||
InkWell(
|
||||
child: Text(
|
||||
'fileIdLinkHint',
|
||||
).tr().fontSize(13).opacity(0.85),
|
||||
onTap: () {
|
||||
launchUrlString('https://fs.solian.app');
|
||||
},
|
||||
).padding(horizontal: 14),
|
||||
const Gap(16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Symbols.add),
|
||||
label: Text('add'.tr()),
|
||||
onPressed: () async {
|
||||
final fileId = idController.text.trim();
|
||||
if (fileId.isEmpty) {
|
||||
errorMessage.value = 'fileIdCannotBeEmpty'.tr();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get(
|
||||
'/drive/files/$fileId/info',
|
||||
);
|
||||
final SnCloudFile cloudFile =
|
||||
SnCloudFile.fromJson(response.data);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop(cloudFile);
|
||||
}
|
||||
} catch (e) {
|
||||
errorMessage.value = 'failedToFetchFile'.tr(
|
||||
args: [e.toString()],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
623
lib/chat/chat_widgets/chat_list_screen.dart
Normal file
623
lib/chat/chat_widgets/chat_list_screen.dart
Normal file
@@ -0,0 +1,623 @@
|
||||
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:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_pod.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_picker.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/chat/chat_pod/chat_room.dart';
|
||||
import 'package:island/chat/chat_pod/chat_summary.dart';
|
||||
import 'package:island/chat/chat_widgets/chat_invites_sheet.dart';
|
||||
import 'package:island/chat/chat_widgets/chat_room_form.dart';
|
||||
import 'package:island/chat/chat_widgets/chat_room_list_tile.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/services/event_bus.dart';
|
||||
import 'package:island/core/services/responsive.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/shared/widgets/app_scaffold.dart';
|
||||
import 'package:island/shared/widgets/extended_refresh_indicator.dart';
|
||||
import 'package:island/shared/widgets/response.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
class ChatListBodyWidget extends HookConsumerWidget {
|
||||
final bool isFloating;
|
||||
final TabController tabController;
|
||||
final ValueNotifier<int> selectedTab;
|
||||
|
||||
const ChatListBodyWidget({
|
||||
super.key,
|
||||
this.isFloating = false,
|
||||
required this.tabController,
|
||||
required this.selectedTab,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chats = ref.watch(chatRoomJoinedProvider);
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
|
||||
Widget bodyWidget = Column(
|
||||
children: [
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final summaryState = ref.watch(chatSummaryProvider);
|
||||
return summaryState.maybeWhen(
|
||||
loading: () => const LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: chats.when(
|
||||
data: (items) {
|
||||
final filteredItems = items.where(
|
||||
(item) =>
|
||||
selectedTab.value == 0 ||
|
||||
(selectedTab.value == 1 && item.type == 1) ||
|
||||
(selectedTab.value == 2 && item.type != 1),
|
||||
);
|
||||
final pinnedItems = filteredItems
|
||||
.where((item) => item.isPinned)
|
||||
.toList();
|
||||
final unpinnedItems = filteredItems
|
||||
.where((item) => !item.isPinned)
|
||||
.toList();
|
||||
|
||||
return ExtendedRefreshIndicator(
|
||||
onRefresh: () => Future.sync(() {
|
||||
ref.invalidate(chatRoomJoinedProvider);
|
||||
}),
|
||||
child: Theme(
|
||||
data: Theme.of(
|
||||
context,
|
||||
).copyWith(dividerColor: Colors.transparent),
|
||||
child: Column(
|
||||
children: [
|
||||
// Always show pinned chats in their own section
|
||||
if (pinnedItems.isNotEmpty)
|
||||
ExpansionTile(
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withOpacity(0.5),
|
||||
collapsedBackgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer.withOpacity(0.5),
|
||||
title: Text('pinnedChatRoom'.tr()),
|
||||
leading: const Icon(Symbols.keep, fill: 1),
|
||||
tilePadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
initiallyExpanded: true,
|
||||
children: [
|
||||
for (final item in pinnedItems)
|
||||
ChatRoomListTile(
|
||||
room: item,
|
||||
isDirect: item.type == 1,
|
||||
onTap: () {
|
||||
if (isWideScreen(context)) {
|
||||
context.replaceNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {'id': item.id},
|
||||
);
|
||||
} else {
|
||||
context.pushNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {'id': item.id},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final summaries =
|
||||
ref
|
||||
.watch(chatSummaryProvider)
|
||||
.whenData((data) => data)
|
||||
.value ??
|
||||
{};
|
||||
|
||||
if (settings.groupedChatList &&
|
||||
selectedTab.value == 0) {
|
||||
// Group by realm (include both pinned and unpinned)
|
||||
final realmGroups = <String?, List<SnChatRoom>>{};
|
||||
final ungrouped = <SnChatRoom>[];
|
||||
|
||||
for (final item in filteredItems) {
|
||||
if (item.realmId != null) {
|
||||
realmGroups
|
||||
.putIfAbsent(item.realmId, () => [])
|
||||
.add(item);
|
||||
} else if (!item.isPinned) {
|
||||
// Only unpinned chats without realm go to ungrouped
|
||||
ungrouped.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
final children = <Widget>[];
|
||||
|
||||
// Add realm groups
|
||||
for (final entry in realmGroups.entries) {
|
||||
final rooms = entry.value;
|
||||
final realm = rooms.first.realm;
|
||||
final realmName =
|
||||
realm?.name ?? 'Unknown Realm';
|
||||
|
||||
// Calculate total unread count for this realm
|
||||
final totalUnread = rooms.fold<int>(
|
||||
0,
|
||||
(sum, room) =>
|
||||
sum +
|
||||
(summaries[room.id]?.unreadCount ?? 0),
|
||||
);
|
||||
|
||||
children.add(
|
||||
ExpansionTile(
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withOpacity(0.5),
|
||||
collapsedBackgroundColor:
|
||||
Colors.transparent,
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(realmName)),
|
||||
Badge(
|
||||
isLabelVisible: totalUnread > 0,
|
||||
label: Text(totalUnread.toString()),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
textColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: ProfilePictureWidget(
|
||||
file: realm?.picture,
|
||||
radius: 16,
|
||||
),
|
||||
tilePadding: const EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 24,
|
||||
),
|
||||
children: rooms.map((room) {
|
||||
return ChatRoomListTile(
|
||||
room: room,
|
||||
isDirect: room.type == 1,
|
||||
onTap: () {
|
||||
if (isWideScreen(context)) {
|
||||
context.replaceNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {'id': room.id},
|
||||
);
|
||||
} else {
|
||||
context.pushNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {'id': room.id},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add ungrouped chats
|
||||
if (ungrouped.isNotEmpty) {
|
||||
children.addAll(
|
||||
ungrouped.map((room) {
|
||||
return ChatRoomListTile(
|
||||
room: room,
|
||||
isDirect: room.type == 1,
|
||||
onTap: () {
|
||||
if (isWideScreen(context)) {
|
||||
context.replaceNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {'id': room.id},
|
||||
);
|
||||
} else {
|
||||
context.pushNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {'id': room.id},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.only(bottom: 96),
|
||||
children: children,
|
||||
);
|
||||
} else {
|
||||
// Normal list view
|
||||
return SuperListView.builder(
|
||||
padding: EdgeInsets.only(bottom: 96),
|
||||
itemCount: unpinnedItems
|
||||
.where(
|
||||
(item) =>
|
||||
selectedTab.value == 0 ||
|
||||
(selectedTab.value == 1 &&
|
||||
item.type == 1) ||
|
||||
(selectedTab.value == 2 &&
|
||||
item.type != 1),
|
||||
)
|
||||
.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = unpinnedItems[index];
|
||||
return ChatRoomListTile(
|
||||
room: item,
|
||||
isDirect: item.type == 1,
|
||||
onTap: () {
|
||||
if (isWideScreen(context)) {
|
||||
context.replaceNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {'id': item.id},
|
||||
);
|
||||
} else {
|
||||
context.pushNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {'id': item.id},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () {
|
||||
ref.invalidate(chatRoomJoinedProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return isFloating ? Card(child: bodyWidget) : bodyWidget;
|
||||
}
|
||||
}
|
||||
|
||||
class ChatShellScreen extends HookConsumerWidget {
|
||||
final Widget child;
|
||||
const ChatShellScreen({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isWide = isWideScreen(context);
|
||||
|
||||
if (isWide) {
|
||||
return AppBackground(
|
||||
isRoot: true,
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: ChatListScreen(
|
||||
isAside: true,
|
||||
isFloating: true,
|
||||
).padding(left: 16, vertical: 16),
|
||||
),
|
||||
const Gap(8),
|
||||
Flexible(
|
||||
flex: 4,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(8),
|
||||
),
|
||||
child: child,
|
||||
).padding(top: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AppBackground(isRoot: true, child: child);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatFabWidget extends HookConsumerWidget {
|
||||
const ChatFabWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userInfo = ref.watch(userInfoProvider);
|
||||
|
||||
if (userInfo.value == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return FloatingActionButton(
|
||||
child: const Icon(Symbols.add),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Gap(40),
|
||||
ListTile(
|
||||
title: const Text('createChatRoom').tr(),
|
||||
leading: const Icon(Symbols.add),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const EditChatScreen(),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
eventBus.fire(const ChatRoomsRefreshEvent());
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('createDirectMessage').tr(),
|
||||
leading: const Icon(Symbols.person),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
onTap: () async {
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const AccountPickerSheet(),
|
||||
);
|
||||
if (result == null) return;
|
||||
final client = ref.read(apiClientProvider);
|
||||
try {
|
||||
await client.post(
|
||||
'/messager/chat/direct',
|
||||
data: {'related_user_id': result.id},
|
||||
);
|
||||
eventBus.fire(const ChatRoomsRefreshEvent());
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
},
|
||||
),
|
||||
const Gap(16),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
).padding(bottom: MediaQuery.of(context).padding.bottom);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatListScreen extends HookConsumerWidget {
|
||||
final bool isAside;
|
||||
final bool isFloating;
|
||||
const ChatListScreen({
|
||||
super.key,
|
||||
this.isAside = false,
|
||||
this.isFloating = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isWide = isWideScreen(context);
|
||||
|
||||
final chatInvites = ref.watch(chatroomInvitesProvider);
|
||||
final tabController = useTabController(initialLength: 3);
|
||||
final selectedTab = useState(
|
||||
0,
|
||||
); // 0 for All, 1 for Direct Messages, 2 for Group Chats
|
||||
|
||||
useEffect(() {
|
||||
tabController.addListener(() {
|
||||
selectedTab.value = tabController.index;
|
||||
});
|
||||
|
||||
// Listen for chat rooms refresh events
|
||||
final subscription = eventBus.on<ChatRoomsRefreshEvent>().listen((event) {
|
||||
ref.invalidate(chatRoomJoinedProvider);
|
||||
});
|
||||
|
||||
return () {
|
||||
subscription.cancel();
|
||||
};
|
||||
}, [tabController]);
|
||||
|
||||
if (isAside) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
dividerColor: Colors.transparent,
|
||||
controller: tabController,
|
||||
tabAlignment: TabAlignment.start,
|
||||
isScrollable: true,
|
||||
tabs: [
|
||||
const Tab(icon: Icon(Symbols.chat)),
|
||||
const Tab(icon: Icon(Symbols.person)),
|
||||
const Tab(icon: Icon(Symbols.group)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: IconButton(
|
||||
icon: Badge(
|
||||
label: Text(
|
||||
chatInvites.when(
|
||||
data: (invites) => invites.length.toString(),
|
||||
error: (_, _) => '0',
|
||||
loading: () => '0',
|
||||
),
|
||||
),
|
||||
isLabelVisible: chatInvites.when(
|
||||
data: (invites) => invites.isNotEmpty,
|
||||
error: (_, _) => false,
|
||||
loading: () => false,
|
||||
),
|
||||
child: const Icon(Symbols.email),
|
||||
),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const ChatInvitesSheet(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 8),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ChatListBodyWidget(
|
||||
isFloating: false,
|
||||
tabController: tabController,
|
||||
selectedTab: selectedTab,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(bottom: 16, right: 16, child: ChatFabWidget()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isWide && !isAside) {
|
||||
return const EmptyPageHolder();
|
||||
}
|
||||
|
||||
final appbarFeColor = Theme.of(context).appBarTheme.foregroundColor;
|
||||
|
||||
final userInfo = ref.watch(userInfoProvider);
|
||||
|
||||
return AppScaffold(
|
||||
extendBody: false, // Prevent conflicts with tabs navigation
|
||||
floatingActionButton: const ChatFabWidget(),
|
||||
appBar: AppBar(
|
||||
flexibleSpace: Container(
|
||||
height: 48,
|
||||
margin: EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 4 + MediaQuery.of(context).padding.top,
|
||||
bottom: 4,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Symbols.inbox,
|
||||
fill: tabController.index == 0 ? 1 : 0,
|
||||
),
|
||||
color: appbarFeColor,
|
||||
onPressed: () => tabController.animateTo(0),
|
||||
tooltip: 'chatTabAll'.tr(),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Symbols.person,
|
||||
fill: tabController.index == 1 ? 1 : 0,
|
||||
),
|
||||
color: appbarFeColor,
|
||||
onPressed: () => tabController.animateTo(1),
|
||||
tooltip: 'chatTabDirect'.tr(),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Symbols.group,
|
||||
fill: tabController.index == 2 ? 1 : 0,
|
||||
),
|
||||
color: appbarFeColor,
|
||||
onPressed: () => tabController.animateTo(2),
|
||||
tooltip: 'chatTabGroup'.tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Badge(
|
||||
label: Text(
|
||||
chatInvites.when(
|
||||
data: (invites) => invites.length.toString(),
|
||||
error: (_, _) => '0',
|
||||
loading: () => '0',
|
||||
),
|
||||
),
|
||||
isLabelVisible: chatInvites.when(
|
||||
data: (invites) => invites.isNotEmpty,
|
||||
error: (_, _) => false,
|
||||
loading: () => false,
|
||||
),
|
||||
child: const Icon(Symbols.email),
|
||||
),
|
||||
color: appbarFeColor,
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const ChatInvitesSheet(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: userInfo.value == null
|
||||
? const ResponseUnauthorizedWidget()
|
||||
: ChatListBodyWidget(
|
||||
isFloating: false,
|
||||
tabController: tabController,
|
||||
selectedTab: selectedTab,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
303
lib/chat/chat_widgets/chat_room_form.dart
Normal file
303
lib/chat/chat_widgets/chat_room_form.dart
Normal file
@@ -0,0 +1,303 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:croppy/croppy.dart' hide cropImage;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/chat/chat_pod/chat_room.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/services/image.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/drive/drive_models/file.dart';
|
||||
import 'package:island/drive/drive_service.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/realms/realm/realms.dart';
|
||||
import 'package:island/realms/realms_models/realm.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class NewChatScreen extends StatelessWidget {
|
||||
const NewChatScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const EditChatScreen();
|
||||
}
|
||||
}
|
||||
|
||||
class EditChatScreen extends HookConsumerWidget {
|
||||
final String? id;
|
||||
const EditChatScreen({super.key, this.id});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final formKey = useMemoized(() => GlobalKey<FormState>(), []);
|
||||
|
||||
final submitting = useState(false);
|
||||
|
||||
final nameController = useTextEditingController();
|
||||
final descriptionController = useTextEditingController();
|
||||
final picture = useState<SnCloudFile?>(null);
|
||||
final background = useState<SnCloudFile?>(null);
|
||||
final isPublic = useState(true);
|
||||
final isCommunity = useState(false);
|
||||
|
||||
final chat = ref.watch(chatRoomProvider(id));
|
||||
|
||||
final joinedRealms = ref.watch(realmsJoinedProvider);
|
||||
final currentRealm = useState<SnRealm?>(null);
|
||||
|
||||
useEffect(() {
|
||||
if (chat.value != null) {
|
||||
nameController.text = chat.value!.name ?? '';
|
||||
descriptionController.text = chat.value!.description ?? '';
|
||||
picture.value = chat.value!.picture;
|
||||
background.value = chat.value!.background;
|
||||
isPublic.value = chat.value!.isPublic;
|
||||
isCommunity.value = chat.value!.isCommunity;
|
||||
currentRealm.value = joinedRealms.value?.firstWhereOrNull(
|
||||
(realm) => realm.id == chat.value!.realmId,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}, [chat, joinedRealms]);
|
||||
|
||||
void setPicture(String position) async {
|
||||
showLoadingModal(context);
|
||||
var result = await ref
|
||||
.read(imagePickerProvider)
|
||||
.pickImage(source: ImageSource.gallery);
|
||||
if (result == null) {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
hideLoadingModal(context);
|
||||
result = await cropImage(
|
||||
context,
|
||||
image: result,
|
||||
allowedAspectRatios: [
|
||||
if (position == 'background')
|
||||
const CropAspectRatio(height: 7, width: 16)
|
||||
else
|
||||
const CropAspectRatio(height: 1, width: 1),
|
||||
],
|
||||
);
|
||||
if (result == null) {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
showLoadingModal(context);
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
final cloudFile = await FileUploader.createCloudFile(
|
||||
ref: ref,
|
||||
fileData: UniversalFile(data: result, type: UniversalFileType.image),
|
||||
).future;
|
||||
if (cloudFile == null) {
|
||||
throw ArgumentError('Failed to upload the file...');
|
||||
}
|
||||
switch (position) {
|
||||
case 'picture':
|
||||
picture.value = cloudFile;
|
||||
case 'background':
|
||||
background.value = cloudFile;
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performAction() async {
|
||||
if (!formKey.currentState!.validate()) return;
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
final resp = await client.request(
|
||||
id == null ? '/messager/chat' : '/messager/chat/$id',
|
||||
data: {
|
||||
'name': nameController.text,
|
||||
'description': descriptionController.text,
|
||||
'background_id': background.value?.id,
|
||||
'picture_id': picture.value?.id,
|
||||
'realm_id': currentRealm.value?.id,
|
||||
'is_public': isPublic.value,
|
||||
'is_community': isCommunity.value,
|
||||
},
|
||||
options: Options(method: id == null ? 'POST' : 'PATCH'),
|
||||
);
|
||||
if (context.mounted) {
|
||||
context.pop(SnChatRoom.fromJson(resp.data));
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: (id == null ? 'createChatRoom' : 'editChatRoom').tr(),
|
||||
onClose: () => context.pop(),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 7,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
GestureDetector(
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: background.value != null
|
||||
? CloudFileWidget(
|
||||
item: background.value!,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
onTap: () {
|
||||
setPicture('background');
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
left: 20,
|
||||
bottom: -32,
|
||||
child: GestureDetector(
|
||||
child: ProfilePictureWidget(
|
||||
file: picture.value,
|
||||
radius: 40,
|
||||
fallbackIcon: Symbols.group,
|
||||
),
|
||||
onTap: () {
|
||||
setPicture('picture');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).padding(bottom: 32),
|
||||
Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Name',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: descriptionController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Description',
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
minLines: 3,
|
||||
maxLines: null,
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<SnRealm>(
|
||||
value: currentRealm.value,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'realm'.tr(),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem<SnRealm>(
|
||||
value: null,
|
||||
child: Text('none'.tr()),
|
||||
),
|
||||
...joinedRealms.maybeWhen(
|
||||
data: (realms) => realms.map(
|
||||
(realm) => DropdownMenuItem(
|
||||
value: realm,
|
||||
child: Text(realm.name),
|
||||
),
|
||||
),
|
||||
orElse: () => [],
|
||||
),
|
||||
],
|
||||
onChanged: joinedRealms.isLoading
|
||||
? null
|
||||
: (SnRealm? value) {
|
||||
currentRealm.value = value;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
CheckboxListTile(
|
||||
secondary: const Icon(Symbols.public),
|
||||
title: Text('publicChat').tr(),
|
||||
subtitle: Text('publicChatDescription').tr(),
|
||||
value: isPublic.value,
|
||||
onChanged: (value) {
|
||||
isPublic.value = value ?? true;
|
||||
},
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
secondary: const Icon(Symbols.travel_explore),
|
||||
title: Text('communityChat').tr(),
|
||||
subtitle: Text('communityChatDescription').tr(),
|
||||
value: isCommunity.value,
|
||||
onChanged: (value) {
|
||||
isCommunity.value = value ?? false;
|
||||
},
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: submitting.value ? null : performAction,
|
||||
label: const Text('Save'),
|
||||
icon: const Icon(Symbols.save),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(all: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
78
lib/chat/chat_widgets/chat_room_list_tile.dart
Normal file
78
lib/chat/chat_widgets/chat_room_list_tile.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_pod.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/chat/chat_pod/chat_summary.dart';
|
||||
import 'package:island/chat/chat_widgets/chat_room_widgets.dart';
|
||||
|
||||
class ChatRoomListTile extends HookConsumerWidget {
|
||||
final SnChatRoom room;
|
||||
final bool isDirect;
|
||||
final Widget? subtitle;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ChatRoomListTile({
|
||||
super.key,
|
||||
required this.room,
|
||||
this.isDirect = false,
|
||||
this.subtitle,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final summary = ref
|
||||
.watch(chatSummaryProvider)
|
||||
.whenData((summaries) => summaries[room.id]);
|
||||
|
||||
var validMembers = room.members ?? [];
|
||||
if (validMembers.isNotEmpty) {
|
||||
final userInfo = ref.watch(userInfoProvider);
|
||||
if (userInfo.value != null) {
|
||||
validMembers = validMembers
|
||||
.where((e) => e.accountId != userInfo.value!.id)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
String titleText;
|
||||
if (isDirect && room.name == null) {
|
||||
if (room.members?.isNotEmpty ?? false) {
|
||||
titleText = validMembers.map((e) => e.account.nick).join(', ');
|
||||
} else {
|
||||
titleText = 'Direct Message';
|
||||
}
|
||||
} else {
|
||||
titleText = room.name ?? '';
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: ChatRoomAvatar(
|
||||
room: room,
|
||||
isDirect: isDirect,
|
||||
summary: summary,
|
||||
validMembers: validMembers,
|
||||
),
|
||||
title: Text(titleText),
|
||||
subtitle: ChatRoomSubtitle(
|
||||
room: room,
|
||||
isDirect: isDirect,
|
||||
validMembers: validMembers,
|
||||
summary: summary,
|
||||
subtitle: subtitle,
|
||||
),
|
||||
trailing: trailing, // Add this line
|
||||
onTap: () async {
|
||||
// Clear unread count if there are unread messages
|
||||
ref.read(chatSummaryProvider.future).then((summary) {
|
||||
if ((summary[room.id]?.unreadCount ?? 0) > 0) {
|
||||
ref.read(chatSummaryProvider.notifier).clearUnreadCount(room.id);
|
||||
}
|
||||
});
|
||||
onTap?.call();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
491
lib/chat/chat_widgets/chat_room_screen.dart
Normal file
491
lib/chat/chat_widgets/chat_room_screen.dart
Normal file
@@ -0,0 +1,491 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/chat/chat_pod/chat_online_count.dart';
|
||||
import 'package:island/chat/chat_pod/chat_room.dart';
|
||||
import 'package:island/chat/chat_widgets/call_button.dart';
|
||||
import 'package:island/chat/chat_widgets/chat_input.dart';
|
||||
import 'package:island/chat/chat_widgets/chat_search_screen.dart';
|
||||
import 'package:island/chat/chat_widgets/public_room_preview.dart';
|
||||
import 'package:island/chat/chat_widgets/room_app_bar.dart';
|
||||
import 'package:island/chat/chat_widgets/room_message_list.dart';
|
||||
import 'package:island/chat/chat_widgets/room_overlays.dart';
|
||||
import 'package:island/chat/chat_widgets/room_selection_mode.dart';
|
||||
import 'package:island/chat/hooks/use_room_file_picker.dart';
|
||||
import 'package:island/chat/hooks/use_room_input.dart';
|
||||
import 'package:island/chat/hooks/use_room_scroll.dart';
|
||||
import 'package:island/chat/messages_notifier.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/services/analytics_service.dart';
|
||||
import 'package:island/core/services/responsive.dart';
|
||||
import 'package:island/drive/drive_models/file.dart';
|
||||
import 'package:island/drive/drive_service.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/shared/widgets/app_scaffold.dart';
|
||||
import 'package:island/shared/widgets/attachment_uploader.dart';
|
||||
import 'package:island/shared/widgets/response.dart';
|
||||
import 'package:island/thought/thought/think_sheet.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class ChatRoomScreen extends HookConsumerWidget {
|
||||
final String id;
|
||||
const ChatRoomScreen({super.key, required this.id});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final chatRoom = ref.watch(chatRoomProvider(id));
|
||||
final chatIdentity = ref.watch(chatRoomIdentityProvider(id));
|
||||
final isSyncing = ref.watch(chatSyncingProvider);
|
||||
final onlineCount = ref.watch(chatOnlineCountProvider(id));
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
|
||||
useEffect(() {
|
||||
if (!chatRoom.isLoading && chatRoom.value != null) {
|
||||
AnalyticsService().logChatRoomOpened(
|
||||
id,
|
||||
chatRoom.value!.isCommunity == true ? 'group' : 'direct',
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [chatRoom]);
|
||||
|
||||
if (chatIdentity.isLoading || chatRoom.isLoading) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(leading: const PageBackButton()),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
} else if (chatIdentity.value == null) {
|
||||
return chatRoom.when(
|
||||
data: (room) {
|
||||
if (room!.isPublic) {
|
||||
return PublicRoomPreview(id: id, room: room);
|
||||
} else {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(leading: const PageBackButton()),
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
room.isCommunity == true
|
||||
? Icons.person_add
|
||||
: Icons.person_remove,
|
||||
size: 36,
|
||||
fill: 1,
|
||||
).padding(bottom: 4),
|
||||
Text('chatNotJoined').tr(),
|
||||
if (room.isCommunity != true)
|
||||
Text(
|
||||
'chatUnableJoin',
|
||||
textAlign: TextAlign.center,
|
||||
).tr().bold()
|
||||
else
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () async {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
await apiClient.post(
|
||||
'/messager/chat/${room.id}/members/me',
|
||||
);
|
||||
ref.invalidate(chatRoomIdentityProvider(id));
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
},
|
||||
label: Text('chatJoin').tr(),
|
||||
icon: const Icon(Icons.add),
|
||||
).padding(top: 8),
|
||||
],
|
||||
),
|
||||
).center(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
loading: () => AppScaffold(
|
||||
appBar: AppBar(leading: const PageBackButton()),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, _) => AppScaffold(
|
||||
appBar: AppBar(leading: const PageBackButton()),
|
||||
body: ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () => ref.refresh(chatRoomProvider(id)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final messages = ref.watch(messagesProvider(id));
|
||||
final messagesNotifier = ref.read(messagesProvider(id).notifier);
|
||||
final compactHeader = isWideScreen(context);
|
||||
|
||||
final scrollManager = useRoomScrollManager(
|
||||
ref,
|
||||
id,
|
||||
messagesNotifier.jumpToMessage,
|
||||
messages,
|
||||
);
|
||||
|
||||
final inputKey = useMemoized(() => GlobalKey(), []);
|
||||
final inputHeight = useState<double>(80.0);
|
||||
final inputManager = useRoomInputManager(ref, id);
|
||||
final roomOpenTime = useMemoized(() => DateTime.now());
|
||||
|
||||
final previousInputHeightRef = useRef<double?>(null);
|
||||
useEffect(() {
|
||||
previousInputHeightRef.value = inputHeight.value;
|
||||
return null;
|
||||
}, [inputHeight.value]);
|
||||
|
||||
useEffect(() {
|
||||
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
|
||||
final renderBox =
|
||||
inputKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
final newHeight = renderBox.size.height;
|
||||
if (newHeight != inputHeight.value) {
|
||||
inputHeight.value = newHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
return timer.cancel;
|
||||
}, []);
|
||||
|
||||
final isSelectionMode = useState<bool>(false);
|
||||
final selectedMessages = useState<Set<String>>({});
|
||||
|
||||
void toggleSelectionMode() {
|
||||
isSelectionMode.value = !isSelectionMode.value;
|
||||
if (!isSelectionMode.value) {
|
||||
selectedMessages.value = {};
|
||||
}
|
||||
}
|
||||
|
||||
void toggleMessageSelection(String messageId) {
|
||||
final newSelection = Set<String>.from(selectedMessages.value);
|
||||
if (newSelection.contains(messageId)) {
|
||||
newSelection.remove(messageId);
|
||||
} else {
|
||||
newSelection.add(messageId);
|
||||
}
|
||||
selectedMessages.value = newSelection;
|
||||
}
|
||||
|
||||
void openThinkingSheet() {
|
||||
if (selectedMessages.value.isEmpty) return;
|
||||
|
||||
final selectedMessageData =
|
||||
messages.value
|
||||
?.where((msg) => selectedMessages.value.contains(msg.id))
|
||||
.map(
|
||||
(msg) => {
|
||||
'id': msg.id,
|
||||
'content': msg.content,
|
||||
'senderId': msg.senderId,
|
||||
'createdAt': msg.createdAt.toIso8601String(),
|
||||
'attachments': msg.attachments,
|
||||
},
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
ThoughtSheet.show(
|
||||
context,
|
||||
attachedMessages: selectedMessageData,
|
||||
attachedPosts: [],
|
||||
);
|
||||
|
||||
toggleSelectionMode();
|
||||
}
|
||||
|
||||
Future<void> uploadAttachment(int index) async {
|
||||
final attachment = inputManager.attachments[index];
|
||||
if (attachment.isOnCloud) return;
|
||||
|
||||
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => AttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
attachments: inputManager.attachments,
|
||||
index: index,
|
||||
),
|
||||
);
|
||||
if (config == null) return;
|
||||
|
||||
try {
|
||||
inputManager.updateAttachmentProgress('chat-upload', 0);
|
||||
|
||||
final cloudFile = await FileUploader.createCloudFile(
|
||||
ref: ref,
|
||||
fileData: attachment,
|
||||
poolId: config.poolId,
|
||||
mode: attachment.type == UniversalFileType.file
|
||||
? FileUploadMode.generic
|
||||
: FileUploadMode.mediaSafe,
|
||||
onProgress: (progress, _) {
|
||||
inputManager.updateAttachmentProgress(
|
||||
'chat-upload',
|
||||
progress ?? 0.0,
|
||||
);
|
||||
},
|
||||
).future;
|
||||
|
||||
if (cloudFile == null) {
|
||||
throw ArgumentError('Failed to upload file...');
|
||||
}
|
||||
|
||||
final clone = List.of(inputManager.attachments);
|
||||
clone[index] = UniversalFile(data: cloudFile, type: attachment.type);
|
||||
inputManager.updateAttachments(clone);
|
||||
} catch (err) {
|
||||
showErrorAlert(err.toString());
|
||||
} finally {
|
||||
final newProgress = Map<String, Map<int, double?>>.from(
|
||||
inputManager.attachmentProgress,
|
||||
);
|
||||
newProgress.remove('chat-upload');
|
||||
}
|
||||
}
|
||||
|
||||
final filePicker = useRoomFilePicker(
|
||||
context,
|
||||
inputManager.attachments,
|
||||
inputManager.updateAttachments,
|
||||
);
|
||||
|
||||
void onJump(String messageId) {
|
||||
messages.when(
|
||||
data: (messageList) {
|
||||
scrollManager.scrollToMessage(
|
||||
messageId: messageId,
|
||||
messageList: messageList,
|
||||
);
|
||||
},
|
||||
loading: () {},
|
||||
error: (_, _) {},
|
||||
);
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: !compactHeader ? const Center(child: PageBackButton()) : null,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: compactHeader ? null : 74,
|
||||
title: chatRoom.when(
|
||||
data: (room) => RoomAppBar(
|
||||
room: room!,
|
||||
onlineCount: onlineCount.value ?? 0,
|
||||
compact: compactHeader,
|
||||
),
|
||||
loading: () => const Text('Loading...'),
|
||||
error: (err, _) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => messagesNotifier.loadInitial(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
chatRoom.when(
|
||||
data: (data) => AudioCallButton(room: data!),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () async {
|
||||
final result = await context.pushNamed(
|
||||
'chatDetail',
|
||||
pathParameters: {'id': id},
|
||||
);
|
||||
if (result is SearchMessagesResult && messages.value != null) {
|
||||
final messageId = result.messageId;
|
||||
messagesNotifier.jumpToMessage(messageId).then((index) {
|
||||
if (index != -1 && context.mounted) {
|
||||
ref
|
||||
.read(flashingMessagesProvider.notifier)
|
||||
.update((set) => set.union({messageId}));
|
||||
messages.when(
|
||||
data: (messageList) {
|
||||
scrollManager.scrollToMessage(
|
||||
messageId: messageId,
|
||||
messageList: messageList,
|
||||
);
|
||||
},
|
||||
loading: () {},
|
||||
error: (_, _) {},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder:
|
||||
(Widget child, Animation<double> animation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: messages.when(
|
||||
data: (messageList) => messageList.isEmpty
|
||||
? Center(
|
||||
key: const ValueKey('empty-messages'),
|
||||
child: Text('No messages yet'.tr()),
|
||||
)
|
||||
: RoomMessageList(
|
||||
key: const ValueKey('message-list'),
|
||||
messages: messageList,
|
||||
roomAsync: chatRoom,
|
||||
chatIdentity: chatIdentity,
|
||||
scrollController: scrollManager.scrollController,
|
||||
listController: scrollManager.listController,
|
||||
isSelectionMode: isSelectionMode.value,
|
||||
selectedMessages: selectedMessages.value,
|
||||
toggleSelectionMode: toggleSelectionMode,
|
||||
toggleMessageSelection: toggleMessageSelection,
|
||||
onMessageAction: inputManager.onMessageAction,
|
||||
onJump: onJump,
|
||||
attachmentProgress:
|
||||
inputManager.attachmentProgress,
|
||||
inputHeight: inputHeight.value,
|
||||
previousInputHeight: previousInputHeightRef.value,
|
||||
roomOpenTime: roomOpenTime,
|
||||
disableAnimation: settings.disableAnimation,
|
||||
),
|
||||
loading: () => const Center(
|
||||
key: ValueKey('loading-messages'),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
error: (error, _) => ResponseErrorWidget(
|
||||
key: const ValueKey('error-messages'),
|
||||
error: error,
|
||||
onRetry: () => messagesNotifier.loadInitial(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
RoomOverlays(
|
||||
roomAsync: chatRoom,
|
||||
isSyncing: isSyncing,
|
||||
showGradient: !isSelectionMode.value,
|
||||
bottomGradientOpacity: scrollManager.bottomGradientOpacity.value,
|
||||
inputHeight: inputHeight.value,
|
||||
),
|
||||
if (!isSelectionMode.value)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: mediaQuery.padding.bottom,
|
||||
child: chatRoom.when(
|
||||
data: (room) => room != null
|
||||
? ChatInput(
|
||||
key: inputKey,
|
||||
messageController: inputManager.messageController,
|
||||
chatRoom: room,
|
||||
onSend: () => inputManager.sendMessage(ref),
|
||||
onClear: () {
|
||||
if (inputManager.messageEditingTo != null) {
|
||||
inputManager.clearAttachmentsOnly();
|
||||
}
|
||||
inputManager.setEditingTo(null);
|
||||
inputManager.setReplyingTo(null);
|
||||
inputManager.setForwardingTo(null);
|
||||
inputManager.setPoll(null);
|
||||
inputManager.setFund(null);
|
||||
},
|
||||
messageEditingTo: inputManager.messageEditingTo,
|
||||
messageReplyingTo: inputManager.messageReplyingTo,
|
||||
messageForwardingTo: inputManager.messageForwardingTo,
|
||||
selectedPoll: inputManager.selectedPoll,
|
||||
onPollSelected: (poll) => inputManager.setPoll(poll),
|
||||
selectedFund: inputManager.selectedFund,
|
||||
onFundSelected: (fund) => inputManager.setFund(fund),
|
||||
onPickFile: (isPhoto) {
|
||||
if (isPhoto) {
|
||||
filePicker.pickPhotos();
|
||||
} else {
|
||||
filePicker.pickVideos();
|
||||
}
|
||||
},
|
||||
onPickAudio: filePicker.pickAudio,
|
||||
onPickGeneralFile: filePicker.pickFiles,
|
||||
onLinkAttachment: filePicker.linkAttachment,
|
||||
attachments: inputManager.attachments,
|
||||
onUploadAttachment: uploadAttachment,
|
||||
onDeleteAttachment: (index) async {
|
||||
final attachment = inputManager.attachments[index];
|
||||
if (attachment.isOnCloud && !attachment.isLink) {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
await client.delete(
|
||||
'/drive/files/${attachment.data.id}',
|
||||
);
|
||||
}
|
||||
final clone = List.of(inputManager.attachments);
|
||||
clone.removeAt(index);
|
||||
inputManager.updateAttachments(clone);
|
||||
},
|
||||
onMoveAttachment: (idx, delta) {
|
||||
if (idx + delta < 0 ||
|
||||
idx + delta >= inputManager.attachments.length) {
|
||||
return;
|
||||
}
|
||||
final clone = List.of(inputManager.attachments);
|
||||
clone.insert(idx + delta, clone.removeAt(idx));
|
||||
inputManager.updateAttachments(clone);
|
||||
},
|
||||
onAttachmentsChanged: inputManager.updateAttachments,
|
||||
attachmentProgress: inputManager.attachmentProgress,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (isSelectionMode.value)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: RoomSelectionMode(
|
||||
visible: isSelectionMode.value,
|
||||
selectedCount: selectedMessages.value.length,
|
||||
onClose: toggleSelectionMode,
|
||||
onAIThink: openThinkingSheet,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
199
lib/chat/chat_widgets/chat_room_widgets.dart
Normal file
199
lib/chat/chat_widgets/chat_room_widgets.dart
Normal file
@@ -0,0 +1,199 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
class ChatRoomAvatar extends StatelessWidget {
|
||||
final SnChatRoom room;
|
||||
final bool isDirect;
|
||||
final AsyncValue<SnChatSummary?> summary;
|
||||
final List<SnChatMember> validMembers;
|
||||
|
||||
const ChatRoomAvatar({
|
||||
super.key,
|
||||
required this.room,
|
||||
required this.isDirect,
|
||||
required this.summary,
|
||||
required this.validMembers,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final avatarChild = (isDirect && room.picture == null)
|
||||
? SplitAvatarWidget(
|
||||
files: validMembers.map((e) => e.account.profile.picture).toList(),
|
||||
)
|
||||
: room.picture == null
|
||||
? CircleAvatar(child: Text((room.name ?? 'DM')[0].toUpperCase()))
|
||||
: ProfilePictureWidget(file: room.picture);
|
||||
|
||||
final badgeChild = Badge(
|
||||
isLabelVisible: summary.when(
|
||||
data: (data) => (data?.unreadCount ?? 0) > 0,
|
||||
loading: () => false,
|
||||
error: (_, _) => false,
|
||||
),
|
||||
child: avatarChild,
|
||||
);
|
||||
|
||||
// Show realm avatar as small overlay if chat belongs to a realm
|
||||
if (room.realm != null) {
|
||||
return Stack(
|
||||
children: [
|
||||
badgeChild,
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.25),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: ProfilePictureWidget(file: room.realm!.picture),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return badgeChild;
|
||||
}
|
||||
}
|
||||
|
||||
class ChatRoomSubtitle extends StatelessWidget {
|
||||
final SnChatRoom room;
|
||||
final bool isDirect;
|
||||
final List<SnChatMember> validMembers;
|
||||
final AsyncValue<SnChatSummary?> summary;
|
||||
final Widget? subtitle;
|
||||
|
||||
const ChatRoomSubtitle({
|
||||
super.key,
|
||||
required this.room,
|
||||
required this.isDirect,
|
||||
required this.validMembers,
|
||||
required this.summary,
|
||||
this.subtitle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (subtitle != null) return subtitle!;
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
layoutBuilder: (currentChild, previousChildren) => Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [...previousChildren, ?currentChild],
|
||||
),
|
||||
child: summary.when(
|
||||
data: (data) => Container(
|
||||
key: const ValueKey('data'),
|
||||
child: data == null
|
||||
? isDirect && room.description == null
|
||||
? Text(
|
||||
validMembers
|
||||
.map((e) => '@${e.account.name}')
|
||||
.join(', '),
|
||||
maxLines: 1,
|
||||
)
|
||||
: Text(
|
||||
room.description ?? 'descriptionNone'.tr(),
|
||||
maxLines: 1,
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (data.unreadCount > 0)
|
||||
Text(
|
||||
'unreadMessages'.plural(data.unreadCount),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
if (data.lastMessage == null)
|
||||
Text(
|
||||
room.description ?? 'descriptionNone'.tr(),
|
||||
maxLines: 1,
|
||||
)
|
||||
else
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Badge(
|
||||
label: Text(data.lastMessage!.sender.account.nick),
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
(data.lastMessage!.content?.isNotEmpty ?? false)
|
||||
? data.lastMessage!.content!
|
||||
: 'messageNone'.tr(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
RelativeTime(
|
||||
context,
|
||||
).format(data.lastMessage!.createdAt),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading: () => Container(
|
||||
key: const ValueKey('loading'),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final seed = DateTime.now().microsecondsSinceEpoch;
|
||||
final len = 4 + (seed % 17); // 4..20 inclusive
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
var s = seed;
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < len; i++) {
|
||||
s = (s * 1103515245 + 12345) & 0x7fffffff;
|
||||
buffer.write(chars[s % chars.length]);
|
||||
}
|
||||
return Skeletonizer(
|
||||
enabled: true,
|
||||
child: Text(buffer.toString()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
error: (_, _) => Container(
|
||||
key: const ValueKey('error'),
|
||||
child: isDirect && room.description == null
|
||||
? Text(
|
||||
validMembers.map((e) => '@${e.account.name}').join(', '),
|
||||
maxLines: 1,
|
||||
)
|
||||
: Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
492
lib/chat/chat_widgets/chat_search_screen.dart
Normal file
492
lib/chat/chat_widgets/chat_search_screen.dart
Normal file
@@ -0,0 +1,492 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/chat/chat_pod/chat_room.dart';
|
||||
import 'package:island/chat/chat_widgets/message_list_tile.dart';
|
||||
import 'package:island/chat/messages_notifier.dart';
|
||||
import 'package:island/core/services/responsive.dart';
|
||||
import 'package:island/shared/widgets/app_scaffold.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
class SearchMessagesResult {
|
||||
final String messageId;
|
||||
const SearchMessagesResult(this.messageId);
|
||||
}
|
||||
|
||||
// Search states for better UX
|
||||
enum SearchState { idle, searching, results, noResults, error }
|
||||
|
||||
class _SearchFilters extends StatelessWidget {
|
||||
final ValueNotifier<bool> withLinks;
|
||||
final ValueNotifier<bool> withAttachments;
|
||||
final void Function(String) performSearch;
|
||||
final TextEditingController searchController;
|
||||
final bool isLarge;
|
||||
|
||||
const _SearchFilters({
|
||||
required this.withLinks,
|
||||
required this.withAttachments,
|
||||
required this.performSearch,
|
||||
required this.searchController,
|
||||
required this.isLarge,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLarge) {
|
||||
return Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Symbols.link,
|
||||
color: withLinks.value
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
onPressed: () {
|
||||
withLinks.value = !withLinks.value;
|
||||
performSearch(searchController.text);
|
||||
},
|
||||
tooltip: 'searchLinks'.tr(),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Symbols.file_copy,
|
||||
color: withAttachments.value
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
onPressed: () {
|
||||
withAttachments.value = !withAttachments.value;
|
||||
performSearch(searchController.text);
|
||||
},
|
||||
tooltip: 'searchAttachments'.tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
children: [
|
||||
FilterChip(
|
||||
avatar: const Icon(Symbols.link, size: 16),
|
||||
label: const Text('searchLinks').tr(),
|
||||
selected: withLinks.value,
|
||||
onSelected: (bool? value) {
|
||||
withLinks.value = value!;
|
||||
performSearch(searchController.text);
|
||||
},
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilterChip(
|
||||
avatar: const Icon(Symbols.file_copy, size: 16),
|
||||
label: const Text('searchAttachments').tr(),
|
||||
selected: withAttachments.value,
|
||||
onSelected: (bool? value) {
|
||||
withAttachments.value = value!;
|
||||
performSearch(searchController.text);
|
||||
},
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SearchMessagesScreen extends HookConsumerWidget {
|
||||
final String roomId;
|
||||
|
||||
const SearchMessagesScreen({super.key, required this.roomId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchController = useTextEditingController();
|
||||
final withLinks = useState(false);
|
||||
final withAttachments = useState(false);
|
||||
final searchState = useState(SearchState.idle);
|
||||
final searchResultCount = useState<int?>(null);
|
||||
final searchResults = useState<AsyncValue<List<dynamic>>>(
|
||||
const AsyncValue.data([]),
|
||||
);
|
||||
|
||||
// Debounce timer for search optimization
|
||||
final debounceTimer = useRef<Timer?>(null);
|
||||
|
||||
final messagesNotifier = ref.read(messagesProvider(roomId).notifier);
|
||||
|
||||
// Optimized search function with debouncing
|
||||
void performSearch(String query) async {
|
||||
final trimmedQuery = query.trim();
|
||||
final hasFilters = withLinks.value || withAttachments.value;
|
||||
|
||||
if (trimmedQuery.isEmpty && !hasFilters) {
|
||||
searchState.value = SearchState.idle;
|
||||
searchResultCount.value = null;
|
||||
searchResults.value = const AsyncValue.data([]);
|
||||
return;
|
||||
}
|
||||
|
||||
searchState.value = SearchState.searching;
|
||||
searchResults.value = const AsyncValue.loading();
|
||||
|
||||
// Cancel previous search if still active
|
||||
debounceTimer.value?.cancel();
|
||||
|
||||
// Debounce search to avoid excessive API calls
|
||||
debounceTimer.value = Timer(const Duration(milliseconds: 300), () async {
|
||||
try {
|
||||
final results = await messagesNotifier.getSearchResults(
|
||||
query.trim(),
|
||||
withLinks: withLinks.value,
|
||||
withAttachments: withAttachments.value,
|
||||
);
|
||||
searchResults.value = AsyncValue.data(results);
|
||||
searchState.value = results.isEmpty
|
||||
? SearchState.noResults
|
||||
: SearchState.results;
|
||||
searchResultCount.value = results.length;
|
||||
} catch (error, stackTrace) {
|
||||
searchResults.value = AsyncValue.error(error, stackTrace);
|
||||
searchState.value = SearchState.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Search state is now managed locally in performSearch
|
||||
|
||||
useEffect(() {
|
||||
// Clear search when screen is disposed
|
||||
return () {
|
||||
debounceTimer.value?.cancel();
|
||||
// Note: Don't access ref here as widget may be disposed
|
||||
// Flashing messages will be cleared by the next screen or jump operation
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Clear flashing messages when screen initializes (safer than in dispose)
|
||||
useEffect(() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Clear flashing messages when entering search screen
|
||||
ref.read(flashingMessagesProvider.notifier).clear();
|
||||
});
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
final isLarge = isWideScreen(context);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('searchMessages').tr(),
|
||||
bottom: searchState.value == SearchState.searching
|
||||
? const PreferredSize(
|
||||
preferredSize: Size.fromHeight(2),
|
||||
child: LinearProgressIndicator(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// Search input section
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
child: isLarge
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'searchMessagesHint'.tr(),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (searchResultCount.value != null &&
|
||||
searchState.value ==
|
||||
SearchState.results)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(
|
||||
12,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'${searchResultCount.value}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (searchController.text.isNotEmpty)
|
||||
IconButton(
|
||||
iconSize: 18,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
performSearch('');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onChanged: performSearch,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: _SearchFilters(
|
||||
withLinks: withLinks,
|
||||
withAttachments: withAttachments,
|
||||
performSearch: performSearch,
|
||||
searchController: searchController,
|
||||
isLarge: isLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: searchController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'searchMessagesHint'.tr(),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (searchResultCount.value != null &&
|
||||
searchState.value == SearchState.results)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${searchResultCount.value}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (searchController.text.isNotEmpty)
|
||||
IconButton(
|
||||
iconSize: 18,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
searchController.clear();
|
||||
performSearch('');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onChanged: performSearch,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
_SearchFilters(
|
||||
withLinks: withLinks,
|
||||
withAttachments: withAttachments,
|
||||
performSearch: performSearch,
|
||||
searchController: searchController,
|
||||
isLarge: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Search results section
|
||||
Expanded(
|
||||
child: searchResults.value.when(
|
||||
data: (messageList) {
|
||||
switch (searchState.value) {
|
||||
case SearchState.idle:
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'searchMessagesHint'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
case SearchState.noResults:
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'noMessagesFound'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'tryDifferentKeywords'.tr(),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
case SearchState.results:
|
||||
return SuperListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
reverse: false, // Show newest messages at the top
|
||||
itemCount: messageList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
return MessageListTile(
|
||||
message: message,
|
||||
onJump: (messageId) {
|
||||
// Return the search result and pop back to room detail
|
||||
context.pop(SearchMessagesResult(messageId));
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
loading: () {
|
||||
if (searchState.value == SearchState.searching) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Searching...'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
error: (error, _) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'searchError'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => performSearch(searchController.text),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('retry').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
194
lib/chat/chat_widgets/message_content.dart
Normal file
194
lib/chat/chat_widgets/message_content.dart
Normal file
@@ -0,0 +1,194 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/chat/chat_pod/call.dart';
|
||||
import 'package:island/core/widgets/content/markdown.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:pretty_diff_text/pretty_diff_text.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class MessageContent extends StatelessWidget {
|
||||
final SnChatMessage item;
|
||||
final String? translatedText;
|
||||
final bool isSelectable;
|
||||
|
||||
const MessageContent({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.translatedText,
|
||||
this.isSelectable = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (item.type == 'messages.delete' || item.deletedAt != null) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.delete,
|
||||
size: 16,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
item.content ?? 'Deleted a message',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontSize: 13,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
switch (item.type) {
|
||||
case 'call.start':
|
||||
case 'call.ended':
|
||||
return _MessageContentCall(
|
||||
isEnded: item.type == 'call.ended',
|
||||
duration: item.meta['duration']?.toDouble(),
|
||||
);
|
||||
case 'messages.update':
|
||||
case 'messages.update.links':
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
item.type == 'messages.update.links'
|
||||
? Symbols.link
|
||||
: Symbols.edit,
|
||||
size: 16,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
const Gap(4),
|
||||
if (item.meta['previous_content'] is String)
|
||||
Flexible(
|
||||
child: PrettyDiffText(
|
||||
oldText: item.meta['previous_content'],
|
||||
newText:
|
||||
item.content ??
|
||||
(item.type == 'messages.update.links'
|
||||
? 'messageUpdateLinks'.tr()
|
||||
: 'messageUpdateEdited'.tr()),
|
||||
defaultTextStyle: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium!.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
addedTextStyle: TextStyle(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryFixedDim.withOpacity(0.4),
|
||||
),
|
||||
deletedTextStyle: TextStyle(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
item.content ?? 'Edited a message',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
case 'text':
|
||||
default:
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.text,
|
||||
child: MarkdownTextContent(
|
||||
content: item.content ?? '*${item.type} has no content*',
|
||||
isSelectable: isSelectable,
|
||||
linesMargin: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (translatedText?.isNotEmpty ?? false)
|
||||
...([
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: math.min(
|
||||
280,
|
||||
MediaQuery.of(context).size.width * 0.4,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('translated').tr().fontSize(11).opacity(0.75),
|
||||
const Gap(8),
|
||||
Flexible(child: Divider()),
|
||||
],
|
||||
).padding(vertical: 4),
|
||||
),
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.text,
|
||||
child: MarkdownTextContent(
|
||||
content: translatedText!,
|
||||
isSelectable: isSelectable,
|
||||
linesMargin: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static bool hasContent(SnChatMessage item) {
|
||||
return item.type != 'text' || (item.content?.isNotEmpty ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageContentCall extends StatelessWidget {
|
||||
final bool isEnded;
|
||||
final double? duration;
|
||||
|
||||
const _MessageContentCall({required this.isEnded, this.duration});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isEnded ? Symbols.call_end : Symbols.phone_in_talk,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
Gap(4),
|
||||
Text(
|
||||
isEnded
|
||||
? 'Call ended after ${formatDuration(Duration(seconds: duration!.toInt()))}'
|
||||
: 'Call started',
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
96
lib/chat/chat_widgets/message_indicators.dart
Normal file
96
lib/chat/chat_widgets/message_indicators.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/data/message.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class MessageIndicators extends StatelessWidget {
|
||||
final DateTime? editedAt;
|
||||
final MessageStatus? status;
|
||||
final bool isCurrentUser;
|
||||
final Color textColor;
|
||||
final EdgeInsets padding;
|
||||
|
||||
const MessageIndicators({
|
||||
super.key,
|
||||
this.editedAt,
|
||||
this.status,
|
||||
required this.isCurrentUser,
|
||||
required this.textColor,
|
||||
this.padding = const EdgeInsets.only(left: 6),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final children = <Widget>[];
|
||||
|
||||
if (editedAt != null) {
|
||||
children.add(
|
||||
Text(
|
||||
'edited'.tr().toLowerCase(),
|
||||
style: TextStyle(fontSize: 11, color: textColor.withOpacity(0.7)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isCurrentUser && status != null && status != MessageStatus.sent) {
|
||||
children.add(
|
||||
_buildStatusIcon(
|
||||
context,
|
||||
status!,
|
||||
textColor.withOpacity(0.7),
|
||||
).padding(bottom: 2),
|
||||
);
|
||||
}
|
||||
|
||||
if (children.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusIcon(
|
||||
BuildContext context,
|
||||
MessageStatus status,
|
||||
Color textColor,
|
||||
) {
|
||||
switch (status) {
|
||||
case MessageStatus.pending:
|
||||
return SizedBox(
|
||||
width: 10,
|
||||
height: 10,
|
||||
child: CircularProgressIndicator(
|
||||
padding: EdgeInsets.zero,
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(textColor),
|
||||
),
|
||||
).padding(bottom: 2);
|
||||
case MessageStatus.sent:
|
||||
// Sent status is hidden
|
||||
return const SizedBox.shrink();
|
||||
case MessageStatus.failed:
|
||||
return Consumer(
|
||||
builder:
|
||||
(context, ref, _) => GestureDetector(
|
||||
onTap: () {
|
||||
// This would need to be passed in or accessed differently
|
||||
// For now, just show the error icon
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.error_outline,
|
||||
size: 12,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
1291
lib/chat/chat_widgets/message_item.dart
Normal file
1291
lib/chat/chat_widgets/message_item.dart
Normal file
File diff suppressed because it is too large
Load Diff
214
lib/chat/chat_widgets/message_item_wrapper.dart
Normal file
214
lib/chat/chat_widgets/message_item_wrapper.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/chat/chat_widgets/message_item.dart';
|
||||
import 'package:island/data/message.dart';
|
||||
|
||||
final animatedMessagesProvider = NotifierProvider.autoDispose(
|
||||
AnimatedMessagesNotifier.new,
|
||||
);
|
||||
|
||||
class AnimatedMessagesNotifier extends Notifier<Set<String>> {
|
||||
@override
|
||||
Set<String> build() {
|
||||
return {};
|
||||
}
|
||||
|
||||
void addMessage(String messageId) {
|
||||
state = {...state, messageId};
|
||||
}
|
||||
}
|
||||
|
||||
class MessageItemWrapper extends HookConsumerWidget {
|
||||
final LocalChatMessage message;
|
||||
final int index;
|
||||
final bool isLastInGroup;
|
||||
final bool isSelectionMode;
|
||||
final Set<String> selectedMessages;
|
||||
final AsyncValue<SnChatMember?> chatIdentity;
|
||||
final VoidCallback toggleSelectionMode;
|
||||
final Function(String) toggleMessageSelection;
|
||||
final Function(String, LocalChatMessage) onMessageAction;
|
||||
final Function(String) onJump;
|
||||
final Map<String, Map<int, double?>> attachmentProgress;
|
||||
final bool disableAnimation;
|
||||
final DateTime roomOpenTime;
|
||||
|
||||
const MessageItemWrapper({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.index,
|
||||
required this.isLastInGroup,
|
||||
required this.isSelectionMode,
|
||||
required this.selectedMessages,
|
||||
required this.chatIdentity,
|
||||
required this.toggleSelectionMode,
|
||||
required this.toggleMessageSelection,
|
||||
required this.onMessageAction,
|
||||
required this.onJump,
|
||||
required this.attachmentProgress,
|
||||
required this.disableAnimation,
|
||||
required this.roomOpenTime,
|
||||
});
|
||||
|
||||
Widget _buildContent(BuildContext context, SnChatMember? identity) {
|
||||
final isSelected = selectedMessages.contains(message.id);
|
||||
final isCurrentUser = identity?.id == message.senderId;
|
||||
|
||||
return GestureDetector(
|
||||
onLongPress: () {
|
||||
if (!isSelectionMode) {
|
||||
toggleSelectionMode();
|
||||
toggleMessageSelection(message.id);
|
||||
}
|
||||
},
|
||||
onTap: () {
|
||||
if (isSelectionMode) {
|
||||
toggleMessageSelection(message.id);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.3)
|
||||
: null,
|
||||
child: Stack(
|
||||
children: [
|
||||
MessageItem(
|
||||
// If animation is disabled, we might want to pass a key to maintain state?
|
||||
// But here we are inside the wrapper.
|
||||
key: ValueKey('item-${message.id}'),
|
||||
message: message,
|
||||
isCurrentUser: isCurrentUser,
|
||||
onAction: isSelectionMode
|
||||
? null
|
||||
: (action) => onMessageAction(action, message),
|
||||
onJump: onJump,
|
||||
progress: attachmentProgress[message.id],
|
||||
showAvatar: isLastInGroup,
|
||||
isSelectionMode: isSelectionMode,
|
||||
isSelected: isSelected,
|
||||
onToggleSelection: toggleMessageSelection,
|
||||
onEnterSelectionMode: () {
|
||||
if (!isSelectionMode) toggleSelectionMode();
|
||||
},
|
||||
),
|
||||
if (isSelected)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoading() {
|
||||
return MessageItem(
|
||||
message: message,
|
||||
isCurrentUser: false,
|
||||
onAction: null,
|
||||
progress: null,
|
||||
showAvatar: false,
|
||||
onJump: (_) {},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Animation logic
|
||||
final animatedMessages = ref.watch(animatedMessagesProvider);
|
||||
final isNewMessage = message.createdAt.isAfter(roomOpenTime);
|
||||
final hasAnimated = animatedMessages.contains(message.id);
|
||||
|
||||
// Only animate if:
|
||||
// 1. Animation is enabled
|
||||
// 2. Message is new (created after room open)
|
||||
// 3. Has not animated yet
|
||||
final shouldAnimate = !disableAnimation && isNewMessage && !hasAnimated;
|
||||
|
||||
final child = chatIdentity.when(
|
||||
skipError: true,
|
||||
data: (identity) => _buildContent(context, identity),
|
||||
loading: () => _buildLoading(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
);
|
||||
|
||||
final controller = useAnimationController(
|
||||
duration: Duration(milliseconds: 400 + (index % 5) * 50),
|
||||
);
|
||||
|
||||
final hasStarted = useState(false);
|
||||
|
||||
useEffect(() {
|
||||
if (shouldAnimate && !hasStarted.value) {
|
||||
hasStarted.value = true;
|
||||
controller.forward().then((_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(animatedMessagesProvider.notifier).addMessage(message.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [shouldAnimate]);
|
||||
|
||||
if (!shouldAnimate) {
|
||||
return child;
|
||||
}
|
||||
|
||||
final curvedAnimation = useMemoized(
|
||||
() => CurvedAnimation(parent: controller, curve: Curves.easeOutQuart),
|
||||
[controller],
|
||||
);
|
||||
|
||||
final sizeAnimation = useMemoized(
|
||||
() => Tween<double>(begin: 0.0, end: 1.0).animate(curvedAnimation),
|
||||
[curvedAnimation],
|
||||
);
|
||||
|
||||
final slideAnimation = useMemoized(
|
||||
() => Tween<Offset>(
|
||||
begin: const Offset(0, 0.12),
|
||||
end: Offset.zero,
|
||||
).animate(curvedAnimation),
|
||||
[curvedAnimation],
|
||||
);
|
||||
|
||||
final fadeAnimation = useMemoized(
|
||||
() => Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: controller,
|
||||
curve: const Interval(0.1, 1.0, curve: Curves.easeOut),
|
||||
),
|
||||
),
|
||||
[controller],
|
||||
);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) => FadeTransition(
|
||||
opacity: fadeAnimation,
|
||||
child: SizeTransition(
|
||||
axis: Axis.vertical,
|
||||
sizeFactor: sizeAnimation,
|
||||
child: SlideTransition(position: slideAnimation, child: child),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/chat/chat_widgets/message_list_tile.dart
Normal file
89
lib/chat/chat_widgets/message_list_tile.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/chat/chat_widgets/message_content.dart';
|
||||
import 'package:island/chat/chat_widgets/message_sender_info.dart';
|
||||
import 'package:island/data/message.dart';
|
||||
import 'package:island/posts/posts_models/embed.dart';
|
||||
import 'package:island/core/utils/mapping.dart';
|
||||
import 'package:island/core/widgets/content/cloud_file_collection.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/core/widgets/content/embed/link.dart';
|
||||
|
||||
class MessageListTile extends StatelessWidget {
|
||||
final LocalChatMessage message;
|
||||
final Function(String messageId) onJump;
|
||||
|
||||
const MessageListTile({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.onJump,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final remoteMessage = message.toRemoteMessage();
|
||||
final sender = remoteMessage.sender;
|
||||
|
||||
return ListTile(
|
||||
isThreeLine: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.transparent,
|
||||
child: ProfilePictureWidget(
|
||||
file: sender.account.profile.picture,
|
||||
radius: 20,
|
||||
),
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MessageSenderInfo(
|
||||
sender: sender,
|
||||
createdAt: message.createdAt,
|
||||
textColor: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
showAvatar: false,
|
||||
isCompact: true,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
MessageContent(item: remoteMessage, isSelectable: false),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (remoteMessage.attachments.isNotEmpty)
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return CloudFileList(
|
||||
files: remoteMessage.attachments,
|
||||
maxWidth: constraints.maxWidth,
|
||||
padding: EdgeInsets.symmetric(vertical: 4),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (remoteMessage.meta['embeds'] != null)
|
||||
...((remoteMessage.meta['embeds'] as List<dynamic>)
|
||||
.map((embed) => convertMapKeysToSnakeCase(embed))
|
||||
.where((embed) => embed['type'] == 'link')
|
||||
.map((embed) => SnScrappedLink.fromJson(embed))
|
||||
.map(
|
||||
(link) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return EmbedLinkWidget(
|
||||
link: link,
|
||||
maxWidth: math.min(constraints.maxWidth, 480),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList()),
|
||||
],
|
||||
),
|
||||
onTap: () => onJump(message.id),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
dense: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
126
lib/chat/chat_widgets/message_sender_info.dart
Normal file
126
lib/chat/chat_widgets/message_sender_info.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_name.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_pfc.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
|
||||
class MessageSenderInfo extends StatelessWidget {
|
||||
final SnChatMember sender;
|
||||
final DateTime createdAt;
|
||||
final Color textColor;
|
||||
final bool showAvatar;
|
||||
final bool isCompact;
|
||||
|
||||
const MessageSenderInfo({
|
||||
super.key,
|
||||
required this.sender,
|
||||
required this.createdAt,
|
||||
required this.textColor,
|
||||
this.showAvatar = true,
|
||||
this.isCompact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final timestamp = DateTime.now().difference(createdAt).inDays > 365
|
||||
? DateFormat('yyyy/MM/dd HH:mm').format(createdAt.toLocal())
|
||||
: DateTime.now().difference(createdAt).inDays > 0
|
||||
? DateFormat('MM/dd HH:mm').format(createdAt.toLocal())
|
||||
: DateFormat('HH:mm').format(createdAt.toLocal());
|
||||
|
||||
if (isCompact) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
if (showAvatar)
|
||||
AccountPfcRegion(
|
||||
uname: sender.account.name,
|
||||
child: ProfilePictureWidget(
|
||||
file: sender.account.profile.picture,
|
||||
radius: 14,
|
||||
),
|
||||
),
|
||||
if (showAvatar) const Gap(4),
|
||||
AccountName(
|
||||
account: sender.account,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Gap(6),
|
||||
Text(
|
||||
timestamp,
|
||||
style: TextStyle(fontSize: 10, color: textColor.withOpacity(0.7)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (showAvatar) {
|
||||
return Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
AccountPfcRegion(
|
||||
uname: sender.account.name,
|
||||
child: ProfilePictureWidget(
|
||||
file: sender.account.profile.picture,
|
||||
radius: 14,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AccountName(
|
||||
account: sender.account,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
timestamp,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: textColor.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
spacing: 8,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showAvatar)
|
||||
AccountPfcRegion(
|
||||
uname: sender.account.name,
|
||||
child: ProfilePictureWidget(
|
||||
file: sender.account.profile.picture,
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 2,
|
||||
children: [
|
||||
Text(timestamp, style: TextStyle(fontSize: 10, color: textColor)),
|
||||
AccountName(
|
||||
account: sender.account,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
216
lib/chat/chat_widgets/public_room_preview.dart
Normal file
216
lib/chat/chat_widgets/public_room_preview.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import "package:easy_localization/easy_localization.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:go_router/go_router.dart";
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:gap/gap.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:island/chat/chat_widgets/message_item.dart";
|
||||
import "package:island/chat/messages_notifier.dart";
|
||||
import "package:island/data/message.dart";
|
||||
import "package:island/chat/chat_models/chat.dart";
|
||||
import "package:island/chat/chat_pod/chat_room.dart";
|
||||
import "package:island/core/network.dart";
|
||||
import "package:island/core/services/responsive.dart";
|
||||
import "package:island/shared/widgets/alert.dart";
|
||||
import "package:island/shared/widgets/app_scaffold.dart";
|
||||
import "package:island/drive/drive_widgets/cloud_files.dart";
|
||||
import "package:island/shared/widgets/response.dart";
|
||||
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||
import "package:styled_widget/styled_widget.dart";
|
||||
import "package:super_sliver_list/super_sliver_list.dart";
|
||||
import "package:material_symbols_icons/symbols.dart";
|
||||
|
||||
class PublicRoomPreview extends HookConsumerWidget {
|
||||
final String id;
|
||||
final SnChatRoom room;
|
||||
|
||||
const PublicRoomPreview({super.key, required this.id, required this.room});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messages = ref.watch(messagesProvider(id));
|
||||
final messagesNotifier = ref.read(messagesProvider(id).notifier);
|
||||
final scrollController = useScrollController();
|
||||
|
||||
final listController = useMemoized(() => ListController(), []);
|
||||
|
||||
var isLoading = false;
|
||||
|
||||
// Add scroll listener for pagination
|
||||
useEffect(() {
|
||||
void onScroll() {
|
||||
if (scrollController.position.pixels >=
|
||||
scrollController.position.maxScrollExtent - 200) {
|
||||
if (isLoading) return;
|
||||
isLoading = true;
|
||||
messagesNotifier.loadMore().then((_) => isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
scrollController.addListener(onScroll);
|
||||
return () => scrollController.removeListener(onScroll);
|
||||
}, [scrollController]);
|
||||
|
||||
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
|
||||
SuperListView.builder(
|
||||
listController: listController,
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
controller: scrollController,
|
||||
reverse: true, // Show newest messages at the bottom
|
||||
itemCount: messageList.length,
|
||||
findChildIndexCallback: (key) {
|
||||
final valueKey = key as ValueKey;
|
||||
final messageId = valueKey.value as String;
|
||||
return messageList.indexWhere((m) => m.id == messageId);
|
||||
},
|
||||
extentEstimation: (_, _) => 40,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messageList[index];
|
||||
final nextMessage = index < messageList.length - 1
|
||||
? messageList[index + 1]
|
||||
: null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
3;
|
||||
|
||||
return MessageItem(
|
||||
message: message,
|
||||
isCurrentUser: false, // User is not a member, so not current user
|
||||
onAction: null, // No actions allowed in preview mode
|
||||
onJump: (_) {}, // No jump functionality in preview
|
||||
progress: null,
|
||||
showAvatar: isLastInGroup,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
final compactHeader = isWideScreen(context);
|
||||
|
||||
Widget comfortHeaderWidget() => Column(
|
||||
spacing: 4,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 26,
|
||||
width: 26,
|
||||
child: (room.type == 1 && room.picture == null)
|
||||
? SplitAvatarWidget(
|
||||
files: room.members!
|
||||
.map((e) => e.account.profile.picture)
|
||||
.toList(),
|
||||
)
|
||||
: room.picture != null
|
||||
? ProfilePictureWidget(
|
||||
file: room.picture,
|
||||
fallbackIcon: Symbols.chat,
|
||||
)
|
||||
: CircleAvatar(
|
||||
child: Text(
|
||||
room.name![0].toUpperCase(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
(room.type == 1 && room.name == null)
|
||||
? room.members!.map((e) => e.account.nick).join(', ')
|
||||
: room.name!,
|
||||
).fontSize(15),
|
||||
],
|
||||
);
|
||||
|
||||
Widget compactHeaderWidget() => Row(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 26,
|
||||
width: 26,
|
||||
child: (room.type == 1 && room.picture == null)
|
||||
? SplitAvatarWidget(
|
||||
files: room.members!
|
||||
.map((e) => e.account.profile.picture)
|
||||
.toList(),
|
||||
)
|
||||
: room.picture != null
|
||||
? ProfilePictureWidget(
|
||||
file: room.picture,
|
||||
fallbackIcon: Symbols.chat,
|
||||
)
|
||||
: CircleAvatar(
|
||||
child: Text(
|
||||
room.name![0].toUpperCase(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
(room.type == 1 && room.name == null)
|
||||
? room.members!.map((e) => e.account.nick).join(', ')
|
||||
: room.name!,
|
||||
).fontSize(19),
|
||||
],
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: !compactHeader ? const Center(child: PageBackButton()) : null,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: compactHeader ? null : 64,
|
||||
title: compactHeader ? compactHeaderWidget() : comfortHeaderWidget(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () {
|
||||
context.pushNamed('chatDetail', pathParameters: {'id': id});
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: messages.when(
|
||||
data: (messageList) => messageList.isEmpty
|
||||
? Center(child: Text('No messages yet'.tr()))
|
||||
: chatMessageListWidget(messageList),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () => messagesNotifier.loadInitial(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Join button at the bottom for public rooms
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: () async {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
await apiClient.post('/messager/chat/${room.id}/members/me');
|
||||
ref.invalidate(chatRoomIdentityProvider(id));
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
},
|
||||
label: Text('chatJoin').tr(),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
129
lib/chat/chat_widgets/room_app_bar.dart
Normal file
129
lib/chat/chat_widgets/room_app_bar.dart
Normal file
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/accounts/accounts_pod.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
List<SnChatMember> getValidMembers(List<SnChatMember> members, String? userId) {
|
||||
return members.where((member) => member.accountId != userId).toList();
|
||||
}
|
||||
|
||||
class RoomAppBar extends ConsumerWidget {
|
||||
final SnChatRoom room;
|
||||
final int onlineCount;
|
||||
final bool compact;
|
||||
|
||||
const RoomAppBar({
|
||||
super.key,
|
||||
required this.room,
|
||||
required this.onlineCount,
|
||||
required this.compact,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userInfo = ref.watch(userInfoProvider);
|
||||
final validMembers = getValidMembers(
|
||||
room.members ?? [],
|
||||
userInfo.value?.id,
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return Row(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
_OnlineCountBadge(
|
||||
onlineCount: onlineCount,
|
||||
child: _RoomAvatar(
|
||||
room: room,
|
||||
validMembers: validMembers,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
(room.type == 1 && room.name == null)
|
||||
? validMembers.map((e) => e.account.nick).join(', ')
|
||||
: room.name!,
|
||||
).fontSize(19),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
spacing: 4,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_OnlineCountBadge(
|
||||
onlineCount: onlineCount,
|
||||
child: _RoomAvatar(room: room, validMembers: validMembers, size: 26),
|
||||
),
|
||||
Text(
|
||||
(room.type == 1 && room.name == null)
|
||||
? validMembers.map((e) => e.account.nick).join(', ')
|
||||
: room.name!,
|
||||
).fontSize(15),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OnlineCountBadge extends StatelessWidget {
|
||||
final int onlineCount;
|
||||
final Widget child;
|
||||
|
||||
const _OnlineCountBadge({required this.onlineCount, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Badge(
|
||||
isLabelVisible: onlineCount > 1,
|
||||
label: Text('$onlineCount'),
|
||||
textStyle: GoogleFonts.robotoMono(fontSize: 10),
|
||||
textColor: Colors.white,
|
||||
backgroundColor: onlineCount > 1 ? Colors.green : Colors.grey,
|
||||
offset: const Offset(6, 14),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoomAvatar extends StatelessWidget {
|
||||
final SnChatRoom room;
|
||||
final List<SnChatMember> validMembers;
|
||||
final double size;
|
||||
|
||||
const _RoomAvatar({
|
||||
required this.room,
|
||||
required this.validMembers,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: size,
|
||||
width: size,
|
||||
child: (room.type == 1 && room.picture == null)
|
||||
? SplitAvatarWidget(
|
||||
files: validMembers
|
||||
.map((e) => e.account.profile.picture)
|
||||
.toList(),
|
||||
)
|
||||
: room.picture != null
|
||||
? ProfilePictureWidget(file: room.picture, fallbackIcon: Symbols.chat)
|
||||
: CircleAvatar(
|
||||
child: Text(
|
||||
room.name![0].toUpperCase(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/chat/chat_widgets/room_message_list.dart
Normal file
170
lib/chat/chat_widgets/room_message_list.dart
Normal file
@@ -0,0 +1,170 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/chat/chat_widgets/message_item_wrapper.dart';
|
||||
import 'package:island/data/message.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:super_sliver_list/super_sliver_list.dart';
|
||||
|
||||
class RoomMessageList extends HookConsumerWidget {
|
||||
final List<LocalChatMessage> messages;
|
||||
final AsyncValue<SnChatRoom?> roomAsync;
|
||||
final AsyncValue<SnChatMember?> chatIdentity;
|
||||
final ScrollController scrollController;
|
||||
final ListController listController;
|
||||
final bool isSelectionMode;
|
||||
final Set<String> selectedMessages;
|
||||
final VoidCallback toggleSelectionMode;
|
||||
final void Function(String) toggleMessageSelection;
|
||||
final void Function(String action, LocalChatMessage message) onMessageAction;
|
||||
final void Function(String messageId) onJump;
|
||||
final Map<String, Map<int, double?>> attachmentProgress;
|
||||
final bool disableAnimation;
|
||||
final DateTime roomOpenTime;
|
||||
final double inputHeight;
|
||||
final double? previousInputHeight;
|
||||
|
||||
const RoomMessageList({
|
||||
super.key,
|
||||
required this.messages,
|
||||
required this.roomAsync,
|
||||
required this.chatIdentity,
|
||||
required this.scrollController,
|
||||
required this.listController,
|
||||
required this.isSelectionMode,
|
||||
required this.selectedMessages,
|
||||
required this.toggleSelectionMode,
|
||||
required this.toggleMessageSelection,
|
||||
required this.onMessageAction,
|
||||
required this.onJump,
|
||||
required this.attachmentProgress,
|
||||
required this.disableAnimation,
|
||||
required this.roomOpenTime,
|
||||
required this.inputHeight,
|
||||
this.previousInputHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
const messageKeyPrefix = 'message-';
|
||||
|
||||
final bottomPadding =
|
||||
inputHeight + MediaQuery.of(context).padding.bottom + 8;
|
||||
|
||||
final listWidget =
|
||||
previousInputHeight != null && previousInputHeight != inputHeight
|
||||
? TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: previousInputHeight, end: inputHeight),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, height, child) => SuperListView.builder(
|
||||
listController: listController,
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
padding: EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: height + MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
itemCount: messages.length,
|
||||
findChildIndexCallback: (key) {
|
||||
if (key is! ValueKey<String>) return null;
|
||||
final messageId = key.value.substring(messageKeyPrefix.length);
|
||||
final index = messages.indexWhere(
|
||||
(m) => (m.nonce ?? m.id) == messageId,
|
||||
);
|
||||
return index >= 0 ? index : null;
|
||||
},
|
||||
extentEstimation: (_, _) => 40,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
final nextMessage = index < messages.length - 1
|
||||
? messages[index + 1]
|
||||
: null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
3;
|
||||
|
||||
final key = Key(
|
||||
'$messageKeyPrefix${message.nonce ?? message.id}',
|
||||
);
|
||||
|
||||
return MessageItemWrapper(
|
||||
key: key,
|
||||
message: message,
|
||||
index: index,
|
||||
isLastInGroup: isLastInGroup,
|
||||
isSelectionMode: isSelectionMode,
|
||||
selectedMessages: selectedMessages,
|
||||
chatIdentity: chatIdentity,
|
||||
toggleSelectionMode: toggleSelectionMode,
|
||||
toggleMessageSelection: toggleMessageSelection,
|
||||
onMessageAction: onMessageAction,
|
||||
onJump: onJump,
|
||||
attachmentProgress: attachmentProgress,
|
||||
disableAnimation: settings.disableAnimation,
|
||||
roomOpenTime: roomOpenTime,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: SuperListView.builder(
|
||||
listController: listController,
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
padding: EdgeInsets.only(top: 8, bottom: bottomPadding),
|
||||
itemCount: messages.length,
|
||||
findChildIndexCallback: (key) {
|
||||
if (key is! ValueKey<String>) return null;
|
||||
final messageId = key.value.substring(messageKeyPrefix.length);
|
||||
final index = messages.indexWhere(
|
||||
(m) => (m.nonce ?? m.id) == messageId,
|
||||
);
|
||||
return index >= 0 ? index : null;
|
||||
},
|
||||
extentEstimation: (_, _) => 40,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
final nextMessage = index < messages.length - 1
|
||||
? messages[index + 1]
|
||||
: null;
|
||||
final isLastInGroup =
|
||||
nextMessage == null ||
|
||||
nextMessage.senderId != message.senderId ||
|
||||
nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes
|
||||
.abs() >
|
||||
3;
|
||||
|
||||
final key = Key(
|
||||
'$messageKeyPrefix${message.nonce ?? message.id}',
|
||||
);
|
||||
|
||||
return MessageItemWrapper(
|
||||
key: key,
|
||||
message: message,
|
||||
index: index,
|
||||
isLastInGroup: isLastInGroup,
|
||||
isSelectionMode: isSelectionMode,
|
||||
selectedMessages: selectedMessages,
|
||||
chatIdentity: chatIdentity,
|
||||
toggleSelectionMode: toggleSelectionMode,
|
||||
toggleMessageSelection: toggleMessageSelection,
|
||||
onMessageAction: onMessageAction,
|
||||
onJump: onJump,
|
||||
attachmentProgress: attachmentProgress,
|
||||
disableAnimation: settings.disableAnimation,
|
||||
roomOpenTime: roomOpenTime,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return listWidget;
|
||||
}
|
||||
}
|
||||
103
lib/chat/chat_widgets/room_overlays.dart
Normal file
103
lib/chat/chat_widgets/room_overlays.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/chat/chat_models/chat.dart';
|
||||
import 'package:island/chat/chat_widgets/call_overlay.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class RoomOverlays extends ConsumerWidget {
|
||||
final AsyncValue<SnChatRoom?> roomAsync;
|
||||
final bool isSyncing;
|
||||
final bool showGradient;
|
||||
final double bottomGradientOpacity;
|
||||
final double inputHeight;
|
||||
|
||||
const RoomOverlays({
|
||||
super.key,
|
||||
required this.roomAsync,
|
||||
required this.isSyncing,
|
||||
required this.showGradient,
|
||||
required this.bottomGradientOpacity,
|
||||
required this.inputHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: roomAsync.when(
|
||||
data: (data) => data != null
|
||||
? CallOverlayBar(room: data).padding(horizontal: 8, top: 12)
|
||||
: const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
if (isSyncing)
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8,
|
||||
8,
|
||||
8,
|
||||
8 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).scaffoldBackgroundColor.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Syncing...',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showGradient)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Opacity(
|
||||
opacity: bottomGradientOpacity,
|
||||
child: Container(
|
||||
height: math.min(MediaQuery.of(context).size.height * 0.1, 128),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer.withOpacity(0.8),
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainer.withOpacity(0.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
54
lib/chat/chat_widgets/room_selection_mode.dart
Normal file
54
lib/chat/chat_widgets/room_selection_mode.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class RoomSelectionMode extends StatelessWidget {
|
||||
final bool visible;
|
||||
final int selectedCount;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onAIThink;
|
||||
|
||||
const RoomSelectionMode({
|
||||
super.key,
|
||||
required this.visible,
|
||||
required this.selectedCount,
|
||||
required this.onClose,
|
||||
required this.onAIThink,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!visible) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 8,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: onClose,
|
||||
tooltip: 'Cancel selection',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$selectedCount selected',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
if (selectedCount > 0)
|
||||
FilledButton.icon(
|
||||
onPressed: onAIThink,
|
||||
icon: const Icon(Symbols.smart_toy),
|
||||
label: const Text('AI Think'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user