import 'dart:io'; import 'package:PiliPlus/common/widgets/button/icon_button.dart'; import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; import 'package:PiliPlus/common/widgets/flutter/text_field/controller.dart'; import 'package:PiliPlus/common/widgets/flutter/text_field/text_field.dart'; import 'package:PiliPlus/http/msg.dart'; import 'package:PiliPlus/models/common/image_preview_type.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; import 'package:PiliPlus/models/dynamics/result.dart' show PicModel, FilePicModel, OpusPicModel; import 'package:PiliPlus/models_new/dynamic/dyn_mention/item.dart'; import 'package:PiliPlus/models_new/emote/emote.dart' as e; import 'package:PiliPlus/models_new/live/live_emote/emoticon.dart'; import 'package:PiliPlus/pages/common/publish/common_publish_page.dart'; import 'package:PiliPlus/pages/dynamics_mention/view.dart'; import 'package:PiliPlus/utils/extension/file_ext.dart'; import 'package:PiliPlus/utils/extension/num_ext.dart'; import 'package:PiliPlus/utils/extension/string_ext.dart'; import 'package:PiliPlus/utils/extension/theme_ext.dart'; import 'package:PiliPlus/utils/feed_back.dart'; import 'package:PiliPlus/utils/image_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:dio/dio.dart' show CancelToken; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:image_cropper/image_cropper.dart'; import 'package:image_picker/image_picker.dart'; abstract class CommonRichTextPubPage extends CommonPublishPage> { const CommonRichTextPubPage({ super.key, this.items, this.pics, super.onSave, super.autofocus, super.imageLengthLimit, }); final List? items; final List? pics; } abstract class CommonRichTextPubPageState extends CommonPublishPageState { final key = GlobalKey(); late final imagePicker = ImagePicker(); late final RxList imageList; int get limit => widget.imageLengthLimit ?? 9; @override late final RichTextEditingController editController; @override void initPubState() { editController = RichTextEditingController( items: widget.items, onMention: onMention, ); if (editController.rawText.trim().isNotEmpty) { enablePublish.value = true; } imageList = RxList(widget.pics ?? []); } @override void dispose() { if (PlatformUtils.isMobile) { for (final img in imageList) { if (img is FilePicModel) { File(img.path).tryDel(); } } } super.dispose(); } @override void didChangeDependencies() { editController.richStyle = null; super.didChangeDependencies(); } Widget buildImage(int index, double height) { final color = Theme.of( context, ).colorScheme.secondaryContainer.withValues(alpha: 0.5); void onClear() { final image = imageList.removeAt(index); if (PlatformUtils.isMobile) { if (image is FilePicModel) { File(image.path).tryDel(); } } if (imageList.isEmpty && editController.rawText.trim().isEmpty) { enablePublish.value = false; } } final image = imageList[index]; return Stack( clipBehavior: Clip.none, children: [ GestureDetector( onTap: () async { controller.keepChatPanel(); await PageUtils.imageView( imgList: imageList .map( (img) => switch (img) { FilePicModel e => SourceModel( url: e.path, sourceType: .fileImage, ), OpusPicModel e => SourceModel( url: e.url!, sourceType: .networkImage, ), }, ) .toList(), initialPage: index, ); controller.restoreChatPanel(); }, onLongPress: () { Feedback.forLongPress(context); onClear(); }, onSecondaryTap: PlatformUtils.isMobile ? null : onClear, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(4)), child: ConstrainedBox( constraints: const BoxConstraints(minWidth: 42), child: switch (image) { FilePicModel e => Image.file( File(e.path), height: height, filterQuality: .low, cacheHeight: height.cacheSize(context), ), OpusPicModel e => CachedNetworkImage( imageUrl: ImageUtils.thumbnailUrl(e.url!), height: height, filterQuality: .low, memCacheHeight: height.cacheSize(context), fadeInDuration: .zero, fadeOutDuration: .zero, placeholder: (_, _) => const SizedBox(width: 42), ), }, ), ), ), if (kDebugMode || PlatformUtils.isMobile) Positioned( top: 34, right: 5, child: iconButton( icon: const Icon(Icons.edit), onPressed: () => onCropImage(index, image), size: 24, iconSize: 14, bgColor: color, ), ), Positioned( top: 5, right: 5, child: iconButton( icon: const Icon(Icons.clear), onPressed: onClear, size: 24, iconSize: 14, bgColor: color, ), ), ], ); } Future onCropImage(int index, PicModel image) async { String? path; switch (image) { case FilePicModel e: path = e.path; case OpusPicModel e: SmartDialog.showLoading(); final file = (await DefaultCacheManager().getSingleFile( e.url.http2https, )); await SmartDialog.dismiss(); path = file.path; } if (!mounted || path.isEmpty) return; late final colorScheme = ColorScheme.of(context); final croppedFile = await ImageCropper.platform.cropImage( sourcePath: path, uiSettings: [ AndroidUiSettings( toolbarTitle: '裁剪', toolbarColor: colorScheme.secondaryContainer, toolbarWidgetColor: colorScheme.onSecondaryContainer, statusBarLight: colorScheme.isLight, ), IOSUiSettings(title: '裁剪'), ], ); if (croppedFile != null) { if (image is FilePicModel) { File(image.path).tryDel(); } imageList[index] = FilePicModel(path: croppedFile.path); } } void onPickImage([VoidCallback? callback]) { EasyThrottle.throttle( 'imagePicker', const Duration(milliseconds: 500), () async { try { List pickedFiles = await imagePicker.pickMultiImage( limit: limit, imageQuality: 100, ); if (pickedFiles.isNotEmpty) { for (int i = 0; i < pickedFiles.length; i++) { if (imageList.length == limit) { SmartDialog.showToast('最多选择$limit张图片'); break; } else { imageList.add(FilePicModel(path: pickedFiles[i].path)); } } callback?.call(); } } catch (e) { SmartDialog.showToast(e.toString()); } }, ); } void onChooseEmote(dynamic emote, double? width, double? height) { if (emote is e.Emote) { final isTextEmote = width == null; onInsertText( isTextEmote ? emote.text! : '\uFFFC', RichTextType.emoji, rawText: emote.text!, emote: isTextEmote ? null : Emote( url: emote.url!, width: width, height: height, ), ); } else if (emote is Emoticon) { onInsertText( '\uFFFC', RichTextType.emoji, rawText: emote.emoji!, emote: Emote( url: emote.url!, width: width!, height: height, ), ); } } List>? getRichContent() { if (editController.items.isEmpty) return null; final list = >[]; for (final e in editController.items) { switch (e.type) { case RichTextType.text || RichTextType.composing || RichTextType.common: list.add({ "raw_text": e.text, "type": 1, "biz_id": "", }); case RichTextType.at: list ..add({ "raw_text": '@${e.rawText}', "type": 2, "biz_id": e.id, }) ..add({ "raw_text": ' ', "type": 1, "biz_id": "", }); case RichTextType.emoji: list.add({ "raw_text": e.rawText, "type": 9, "biz_id": "", }); case RichTextType.vote: list ..add({ "raw_text": e.rawText, "type": 4, "biz_id": e.id, }) ..add({ "raw_text": ' ', "type": 1, "biz_id": "", }); } } return list; } late double _mentionOffset = 0; Future? onMention([bool fromClick = false]) async { controller.keepChatPanel(); final res = await DynMentionPanel.onDynMention( context, offset: _mentionOffset, onCachePos: (offset) => _mentionOffset = offset, ); if (res != null) { if (res is MentionItem) { _onInsertUser(res, fromClick); } else if (res is Set) { for (final e in res) { e.checked = false; _onInsertUser(e, fromClick); } res.clear(); } } controller.restoreChatPanel(); } void _onInsertUser(MentionItem e, bool fromClick) { onInsertText( '@${e.name} ', RichTextType.at, rawText: e.name, id: e.uid, fromClick: fromClick, ); } void onInsertText( String text, RichTextType type, { String? rawText, Emote? emote, String? id, bool? fromClick, }) { if (text.isEmpty) { return; } enablePublish.value = true; final oldValue = editController.value; final selection = oldValue.selection; if (selection.isValid) { TextEditingDelta delta; if (selection.isCollapsed) { if (type == RichTextType.at && fromClick == false) { delta = RichTextEditingDeltaReplacement( oldText: oldValue.text, replacementText: text, replacedRange: TextRange( start: selection.start - 1, end: selection.end, ), selection: TextSelection.collapsed( offset: selection.start - 1 + text.length, ), composing: TextRange.empty, rawText: rawText, type: type, emote: emote, id: id, ); } else { delta = RichTextEditingDeltaInsertion( oldText: oldValue.text, textInserted: text, insertionOffset: selection.start, selection: TextSelection.collapsed( offset: selection.start + text.length, ), composing: TextRange.empty, rawText: rawText, type: type, emote: emote, id: id, ); } } else { delta = RichTextEditingDeltaReplacement( oldText: oldValue.text, replacementText: text, replacedRange: selection, selection: TextSelection.collapsed( offset: selection.start + text.length, ), composing: TextRange.empty, rawText: rawText, type: type, emote: emote, id: id, ); } final newValue = delta.apply(oldValue); if (oldValue == newValue) { return; } editController ..syncRichText(delta) ..value = newValue; } else { editController.items ..clear() ..add( RichTextItem( type: type, text: text, rawText: rawText, range: TextRange( start: 0, end: text.length, ), emote: emote, id: id, ), ); editController.value = TextEditingValue( text: text, selection: TextSelection.collapsed(offset: text.length), ); } key.currentState?.scheduleShowCaretOnScreen(withAnimation: true); } @override void onSave() => widget.onSave?.call(editController.items); Widget get emojiBtn => Obx( () { final isEmoji = panelType.value == PanelType.emoji; return ToolbarIconButton( tooltip: isEmoji ? '输入' : '表情', onPressed: () { if (isEmoji) { updatePanelType(PanelType.keyboard); } else { updatePanelType(PanelType.emoji); } }, icon: isEmoji ? const Icon(Icons.keyboard, size: 22) : const Icon(Icons.emoji_emotions, size: 22), selected: isEmoji, ); }, ); Widget get atBtn => ToolbarIconButton( onPressed: () => onMention(true), icon: const Icon(Icons.alternate_email, size: 22), tooltip: '@', selected: false, ); Widget get moreBtn => Obx( () { final isMore = panelType.value == PanelType.more; return ToolbarIconButton( tooltip: isMore ? '输入' : '更多', onPressed: () { if (isMore) { updatePanelType(PanelType.keyboard); } else { updatePanelType(PanelType.more); } }, icon: isMore ? const Icon(Icons.keyboard, size: 22) : const Icon(Icons.add_circle_outline, size: 22), selected: isMore, ); }, ); @override Future onPublish() async { feedBack(); List>? pictures; if (imageList.isNotEmpty) { SmartDialog.showLoading(msg: '正在上传图片...'); final cancelToken = CancelToken(); try { pictures = await Future.wait>( imageList.map((img) async { switch (img) { case FilePicModel e: final result = await MsgHttp.uploadBfs( path: e.path, category: 'daily', biz: 'new_dyn', cancelToken: cancelToken, ); final data = result.data; return { 'img_width': data.imageWidth, 'img_height': data.imageHeight, 'img_size': data.imgSize, 'img_src': data.imageUrl, }; case OpusPicModel e: return e.toJson(); } }), eagerError: true, ); SmartDialog.dismiss(); } on HttpException catch (e) { cancelToken.cancel(); SmartDialog.dismiss(); SmartDialog.showToast(e.message); return; } } onCustomPublish(pictures: pictures); } }