🎨 Use feature based folder structure
This commit is contained in:
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user