import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:PiliPlus/common/widgets/button/icon_button.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_new/dynamic/dyn_mention/item.dart'; import 'package:PiliPlus/models_new/emote/emote.dart'; import 'package:PiliPlus/models_new/live/live_emote/emoticon.dart'; import 'package:PiliPlus/models_new/upload_bfs/data.dart'; import 'package:PiliPlus/pages/dynamics_mention/view.dart'; import 'package:PiliPlus/utils/extension.dart'; import 'package:PiliPlus/utils/feed_back.dart'; import 'package:chat_bottom_container/chat_bottom_container.dart'; import 'package:dio/dio.dart'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.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 CommonPublishPage extends StatefulWidget { const CommonPublishPage({ super.key, this.initialValue, this.mentions, this.imageLengthLimit, this.onSave, this.autofocus = true, }); final String? initialValue; final List? mentions; final int? imageLengthLimit; final ValueChanged<({String text, List? mentions})>? onSave; final bool autofocus; } abstract class CommonPublishPageState extends State with WidgetsBindingObserver { late final focusNode = FocusNode(); late final controller = ChatBottomPanelContainerController(); late final editController = TextEditingController(text: widget.initialValue); Rx panelType = PanelType.none.obs; late final RxBool readOnly = false.obs; late final RxBool enablePublish = false.obs; late final imagePicker = ImagePicker(); late final RxList pathList = [].obs; int get limit => widget.imageLengthLimit ?? 9; List? mentions; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); mentions = widget.mentions; if (widget.initialValue?.trim().isNotEmpty == true) { enablePublish.value = true; } if (widget.autofocus) { Future.delayed(const Duration(milliseconds: 300)).whenComplete(() { if (mounted) { focusNode.requestFocus(); } }); } } @override void dispose() { focusNode.dispose(); editController.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } Future _requestFocus() async { await Future.delayed(const Duration(microseconds: 200)); focusNode.requestFocus(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { if (mounted && widget.autofocus && panelType.value == PanelType.keyboard) { WidgetsBinding.instance.addPostFrameCallback((_) { if (focusNode.hasFocus) { focusNode.unfocus(); _requestFocus(); } else { _requestFocus(); } }); } } else if (state == AppLifecycleState.paused) { controller.keepChatPanel(); if (focusNode.hasFocus) { focusNode.unfocus(); } } } void updatePanelType(PanelType type) { final isSwitchToKeyboard = PanelType.keyboard == type; final isSwitchToEmojiPanel = PanelType.emoji == type; bool isUpdated = false; switch (type) { case PanelType.keyboard: updateInputView(isReadOnly: false); break; case PanelType.emoji: isUpdated = updateInputView(isReadOnly: true); break; default: break; } void updatePanelTypeFunc() { controller.updatePanelType( isSwitchToKeyboard ? ChatBottomPanelType.keyboard : ChatBottomPanelType.other, data: type, forceHandleFocus: isSwitchToEmojiPanel ? ChatBottomHandleFocus.requestFocus : ChatBottomHandleFocus.none, ); } if (isUpdated) { // Waiting for the input view to update. WidgetsBinding.instance.addPostFrameCallback((timeStamp) { updatePanelTypeFunc(); }); } else { updatePanelTypeFunc(); } } Future hidePanel() async { if (focusNode.hasFocus) { await Future.delayed(const Duration(milliseconds: 100)); focusNode.unfocus(); } updateInputView(isReadOnly: false); if (ChatBottomPanelType.none == controller.currentPanelType) return; controller.updatePanelType(ChatBottomPanelType.none); } bool updateInputView({ required bool isReadOnly, }) { if (readOnly.value != isReadOnly) { readOnly.value = isReadOnly; return true; } return false; } Future onPublish() async { feedBack(); List>? pictures; if (pathList.isNotEmpty) { SmartDialog.showLoading(msg: '正在上传图片...'); final cancelToken = CancelToken(); try { pictures = await Future.wait>( pathList.map((path) async { Map result = await MsgHttp.uploadBfs( path: path, category: 'daily', biz: 'new_dyn', cancelToken: cancelToken, ); if (!result['status']) throw HttpException(result['msg']); UploadBfsResData data = result['data']; return { 'img_width': data.imageWidth, 'img_height': data.imageHeight, 'img_size': data.imgSize, 'img_src': data.imageUrl, }; }).toList(), eagerError: true); SmartDialog.dismiss(); } on HttpException catch (e) { cancelToken.cancel(); SmartDialog.dismiss(); SmartDialog.showToast(e.message); return; } } onCustomPublish(message: editController.text, pictures: pictures); } Future onCustomPublish({required String message, List? pictures}); void onChooseEmote(dynamic emote) { if (emote is Emote) { onInsertText(emote.text!); } else if (emote is Emoticon) { onInsertText(emote.emoji!); } } Widget? get customPanel => null; Widget buildEmojiPickerPanel() { double height = context.isTablet ? 300 : 170; final keyboardHeight = controller.keyboardHeight; if (keyboardHeight != 0) { height = max(height, keyboardHeight); } return SizedBox( height: height, child: customPanel, ); } Widget buildPanelContainer([Color? panelBgColor]) { return ChatBottomPanelContainer( controller: controller, inputFocusNode: focusNode, otherPanelWidget: (type) { if (type == null) return const SizedBox.shrink(); switch (type) { case PanelType.emoji: return buildEmojiPickerPanel(); default: return const SizedBox.shrink(); } }, onPanelTypeChange: (panelType, data) { // if (kDebugMode) debugPrint('panelType: $panelType'); switch (panelType) { case ChatBottomPanelType.none: this.panelType.value = PanelType.none; break; case ChatBottomPanelType.keyboard: this.panelType.value = PanelType.keyboard; break; case ChatBottomPanelType.other: if (data == null) return; switch (data) { case PanelType.emoji: this.panelType.value = PanelType.emoji; break; default: this.panelType.value = PanelType.none; break; } break; } }, panelBgColor: panelBgColor ?? Theme.of(context).colorScheme.surface, ); } Widget buildImage(int index, double height) { final color = Theme.of(context).colorScheme.secondaryContainer.withValues(alpha: 0.5); void onClear() { pathList.removeAt(index); if (pathList.isEmpty && editController.text.trim().isEmpty) { enablePublish.value = false; } } return Stack( clipBehavior: Clip.none, children: [ GestureDetector( onTap: () { controller.keepChatPanel(); context.imageView( imgList: pathList .map((path) => SourceModel( url: path, sourceType: SourceType.fileImage, )) .toList(), initialPage: index, ); }, onLongPress: onClear, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(4)), child: Image( height: height, fit: BoxFit.fitHeight, filterQuality: FilterQuality.low, image: FileImage(File(pathList[index])), ), ), ), Positioned( top: 34, right: 5, child: iconButton( context: context, icon: Icons.edit, onPressed: () => onCropImage(index), size: 24, iconSize: 14, bgColor: color, ), ), Positioned( top: 5, right: 5, child: iconButton( context: context, icon: Icons.clear, onPressed: onClear, size: 24, iconSize: 14, bgColor: color, ), ), ], ); } Future onCropImage(int index) async { final theme = Theme.of(context); CroppedFile? croppedFile = await ImageCropper().cropImage( sourcePath: pathList[index], uiSettings: [ AndroidUiSettings( toolbarTitle: '裁剪', toolbarColor: theme.colorScheme.secondaryContainer, toolbarWidgetColor: theme.colorScheme.onSecondaryContainer, ), IOSUiSettings( title: '裁剪', ), ], ); if (croppedFile != null) { pathList[index] = 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 (pathList.length == limit) { SmartDialog.showToast('最多选择$limit张图片'); break; } else { pathList.add(pickedFiles[i].path); } } callback?.call(); } } catch (e) { SmartDialog.showToast(e.toString()); } }); } List>? getRichContent() { if (mentions.isNullOrEmpty) { return null; } List> content = []; void addPlainText(String text) { content.add({ "raw_text": text, "type": 1, "biz_id": "", }); } final pattern = RegExp( mentions!.toSet().map((e) => RegExp.escape('@${e.name!}')).join('|')); editController.text.splitMapJoin( pattern, onMatch: (Match match) { final name = match.group(0)!; final item = mentions!.firstWhereOrNull((e) => e.name == name.substring(1)); if (item != null) { content.add({ "raw_text": name, "type": 2, "biz_id": item.uid, }); } else { addPlainText(name); } return ''; }, onNonMatch: (String text) { addPlainText(text); return ''; }, ); return content; } double _mentionOffset = 0; void onMention([bool fromClick = false]) { controller.keepChatPanel(); DynMentionPanel.onDynMention( context, offset: _mentionOffset, callback: (offset) => _mentionOffset = offset, ).then((MentionItem? res) { if (res != null) { (mentions ??= []).add(res); String atName = '${fromClick ? '@' : ''}${res.name} '; onInsertText(atName); } }); } void onInsertText(String text) { if (text.isEmpty) { return; } enablePublish.value = true; final oldValue = editController.value; final selection = oldValue.selection; if (selection.isValid) { TextEditingDelta delta; if (selection.isCollapsed) { delta = TextEditingDeltaInsertion( oldText: oldValue.text, textInserted: text, insertionOffset: selection.start, selection: TextSelection.collapsed( offset: selection.start + text.length, ), composing: TextRange.empty, ); } else { delta = TextEditingDeltaReplacement( oldText: oldValue.text, replacementText: text, replacedRange: selection, selection: TextSelection.collapsed( offset: selection.start + text.length, ), composing: TextRange.empty, ); } final newValue = delta.apply(oldValue); if (oldValue == newValue) { return; } editController.value = newValue; } else { editController.value = TextEditingValue( text: text, selection: TextSelection.collapsed(offset: text.length), ); } widget.onSave?.call((text: editController.text, mentions: mentions)); } void onDelAtUser(String name) { mentions!.removeFirstWhere((e) => e.name == name); } void onChanged(String value) { bool isEmpty = value.trim().isEmpty; if (isEmpty) { enablePublish.value = false; mentions?.clear(); } else { enablePublish.value = true; } widget.onSave?.call((text: value, mentions: mentions)); } }