♻️ Decouple the room.dart

This commit is contained in:
2026-01-10 14:18:59 +08:00
parent 64903bf1f3
commit 3847581f1f
9 changed files with 1132 additions and 722 deletions

View File

@@ -0,0 +1,135 @@
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart';
import 'package:island/widgets/chat/chat_link_attachments.dart';
class RoomFilePicker {
final List<UniversalFile> attachments;
final void Function(List<UniversalFile>) updateAttachments;
final Future<void> Function() pickPhotos;
final Future<void> Function() pickVideos;
final Future<void> Function() pickAudio;
final Future<void> Function() pickFiles;
final Future<void> Function() linkAttachment;
RoomFilePicker({
required this.attachments,
required this.updateAttachments,
required this.pickPhotos,
required this.pickVideos,
required this.pickAudio,
required this.pickFiles,
required this.linkAttachment,
});
}
RoomFilePicker useRoomFilePicker(
BuildContext context,
List<UniversalFile> currentAttachments,
Function(List<UniversalFile>) onAttachmentsChanged,
) {
final attachments = useState<List<UniversalFile>>(currentAttachments);
Future<void> pickPhotos() async {
final picker = ImagePicker();
final results = await picker.pickMultiImage();
if (results.isEmpty) return;
attachments.value = [
...attachments.value,
...results.map(
(xfile) => UniversalFile(data: xfile, type: UniversalFileType.image),
),
];
onAttachmentsChanged(attachments.value);
}
Future<void> pickVideos() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.video,
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
attachments.value = [
...attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.video),
),
];
onAttachmentsChanged(attachments.value);
}
Future<void> pickAudio() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.audio,
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
attachments.value = [
...attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.audio),
),
];
onAttachmentsChanged(attachments.value);
}
Future<void> pickFiles() async {
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
attachments.value = [
...attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.file),
),
];
onAttachmentsChanged(attachments.value);
}
Future<void> linkAttachment() async {
final cloudFile = await showModalBottomSheet<SnCloudFile?>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const ChatLinkAttachment(),
);
if (cloudFile == null) return;
attachments.value = [
...attachments.value,
UniversalFile(
data: cloudFile,
type: switch (cloudFile.mimeType?.split('/').firstOrNull) {
'image' => UniversalFileType.image,
'video' => UniversalFileType.video,
'audio' => UniversalFileType.audio,
_ => UniversalFileType.file,
},
isLink: true,
),
];
onAttachmentsChanged(attachments.value);
}
void updateAttachments(List<UniversalFile> newAttachments) {
attachments.value = newAttachments;
onAttachmentsChanged(attachments.value);
}
return RoomFilePicker(
attachments: attachments.value,
updateAttachments: updateAttachments,
pickPhotos: pickPhotos,
pickVideos: pickVideos,
pickAudio: pickAudio,
pickFiles: pickFiles,
linkAttachment: linkAttachment,
);
}

View File

@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart';
import 'package:island/models/chat.dart';
import 'package:island/models/poll.dart';
import 'package:island/models/wallet.dart';
import 'package:island/pods/chat/chat_subscribe.dart';
import 'package:island/database/message.dart';
import 'package:island/pods/chat/messages_notifier.dart';
import 'package:island/widgets/chat/message_item.dart';
import 'package:pasteboard/pasteboard.dart';
class RoomInputManager {
final TextEditingController messageController;
final List<UniversalFile> attachments;
final Map<String, Map<int, double?>> attachmentProgress;
final SnChatMessage? messageEditingTo;
final SnChatMessage? messageReplyingTo;
final SnChatMessage? messageForwardingTo;
final SnPoll? selectedPoll;
final SnWalletFund? selectedFund;
final void Function(List<UniversalFile>) updateAttachments;
final void Function(String, double?) updateAttachmentProgress;
final void Function(SnChatMessage?) setEditingTo;
final void Function(SnChatMessage?) setReplyingTo;
final void Function(SnChatMessage?) setForwardingTo;
final void Function(SnPoll?) setPoll;
final void Function(SnWalletFund?) setFund;
final void Function() clear;
final void Function() clearAttachmentsOnly;
final Future<void> Function() handlePaste;
final void Function(WidgetRef ref) sendMessage;
final void Function(String action, LocalChatMessage message) onMessageAction;
RoomInputManager({
required this.messageController,
required this.attachments,
required this.attachmentProgress,
this.messageEditingTo,
this.messageReplyingTo,
this.messageForwardingTo,
this.selectedPoll,
this.selectedFund,
required this.updateAttachments,
required this.updateAttachmentProgress,
required this.setEditingTo,
required this.setReplyingTo,
required this.setForwardingTo,
required this.setPoll,
required this.setFund,
required this.clear,
required this.clearAttachmentsOnly,
required this.handlePaste,
required this.sendMessage,
required this.onMessageAction,
});
}
RoomInputManager useRoomInputManager(WidgetRef ref, String roomId) {
final messageController = useTextEditingController();
final attachments = useState<List<UniversalFile>>([]);
final attachmentProgress = useState<Map<String, Map<int, double?>>>({});
final messageEditingTo = useState<SnChatMessage?>(null);
final messageReplyingTo = useState<SnChatMessage?>(null);
final messageForwardingTo = useState<SnChatMessage?>(null);
final selectedPoll = useState<SnPoll?>(null);
final selectedFund = useState<SnWalletFund?>(null);
final chatSubscribeNotifier = ref.read(
chatSubscribeProvider(roomId).notifier,
);
final messagesNotifier = ref.read(messagesProvider(roomId).notifier);
void updateAttachments(List<UniversalFile> newAttachments) {
attachments.value = newAttachments;
}
void updateAttachmentProgress(String messageId, double? progress) {
attachmentProgress.value = {
...attachmentProgress.value,
messageId: {0: progress},
};
}
void setEditingTo(SnChatMessage? message) {
messageEditingTo.value = message;
if (message != null) {
messageController.text = message.content ?? '';
attachments.value = message.attachments
.map((e) => UniversalFile.fromAttachment(e))
.toList();
}
}
void setReplyingTo(SnChatMessage? message) {
messageReplyingTo.value = message;
}
void setForwardingTo(SnChatMessage? message) {
messageForwardingTo.value = message;
}
void setPoll(SnPoll? poll) {
selectedPoll.value = poll;
}
void setFund(SnWalletFund? fund) {
selectedFund.value = fund;
}
void clear() {
messageController.clear();
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
selectedPoll.value = null;
selectedFund.value = null;
attachments.value = [];
}
void clearAttachmentsOnly() {
messageController.clear();
attachments.value = [];
}
void onTextChange() {
if (messageController.text.isNotEmpty) {
chatSubscribeNotifier.sendTypingStatus();
}
}
useEffect(() {
messageController.addListener(onTextChange);
return () => messageController.removeListener(onTextChange);
}, [messageController]);
Future<void> handlePaste() async {
final image = await Pasteboard.image;
if (image != null) {
final newAttachments = [
...attachments.value,
UniversalFile(
data: XFile.fromData(image, mimeType: "image/jpeg"),
type: UniversalFileType.image,
),
];
attachments.value = newAttachments;
}
final textData = await Clipboard.getData(Clipboard.kTextPlain);
if (textData != null && textData.text != null) {
final text = messageController.text;
final selection = messageController.selection;
final start = selection.start >= 0 ? selection.start : text.length;
final end = selection.end >= 0 ? selection.end : text.length;
final newText = text.replaceRange(start, end, textData.text!);
messageController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: start + textData.text!.length,
),
);
}
}
void onMessageAction(String action, LocalChatMessage message) {
switch (action) {
case MessageItemAction.delete:
messagesNotifier.deleteMessage(message.id);
case MessageItemAction.edit:
setEditingTo(message.toRemoteMessage());
case MessageItemAction.forward:
setForwardingTo(message.toRemoteMessage());
case MessageItemAction.reply:
setReplyingTo(message.toRemoteMessage());
case MessageItemAction.resend:
messagesNotifier.retryMessage(message.id);
}
}
void sendMessage(WidgetRef ref) {
if (messageController.text.trim().isNotEmpty ||
attachments.value.isNotEmpty ||
selectedPoll.value != null ||
selectedFund.value != null) {
messagesNotifier.sendMessage(
ref,
messageController.text.trim(),
attachments.value,
poll: selectedPoll.value,
fund: selectedFund.value,
editingTo: messageEditingTo.value,
forwardingTo: messageForwardingTo.value,
replyingTo: messageReplyingTo.value,
onProgress: (messageId, progress) {
attachmentProgress.value = {
...attachmentProgress.value,
messageId: progress,
};
},
);
clear();
}
}
return RoomInputManager(
messageController: messageController,
attachments: attachments.value,
attachmentProgress: attachmentProgress.value,
messageEditingTo: messageEditingTo.value,
messageReplyingTo: messageReplyingTo.value,
messageForwardingTo: messageForwardingTo.value,
selectedPoll: selectedPoll.value,
selectedFund: selectedFund.value,
updateAttachments: updateAttachments,
updateAttachmentProgress: updateAttachmentProgress,
setEditingTo: setEditingTo,
setReplyingTo: setReplyingTo,
setForwardingTo: setForwardingTo,
setPoll: setPoll,
setFund: setFund,
clear: clear,
clearAttachmentsOnly: clearAttachmentsOnly,
handlePaste: handlePaste,
sendMessage: sendMessage,
onMessageAction: onMessageAction,
);
}

View File

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/chat/chat_room.dart';
import 'package:island/database/message.dart';
import 'package:island/pods/chat/messages_notifier.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
class RoomScrollManager {
final ScrollController scrollController;
final ListController listController;
final ValueNotifier<double> bottomGradientOpacity;
bool isScrollingToMessage;
final void Function({
required String messageId,
required List<LocalChatMessage> messageList,
})
scrollToMessage;
RoomScrollManager({
required this.scrollController,
required this.listController,
required this.bottomGradientOpacity,
required this.scrollToMessage,
this.isScrollingToMessage = false,
});
}
RoomScrollManager useRoomScrollManager(
WidgetRef ref,
String roomId,
Future<int> Function(String) jumpToMessage,
AsyncValue<List<LocalChatMessage>> messagesAsync,
) {
final scrollController = useScrollController();
final listController = useMemoized(() => ListController(), []);
final bottomGradientOpacity = useState(ValueNotifier<double>(0.0));
var isLoading = false;
var isScrollingToMessage = false;
final messagesNotifier = ref.read(messagesProvider(roomId).notifier);
final flashingMessagesNotifier = ref.read(flashingMessagesProvider.notifier);
void performScrollAnimation({required int index, required String messageId}) {
flashingMessagesNotifier.update((set) => set.union({messageId}));
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
listController.animateToItem(
index: index,
scrollController: scrollController,
alignment: 0.5,
duration: (estimatedDistance) => Duration(
milliseconds: (estimatedDistance * 0.5).clamp(200, 800).toInt(),
),
curve: (estimatedDistance) => Curves.easeOutCubic,
);
Future.delayed(const Duration(milliseconds: 800), () {
isScrollingToMessage = false;
});
} catch (e) {
isScrollingToMessage = false;
}
});
});
}
void scrollToMessageWrapper({
required String messageId,
required List<LocalChatMessage> messageList,
}) {
if (isScrollingToMessage) return;
isScrollingToMessage = true;
final messageIndex = messageList.indexWhere((m) => m.id == messageId);
if (messageIndex == -1) {
jumpToMessage(messageId).then((index) {
if (index != -1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
performScrollAnimation(index: index, messageId: messageId);
});
} else {
isScrollingToMessage = false;
}
});
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
performScrollAnimation(index: messageIndex, messageId: messageId);
});
}
}
useEffect(() {
void onScroll() {
messagesAsync.when(
data: (messageList) {
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent - 200) {
if (!isLoading) {
isLoading = true;
messagesNotifier.loadMore().then((_) => isLoading = false);
}
}
final pixels = scrollController.position.pixels;
bottomGradientOpacity.value.value = (pixels / 500.0).clamp(0.0, 1.0);
},
loading: () {},
error: (_, _) {},
);
}
scrollController.addListener(onScroll);
return () => scrollController.removeListener(onScroll);
}, [scrollController, messagesAsync]);
return RoomScrollManager(
scrollController: scrollController,
listController: listController,
bottomGradientOpacity: bottomGradientOpacity.value,
scrollToMessage: scrollToMessageWrapper,
isScrollingToMessage: isScrollingToMessage,
);
}