From 3ec54868d02f538e1ef549c45225ba05aa72aacb Mon Sep 17 00:00:00 2001 From: dom Date: Sun, 5 Apr 2026 12:10:18 +0800 Subject: [PATCH] show reserve btn in space page Signed-off-by: dom --- lib/http/api.dart | 4 + lib/http/user.dart | 19 +++ lib/pages/live_room/view.dart | 2 +- lib/pages/member/controller.dart | 5 + lib/pages/member/view.dart | 149 ++++++++++++++++++++ lib/pages/member/widget/reserve_button.dart | 126 +++++++++++++++++ 6 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 lib/pages/member/widget/reserve_button.dart diff --git a/lib/http/api.dart b/lib/http/api.dart index fdba2b4ce..e98b163cc 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -830,6 +830,10 @@ abstract final class Api { static const String dynReserve = '/x/dynamic/feed/reserve/click'; + static const String spaceReserve = '/x/space/reserve'; + + static const String spaceReserveCancel = '/x/space/reserve/cancel'; + static const String favPugv = '/pugv/app/web/favorite/page'; static const String addFavPugv = '/pugv/app/web/favorite/add'; diff --git a/lib/http/user.dart b/lib/http/user.dart index 626a5c547..a5110789c 100644 --- a/lib/http/user.dart +++ b/lib/http/user.dart @@ -571,4 +571,23 @@ abstract final class UserHttp { return Error(res.data['message']); } } + + static Future> spaceReserve({ + required Object sid, + required bool isFollow, + }) async { + final res = await Request().post( + isFollow ? Api.spaceReserveCancel : Api.spaceReserve, + data: { + 'sid': sid, + 'csrf': Accounts.main.csrf, + }, + options: Options(contentType: Headers.formUrlEncodedContentType), + ); + if (res.data['code'] == 0) { + return const Success(null); + } else { + return Error(res.data['message']); + } + } } diff --git a/lib/pages/live_room/view.dart b/lib/pages/live_room/view.dart index aedbfacd4..eccce7f92 100644 --- a/lib/pages/live_room/view.dart +++ b/lib/pages/live_room/view.dart @@ -831,7 +831,7 @@ class _LiveRoomPageState extends State ), ), Positioned( - right: -12, + left: 30, top: -12, child: Obx(() { final likeClickTime = diff --git a/lib/pages/member/controller.dart b/lib/pages/member/controller.dart index 9dd5d62f3..580cf69f2 100644 --- a/lib/pages/member/controller.dart +++ b/lib/pages/member/controller.dart @@ -9,6 +9,7 @@ import 'package:PiliPlus/models/model_owner.dart'; import 'package:PiliPlus/models_new/space/space/data.dart'; import 'package:PiliPlus/models_new/space/space/elec.dart'; import 'package:PiliPlus/models_new/space/space/live.dart'; +import 'package:PiliPlus/models_new/space/space/reservation_card_list.dart'; import 'package:PiliPlus/models_new/space/space/setting.dart'; import 'package:PiliPlus/models_new/space/space/tab2.dart'; import 'package:PiliPlus/pages/common/common_data_controller.dart'; @@ -53,6 +54,8 @@ class MemberController extends CommonDataController Object? guardCount; bool get hasGuard => guards?.isNotEmpty ?? false; + List? reserves; + final fromViewAid = Get.parameters['from_view_aid']; final key = GlobalKey(); @@ -78,6 +81,8 @@ class MemberController extends CommonDataController guards = guard?.item; guardCount = guard?.count; + reserves = data.reservationCardList; + if (data.relation == -1) { relation.value = 128; } else { diff --git a/lib/pages/member/view.dart b/lib/pages/member/view.dart index 0604e55ca..6c9eb5a3c 100644 --- a/lib/pages/member/view.dart +++ b/lib/pages/member/view.dart @@ -1,13 +1,17 @@ import 'dart:io' show Platform; +import 'dart:math' as math; +import 'package:PiliPlus/common/style.dart'; import 'package:PiliPlus/common/widgets/dialog/report_member.dart'; import 'package:PiliPlus/common/widgets/dynamic_sliver_app_bar/dynamic_sliver_app_bar.dart'; +import 'package:PiliPlus/common/widgets/gesture/tap_gesture_recognizer.dart'; import 'package:PiliPlus/common/widgets/loading_widget/loading_widget.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart'; import 'package:PiliPlus/http/live.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/user.dart'; import 'package:PiliPlus/models_new/live/live_medal_wall/data.dart'; +import 'package:PiliPlus/models_new/space/space/reservation_card_list.dart'; import 'package:PiliPlus/pages/coin_log/controller.dart'; import 'package:PiliPlus/pages/exp_log/controller.dart'; import 'package:PiliPlus/pages/log_table/view.dart'; @@ -15,6 +19,7 @@ import 'package:PiliPlus/pages/login_devices/view.dart'; import 'package:PiliPlus/pages/login_log/controller.dart'; import 'package:PiliPlus/pages/member/controller.dart'; import 'package:PiliPlus/pages/member/widget/medal_wall.dart'; +import 'package:PiliPlus/pages/member/widget/reserve_button.dart'; import 'package:PiliPlus/pages/member/widget/user_info_card.dart'; import 'package:PiliPlus/pages/member_cheese/view.dart'; import 'package:PiliPlus/pages/member_contribute/controller.dart'; @@ -27,6 +32,7 @@ import 'package:PiliPlus/pages/member_shop/view.dart'; import 'package:PiliPlus/pages/member_video_web/archive/view.dart'; import 'package:PiliPlus/pages/member_video_web/season_series/view.dart'; import 'package:PiliPlus/utils/date_utils.dart'; +import 'package:PiliPlus/utils/extension/context_ext.dart'; import 'package:PiliPlus/utils/page_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; @@ -156,7 +162,150 @@ class _MemberPageState extends State { ); } + Widget _reserveBtn(List list, ColorScheme theme) { + return IconButton( + tooltip: '预约', + onPressed: () => _showReserveList(list), + icon: ReserveButton( + count: list.length, + color: theme.onSurfaceVariant, + child: const Icon(Icons.notifications_none), + ), + ); + } + + void _showReserveList(List list) { + showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + constraints: BoxConstraints( + maxWidth: math.min(640, context.mediaQueryShortestSide), + ), + builder: (context) { + final scheme = ColorScheme.of(context); + return Padding( + padding: .only(bottom: MediaQuery.viewPaddingOf(context).bottom + 30), + child: Column( + mainAxisSize: .min, + children: [ + InkWell( + onTap: Get.back, + borderRadius: Style.bottomSheetRadius, + child: SizedBox( + height: 35, + child: Center( + child: Container( + width: 32, + height: 3, + decoration: BoxDecoration( + color: scheme.outline, + borderRadius: const .all(.circular(1.5)), + ), + ), + ), + ), + ), + ...list.map((e) { + return Builder( + builder: (context) { + return ListTile( + dense: true, + title: Text( + e.name!, + style: const TextStyle(fontSize: 14), + ), + subtitle: Padding( + padding: const .only(top: 2.0), + child: Text.rich( + style: TextStyle(fontSize: 12, color: scheme.outline), + TextSpan( + children: [ + TextSpan(text: '${e.descText1} ${e.total}人预约'), + if (e.lotteryPrizeInfo case final lottery?) ...[ + const TextSpan(text: '\n'), + WidgetSpan( + alignment: .middle, + child: Icon( + size: 15, + Icons.card_giftcard, + color: scheme.primary, + ), + ), + TextSpan( + text: ' ${lottery.text}', + style: TextStyle( + fontSize: 12, + color: scheme.primary, + ), + recognizer: + lottery.jumpUrl?.isNotEmpty == true + ? (NoDeadlineTapGestureRecognizer() + ..onTap = () => Get.toNamed( + '/webview', + parameters: { + 'url': lottery.jumpUrl!, + }, + )) + : null, + ), + ], + ], + ), + ), + ), + trailing: FilledButton.tonal( + onPressed: () async { + final isFollow = e.isFollow; + final res = await UserHttp.spaceReserve( + sid: e.sid!, + isFollow: isFollow, + ); + if (res.isSuccess) { + if (!context.mounted) return; + e + ..total += isFollow ? -1 : 1 + ..isFollow = !isFollow; + (context as Element).markNeedsBuild(); + } else { + res.toast(); + } + }, + style: FilledButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: .all(.circular(6)), + ), + backgroundColor: e.isFollow + ? scheme.onInverseSurface + : null, + foregroundColor: e.isFollow ? scheme.outline : null, + visualDensity: const VisualDensity( + horizontal: -2, + vertical: -3, + ), + tapTargetSize: .shrinkWrap, + padding: const .symmetric(horizontal: 10), + minimumSize: const Size(68, 40), + ), + child: Text( + '${e.isFollow ? '已' : ''}预约', + style: const TextStyle(fontSize: 13), + ), + ), + ); + }, + ); + }), + ], + ), + ); + }, + ); + } + List _actions(ColorScheme theme) => [ + if (_userController.reserves?.isNotEmpty ?? false) + _reserveBtn(_userController.reserves!, theme), IconButton( tooltip: '搜索', onPressed: () => Get.toNamed( diff --git a/lib/pages/member/widget/reserve_button.dart b/lib/pages/member/widget/reserve_button.dart new file mode 100644 index 000000000..c19649f87 --- /dev/null +++ b/lib/pages/member/widget/reserve_button.dart @@ -0,0 +1,126 @@ +/* + * This file is part of PiliPlus + * + * PiliPlus is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * PiliPlus is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with PiliPlus. If not, see . + */ + +import 'package:flutter/rendering.dart' show RenderProxyBox; +import 'package:flutter/widgets.dart'; + +class ReserveButton extends SingleChildRenderObjectWidget { + const ReserveButton({ + super.key, + required this.count, + required this.color, + required Widget super.child, + }); + + final int count; + final Color color; + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderReserveBtn(count: count, color: color); + } + + @override + void updateRenderObject(BuildContext context, RenderReserveBtn renderObject) { + renderObject + ..color = color + ..count = count; + } +} + +class RenderReserveBtn extends RenderProxyBox { + RenderReserveBtn({ + required int count, + required Color color, + }) : _count = count, + _color = color { + _textPainter = TextPainter( + textDirection: .ltr, + text: _getTextSpan(count), + )..layout(); + } + + int _count; + int get count => _count; + set count(int value) { + if (_count == value) return; + _count = value; + _updateTextSpan(); + markNeedsPaint(); + } + + Color _color; + Color get color => _color; + set color(Color value) { + if (_color == value) return; + _color = value; + _updateTextSpan(); + markNeedsPaint(); + } + + late final TextPainter _textPainter; + + void _updateTextSpan() { + _textPainter + ..text = _getTextSpan(_count) + ..layout(); + } + + TextSpan _getTextSpan(int count) { + return TextSpan( + text: count.toString(), + style: TextStyle( + height: 1, + fontSize: 12, + color: _color, + fontWeight: .bold, + ), + ); + } + + @override + void paint(PaintingContext context, Offset offset) { + final size = this.size; + final dx = offset.dx; + final dy = offset.dy; + final width = dx + size.width; + final height = dy + size.height; + final offsetDx = dx + 13.0; + final offsetDy = dy + 14.0; + final path = Path() + ..moveTo(dx, dy) + ..lineTo(offsetDx, dy) + ..lineTo(offsetDx, offsetDy) + ..lineTo(width, offsetDy) + ..lineTo(width, height) + ..lineTo(dx, height) + ..close(); + final canvas = context.canvas + ..save() + ..clipPath(path); + context.paintChild(child!, offset); + canvas.restore(); + + _textPainter.paint(canvas, Offset(offset.dx + 15.0, offset.dy)); + } + + @override + void dispose() { + _textPainter.dispose(); + super.dispose(); + } +}