From 05153fda72f338c0c2c003fba85def6aea3d8eb7 Mon Sep 17 00:00:00 2001 From: bggRGjQaUbCoE Date: Tue, 8 Jul 2025 21:42:35 +0800 Subject: [PATCH] opt pub page Signed-off-by: bggRGjQaUbCoE --- assets/fonts/custom_icon.ttf | Bin 10188 -> 11420 bytes lib/common/widgets/custom_icon.dart | 27 +- lib/common/widgets/text_field/controller.dart | 4 +- lib/common/widgets/text_field/editable.dart | 22 +- lib/grpc/reply.dart | 22 ++ lib/grpc/url.dart | 1 + lib/http/api.dart | 6 + lib/http/dynamics.dart | 72 +++- lib/models/common/publish_panel_type.dart | 2 +- .../common/reply/reply_search_type.dart | 1 + .../dynamic/dyn_reserve_info/data.dart | 48 +++ .../common/publish/common_publish_page.dart | 21 +- .../publish/common_rich_text_pub_page.dart | 52 ++- lib/pages/common/reply_controller.dart | 1 + lib/pages/dynamics_create/view.dart | 368 ++++++++++++------ .../dynamics_create_reserve/controller.dart | 70 ++++ lib/pages/dynamics_create_reserve/view.dart | 201 ++++++++++ lib/pages/dynamics_repost/view.dart | 25 +- lib/pages/live_room/send_danmaku/view.dart | 29 +- lib/pages/video/reply_new/view.dart | 231 ++++++++--- .../reply_search_item/child/controller.dart | 36 ++ .../video/reply_search_item/child/view.dart | 94 +++++ .../reply_search_item/child/widgets/item.dart | 125 ++++++ .../video/reply_search_item/controller.dart | 56 +++ lib/pages/video/reply_search_item/view.dart | 99 +++++ lib/pages/video/send_danmaku/view.dart | 47 ++- lib/pages/whisper_detail/view.dart | 2 +- 27 files changed, 1374 insertions(+), 288 deletions(-) create mode 100644 lib/models/common/reply/reply_search_type.dart create mode 100644 lib/models_new/dynamic/dyn_reserve_info/data.dart create mode 100644 lib/pages/dynamics_create_reserve/controller.dart create mode 100644 lib/pages/dynamics_create_reserve/view.dart create mode 100644 lib/pages/video/reply_search_item/child/controller.dart create mode 100644 lib/pages/video/reply_search_item/child/view.dart create mode 100644 lib/pages/video/reply_search_item/child/widgets/item.dart create mode 100644 lib/pages/video/reply_search_item/controller.dart create mode 100644 lib/pages/video/reply_search_item/view.dart diff --git a/assets/fonts/custom_icon.ttf b/assets/fonts/custom_icon.ttf index 387f5219e3de8367ce8efe609c4f8aaf66db4a55..ea091ac596834b71780fd8cc0b18696d4b1a15ce 100644 GIT binary patch delta 1634 zcmYLHU2IfE6rMAGckbN3y?=YR3zT-3?ozfbWp}$<iVW8`l7yQRERu?i4Q~*Qxkn8G5#c^>5KYgV*Dwd*ob#RUoSEgv z-+X$h+RxRnUiB%e|Q~z zaZg&a2w1z>EUs_k*fKkR>B?b+D+2fMcVzC|iJ23B&LP5Y;(ly?=E@>W!VR<+a6hy# zGv9gs=i=D~LbmQFgl$q;?XWre3{Wek3J;zBoS5fBN;YVE6CDVXKIc zdrRNE{QrFaJX>WY+$%y2ijRp);XM-eO7H>1y>H>NWcXN?ip z2q%XyjN^;pt8s6Uwnc&#+54FO-TQ)Cf%_k~nMu;S%~qyYW8-YLeUOlw^borv7Gz!C zv5WSsCi+9?eYfep>s|0x{DXlGzR&#;esp7*zu>!FbWNr zhCOf)4#QD64yWJ^co&uUVBxnzT0YHZgRl$Qt#+%P{PClo_R#M14q-mq>;YSpzlUTt~hx8VpO8Ur*j{Vd7?= zv$7o;rY4kDn(E{0C0Uq)nF7;lxoVt5TApf%q-@D{YacSn?XVA;S-y{By=cTTDtobA z*<@o75W=W>-`q8(YGtb0$P!rtbj7;*f=qXfFjFq7*!Q5rMiAyUb(Kt?nMhDpxnvk& z#SH-{FJ;APk83WKn;WkO4+r(}oU0@v-gGf5aSC)~MM}md%UzEvAuJYbtnt{heV5yw zPaQsT_7!O}ODJW7@EptDG{$$2N5TV-hod`zxh~`shtT%~=fVjhh!Rh@UJ6`q01#H} zM+FYrbtv;}sU1IXl)**rw>^^ssbZG$CGT>9*V>m18IsVV)aD!n17KW6NmOfBY{>y6 zTDStLb5RsiOK=(5lndo(DNw!~F6L}N@U&$K)HAg9&e9Sbciap_lpKng9>$vG+LUE zU(v3VxD-0joKg>{3UbeM!n~rutyBptefG5N3ji?2K?`whaV4=`*ve2K^+UU)a9xF* zaj1Y5w6Hkm8xpZ;fbr0=bqBytgD}98atxCYUdE{uc8(`q*Afr~o-MSbDwh_j($$no zZKVUHl7$BWj!=P@!^E+2sB%IsI8%NIFHEX@my$6U@GIC2Q{1kvJ2F52qA|NL}IWhcRz#5?b1wg)5 zZem3NqXwfTP+kJaSIA4uO>KK9m7T}HpxnT~z^qb`UtGea)2PS5ptc04NNwUCWoHov zMxa_Ce&avIIiBCO2^^9yDrAfJPQ2_y;xAblc?x(r;Cl^EAD z3QfMim@?UdNm#Q2EW^N1#J~(@=>Taipz;a^76vz<9u^=3>U|+JxrE6asDy3vMkXdk z0R|qBp$uQZBm;x+5AFy=tZWa|U5MdOV+@`HDd4sn4 needRemoveGrpc(item)); } + + static Future> searchItem({ + required int page, + required SearchItemType itemType, + required int oid, + int type = 1, + String? keyword, + }) { + return GrpcReq.request( + GrpcUrl.searchItem, + SearchItemReq( + cursor: SearchItemCursorReq( + next: Int64(page), + itemType: itemType, + ), + oid: Int64(oid), + type: Int64(type), + keyword: keyword, + ), + SearchItemReply.fromBuffer, + ); + } } diff --git a/lib/grpc/url.dart b/lib/grpc/url.dart index eefb7b10d..fbe394a7a 100644 --- a/lib/grpc/url.dart +++ b/lib/grpc/url.dart @@ -21,6 +21,7 @@ class GrpcUrl { static const detailList = '$reply/DetailList'; static const dialogList = '$reply/DialogList'; // static const replyInfo = '$reply/ReplyInfo'; + static const searchItem = '$reply/SearchItem'; // im static const im = '/bilibili.im.interface.v1.ImInterface'; diff --git a/lib/http/api.dart b/lib/http/api.dart index 3af3b34ca..3f0dd696f 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -923,4 +923,10 @@ class Api { static const String createVote = '/x/vote/create'; static const String updateVote = '/x/vote/update'; + + static const String createReserve = '/x/new-reserve/up/reserve/create'; + + static const String updateReserve = '/x/new-reserve/up/reserve/update'; + + static const String reserveInfo = '/x/new-reserve/up/reserve/info'; } diff --git a/lib/http/dynamics.dart b/lib/http/dynamics.dart index 0308607f6..a506b5b9e 100644 --- a/lib/http/dynamics.dart +++ b/lib/http/dynamics.dart @@ -14,6 +14,7 @@ import 'package:PiliPlus/models_new/article/article_view/data.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_mention/data.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_mention/group.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_reserve/data.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_reserve_info/data.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_feed/topic_card_list.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/top_details.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/topic_item.dart'; @@ -131,6 +132,7 @@ class DynamicsHttp { List>? extraContent, Pair? topic, String? title, + Map? attachCard, }) async { var res = await Request().post( Api.createDynamic, @@ -171,7 +173,7 @@ class DynamicsHttp { ? 2 : 1, if (pics != null) 'pics': pics, - "attach_card": null, + "attach_card": attachCard, "upload_id": "${rid != null ? 0 : mid}_${DateTime.now().millisecondsSinceEpoch ~/ 1000}_${Utils.random.nextInt(9000) + 1000}", "meta": { @@ -555,4 +557,72 @@ class DynamicsHttp { return Error(res.data['message']); } } + + static Future> createReserve({ + int subType = 0, + required String title, + required int livePlanStartTime, + }) async { + final res = await Request().post( + Api.createReserve, + data: { + 'type': 2, + 'sub_type': subType, + 'from': 1, + 'title': title, + 'live_plan_start_time': livePlanStartTime, + 'csrf': Accounts.main.csrf, + }, + options: Options(contentType: Headers.formUrlEncodedContentType), + ); + if (res.data['code'] == 0) { + return Success(res.data['data']?['sid']); + } else { + return Error(res.data['message']); + } + } + + static Future> updateReserve({ + int subType = 0, + required String title, + required int livePlanStartTime, + required int sid, + }) async { + final res = await Request().post( + Api.updateReserve, + data: { + 'type': 2, + 'sub_type': subType, + 'from': 1, + 'title': title, + 'live_plan_start_time': livePlanStartTime, + 'id': sid, + 'csrf': Accounts.main.csrf, + }, + options: Options(contentType: Headers.formUrlEncodedContentType), + ); + if (res.data['code'] == 0) { + return Success(res.data['data']?['sid']); + } else { + return Error(res.data['message']); + } + } + + static Future> reserveInfo({ + required dynamic sid, + }) async { + final res = await Request().get( + Api.reserveInfo, + queryParameters: { + 'from': 1, + 'id': sid, + 'web_location': 333.1365, + }, + ); + if (res.data['code'] == 0) { + return Success(ReserveInfoData.fromJson(res.data['data'])); + } else { + return Error(res.data['message']); + } + } } diff --git a/lib/models/common/publish_panel_type.dart b/lib/models/common/publish_panel_type.dart index 066b4d15b..fce696930 100644 --- a/lib/models/common/publish_panel_type.dart +++ b/lib/models/common/publish_panel_type.dart @@ -1 +1 @@ -enum PanelType { none, keyboard, emoji } +enum PanelType { none, keyboard, emoji, more } diff --git a/lib/models/common/reply/reply_search_type.dart b/lib/models/common/reply/reply_search_type.dart new file mode 100644 index 000000000..2844ce8e1 --- /dev/null +++ b/lib/models/common/reply/reply_search_type.dart @@ -0,0 +1 @@ +enum ReplySearchType { video, article } diff --git a/lib/models_new/dynamic/dyn_reserve_info/data.dart b/lib/models_new/dynamic/dyn_reserve_info/data.dart new file mode 100644 index 000000000..560f3bc90 --- /dev/null +++ b/lib/models_new/dynamic/dyn_reserve_info/data.dart @@ -0,0 +1,48 @@ +class ReserveInfoData { + int? id; + String? title; + int? stime; + int? etime; + int? type; + int? livePlanStartTime; + int? lotteryType; + String? lotteryId; + int? subType; + + ReserveInfoData({ + this.id, + this.title, + this.stime, + this.etime, + this.type, + this.livePlanStartTime, + this.lotteryType, + this.lotteryId, + this.subType, + }); + + factory ReserveInfoData.fromJson(Map json) => + ReserveInfoData( + id: json['id'] as int?, + title: json['title'] as String?, + stime: json['stime'] as int?, + etime: json['etime'] as int?, + type: json['type'] as int?, + livePlanStartTime: json['live_plan_start_time'] as int?, + lotteryType: json['lottery_type'] as int?, + lotteryId: json['lottery_id'] as String?, + subType: json['sub_type'] as int?, + ); + + Map toJson() => { + 'id': id, + 'title': title, + 'stime': stime, + 'etime': etime, + 'type': type, + 'live_plan_start_time': livePlanStartTime, + 'lottery_type': lotteryType, + 'lottery_id': lotteryId, + 'sub_type': subType, + }; +} diff --git a/lib/pages/common/publish/common_publish_page.dart b/lib/pages/common/publish/common_publish_page.dart index 465c2a4bc..d2a36199a 100644 --- a/lib/pages/common/publish/common_publish_page.dart +++ b/lib/pages/common/publish/common_publish_page.dart @@ -107,13 +107,14 @@ abstract class CommonPublishPageState void updatePanelType(PanelType type) { final isSwitchToKeyboard = PanelType.keyboard == type; - final isSwitchToEmojiPanel = PanelType.emoji == type; + final isSwitchToEmojiPanel = + PanelType.emoji == type || PanelType.more == type; bool isUpdated = false; switch (type) { case PanelType.keyboard: updateInputView(isReadOnly: false); break; - case PanelType.emoji: + case PanelType.emoji || PanelType.more: isUpdated = updateInputView(isReadOnly: true); break; default: @@ -174,7 +175,9 @@ abstract class CommonPublishPageState ); } - Widget buildPanelContainer([Color? panelBgColor]) { + Widget buildMorePanel(ThemeData theme) => throw UnimplementedError(); + + Widget buildPanelContainer(ThemeData theme, [Color? panelBgColor]) { return ChatBottomPanelContainer( controller: controller, inputFocusNode: focusNode, @@ -183,12 +186,13 @@ abstract class CommonPublishPageState switch (type) { case PanelType.emoji: return buildEmojiPickerPanel(); + case PanelType.more: + return buildMorePanel(theme); default: return const SizedBox.shrink(); } }, onPanelTypeChange: (panelType, data) { - // if (kDebugMode) debugPrint('panelType: $panelType'); switch (panelType) { case ChatBottomPanelType.none: this.panelType.value = PanelType.none; @@ -198,14 +202,7 @@ abstract class CommonPublishPageState 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; - } + this.panelType.value = data; break; } }, diff --git a/lib/pages/common/publish/common_rich_text_pub_page.dart b/lib/pages/common/publish/common_rich_text_pub_page.dart index 22496fb88..40965bc55 100644 --- a/lib/pages/common/publish/common_rich_text_pub_page.dart +++ b/lib/pages/common/publish/common_rich_text_pub_page.dart @@ -1,9 +1,11 @@ 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/text_field/controller.dart'; import 'package:PiliPlus/common/widgets/text_field/text_field.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' as e; import 'package:PiliPlus/models_new/live/live_emote/emoticon.dart'; @@ -14,6 +16,7 @@ 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'; @@ -200,7 +203,7 @@ abstract class CommonRichTextPubPageState final list = >[]; for (var e in editController.items) { switch (e.type) { - case RichTextType.text || RichTextType.composing: + case RichTextType.text || RichTextType.composing || RichTextType.common: list.add({ "raw_text": e.text, "type": 1, @@ -360,4 +363,51 @@ abstract class CommonRichTextPubPageState 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, + ); + }, + ); } diff --git a/lib/pages/common/reply_controller.dart b/lib/pages/common/reply_controller.dart index 4c964a1a6..37f70623d 100644 --- a/lib/pages/common/reply_controller.dart +++ b/lib/pages/common/reply_controller.dart @@ -157,6 +157,7 @@ abstract class ReplyController extends CommonListController { child: child, ); }, + settings: RouteSettings(arguments: Get.arguments), ), ) .then( diff --git a/lib/pages/dynamics_create/view.dart b/lib/pages/dynamics_create/view.dart index 9933def86..f59ed3574 100644 --- a/lib/pages/dynamics_create/view.dart +++ b/lib/pages/dynamics_create/view.dart @@ -1,3 +1,5 @@ +import 'dart:math' show max; + import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/button/icon_button.dart'; import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; @@ -11,8 +13,10 @@ import 'package:PiliPlus/http/dynamics.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; import 'package:PiliPlus/models/common/reply/reply_option_type.dart'; import 'package:PiliPlus/models/dynamics/vote_model.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_reserve_info/data.dart'; import 'package:PiliPlus/models_new/dynamic/dyn_topic_top/topic_item.dart'; import 'package:PiliPlus/pages/common/publish/common_rich_text_pub_page.dart'; +import 'package:PiliPlus/pages/dynamics_create_reserve/view.dart'; import 'package:PiliPlus/pages/dynamics_create_vote/view.dart'; import 'package:PiliPlus/pages/dynamics_mention/controller.dart'; import 'package:PiliPlus/pages/dynamics_select_topic/controller.dart'; @@ -21,6 +25,7 @@ import 'package:PiliPlus/pages/emote/controller.dart'; import 'package:PiliPlus/pages/emote/view.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/date_util.dart'; +import 'package:PiliPlus/utils/grid.dart'; import 'package:PiliPlus/utils/request_utils.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart' hide DraggableScrollableSheet; @@ -68,6 +73,7 @@ class _CreateDynPanelState extends CommonRichTextPubPageState { final Rx _replyOption = ReplyOptionType.allow.obs; final _titleEditCtr = TextEditingController(); final Rx?> topic = Rx?>(null); + final Rx _reserveCard = Rx(null); @override void initState() { @@ -202,6 +208,7 @@ class _CreateDynPanelState extends CommonRichTextPubPageState { child: _buildEditWidget(theme), ), const SizedBox(height: 16), + _buildReserveItem(theme), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( @@ -226,7 +233,7 @@ class _CreateDynPanelState extends CommonRichTextPubPageState { ), ), _buildToolbar, - buildPanelContainer(Colors.transparent), + buildPanelContainer(theme, Colors.transparent), ], ); } @@ -460,9 +467,10 @@ class _CreateDynPanelState extends CommonRichTextPubPageState { ), onPressed: _isPrivate.value ? null - : () { + : () async { + controller.keepChatPanel(); DateTime nowDate = DateTime.now(); - showDatePicker( + final selectedDate = await showDatePicker( context: context, initialDate: nowDate, firstDate: nowDate, @@ -471,45 +479,42 @@ class _CreateDynPanelState extends CommonRichTextPubPageState { nowDate.month, nowDate.day + 7, ), - ).then( - (selectedDate) { - if (selectedDate != null && mounted) { - TimeOfDay nowTime = TimeOfDay.now(); - showTimePicker( - context: context, - initialTime: nowTime.replacing( - hour: nowTime.minute + 6 >= 60 - ? (nowTime.hour + 1) % 24 - : nowTime.hour, - minute: (nowTime.minute + 6) % 60, - ), - ).then((selectedTime) { - if (selectedTime != null) { - if (selectedDate.day == nowDate.day) { - if (selectedTime.hour < nowTime.hour) { - SmartDialog.showToast('时间设置错误,至少选择6分钟之后'); - return; - } else if (selectedTime.hour == nowTime.hour) { - if (selectedTime.minute < nowTime.minute + 6) { - if (selectedDate.day == nowDate.day) { - SmartDialog.showToast('时间设置错误,至少选择6分钟之后'); - } - return; - } - } - } - _publishTime.value = DateTime( - selectedDate.year, - selectedDate.month, - selectedDate.day, - selectedTime.hour, - selectedTime.minute, - ); - } - }); - } - }, ); + if (selectedDate != null && mounted) { + TimeOfDay nowTime = TimeOfDay.now(); + final selectedTime = await showTimePicker( + context: context, + initialTime: nowTime.replacing( + hour: nowTime.minute + 6 >= 60 + ? (nowTime.hour + 1) % 24 + : nowTime.hour, + minute: (nowTime.minute + 6) % 60, + ), + ); + if (selectedTime != null) { + if (selectedDate.day == nowDate.day) { + if (selectedTime.hour < nowTime.hour) { + SmartDialog.showToast('时间设置错误,至少选择6分钟之后'); + return; + } else if (selectedTime.hour == nowTime.hour) { + if (selectedTime.minute < nowTime.minute + 6) { + if (selectedDate.day == nowDate.day) { + SmartDialog.showToast('时间设置错误,至少选择6分钟之后'); + } + return; + } + } + } + _publishTime.value = DateTime( + selectedDate.year, + selectedDate.month, + selectedDate.day, + selectedTime.hour, + selectedTime.minute, + ); + } + } + controller.restoreChatPanel(); }, child: const Text('定时发布'), ) @@ -532,75 +537,10 @@ class _CreateDynPanelState extends CommonRichTextPubPageState { child: Row( spacing: 16, children: [ - Obx( - () => ToolbarIconButton( - onPressed: () => updatePanelType( - panelType.value == PanelType.emoji - ? PanelType.keyboard - : PanelType.emoji, - ), - icon: const Icon(Icons.emoji_emotions, size: 22), - tooltip: '表情', - selected: panelType.value == PanelType.emoji, - ), - ), - ToolbarIconButton( - onPressed: () => onMention(true), - icon: const Icon(Icons.alternate_email, size: 22), - tooltip: '@', - selected: false, - ), - ToolbarIconButton( - onPressed: () async { - controller.keepChatPanel(); - RichTextItem? voteItem = editController.items - .firstWhereOrNull((e) => e.type == RichTextType.vote); - VoteInfo? voteInfo = await Navigator.of(context).push( - GetPageRoute( - page: () => CreateVotePage( - voteId: voteItem?.id == null - ? null - : int.parse(voteItem!.id!))), - ); - if (voteInfo != null) { - if (voteItem != null) { - final range = voteItem.range; - final text = ' ${voteInfo.title} '; - final selection = TextSelection.collapsed( - offset: range.start + text.length); - final delta = RichTextEditingDeltaReplacement( - oldText: editController.text, - replacementText: text, - replacedRange: range, - selection: selection, - composing: TextRange.empty, - type: RichTextType.vote, - id: voteInfo.voteId.toString(), - rawText: voteInfo.title, - ); - final newValue = delta.apply(editController.value); - editController - ..syncRichText(delta) - ..value = newValue; - } else { - onInsertText( - '我发起了一个投票', - RichTextType.text, - ); - onInsertText( - ' ${voteInfo.title} ', - RichTextType.vote, - rawText: voteInfo.title, - id: voteInfo.voteId.toString(), - ); - } - } - controller.restoreChatPanel(); - }, - icon: const Icon(Icons.bar_chart_rounded, size: 24), - tooltip: '投票', - selected: false, - ), + emojiBtn, + atBtn, + voteBtn, + moreBtn, // if (kDebugMode) // ToolbarIconButton( // onPressed: editController.clear, @@ -611,6 +551,121 @@ class _CreateDynPanelState extends CommonRichTextPubPageState { ), ); + @override + Widget buildMorePanel(ThemeData theme) { + double height = context.isTablet ? 300 : 170; + final keyboardHeight = controller.keyboardHeight; + if (keyboardHeight != 0) { + height = max(height, keyboardHeight); + } + + Widget item({ + required VoidCallback onTap, + required Icon icon, + required String title, + }) { + return GestureDetector( + onTap: onTap, + child: Column( + spacing: 5, + mainAxisSize: MainAxisSize.min, + children: [ + AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.onInverseSurface, + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + alignment: Alignment.center, + child: icon, + ), + ), + Text( + title, + maxLines: 1, + style: const TextStyle(fontSize: 13), + ), + ], + ), + ); + } + + final color = theme.colorScheme.onSurfaceVariant; + + return SizedBox( + height: height, + child: GridView( + padding: const EdgeInsets.only(left: 12, bottom: 12, right: 12), + gridDelegate: const SliverGridDelegateWithExtentAndRatio( + maxCrossAxisExtent: 65, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + mainAxisExtent: 25, + ), + children: [ + item( + onTap: _onReserve, + icon: Icon(CustomIcon.live_reserve, size: 28, color: color), + title: '直播预约', + ), + ], + ), + ); + } + + Widget get voteBtn => ToolbarIconButton( + onPressed: () async { + controller.keepChatPanel(); + RichTextItem? voteItem = editController.items + .firstWhereOrNull((e) => e.type == RichTextType.vote); + VoteInfo? voteInfo = await Navigator.of(context).push( + GetPageRoute( + page: () => CreateVotePage( + voteId: voteItem?.id == null + ? null + : int.parse(voteItem!.id!))), + ); + if (voteInfo != null) { + if (voteItem != null) { + final range = voteItem.range; + final text = ' ${voteInfo.title} '; + final selection = + TextSelection.collapsed(offset: range.start + text.length); + final delta = RichTextEditingDeltaReplacement( + oldText: editController.text, + replacementText: text, + replacedRange: range, + selection: selection, + composing: TextRange.empty, + type: RichTextType.vote, + id: voteInfo.voteId.toString(), + rawText: voteInfo.title, + ); + final newValue = delta.apply(editController.value); + editController + ..syncRichText(delta) + ..value = newValue; + } else { + onInsertText( + '我发起了一个投票', + RichTextType.text, + ); + onInsertText( + ' ${voteInfo.title} ', + RichTextType.vote, + rawText: voteInfo.title, + id: voteInfo.voteId.toString(), + ); + } + } + controller.restoreChatPanel(); + }, + icon: const Icon(Icons.bar_chart_rounded, size: 24), + tooltip: '投票', + selected: false, + ); + Widget _buildEditWidget(ThemeData theme) => Form( autovalidateMode: AutovalidateMode.onUserInteraction, child: Listener( @@ -651,6 +706,7 @@ class _CreateDynPanelState extends CommonRichTextPubPageState { SmartDialog.showLoading(msg: '正在发布'); List>? extraContent = getRichContent(); final hasRichText = extraContent != null; + final reserveCard = _reserveCard.value; var result = await DynamicsHttp.createDynamic( mid: Accounts.main.mid, rawText: hasRichText ? null : editController.text, @@ -663,6 +719,16 @@ class _CreateDynPanelState extends CommonRichTextPubPageState { title: _titleEditCtr.text, topic: topic.value, extraContent: extraContent, + attachCard: reserveCard == null + ? null + : { + "common_card": { + "type": 14, + "biz_id": reserveCard.id, + "reserve_source": 0, + "reserve_lottery": 0, + }, + }, ); SmartDialog.dismiss(); if (result['status']) { @@ -682,18 +748,86 @@ class _CreateDynPanelState extends CommonRichTextPubPageState { } double _topicOffset = 0; - void _onSelectTopic() { - SelectTopicPanel.onSelectTopic( + Future _onSelectTopic() async { + controller.keepChatPanel(); + TopicItem? res = await SelectTopicPanel.onSelectTopic( context, offset: _topicOffset, callback: (offset) => _topicOffset = offset, - ).then((TopicItem? res) { - if (res != null) { - topic.value = Pair(first: res.id, second: res.name); - } - }); + ); + if (res != null) { + topic.value = Pair(first: res.id, second: res.name); + } + controller.restoreChatPanel(); } @override void onSave() {} + + Widget _buildReserveItem(ThemeData theme) { + return Obx( + () { + final reserveCard = _reserveCard.value; + if (reserveCard == null) { + return const SizedBox.shrink(); + } + return Stack( + clipBehavior: Clip.none, + children: [ + GestureDetector( + onTap: _onReserve, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + color: theme.colorScheme.onInverseSurface, + ), + margin: const EdgeInsets.only(left: 16, right: 16, bottom: 10), + padding: const EdgeInsets.fromLTRB(12, 12, 30, 12), + child: Column( + spacing: 3, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('直播预约: ${reserveCard.title}'), + Text( + '${DateUtil.longFormatD.format( + DateTime.fromMillisecondsSinceEpoch( + reserveCard.livePlanStartTime! * 1000), + )} 直播', + ), + ], + ), + ), + ), + Positioned( + right: 18, + top: 2, + child: iconButton( + context: context, + size: 30, + iconSize: 18, + icon: Icons.clear, + onPressed: () => _reserveCard.value = null, + bgColor: Colors.transparent, + iconColor: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ); + }, + ); + } + + Future _onReserve() async { + controller.keepChatPanel(); + ReserveInfoData? reserveInfo = await Navigator.of(context).push( + GetPageRoute( + page: () => CreateReservePage(sid: _reserveCard.value?.id), + ), + ); + if (reserveInfo != null) { + _reserveCard.value = reserveInfo; + } + controller.restoreChatPanel(); + } } diff --git a/lib/pages/dynamics_create_reserve/controller.dart b/lib/pages/dynamics_create_reserve/controller.dart new file mode 100644 index 000000000..25f9b4819 --- /dev/null +++ b/lib/pages/dynamics_create_reserve/controller.dart @@ -0,0 +1,70 @@ +import 'package:PiliPlus/http/dynamics.dart'; +import 'package:PiliPlus/models_new/dynamic/dyn_reserve_info/data.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:get/get.dart'; + +class CreateReserveController extends GetxController { + CreateReserveController(this.sid); + final int? sid; + final RxInt subType = 0.obs; + String key = Utils.generateRandomString(6); + final RxString title = ''.obs; + final now = DateTime.now(); + late final Rx date; + late final end = now.copyWith(day: now.day + 90); + final RxBool canCreate = false.obs; + + @override + void onInit() { + super.onInit(); + date = DateTime(now.year, now.month, now.day + 1, 20, 0).obs; + if (sid != null) { + queryData(); + } + } + + void updateCanCreate() { + canCreate.value = title.value.trim().isNotEmpty; + } + + Future queryData() async { + var res = await DynamicsHttp.reserveInfo(sid: sid); + if (res.isSuccess) { + ReserveInfoData data = res.data; + key = Utils.generateRandomString(6); + title.value = data.title!; + date.value = + DateTime.fromMillisecondsSinceEpoch(data.livePlanStartTime! * 1000); + canCreate.value = true; + } else { + res.toast(); + } + } + + Future onCreate() async { + final livePlanStartTime = date.value.millisecondsSinceEpoch ~/ 1000; + var res = sid == null + ? await DynamicsHttp.createReserve( + title: title.value, + subType: subType.value, + livePlanStartTime: livePlanStartTime, + ) + : await DynamicsHttp.updateReserve( + sid: sid!, + subType: subType.value, + title: title.value, + livePlanStartTime: livePlanStartTime, + ); + if (res.isSuccess) { + Get.back( + result: ReserveInfoData( + id: res.data, + title: title.value, + livePlanStartTime: livePlanStartTime, + ), + ); + } else { + res.toast(); + } + } +} diff --git a/lib/pages/dynamics_create_reserve/view.dart b/lib/pages/dynamics_create_reserve/view.dart new file mode 100644 index 000000000..d8611d642 --- /dev/null +++ b/lib/pages/dynamics_create_reserve/view.dart @@ -0,0 +1,201 @@ +import 'package:PiliPlus/pages/dynamics_create_reserve/controller.dart'; +import 'package:PiliPlus/utils/date_util.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' + show TextInputFormatter, LengthLimitingTextInputFormatter; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; + +class CreateReservePage extends StatefulWidget { + const CreateReservePage({super.key, this.sid}); + + final int? sid; + + @override + State createState() => _CreateReservePageState(); +} + +class _CreateReservePageState extends State { + late final _controller = Get.put(CreateReserveController(widget.sid), + tag: Utils.generateRandomString(6)); + late TextStyle _leadingStyle; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + _leadingStyle = TextStyle( + fontSize: 15, + color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.9), + ); + final padding = MediaQuery.paddingOf(context); + final divider = [ + const SizedBox(height: 10), + Divider( + height: 1, + color: theme.colorScheme.outline.withValues(alpha: 0.1), + ), + const SizedBox(height: 10), + ]; + return Scaffold( + appBar: AppBar(title: const Text('添加直播预约')), + body: ListView( + padding: EdgeInsets.only( + top: 16, + left: padding.left + 16, + right: padding.right + 16, + bottom: padding.bottom + 80, + ), + children: [ + Row( + spacing: 12, + children: [ + SizedBox( + width: 65, + child: Text('类型', style: _leadingStyle), + ), + Obx( + () => PopupMenuButton( + requestFocus: false, + initialValue: _controller.subType.value, + onSelected: (value) => _controller.subType.value = value, + itemBuilder: (context) { + return const [ + PopupMenuItem( + value: 0, + child: Text('公开直播'), + ), + PopupMenuItem( + value: 1, + child: Text('大航海直播'), + ), + ]; + }, + child: + Text(_controller.subType.value == 0 ? '公开直播' : '大航海直播'), + ), + ), + ], + ), + ...divider, + Row( + spacing: 12, + children: [ + SizedBox( + width: 65, + child: Text('时间', style: _leadingStyle), + ), + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () async { + FocusManager.instance.primaryFocus?.unfocus(); + DateTime? newDate = await showDatePicker( + context: context, + initialDate: _controller.date.value, + firstDate: _controller.now, + lastDate: _controller.end, + ); + if (newDate != null && context.mounted) { + TimeOfDay? newTime = await showTimePicker( + context: context, + initialTime: + TimeOfDay.fromDateTime(_controller.date.value), + ); + if (newTime != null) { + final newEndtime = DateTime( + newDate.year, + newDate.month, + newDate.day, + newTime.hour, + newTime.minute, + ); + if (newEndtime.difference(DateTime.now()) >= + const Duration(minutes: 5)) { + _controller.date.value = newEndtime; + } else { + SmartDialog.showToast('至少选择5分钟之后'); + } + } + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Obx( + () => Text( + DateUtil.longFormatD.format(_controller.date.value)), + ), + ), + ), + ), + ], + ), + ...divider, + Obx( + () => _buildInput( + theme, + key: ValueKey(_controller.key), + initialValue: _controller.title.value, + onChanged: (value) => _controller + ..title.value = value + ..updateCanCreate(), + desc: '标题', + hintText: '请填写标题,最多14字', + inputFormatters: [LengthLimitingTextInputFormatter(14)], + ), + ), + ...divider, + const SizedBox(height: 25), + Obx(() { + return FilledButton.tonal( + onPressed: + _controller.canCreate.value ? _controller.onCreate : null, + child: const Text('添加预约'), + ); + }), + ], + ), + ); + } + + Widget _buildInput( + ThemeData theme, { + Key? key, + String? initialValue, + required ValueChanged onChanged, + required String desc, + String? hintText, + List? inputFormatters, + }) { + return Row( + spacing: 12, + children: [ + SizedBox( + width: 65, + child: Text( + desc, + style: _leadingStyle, + ), + ), + Expanded( + child: TextFormField( + key: key, + initialValue: initialValue, + onChanged: onChanged, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + hintText: hintText ?? desc, + hintStyle: TextStyle( + fontSize: 15, + color: theme.colorScheme.outline.withValues(alpha: 0.7), + ), + ), + inputFormatters: inputFormatters, + ), + ), + ], + ); + } +} diff --git a/lib/pages/dynamics_repost/view.dart b/lib/pages/dynamics_repost/view.dart index 2be8fedf6..fa55489ac 100644 --- a/lib/pages/dynamics_repost/view.dart +++ b/lib/pages/dynamics_repost/view.dart @@ -1,4 +1,3 @@ -import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; import 'package:PiliPlus/common/widgets/draggable_sheet/draggable_scrollable_sheet_dyn.dart' show DraggableScrollableSheet; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; @@ -96,7 +95,7 @@ class _RepostPanelState extends CommonRichTextPubPageState { ), ), _buildToolbar, - buildPanelContainer(Colors.transparent), + buildPanelContainer(theme, Colors.transparent), ] else ...[ ..._buildEditPanel(theme), ..._biuldDismiss(theme), @@ -328,26 +327,8 @@ class _RepostPanelState extends CommonRichTextPubPageState { child: Row( spacing: 16, children: [ - Obx( - () => ToolbarIconButton( - onPressed: () { - updatePanelType( - panelType.value == PanelType.emoji - ? PanelType.keyboard - : PanelType.emoji, - ); - }, - icon: const Icon(Icons.emoji_emotions, size: 22), - tooltip: '表情', - selected: panelType.value == PanelType.emoji, - ), - ), - ToolbarIconButton( - onPressed: () => onMention(true), - icon: const Icon(Icons.alternate_email, size: 22), - tooltip: '@', - selected: false, - ), + emojiBtn, + atBtn, ], ), ); diff --git a/lib/pages/live_room/send_danmaku/view.dart b/lib/pages/live_room/send_danmaku/view.dart index 9ba5ccaed..0dbcac0e6 100644 --- a/lib/pages/live_room/send_danmaku/view.dart +++ b/lib/pages/live_room/send_danmaku/view.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; import 'package:PiliPlus/common/widgets/text_field/text_field.dart'; import 'package:PiliPlus/http/live.dart'; import 'package:PiliPlus/models/common/publish_panel_type.dart'; @@ -66,7 +65,7 @@ class _ReplyPageState extends CommonRichTextPubPageState { mainAxisSize: MainAxisSize.min, children: [ ...buildInputView(theme), - buildPanelContainer(Colors.transparent), + buildPanelContainer(theme, Colors.transparent), ], ), ), @@ -132,31 +131,7 @@ class _ReplyPageState extends CommonRichTextPubPageState { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Obx( - () => ToolbarIconButton( - tooltip: '输入', - onPressed: () { - if (panelType.value != PanelType.keyboard) { - updatePanelType(PanelType.keyboard); - } - }, - icon: const Icon(Icons.keyboard, size: 22), - selected: panelType.value == PanelType.keyboard, - ), - ), - const SizedBox(width: 10), - Obx( - () => ToolbarIconButton( - tooltip: '表情', - onPressed: () { - if (panelType.value != PanelType.emoji) { - updatePanelType(PanelType.emoji); - } - }, - icon: const Icon(Icons.emoji_emotions, size: 22), - selected: panelType.value == PanelType.emoji, - ), - ), + emojiBtn, const Spacer(), Obx( () => FilledButton.tonal( diff --git a/lib/pages/video/reply_new/view.dart b/lib/pages/video/reply_new/view.dart index 4b5cea9ba..6be457cd8 100644 --- a/lib/pages/video/reply_new/view.dart +++ b/lib/pages/video/reply_new/view.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:math' show max; import 'package:PiliPlus/common/widgets/button/toolbar_icon_button.dart'; import 'package:PiliPlus/common/widgets/text_field/controller.dart' @@ -12,10 +14,16 @@ 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/dynamics_mention/controller.dart'; import 'package:PiliPlus/pages/emote/view.dart'; +import 'package:PiliPlus/pages/video/controller.dart'; +import 'package:PiliPlus/pages/video/reply_search_item/view.dart'; +import 'package:PiliPlus/utils/duration_util.dart'; +import 'package:PiliPlus/utils/grid.dart'; import 'package:PiliPlus/utils/storage_pref.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:path_provider/path_provider.dart'; class ReplyPage extends CommonRichTextPubPage { final int oid; @@ -44,31 +52,7 @@ class ReplyPage extends CommonRichTextPubPage { class _ReplyPageState extends CommonRichTextPubPageState { final RxBool _syncToDynamic = false.obs; - - Widget get child => SafeArea( - bottom: false, - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - constraints: const BoxConstraints(maxWidth: 640), - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), - ), - color: themeData.colorScheme.surface, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ...buildInputView(), - buildImagePreview(), - buildPanelContainer(Colors.transparent), - ], - ), - ), - ), - ); + final heroTag = Get.arguments?['heroTag']; @override void dispose() { @@ -90,6 +74,30 @@ class _ReplyPageState extends CommonRichTextPubPageState { @override Widget build(BuildContext context) { + Widget child = SafeArea( + bottom: false, + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + constraints: const BoxConstraints(maxWidth: 640), + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + color: themeData.colorScheme.surface, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...buildInputView(), + buildImagePreview(), + buildPanelContainer(themeData, Colors.transparent), + ], + ), + ), + ), + ); return darkVideoPage ? Theme(data: themeData, child: child) : child; } @@ -167,31 +175,7 @@ class _ReplyPageState extends CommonRichTextPubPageState { padding: const EdgeInsets.only(left: 12, right: 12), child: Row( children: [ - Obx( - () => ToolbarIconButton( - tooltip: '输入', - onPressed: () { - if (panelType.value != PanelType.keyboard) { - updatePanelType(PanelType.keyboard); - } - }, - icon: const Icon(Icons.keyboard, size: 22), - selected: panelType.value == PanelType.keyboard, - ), - ), - const SizedBox(width: 8), - Obx( - () => ToolbarIconButton( - tooltip: '表情', - onPressed: () { - if (panelType.value != PanelType.emoji) { - updatePanelType(PanelType.emoji); - } - }, - icon: const Icon(Icons.emoji_emotions, size: 22), - selected: panelType.value == PanelType.emoji, - ), - ), + emojiBtn, if (widget.root == 0) ...[ const SizedBox(width: 8), ToolbarIconButton( @@ -202,12 +186,9 @@ class _ReplyPageState extends CommonRichTextPubPageState { ), ], const SizedBox(width: 8), - ToolbarIconButton( - onPressed: () => onMention(true), - icon: const Icon(Icons.alternate_email, size: 22), - tooltip: '@', - selected: false, - ), + atBtn, + const SizedBox(width: 8), + moreBtn, Expanded( child: Center( child: Obx( @@ -264,6 +245,144 @@ class _ReplyPageState extends CommonRichTextPubPageState { ]; } + @override + Widget buildMorePanel(ThemeData theme) { + double height = context.isTablet ? 300 : 170; + final keyboardHeight = controller.keyboardHeight; + if (keyboardHeight != 0) { + height = max(height, keyboardHeight); + } + + Widget item({ + required VoidCallback onTap, + required Icon icon, + required String title, + }) { + return GestureDetector( + onTap: onTap, + child: Column( + spacing: 5, + mainAxisSize: MainAxisSize.min, + children: [ + AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: themeData.colorScheme.onInverseSurface, + borderRadius: const BorderRadius.all(Radius.circular(6)), + ), + alignment: Alignment.center, + child: icon, + ), + ), + Text( + title, + maxLines: 1, + style: const TextStyle(fontSize: 13), + ), + ], + ), + ); + } + + final isRoot = widget.root == 0; + final color = themeData.colorScheme.onSurfaceVariant; + + return SizedBox( + height: height, + child: GridView( + padding: const EdgeInsets.only(left: 12, bottom: 12, right: 12), + gridDelegate: const SliverGridDelegateWithExtentAndRatio( + maxCrossAxisExtent: 65, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + mainAxisExtent: 25, + ), + children: [ + item( + onTap: () async { + controller.keepChatPanel(); + ({String title, String url})? res = await Get.to( + ReplySearchPage(type: widget.replyType, oid: widget.oid)); + if (res != null) { + onInsertText( + '${res.title} ', + RichTextType.common, + rawText: '${res.url} ', + ); + } + controller.restoreChatPanel(); + }, + icon: Icon(Icons.post_add, size: 28, color: color), + title: '插入内容', + ), + if (heroTag != null) ...[ + // if (isRoot) + // item( + // onTap: () { + // Get.back(); + // try { + // Get.find(tag: heroTag) + // .showNoteList(context); + // } catch (e) { + // debugPrint(e.toString()); + // } + // }, + // icon: Icon(Icons.edit_note, size: 28, color: color), + // title: '笔记', + // ), + item( + onTap: () { + try { + final plPlayerController = + Get.find(tag: heroTag); + onInsertText( + ' ${DurationUtil.formatDuration((plPlayerController.playedTime ?? Duration.zero).inSeconds)} ', + RichTextType.common, + ); + } catch (e) { + debugPrint(e.toString()); + } + }, + icon: Icon(Icons.my_location, size: 28, color: color), + title: '视频进度', + ), + if (isRoot) + item( + onTap: () async { + if (pathList.length >= limit) { + SmartDialog.showToast('最多选择$limit张图片'); + return; + } + try { + final plPlayerController = + Get.find(tag: heroTag); + final res = await plPlayerController + .plPlayerController.videoPlayerController + ?.screenshot(format: 'image/png'); + if (res != null) { + final tempDir = await getTemporaryDirectory(); + File file = File( + '${tempDir.path}/${Utils.generateRandomString(8)}.png'); + await file.writeAsBytes(res); + pathList.add(file.path); + } else { + debugPrint('null screenshot'); + } + } catch (e) { + debugPrint(e.toString()); + } + }, + icon: Icon(Icons.enhance_photo_translate_outlined, + size: 28, color: color), + title: '视频截图', + ), + ], + ], + ), + ); + } + @override Future onCustomPublish({List? pictures}) async { Map atNameToMid = {}; diff --git a/lib/pages/video/reply_search_item/child/controller.dart b/lib/pages/video/reply_search_item/child/controller.dart new file mode 100644 index 000000000..241938346 --- /dev/null +++ b/lib/pages/video/reply_search_item/child/controller.dart @@ -0,0 +1,36 @@ +import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' + show SearchItemReply, SearchItem, SearchItemType; +import 'package:PiliPlus/grpc/reply.dart'; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/models/common/reply/reply_search_type.dart'; +import 'package:PiliPlus/pages/common/common_list_controller.dart'; +import 'package:PiliPlus/pages/video/reply_search_item/controller.dart'; + +class ReplySearchChildController + extends CommonListController { + ReplySearchChildController(this.controller, this.searchType); + + final ReplySearchController controller; + final ReplySearchType searchType; + + @override + List? getDataList(SearchItemReply response) { + if (response.cursor.hasNext == false) { + isEnd = true; + } + return response.items; + } + + @override + Future> customGetData() { + return ReplyGrpc.searchItem( + page: page, + itemType: searchType == ReplySearchType.video + ? SearchItemType.VIDEO + : SearchItemType.ARTICLE, + oid: controller.oid, + type: controller.type, + keyword: controller.editingController.text, + ); + } +} diff --git a/lib/pages/video/reply_search_item/child/view.dart b/lib/pages/video/reply_search_item/child/view.dart new file mode 100644 index 000000000..dcfd00595 --- /dev/null +++ b/lib/pages/video/reply_search_item/child/view.dart @@ -0,0 +1,94 @@ +import 'package:PiliPlus/common/skeleton/video_card_h.dart'; +import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; +import 'package:PiliPlus/common/widgets/refresh_indicator.dart'; +import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' + show SearchItem; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/models/common/reply/reply_search_type.dart'; +import 'package:PiliPlus/pages/video/reply_search_item/child/controller.dart'; +import 'package:PiliPlus/pages/video/reply_search_item/child/widgets/item.dart'; +import 'package:PiliPlus/utils/grid.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class ReplySearchChildPage extends StatefulWidget { + const ReplySearchChildPage({ + super.key, + required this.controller, + required this.searchType, + }); + + final ReplySearchChildController controller; + final ReplySearchType searchType; + + @override + State createState() => _ReplySearchChildPageState(); +} + +class _ReplySearchChildPageState extends State + with AutomaticKeepAliveClientMixin { + ReplySearchChildController get _controller => widget.controller; + + @override + Widget build(BuildContext context) { + super.build(context); + return refreshIndicator( + onRefresh: _controller.onRefresh, + child: CustomScrollView( + controller: _controller.scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + top: 7, + bottom: MediaQuery.paddingOf(context).bottom + 80, + ), + sliver: Obx(() => _buildBody(_controller.loadingState.value)), + ), + ], + ), + ); + } + + Widget get _buildLoading { + return SliverGrid( + gridDelegate: Grid.videoCardHDelegate(context), + delegate: SliverChildBuilderDelegate( + (context, index) { + return const VideoCardHSkeleton(); + }, + childCount: 10, + ), + ); + } + + Widget _buildBody(LoadingState?> loadingState) { + return switch (loadingState) { + Loading() => _buildLoading, + Success(:var response) => response?.isNotEmpty == true + ? SliverGrid( + gridDelegate: Grid.videoCardHDelegate(context), + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == response.length - 1) { + _controller.onLoadMore(); + } + return ReplySearchItem( + item: response[index], + type: widget.searchType, + ); + }, + childCount: response!.length, + ), + ) + : HttpError(onReload: _controller.onReload), + Error(:var errMsg) => HttpError( + errMsg: errMsg, + onReload: _controller.onReload, + ), + }; + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/pages/video/reply_search_item/child/widgets/item.dart b/lib/pages/video/reply_search_item/child/widgets/item.dart new file mode 100644 index 000000000..8d6764c7d --- /dev/null +++ b/lib/pages/video/reply_search_item/child/widgets/item.dart @@ -0,0 +1,125 @@ +import 'package:PiliPlus/common/constants.dart'; +import 'package:PiliPlus/common/widgets/badge.dart'; +import 'package:PiliPlus/common/widgets/image/image_save.dart'; +import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart' + show SearchItem, SearchItemVideoSubType; +import 'package:PiliPlus/models/common/badge_type.dart'; +import 'package:PiliPlus/models/common/reply/reply_search_type.dart'; +import 'package:PiliPlus/utils/duration_util.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class ReplySearchItem extends StatelessWidget { + const ReplySearchItem({ + super.key, + required this.item, + required this.type, + }); + + final SearchItem item; + final ReplySearchType type; + + @override + Widget build(BuildContext context) { + String title = ''; + String cover = ''; + String? upNickname; + String? category; + int? duration; + switch (type) { + case ReplySearchType.video: + if (item.video.type == SearchItemVideoSubType.UGC) { + final ugc = item.video.ugc; + title = ugc.title; + cover = ugc.cover; + upNickname = ugc.upNickname; + duration = ugc.duration.toInt(); + } else { + final pgc = item.video.pgc; + title = pgc.title; + cover = pgc.cover; + category = pgc.category; + } + case ReplySearchType.article: + final article = item.article; + title = article.title; + cover = article.covers.firstOrNull ?? ''; + upNickname = article.upNickname; + } + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => Get.back(result: (title: title, url: item.url)), + onLongPress: () => imageSaveDialog( + title: title, + cover: cover, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StyleString.safeSpace, + vertical: 5, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: StyleString.aspectRatio, + child: LayoutBuilder( + builder: (context, boxConstraints) { + return Stack( + children: [ + NetworkImgLayer( + src: cover, + width: boxConstraints.maxWidth, + height: boxConstraints.maxHeight, + ), + if (category != null) + PBadge( + right: 6, + top: 6, + text: category, + ), + if (duration != null) + PBadge( + right: 6, + bottom: 6, + text: DurationUtil.formatDuration(duration), + type: PBadgeType.gray, + ), + ], + ); + }, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (upNickname != null) + Text( + 'UP: $upNickname', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/video/reply_search_item/controller.dart b/lib/pages/video/reply_search_item/controller.dart new file mode 100644 index 000000000..802f5d9fb --- /dev/null +++ b/lib/pages/video/reply_search_item/controller.dart @@ -0,0 +1,56 @@ +import 'package:PiliPlus/models/common/reply/reply_search_type.dart'; +import 'package:PiliPlus/pages/video/reply_search_item/child/controller.dart'; +import 'package:PiliPlus/utils/extension.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class ReplySearchController extends GetxController + with GetSingleTickerProviderStateMixin { + ReplySearchController(this.type, this.oid); + final int type; + final int oid; + + late final tabController = TabController(vsync: this, length: 2); + final editingController = TextEditingController(); + final focusNode = FocusNode(); + + late final videoCtr = Get.put( + ReplySearchChildController(this, ReplySearchType.video), + tag: Utils.generateRandomString(8)); + late final articleCtr = Get.put( + ReplySearchChildController(this, ReplySearchType.article), + tag: Utils.generateRandomString(8)); + + void onClear() { + if (editingController.value.text.isNotEmpty) { + editingController.clear(); + focusNode.requestFocus(); + } else { + Get.back(); + } + } + + @override + void onInit() { + super.onInit(); + submit(); + } + + void submit() { + videoCtr + ..scrollController.jumpToTop() + ..onReload(); + articleCtr + ..scrollController.jumpToTop() + ..onReload(); + } + + @override + void onClose() { + editingController.dispose(); + focusNode.dispose(); + tabController.dispose(); + super.onClose(); + } +} diff --git a/lib/pages/video/reply_search_item/view.dart b/lib/pages/video/reply_search_item/view.dart new file mode 100644 index 000000000..0643328f1 --- /dev/null +++ b/lib/pages/video/reply_search_item/view.dart @@ -0,0 +1,99 @@ +import 'package:PiliPlus/common/widgets/scroll_physics.dart'; +import 'package:PiliPlus/models/common/reply/reply_search_type.dart'; +import 'package:PiliPlus/pages/video/reply_search_item/child/view.dart'; +import 'package:PiliPlus/pages/video/reply_search_item/controller.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class ReplySearchPage extends StatefulWidget { + const ReplySearchPage({ + super.key, + required this.type, + required this.oid, + }); + + final int type; + final int oid; + + @override + State createState() => _ReplySearchPageState(); +} + +class _ReplySearchPageState extends State { + late final _controller = Get.put( + ReplySearchController(widget.type, widget.oid), + tag: Utils.generateRandomString(8)); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + actions: [ + IconButton( + tooltip: '搜索', + onPressed: _controller.submit, + icon: const Icon(Icons.search, size: 22), + ), + const SizedBox(width: 10) + ], + title: TextField( + autofocus: true, + focusNode: _controller.focusNode, + controller: _controller.editingController, + textInputAction: TextInputAction.search, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + hintText: '搜索', + border: InputBorder.none, + suffixIcon: IconButton( + tooltip: '清空', + icon: const Icon(Icons.clear, size: 22), + onPressed: _controller.onClear, + ), + ), + onSubmitted: (value) => _controller.submit(), + ), + ), + body: SafeArea( + top: false, + bottom: false, + child: Column( + children: [ + TabBar( + controller: _controller.tabController, + tabs: [ + const Tab(text: '视频'), + const Tab(text: '专栏'), + ], + onTap: (index) { + if (!_controller.tabController.indexIsChanging) { + if (index == 0) { + _controller.videoCtr.animateToTop(); + } else { + _controller.articleCtr.animateToTop(); + } + } + }, + ), + Expanded( + child: tabBarView( + controller: _controller.tabController, + children: [ + ReplySearchChildPage( + controller: _controller.videoCtr, + searchType: ReplySearchType.video, + ), + ReplySearchChildPage( + controller: _controller.articleCtr, + searchType: ReplySearchType.article, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/video/send_danmaku/view.dart b/lib/pages/video/send_danmaku/view.dart index c8b11eeb7..45a0a7582 100644 --- a/lib/pages/video/send_danmaku/view.dart +++ b/lib/pages/video/send_danmaku/view.dart @@ -134,30 +134,6 @@ class _SendDanmakuPanelState extends CommonTextPubPageState { ), ); - Widget get child => SafeArea( - bottom: false, - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - constraints: const BoxConstraints(maxWidth: 450), - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), - ), - color: themeData.colorScheme.surface, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildInputView(), - buildPanelContainer(Colors.transparent), - ], - ), - ), - ), - ); - @override void didChangeDependencies() { super.didChangeDependencies(); @@ -170,6 +146,29 @@ class _SendDanmakuPanelState extends CommonTextPubPageState { @override Widget build(BuildContext context) { + Widget child = SafeArea( + bottom: false, + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + constraints: const BoxConstraints(maxWidth: 450), + decoration: BoxDecoration( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + color: themeData.colorScheme.surface, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildInputView(), + buildPanelContainer(themeData, Colors.transparent), + ], + ), + ), + ), + ); return widget.darkVideoPage ? Theme(data: themeData, child: child) : child; } diff --git a/lib/pages/whisper_detail/view.dart b/lib/pages/whisper_detail/view.dart index 2b42ba032..49bed24e3 100644 --- a/lib/pages/whisper_detail/view.dart +++ b/lib/pages/whisper_detail/view.dart @@ -127,7 +127,7 @@ class _WhisperDetailPageState ), if (_whisperDetailController.mid != null) ...[ _buildInputView(theme), - buildPanelContainer(theme.colorScheme.onInverseSurface), + buildPanelContainer(theme, theme.colorScheme.onInverseSurface), ] else SizedBox(height: MediaQuery.paddingOf(context).bottom), ],