Chat input full featured upload

This commit is contained in:
2025-10-10 20:54:37 +08:00
parent e1ea61c5f1
commit 598c51bc1a
7 changed files with 404 additions and 164 deletions

View File

@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$messagesNotifierHash() => r'70acac63c720987d8b1688500e3735f1c2d16fdc'; String _$messagesNotifierHash() => r'e4b760068f7349cc2991d0788055dbd855184f82';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

@@ -31,6 +31,7 @@ import "package:super_sliver_list/super_sliver_list.dart";
import "package:material_symbols_icons/symbols.dart"; import "package:material_symbols_icons/symbols.dart";
import "package:island/widgets/chat/call_button.dart"; import "package:island/widgets/chat/call_button.dart";
import "package:island/widgets/chat/chat_input.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"; import "package:island/widgets/chat/public_room_preview.dart";
class ChatRoomScreen extends HookConsumerWidget { class ChatRoomScreen extends HookConsumerWidget {
@@ -192,6 +193,59 @@ class ChatRoomScreen extends HookConsumerWidget {
]; ];
} }
Future<void> 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<void> 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<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,
),
];
}
void sendMessage() { void sendMessage() {
if (messageController.text.trim().isNotEmpty || if (messageController.text.trim().isNotEmpty ||
attachments.value.isNotEmpty) { attachments.value.isNotEmpty) {
@@ -680,11 +734,14 @@ class ChatRoomScreen extends HookConsumerWidget {
pickVideoMedia(); pickVideoMedia();
} }
}, },
onPickAudio: pickAudioMedia,
onPickGeneralFile: pickGeneralFile,
onLinkAttachment: linkAttachment,
attachments: attachments.value, attachments: attachments.value,
onUploadAttachment: uploadAttachment, onUploadAttachment: uploadAttachment,
onDeleteAttachment: (index) async { onDeleteAttachment: (index) async {
final attachment = attachments.value[index]; final attachment = attachments.value[index];
if (attachment.isOnCloud) { if (attachment.isOnCloud && !attachment.isLink) {
final client = ref.watch(apiClientProvider); final client = ref.watch(apiClientProvider);
await client.delete( await client.delete(
'/drive/files/${attachment.data.id}', '/drive/files/${attachment.data.id}',

View File

@@ -11,6 +11,7 @@ import "package:island/models/file.dart";
import "package:island/pods/config.dart"; import "package:island/pods/config.dart";
import "package:island/services/responsive.dart"; import "package:island/services/responsive.dart";
import "package:island/widgets/content/attachment_preview.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:material_symbols_icons/material_symbols_icons.dart";
import "package:pasteboard/pasteboard.dart"; import "package:pasteboard/pasteboard.dart";
import "package:styled_widget/styled_widget.dart"; import "package:styled_widget/styled_widget.dart";
@@ -24,6 +25,9 @@ class ChatInput extends HookConsumerWidget {
final VoidCallback onSend; final VoidCallback onSend;
final VoidCallback onClear; final VoidCallback onClear;
final Function(bool isPhoto) onPickFile; final Function(bool isPhoto) onPickFile;
final VoidCallback onPickAudio;
final VoidCallback onPickGeneralFile;
final VoidCallback? onLinkAttachment;
final SnChatMessage? messageReplyingTo; final SnChatMessage? messageReplyingTo;
final SnChatMessage? messageForwardingTo; final SnChatMessage? messageForwardingTo;
final SnChatMessage? messageEditingTo; final SnChatMessage? messageEditingTo;
@@ -41,6 +45,9 @@ class ChatInput extends HookConsumerWidget {
required this.onSend, required this.onSend,
required this.onClear, required this.onClear,
required this.onPickFile, required this.onPickFile,
required this.onPickAudio,
required this.onPickGeneralFile,
this.onLinkAttachment,
required this.messageReplyingTo, required this.messageReplyingTo,
required this.messageForwardingTo, required this.messageForwardingTo,
required this.messageEditingTo, required this.messageEditingTo,
@@ -336,31 +343,32 @@ class ChatInput extends HookConsumerWidget {
); );
}, },
), ),
PopupMenuButton( UploadMenu(
icon: const Icon(Symbols.photo_library), items: [
itemBuilder: MenuItemData(
(context) => [ Symbols.add_a_photo,
PopupMenuItem( 'addPhoto',
onTap: () => onPickFile(true), () => onPickFile(true),
child: Row( ),
spacing: 12, MenuItemData(
children: [ Symbols.videocam,
const Icon(Symbols.photo), 'addVideo',
Text('addPhoto').tr(), () => onPickFile(false),
], ),
), MenuItemData(Symbols.mic, 'addAudio', onPickAudio),
), MenuItemData(
PopupMenuItem( Symbols.file_upload,
onTap: () => onPickFile(false), 'uploadFile',
child: Row( onPickGeneralFile,
spacing: 12, ),
children: [ if (onLinkAttachment != null)
const Icon(Symbols.video_call), MenuItemData(
Text('addVideo').tr(), Symbols.attach_file,
], 'linkAttachment',
), onLinkAttachment!,
), ),
], ],
iconColor: Colors.white,
), ),
], ],
), ),

View File

@@ -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<SnCloudFile> {
@override
Future<CursorPagingData<SnCloudFile>> build() => fetch(cursor: null);
@override
Future<CursorPagingData<SnCloudFile>> 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<SnCloudFile> items =
(response.data as List)
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.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<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: [
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),
),
],
),
),
],
),
),
);
}
}

View File

@@ -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<SnCloudFile>
>.internal(
ChatCloudFileListNotifier.new,
name: r'chatCloudFileListNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$chatCloudFileListNotifierHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$ChatCloudFileListNotifier =
AutoDisposeAsyncNotifier<CursorPagingData<SnCloudFile>>;
// 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

View File

@@ -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_embed_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/draft_manager.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:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.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; final colorScheme = Theme.of(context).colorScheme;
if (isCompact) { if (isCompact) {
@@ -96,86 +104,9 @@ class ComposeToolbar extends HookConsumerWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: [ children: [
MenuAnchor( UploadMenu(
builder: items: uploadMenuItems,
(context, controller, child) => IconButton( isCompact: isCompact,
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()),
),
],
), ),
IconButton( IconButton(
onPressed: linkAttachment, onPressed: linkAttachment,
@@ -290,63 +221,7 @@ class ComposeToolbar extends HookConsumerWidget {
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: [ children: [
MenuAnchor( UploadMenu(items: uploadMenuItems, isCompact: isCompact),
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()),
),
),
],
),
IconButton( IconButton(
onPressed: linkAttachment, onPressed: linkAttachment,
icon: const Icon(Symbols.attach_file), icon: const Icon(Symbols.attach_file),

View File

@@ -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<MenuItemData> 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(),
);
}
}