Files
PiliPlus/lib/pages/whisper_detail/view.dart
My-Responsitories 7260a387f9 tweaks (#1821)
* opt: _onTapUp

* opt: set contains

* opt: elementAtOrNull
2026-02-02 20:12:12 +08:00

424 lines
15 KiB
Dart

import 'dart:async';
import 'dart:io' show File;
import 'package:PiliPlus/common/widgets/dialog/report.dart';
import 'package:PiliPlus/common/widgets/flutter/text_field/text_field.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart';
import 'package:PiliPlus/grpc/bilibili/im/type.pb.dart' show Msg;
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/http/msg.dart';
import 'package:PiliPlus/models/common/image_type.dart';
import 'package:PiliPlus/models/common/publish_panel_type.dart';
import 'package:PiliPlus/pages/common/publish/common_rich_text_pub_page.dart';
import 'package:PiliPlus/pages/emote/view.dart';
import 'package:PiliPlus/pages/whisper_detail/controller.dart';
import 'package:PiliPlus/pages/whisper_detail/widget/chat_item.dart';
import 'package:PiliPlus/pages/whisper_link_setting/view.dart';
import 'package:PiliPlus/utils/extension/file_ext.dart';
import 'package:PiliPlus/utils/extension/num_ext.dart';
import 'package:PiliPlus/utils/extension/widget_ext.dart';
import 'package:PiliPlus/utils/feed_back.dart';
import 'package:PiliPlus/utils/page_utils.dart';
import 'package:PiliPlus/utils/platform_utils.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart' hide TextField;
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:mime/mime.dart';
class WhisperDetailPage extends CommonRichTextPubPage {
const WhisperDetailPage({
super.key,
super.autofocus = false,
});
@override
State<WhisperDetailPage> createState() => _WhisperDetailPageState();
}
class _WhisperDetailPageState
extends CommonRichTextPubPageState<WhisperDetailPage> {
final _whisperDetailController = Get.put(
WhisperDetailController(),
tag: Utils.makeHeroTag(Get.parameters['talkerId']),
);
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final padding = MediaQuery.viewPaddingOf(context);
late final containerColor = ElevationOverlay.colorWithOverlay(
theme.colorScheme.surface,
theme.hoverColor,
1,
);
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (_whisperDetailController.mid != null) {
feedBack();
Get.toNamed('/member?mid=${_whisperDetailController.mid}');
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
NetworkImgLayer(
width: 34,
height: 34,
type: ImageType.avatar,
src: _whisperDetailController.face,
),
const SizedBox(width: 6),
Flexible(
child: Text(
_whisperDetailController.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(height: 1, fontSize: 16),
strutStyle: const StrutStyle(
leading: 0,
height: 1,
fontSize: 16,
),
),
),
if (_whisperDetailController.isLive) ...[
const SizedBox(width: 10),
Image.asset(
'assets/images/live/live.gif',
height: 16,
cacheHeight: 16.cacheSize(context),
filterQuality: FilterQuality.low,
),
],
],
),
),
actions: [
IconButton(
tooltip: '设置',
onPressed: () => Get.to(
WhisperLinkSettingPage(
talkerUid: _whisperDetailController.talkerId,
),
),
icon: Icon(
size: 22,
Icons.settings,
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.8),
),
),
const SizedBox(width: 5),
],
),
body: Padding(
padding: EdgeInsets.only(left: padding.left, right: padding.right),
child: Column(
children: [
Expanded(
child: Listener(
onPointerDown: (event) {
hidePanel();
},
behavior: HitTestBehavior.opaque,
child: Align(
alignment: Alignment.topCenter,
child: Obx(
() =>
_buildBody(_whisperDetailController.loadingState.value),
),
),
),
),
if (_whisperDetailController.mid != null) ...[
_buildInputView(theme, containerColor),
buildPanelContainer(
theme,
containerColor,
),
] else
SizedBox(height: padding.bottom),
],
),
).constraintWidth(),
);
}
Widget _buildBody(LoadingState<List<Msg>?> loadingState) {
return switch (loadingState) {
Loading() => loadingWidget,
Success(:final response) =>
response != null && response.isNotEmpty
? ListView.separated(
shrinkWrap: true,
reverse: true,
itemCount: response.length,
padding: const EdgeInsets.all(14),
physics: const AlwaysScrollableScrollPhysics(
parent: ClampingScrollPhysics(),
),
controller: _whisperDetailController.scrollController,
itemBuilder: (context, int index) {
if (index == response.length - 1) {
_whisperDetailController.onLoadMore();
}
final item = response[index];
final isOwner =
item.senderUid.toInt() ==
_whisperDetailController.account.mid;
return ChatItem(
item: item,
eInfos: _whisperDetailController.eInfos,
onLongPress: () => onLongPress(index, item, isOwner),
onSecondaryTapUp: PlatformUtils.isDesktop
? (e) =>
_showMenu(e.globalPosition, index, item, isOwner)
: null,
isOwner: isOwner,
);
},
separatorBuilder: (context, index) =>
const SizedBox(height: 12),
)
: scrollErrorWidget(onReload: _whisperDetailController.onReload),
Error(:final errMsg) => scrollErrorWidget(
errMsg: errMsg,
onReload: _whisperDetailController.onReload,
),
};
}
void _showMenu(Offset offset, int index, Msg item, bool isOwner) {
showMenu(
context: context,
position: PageUtils.menuPosition(offset),
items: [
if (isOwner)
PopupMenuItem(
height: 42,
onTap: () => _whisperDetailController.sendMsg(
message: '${item.msgKey}',
onClearText: editController.clear,
msgType: 5,
index: index,
),
child: const Text('撤回', style: TextStyle(fontSize: 14)),
)
else
PopupMenuItem(
height: 42,
onTap: () => autoWrapReportDialog(
context,
ban: false,
ReportOptions.imMsgReport,
(reasonType, reasonDesc, banUid) =>
_whisperDetailController.onReport(
item,
reasonType,
reasonType == 0
? reasonDesc!
: ReportOptions.imMsgReport['']![reasonType]!,
),
),
child: const Text('举报', style: TextStyle(fontSize: 14)),
),
],
);
}
void onLongPress(int index, Msg item, bool isOwner) {
Feedback.forLongPress(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
clipBehavior: Clip.hardEdge,
contentPadding: const EdgeInsets.symmetric(vertical: 12),
content: isOwner
? ListTile(
onTap: () {
Get.back();
_whisperDetailController.sendMsg(
message: '${item.msgKey}',
onClearText: editController.clear,
msgType: 5,
index: index,
);
},
dense: true,
title: const Text('撤回', style: TextStyle(fontSize: 14)),
)
: ListTile(
onTap: () {
Get.back();
autoWrapReportDialog(
context,
ban: false,
ReportOptions.imMsgReport,
(reasonType, reasonDesc, banUid) =>
_whisperDetailController.onReport(
item,
reasonType,
reasonType == 0
? reasonDesc!
: ReportOptions.imMsgReport['']![reasonType]!,
),
);
},
dense: true,
title: const Text('举报', style: TextStyle(fontSize: 14)),
),
),
);
}
Widget _buildInputView(ThemeData theme, Color containerColor) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: containerColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
onPressed: () => updatePanelType(
panelType.value == PanelType.emoji
? PanelType.keyboard
: PanelType.emoji,
),
icon: const Icon(Icons.emoji_emotions),
tooltip: '表情',
),
Expanded(
child: Listener(
onPointerUp: (event) {
// Currently it may be emojiPanel.
if (readOnly.value) {
updatePanelType(PanelType.keyboard);
}
},
child: Obx(
() => RichTextField(
key: key,
readOnly: readOnly.value,
focusNode: focusNode,
controller: editController,
minLines: 1,
maxLines: 4,
onChanged: onChanged,
onSubmitted: onSubmitted,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
filled: true,
hintText: '发个消息聊聊呗~',
fillColor: theme.colorScheme.surface,
border: const OutlineInputBorder(
borderSide: BorderSide.none,
borderRadius: BorderRadius.all(Radius.circular(6)),
gapPadding: 0,
),
contentPadding: const EdgeInsets.all(10),
),
// inputFormatters: [LengthLimitingTextInputFormatter(500)],
),
),
),
),
Obx(
() {
final enablePublish = this.enablePublish.value;
return IconButton(
onPressed: () async {
if (enablePublish) {
_whisperDetailController.sendMsg(
message: editController.rawText,
onClearText: () {
editController.clear();
this.enablePublish.value = false;
},
);
} else {
try {
final XFile? pickedFile = await imagePicker.pickImage(
source: ImageSource.gallery,
imageQuality: 100,
);
if (pickedFile != null) {
final path = pickedFile.path;
SmartDialog.showLoading(msg: '正在上传图片');
final result = await MsgHttp.uploadBfs(
path: path,
biz: 'im',
);
if (result case Success(:final response)) {
final mimeType =
lookupMimeType(
path,
)?.split('/').elementAtOrNull(1) ??
'jpg';
final picMsg = {
'url': response.imageUrl,
'height': response.imageHeight,
'width': response.imageWidth,
'imageType': mimeType,
'original': 1,
'size': response.imgSize,
};
SmartDialog.showLoading(msg: '正在发送');
await _whisperDetailController
.sendMsg(
picMsg: picMsg,
onClearText: editController.clear,
)
.whenComplete(() {
if (PlatformUtils.isMobile) {
File(path).tryDel();
}
});
} else {
SmartDialog.dismiss();
result.toast();
return;
}
}
} catch (e) {
SmartDialog.showToast(e.toString());
}
}
},
icon: Icon(
enablePublish
? Icons.send
: Icons.add_photo_alternate_outlined,
),
tooltip: enablePublish ? '发送' : '图片',
);
},
),
],
),
);
}
@override
Widget? get customPanel => EmotePanel(onChoose: onChooseEmote);
@override
Future<void> onCustomPublish({List? pictures}) {
throw UnimplementedError();
}
@override
Future<void>? onMention([bool fromClick = false]) => null;
@override
void onSave() {}
}