From 598c51bc1ab636fb0980068d13c440c13d2b2a1e Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 10 Oct 2025 20:54:37 +0800 Subject: [PATCH] :sparkles: Chat input full featured upload --- lib/pods/chat/messages_notifier.g.dart | 2 +- lib/screens/chat/room.dart | 59 ++++- lib/widgets/chat/chat_input.dart | 58 ++--- lib/widgets/chat/chat_link_attachments.dart | 205 ++++++++++++++++++ lib/widgets/chat/chat_link_attachments.g.dart | 31 +++ lib/widgets/post/compose_toolbar.dart | 149 +------------ lib/widgets/shared/upload_menu.dart | 64 ++++++ 7 files changed, 404 insertions(+), 164 deletions(-) create mode 100644 lib/widgets/chat/chat_link_attachments.dart create mode 100644 lib/widgets/chat/chat_link_attachments.g.dart create mode 100644 lib/widgets/shared/upload_menu.dart diff --git a/lib/pods/chat/messages_notifier.g.dart b/lib/pods/chat/messages_notifier.g.dart index 5448754c..88c4961c 100644 --- a/lib/pods/chat/messages_notifier.g.dart +++ b/lib/pods/chat/messages_notifier.g.dart @@ -6,7 +6,7 @@ part of 'messages_notifier.dart'; // RiverpodGenerator // ************************************************************************** -String _$messagesNotifierHash() => r'70acac63c720987d8b1688500e3735f1c2d16fdc'; +String _$messagesNotifierHash() => r'e4b760068f7349cc2991d0788055dbd855184f82'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index e1e88843..f6a619e6 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -31,6 +31,7 @@ import "package:super_sliver_list/super_sliver_list.dart"; import "package:material_symbols_icons/symbols.dart"; import "package:island/widgets/chat/call_button.dart"; import "package:island/widgets/chat/chat_input.dart"; +import "package:island/widgets/chat/chat_link_attachments.dart"; import "package:island/widgets/chat/public_room_preview.dart"; class ChatRoomScreen extends HookConsumerWidget { @@ -192,6 +193,59 @@ class ChatRoomScreen extends HookConsumerWidget { ]; } + Future pickAudioMedia() 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), + ), + ]; + } + + Future pickGeneralFile() 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), + ), + ]; + } + + void linkAttachment() async { + final cloudFile = await showModalBottomSheet( + 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, + ), + ]; + } + void sendMessage() { if (messageController.text.trim().isNotEmpty || attachments.value.isNotEmpty) { @@ -680,11 +734,14 @@ class ChatRoomScreen extends HookConsumerWidget { pickVideoMedia(); } }, + onPickAudio: pickAudioMedia, + onPickGeneralFile: pickGeneralFile, + onLinkAttachment: linkAttachment, attachments: attachments.value, onUploadAttachment: uploadAttachment, onDeleteAttachment: (index) async { final attachment = attachments.value[index]; - if (attachment.isOnCloud) { + if (attachment.isOnCloud && !attachment.isLink) { final client = ref.watch(apiClientProvider); await client.delete( '/drive/files/${attachment.data.id}', diff --git a/lib/widgets/chat/chat_input.dart b/lib/widgets/chat/chat_input.dart index 2f248960..cfecc88f 100644 --- a/lib/widgets/chat/chat_input.dart +++ b/lib/widgets/chat/chat_input.dart @@ -11,6 +11,7 @@ import "package:island/models/file.dart"; import "package:island/pods/config.dart"; import "package:island/services/responsive.dart"; import "package:island/widgets/content/attachment_preview.dart"; +import "package:island/widgets/shared/upload_menu.dart"; import "package:material_symbols_icons/material_symbols_icons.dart"; import "package:pasteboard/pasteboard.dart"; import "package:styled_widget/styled_widget.dart"; @@ -24,6 +25,9 @@ class ChatInput extends HookConsumerWidget { final VoidCallback onSend; final VoidCallback onClear; final Function(bool isPhoto) onPickFile; + final VoidCallback onPickAudio; + final VoidCallback onPickGeneralFile; + final VoidCallback? onLinkAttachment; final SnChatMessage? messageReplyingTo; final SnChatMessage? messageForwardingTo; final SnChatMessage? messageEditingTo; @@ -41,6 +45,9 @@ class ChatInput extends HookConsumerWidget { required this.onSend, required this.onClear, required this.onPickFile, + required this.onPickAudio, + required this.onPickGeneralFile, + this.onLinkAttachment, required this.messageReplyingTo, required this.messageForwardingTo, required this.messageEditingTo, @@ -336,31 +343,32 @@ class ChatInput extends HookConsumerWidget { ); }, ), - PopupMenuButton( - icon: const Icon(Symbols.photo_library), - itemBuilder: - (context) => [ - PopupMenuItem( - onTap: () => onPickFile(true), - child: Row( - spacing: 12, - children: [ - const Icon(Symbols.photo), - Text('addPhoto').tr(), - ], - ), - ), - PopupMenuItem( - onTap: () => onPickFile(false), - child: Row( - spacing: 12, - children: [ - const Icon(Symbols.video_call), - Text('addVideo').tr(), - ], - ), - ), - ], + UploadMenu( + items: [ + MenuItemData( + Symbols.add_a_photo, + 'addPhoto', + () => onPickFile(true), + ), + MenuItemData( + Symbols.videocam, + 'addVideo', + () => onPickFile(false), + ), + MenuItemData(Symbols.mic, 'addAudio', onPickAudio), + MenuItemData( + Symbols.file_upload, + 'uploadFile', + onPickGeneralFile, + ), + if (onLinkAttachment != null) + MenuItemData( + Symbols.attach_file, + 'linkAttachment', + onLinkAttachment!, + ), + ], + iconColor: Colors.white, ), ], ), diff --git a/lib/widgets/chat/chat_link_attachments.dart b/lib/widgets/chat/chat_link_attachments.dart new file mode 100644 index 00000000..70187a73 --- /dev/null +++ b/lib/widgets/chat/chat_link_attachments.dart @@ -0,0 +1,205 @@ +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/models/file.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/widgets/content/cloud_files.dart'; +import 'package:island/widgets/content/sheet.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; +import 'package:styled_widget/styled_widget.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +part 'chat_link_attachments.g.dart'; + +@riverpod +class ChatCloudFileListNotifier extends _$ChatCloudFileListNotifier + with CursorPagingNotifierMixin { + @override + Future> build() => fetch(cursor: null); + + @override + Future> fetch({required String? cursor}) async { + final client = ref.read(apiClientProvider); + final offset = cursor == null ? 0 : int.parse(cursor); + final take = 20; + + final queryParameters = {'offset': offset, 'take': take}; + + final response = await client.get( + '/drive/files/me', + queryParameters: queryParameters, + ); + + final List items = + (response.data as List) + .map((e) => SnCloudFile.fromJson(e as Map)) + .toList(); + final total = int.parse(response.headers.value('X-Total') ?? '0'); + + final hasMore = offset + items.length < total; + final nextCursor = hasMore ? (offset + items.length).toString() : null; + + return CursorPagingData( + items: items, + hasMore: hasMore, + nextCursor: nextCursor, + ); + } +} + +class ChatLinkAttachment extends HookConsumerWidget { + const ChatLinkAttachment({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final idController = useTextEditingController(); + final errorMessage = useState(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: [ + PagingHelperView( + provider: chatCloudFileListNotifierProvider, + futureRefreshable: chatCloudFileListNotifierProvider.future, + notifierRefreshable: + chatCloudFileListNotifierProvider.notifier, + contentBuilder: + (data, widgetCount, endItemView) => ListView.builder( + padding: EdgeInsets.only(top: 8), + itemCount: widgetCount, + itemBuilder: (context, index) { + if (index == widgetCount - 1) { + return endItemView; + } + + final item = data.items[index]; + 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(), + ), + 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), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/chat/chat_link_attachments.g.dart b/lib/widgets/chat/chat_link_attachments.g.dart new file mode 100644 index 00000000..5b9ad693 --- /dev/null +++ b/lib/widgets/chat/chat_link_attachments.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_link_attachments.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$chatCloudFileListNotifierHash() => + r'5da3929229fe00212530f63bd19ae4cd829176f5'; + +/// See also [ChatCloudFileListNotifier]. +@ProviderFor(ChatCloudFileListNotifier) +final chatCloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider< + ChatCloudFileListNotifier, + CursorPagingData +>.internal( + ChatCloudFileListNotifier.new, + name: r'chatCloudFileListNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$chatCloudFileListNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ChatCloudFileListNotifier = + AutoDisposeAsyncNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/widgets/post/compose_toolbar.dart b/lib/widgets/post/compose_toolbar.dart index 0cb081ca..07d8228f 100644 --- a/lib/widgets/post/compose_toolbar.dart +++ b/lib/widgets/post/compose_toolbar.dart @@ -6,6 +6,7 @@ import 'package:island/services/compose_storage_db.dart'; import 'package:island/widgets/post/compose_embed_sheet.dart'; import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/draft_manager.dart'; +import 'package:island/widgets/shared/upload_menu.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -80,6 +81,13 @@ class ComposeToolbar extends HookConsumerWidget { ); } + final uploadMenuItems = [ + MenuItemData(Symbols.add_a_photo, 'addPhoto', pickPhotoMedia), + MenuItemData(Symbols.videocam, 'addVideo', pickVideoMedia), + MenuItemData(Symbols.mic, 'addAudio', addAudio), + MenuItemData(Symbols.file_upload, 'uploadFile', pickGeneralFile), + ]; + final colorScheme = Theme.of(context).colorScheme; if (isCompact) { @@ -96,86 +104,9 @@ class ComposeToolbar extends HookConsumerWidget { scrollDirection: Axis.horizontal, child: Row( children: [ - MenuAnchor( - builder: - (context, controller, child) => IconButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - tooltip: 'uploadFile'.tr(), - icon: const Icon(Symbols.file_upload), - color: colorScheme.primary, - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -2, - ), - ), - menuChildren: [ - MenuItemButton( - onPressed: () { - pickPhotoMedia(); - }, - style: ButtonStyle( - padding: WidgetStatePropertyAll( - EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), - ), - ), - leadingIcon: const Icon(Symbols.add_a_photo), - child: Text('addPhoto'.tr()), - ), - MenuItemButton( - onPressed: () { - pickVideoMedia(); - }, - leadingIcon: const Icon(Symbols.videocam), - style: ButtonStyle( - padding: WidgetStatePropertyAll( - EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), - ), - ), - child: Text('addVideo'.tr()), - ), - MenuItemButton( - onPressed: () { - addAudio(); - }, - leadingIcon: const Icon(Symbols.mic), - style: ButtonStyle( - padding: WidgetStatePropertyAll( - EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), - ), - ), - child: Text('addAudio'.tr()), - ), - MenuItemButton( - onPressed: () { - pickGeneralFile(); - }, - leadingIcon: const Icon(Symbols.file_upload), - style: ButtonStyle( - padding: WidgetStatePropertyAll( - EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, - ), - ), - ), - child: Text('uploadFile'.tr()), - ), - ], + UploadMenu( + items: uploadMenuItems, + isCompact: isCompact, ), IconButton( onPressed: linkAttachment, @@ -290,63 +221,7 @@ class ComposeToolbar extends HookConsumerWidget { scrollDirection: Axis.horizontal, child: Row( children: [ - MenuAnchor( - builder: - (context, controller, child) => IconButton( - onPressed: () { - if (controller.isOpen) { - controller.close(); - } else { - controller.open(); - } - }, - tooltip: 'uploadFile'.tr(), - icon: const Icon(Symbols.file_upload), - color: colorScheme.primary, - ), - menuChildren: [ - MenuItemButton( - onPressed: () { - pickPhotoMedia(); - }, - leadingIcon: const Icon(Symbols.add_a_photo), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text('addPhoto'.tr()), - ), - ), - MenuItemButton( - onPressed: () { - pickVideoMedia(); - }, - leadingIcon: const Icon(Symbols.videocam), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text('addVideo'.tr()), - ), - ), - MenuItemButton( - onPressed: () { - addAudio(); - }, - leadingIcon: const Icon(Symbols.mic), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text('addAudio'.tr()), - ), - ), - MenuItemButton( - onPressed: () { - pickGeneralFile(); - }, - leadingIcon: const Icon(Symbols.file_upload), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text('uploadFile'.tr()), - ), - ), - ], - ), + UploadMenu(items: uploadMenuItems, isCompact: isCompact), IconButton( onPressed: linkAttachment, icon: const Icon(Symbols.attach_file), diff --git a/lib/widgets/shared/upload_menu.dart b/lib/widgets/shared/upload_menu.dart new file mode 100644 index 00000000..00d50ffe --- /dev/null +++ b/lib/widgets/shared/upload_menu.dart @@ -0,0 +1,64 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class MenuItemData { + final IconData icon; + final String textKey; + final VoidCallback onPressed; + + const MenuItemData(this.icon, this.textKey, this.onPressed); +} + +class UploadMenu extends StatelessWidget { + final List items; + final bool isCompact; + final Color? iconColor; + + const UploadMenu({ + super.key, + required this.items, + this.isCompact = false, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return MenuAnchor( + builder: + (context, controller, child) => IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + tooltip: 'uploadFile'.tr(), + icon: const Icon(Symbols.file_upload), + color: iconColor ?? colorScheme.primary, + visualDensity: + isCompact + ? const VisualDensity(horizontal: -4, vertical: -2) + : null, + ), + menuChildren: + items + .map( + (item) => MenuItemButton( + onPressed: item.onPressed, + leadingIcon: Icon(item.icon), + style: ButtonStyle( + padding: WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 16, vertical: 20), + ), + ), + child: Text(item.textKey.tr()), + ), + ) + .toList(), + ); + } +}