♻️ Decouple the room.dart
This commit is contained in:
135
lib/hooks/use_room_file_picker.dart
Normal file
135
lib/hooks/use_room_file_picker.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
231
lib/hooks/use_room_input.dart
Normal file
231
lib/hooks/use_room_input.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
127
lib/hooks/use_room_scroll.dart
Normal file
127
lib/hooks/use_room_scroll.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -146,16 +146,19 @@ class WindowScaffold extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ShakeDetector detector = ShakeDetector.autoStart(
|
ShakeDetector? detactor;
|
||||||
onPhoneShake: (_) {
|
if (!kIsWeb && (Platform.isIOS && Platform.isAndroid)) {
|
||||||
showPalette.value = true;
|
detactor = ShakeDetector.autoStart(
|
||||||
},
|
onPhoneShake: (_) {
|
||||||
);
|
showPalette.value = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return () {
|
return () {
|
||||||
hotKeyManager.unregister(popHotKey);
|
hotKeyManager.unregister(popHotKey);
|
||||||
hotKeyManager.unregister(cmpHotKey);
|
hotKeyManager.unregister(cmpHotKey);
|
||||||
detector.stopListening();
|
detactor?.stopListening();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
129
lib/widgets/chat/room_app_bar.dart
Normal file
129
lib/widgets/chat/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/models/chat.dart';
|
||||||
|
import 'package:island/pods/userinfo.dart';
|
||||||
|
import 'package:island/widgets/content/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/widgets/chat/room_message_list.dart
Normal file
170
lib/widgets/chat/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/database/message.dart';
|
||||||
|
import 'package:island/models/chat.dart';
|
||||||
|
import 'package:island/pods/config.dart';
|
||||||
|
import 'package:island/screens/chat/widgets/message_item_wrapper.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/widgets/chat/room_overlays.dart
Normal file
103
lib/widgets/chat/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/models/chat.dart';
|
||||||
|
import 'package:island/widgets/chat/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/widgets/chat/room_selection_mode.dart
Normal file
54
lib/widgets/chat/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