opt pub page

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-07-08 21:42:35 +08:00
parent 8bf55ec95a
commit 05153fda72
27 changed files with 1374 additions and 288 deletions

Binary file not shown.

View File

@@ -9,19 +9,20 @@ class CustomIcon {
static const IconData dm_settings = _CustomIconData(0xe803);
static const IconData dyn = _CustomIconData(0xe804);
static const IconData fav = _CustomIconData(0xe805);
static const IconData share = _CustomIconData(0xe806);
static const IconData share_line = _CustomIconData(0xe807);
static const IconData share_node = _CustomIconData(0xe808);
static const IconData star_favorite_line = _CustomIconData(0xe809);
static const IconData star_favorite_solid = _CustomIconData(0xe80a);
static const IconData thumbs_down = _CustomIconData(0xe80b);
static const IconData thumbs_down_outline = _CustomIconData(0xe80c);
static const IconData thumbs_up = _CustomIconData(0xe80d);
static const IconData thumbs_up_fill = _CustomIconData(0xe80e);
static const IconData thumbs_up_line = _CustomIconData(0xe80f);
static const IconData thumbs_up_outline = _CustomIconData(0xe810);
static const IconData topic_tag = _CustomIconData(0xe811);
static const IconData watch_later = _CustomIconData(0xe812);
static const IconData live_reserve = _CustomIconData(0xe806);
static const IconData share = _CustomIconData(0xe807);
static const IconData share_line = _CustomIconData(0xe808);
static const IconData share_node = _CustomIconData(0xe809);
static const IconData star_favorite_line = _CustomIconData(0xe80a);
static const IconData star_favorite_solid = _CustomIconData(0xe80b);
static const IconData thumbs_down = _CustomIconData(0xe80c);
static const IconData thumbs_down_outline = _CustomIconData(0xe80d);
static const IconData thumbs_up = _CustomIconData(0xe80e);
static const IconData thumbs_up_fill = _CustomIconData(0xe80f);
static const IconData thumbs_up_line = _CustomIconData(0xe810);
static const IconData thumbs_up_outline = _CustomIconData(0xe811);
static const IconData topic_tag = _CustomIconData(0xe812);
static const IconData watch_later = _CustomIconData(0xe813);
}
class _CustomIconData extends IconData {

View File

@@ -26,7 +26,7 @@ import 'package:flutter/services.dart';
/// created by bggRGjQaUbCoE on 2025/6/27
///
enum RichTextType { text, composing, at, emoji, vote }
enum RichTextType { text, composing, at, emoji, vote, common }
class Emote {
late String url;
@@ -752,7 +752,7 @@ class RichTextEditingController extends TextEditingController {
text: e.text,
style: composingRegionOutOfRange ? null : composingStyle,
);
case RichTextType.at:
case RichTextType.at || RichTextType.common:
richStyle ??= (style ?? const TextStyle())
.copyWith(color: Theme.of(context).colorScheme.primary);
return TextSpan(

View File

@@ -2305,18 +2305,18 @@ class RenderEditable extends RenderBox
localPos: localPos,
lastTapDownPosition: _lastTapDownPosition!,
);
position = TextPosition(offset: newOffset);
final newSelection = TextSelection.collapsed(offset: newOffset);
final TextRange word = _textPainter.getWordBoundary(position);
late TextSelection newSelection;
if (position.offset <= word.start) {
newSelection = TextSelection.collapsed(offset: word.start);
} else {
newSelection = TextSelection.collapsed(
offset: word.end,
affinity: TextAffinity.upstream,
);
}
// final TextRange word = _textPainter.getWordBoundary(position);
// late TextSelection newSelection;
// if (position.offset <= word.start) {
// newSelection = TextSelection.collapsed(offset: word.start);
// } else {
// newSelection = TextSelection.collapsed(
// offset: word.end,
// affinity: TextAffinity.upstream,
// );
// }
_setSelection(newSelection, cause);
}

View File

@@ -125,4 +125,26 @@ class ReplyGrpc {
);
return res..dataOrNull?.replies.removeWhere((item) => needRemoveGrpc(item));
}
static Future<LoadingState<SearchItemReply>> 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,
);
}
}

View File

@@ -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';

View File

@@ -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';
}

View File

@@ -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<Map<String, dynamic>>? extraContent,
Pair<int, String>? 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<LoadingState<int?>> 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<LoadingState<int?>> 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<LoadingState<ReserveInfoData>> 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']);
}
}
}

View File

@@ -1 +1 @@
enum PanelType { none, keyboard, emoji }
enum PanelType { none, keyboard, emoji, more }

View File

@@ -0,0 +1 @@
enum ReplySearchType { video, article }

View File

@@ -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<String, dynamic> 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<String, dynamic> 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,
};
}

View File

@@ -107,13 +107,14 @@ abstract class CommonPublishPageState<T extends CommonPublishPage>
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<T extends CommonPublishPage>
);
}
Widget buildPanelContainer([Color? panelBgColor]) {
Widget buildMorePanel(ThemeData theme) => throw UnimplementedError();
Widget buildPanelContainer(ThemeData theme, [Color? panelBgColor]) {
return ChatBottomPanelContainer<PanelType>(
controller: controller,
inputFocusNode: focusNode,
@@ -183,12 +186,13 @@ abstract class CommonPublishPageState<T extends CommonPublishPage>
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<T extends CommonPublishPage>
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;
}
},

View File

@@ -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<T extends CommonRichTextPubPage>
final list = <Map<String, dynamic>>[];
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<T extends CommonRichTextPubPage>
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,
);
},
);
}

View File

@@ -157,6 +157,7 @@ abstract class ReplyController<R> extends CommonListController<R, ReplyInfo> {
child: child,
);
},
settings: RouteSettings(arguments: Get.arguments),
),
)
.then(

View File

@@ -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<CreateDynPanel> {
final Rx<ReplyOptionType> _replyOption = ReplyOptionType.allow.obs;
final _titleEditCtr = TextEditingController();
final Rx<Pair<int, String>?> topic = Rx<Pair<int, String>?>(null);
final Rx<ReserveInfoData?> _reserveCard = Rx<ReserveInfoData?>(null);
@override
void initState() {
@@ -202,6 +208,7 @@ class _CreateDynPanelState extends CommonRichTextPubPageState<CreateDynPanel> {
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<CreateDynPanel> {
),
),
_buildToolbar,
buildPanelContainer(Colors.transparent),
buildPanelContainer(theme, Colors.transparent),
],
);
}
@@ -460,9 +467,10 @@ class _CreateDynPanelState extends CommonRichTextPubPageState<CreateDynPanel> {
),
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<CreateDynPanel> {
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<CreateDynPanel> {
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<CreateDynPanel> {
),
);
@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<CreateDynPanel> {
SmartDialog.showLoading(msg: '正在发布');
List<Map<String, dynamic>>? 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<CreateDynPanel> {
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<CreateDynPanel> {
}
double _topicOffset = 0;
void _onSelectTopic() {
SelectTopicPanel.onSelectTopic(
Future<void> _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<void> _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();
}
}

View File

@@ -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<DateTime> 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<void> 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<void> 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();
}
}
}

View File

@@ -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<CreateReservePage> createState() => _CreateReservePageState();
}
class _CreateReservePageState extends State<CreateReservePage> {
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<String> onChanged,
required String desc,
String? hintText,
List<TextInputFormatter>? 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,
),
),
],
);
}
}

View File

@@ -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<RepostPanel> {
),
),
_buildToolbar,
buildPanelContainer(Colors.transparent),
buildPanelContainer(theme, Colors.transparent),
] else ...[
..._buildEditPanel(theme),
..._biuldDismiss(theme),
@@ -328,26 +327,8 @@ class _RepostPanelState extends CommonRichTextPubPageState<RepostPanel> {
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,
],
),
);

View File

@@ -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<LiveSendDmPanel> {
mainAxisSize: MainAxisSize.min,
children: [
...buildInputView(theme),
buildPanelContainer(Colors.transparent),
buildPanelContainer(theme, Colors.transparent),
],
),
),
@@ -132,31 +131,7 @@ class _ReplyPageState extends CommonRichTextPubPageState<LiveSendDmPanel> {
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(

View File

@@ -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<ReplyPage> {
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<ReplyPage> {
@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<ReplyPage> {
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<ReplyPage> {
),
],
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<ReplyPage> {
];
}
@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<VideoDetailController>(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<VideoDetailController>(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<VideoDetailController>(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<void> onCustomPublish({List? pictures}) async {
Map<String, int> atNameToMid = {};

View File

@@ -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<SearchItemReply, SearchItem> {
ReplySearchChildController(this.controller, this.searchType);
final ReplySearchController controller;
final ReplySearchType searchType;
@override
List<SearchItem>? getDataList(SearchItemReply response) {
if (response.cursor.hasNext == false) {
isEnd = true;
}
return response.items;
}
@override
Future<LoadingState<SearchItemReply>> customGetData() {
return ReplyGrpc.searchItem(
page: page,
itemType: searchType == ReplySearchType.video
? SearchItemType.VIDEO
: SearchItemType.ARTICLE,
oid: controller.oid,
type: controller.type,
keyword: controller.editingController.text,
);
}
}

View File

@@ -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<ReplySearchChildPage> createState() => _ReplySearchChildPageState();
}
class _ReplySearchChildPageState extends State<ReplySearchChildPage>
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<List<SearchItem>?> 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;
}

View File

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

View File

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

View File

@@ -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<ReplySearchPage> createState() => _ReplySearchPageState();
}
class _ReplySearchPageState extends State<ReplySearchPage> {
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,
),
],
),
),
],
),
),
);
}
}

View File

@@ -134,30 +134,6 @@ class _SendDanmakuPanelState extends CommonTextPubPageState<SendDanmakuPanel> {
),
);
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<SendDanmakuPanel> {
@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;
}

View File

@@ -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),
],