diff --git a/assets/fonts/custom_icon.ttf b/assets/fonts/custom_icon.ttf index a95e03e9..387f5219 100644 Binary files a/assets/fonts/custom_icon.ttf and b/assets/fonts/custom_icon.ttf differ diff --git a/lib/common/widgets/custom_icon.dart b/lib/common/widgets/custom_icon.dart index aa610f1b..10bc178a 100644 --- a/lib/common/widgets/custom_icon.dart +++ b/lib/common/widgets/custom_icon.dart @@ -13,13 +13,15 @@ class CustomIcon { 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 thumbs_down = _CustomIconData(0xe80a); - static const IconData thumbs_down_outline = _CustomIconData(0xe80b); - static const IconData thumbs_up = _CustomIconData(0xe80c); - static const IconData thumbs_up_line = _CustomIconData(0xe80d); - static const IconData thumbs_up_outline = _CustomIconData(0xe80e); - static const IconData topic_tag = _CustomIconData(0xe80f); - static const IconData watch_later = _CustomIconData(0xe810); + 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); } class _CustomIconData extends IconData { diff --git a/lib/http/api.dart b/lib/http/api.dart index 1460ae5c..086c2aa0 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -855,4 +855,14 @@ class Api { static const String delFavTopic = '/x/topic/fav/sub/cancel'; static const String likeTopic = '/x/topic/like'; + + static const String pgcReviewL = '/pgc/review/long/list'; + + static const String pgcReviewS = '/pgc/review/short/list'; + + static const String pgcReviewLike = '/pgc/review/action/like'; + + static const String pgcReviewDislike = '/pgc/review/action/dislike'; + + static const String pgcReviewPost = '/pgc/review/short/post'; } diff --git a/lib/http/bangumi.dart b/lib/http/bangumi.dart index 581dacb9..3f489068 100644 --- a/lib/http/bangumi.dart +++ b/lib/http/bangumi.dart @@ -3,8 +3,12 @@ import 'package:PiliPlus/http/init.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models/bangumi/list.dart'; import 'package:PiliPlus/models/bangumi/pgc_index/condition.dart'; +import 'package:PiliPlus/models/bangumi/pgc_review/data.dart'; import 'package:PiliPlus/models/bangumi/pgc_timeline/pgc_timeline.dart'; import 'package:PiliPlus/models/bangumi/pgc_timeline/result.dart'; +import 'package:PiliPlus/models/common/pgc_review_type.dart'; +import 'package:PiliPlus/utils/storage.dart' show Accounts; +import 'package:dio/dio.dart'; class BangumiHttp { static Future pgcIndexResult({ @@ -107,4 +111,93 @@ class BangumiHttp { return Error(res.data['message']); } } + + static Future> pgcReview({ + required PgcReviewType type, + required mediaId, + int sort = 0, + String? next, + }) async { + var res = await Request().get( + type.api, + queryParameters: { + 'media_id': mediaId, + 'ps': 20, + 'sort': sort, + if (next != null) 'cursor': next, + 'web_location': 666.19, + }, + ); + if (res.data['code'] == 0) { + return Success(PgcReviewData.fromJson(res.data['data'])); + } else { + return Error(res.data['message']); + } + } + + static Future pgcReviewLike({ + required mediaId, + required reviewId, + }) async { + var res = await Request().post( + Api.pgcReviewLike, + data: { + 'media_id': mediaId, + 'review_type': 2, + 'review_id': reviewId, + 'csrf': Accounts.main.csrf, + }, + options: Options(contentType: Headers.formUrlEncodedContentType), + ); + if (res.data['code'] == 0) { + return {'status': true}; + } else { + return {'status': false, 'msg': res.data['message']}; + } + } + + static Future pgcReviewDislike({ + required mediaId, + required reviewId, + }) async { + var res = await Request().post( + Api.pgcReviewDislike, + data: { + 'media_id': mediaId, + 'review_type': 2, + 'review_id': reviewId, + 'csrf': Accounts.main.csrf, + }, + options: Options(contentType: Headers.formUrlEncodedContentType), + ); + if (res.data['code'] == 0) { + return {'status': true}; + } else { + return {'status': false, 'msg': res.data['message']}; + } + } + + static Future pgcReviewPost({ + required mediaId, + required int score, + required String content, + bool shareFeed = false, + }) async { + var res = await Request().post( + Api.pgcReviewPost, + data: { + 'media_id': mediaId, + 'score': score, + 'content': content, + if (shareFeed) 'share_feed': 1, + 'csrf': Accounts.main.csrf, + }, + options: Options(contentType: Headers.formUrlEncodedContentType), + ); + if (res.data['code'] == 0) { + return {'status': true}; + } else { + return {'status': false, 'msg': res.data['message']}; + } + } } diff --git a/lib/models/bangumi/pgc_review/author.dart b/lib/models/bangumi/pgc_review/author.dart new file mode 100644 index 00000000..789e9a04 --- /dev/null +++ b/lib/models/bangumi/pgc_review/author.dart @@ -0,0 +1,27 @@ +import 'package:PiliPlus/models/model_avatar.dart' show Vip; + +class Author { + String? avatar; + int? level; + int? mid; + String? uname; + Vip? vip; + + Author({ + this.avatar, + this.level, + this.mid, + this.uname, + this.vip, + }); + + factory Author.fromJson(Map json) => Author( + avatar: json['avatar'] as String?, + level: json['level'] as int?, + mid: json['mid'] as int?, + uname: json['uname'] as String?, + vip: json['vip'] == null + ? null + : Vip.fromJson(json['vip'] as Map), + ); +} diff --git a/lib/models/bangumi/pgc_review/data.dart b/lib/models/bangumi/pgc_review/data.dart new file mode 100644 index 00000000..fb6b10d4 --- /dev/null +++ b/lib/models/bangumi/pgc_review/data.dart @@ -0,0 +1,17 @@ +import 'package:PiliPlus/models/bangumi/pgc_review/list.dart'; + +class PgcReviewData { + List? list; + String? next; + int? count; + + PgcReviewData({this.list, this.next, this.count}); + + factory PgcReviewData.fromJson(Map json) => PgcReviewData( + list: (json['list'] as List?) + ?.map((e) => PgcReviewItemModel.fromJson(e as Map)) + .toList(), + next: json['next'] as String?, + count: json['count'] as int?, + ); +} diff --git a/lib/models/bangumi/pgc_review/list.dart b/lib/models/bangumi/pgc_review/list.dart new file mode 100644 index 00000000..8344d36e --- /dev/null +++ b/lib/models/bangumi/pgc_review/list.dart @@ -0,0 +1,55 @@ +import 'package:PiliPlus/models/bangumi/pgc_review/author.dart'; +import 'package:PiliPlus/models/bangumi/pgc_review/stat.dart'; + +class PgcReviewItemModel { + Author? author; + String? title; + String? content; + int? ctime; + int? mediaId; + int? mid; + int? mtime; + String? progress; + String? pushTimeStr; + int? reviewId; + late int score; + Stat? stat; + int? articleId; + + PgcReviewItemModel({ + this.author, + this.title, + this.content, + this.ctime, + this.mediaId, + this.mid, + this.mtime, + this.progress, + this.pushTimeStr, + this.reviewId, + required this.score, + this.stat, + this.articleId, + }); + + factory PgcReviewItemModel.fromJson(Map json) => + PgcReviewItemModel( + articleId: json['article_id'], + author: json['author'] == null + ? null + : Author.fromJson(json['author'] as Map), + title: json['title'] as String?, + content: json['content'] as String?, + ctime: json['ctime'] as int?, + mediaId: json['media_id'] as int?, + mid: json['mid'] as int?, + mtime: json['mtime'] as int?, + progress: json['progress'] as String?, + pushTimeStr: json['push_time_str'] as String?, + reviewId: json['review_id'] as int?, + score: json['score'] == null ? 0 : json['score'] ~/ 2, + stat: json['stat'] == null + ? null + : Stat.fromJson(json['stat'] as Map), + ); +} diff --git a/lib/models/bangumi/pgc_review/stat.dart b/lib/models/bangumi/pgc_review/stat.dart new file mode 100644 index 00000000..3e1a0edc --- /dev/null +++ b/lib/models/bangumi/pgc_review/stat.dart @@ -0,0 +1,19 @@ +class Stat { + int? disliked; + int? liked; + int? likes; + + Stat({this.disliked, this.liked, this.likes}); + + factory Stat.fromJson(Map json) => Stat( + disliked: json['disliked'] as int?, + liked: json['liked'] as int?, + likes: json['likes'] as int?, + ); + + Map toJson() => { + 'disliked': disliked, + 'liked': liked, + 'likes': likes, + }; +} diff --git a/lib/models/common/pgc_review_type.dart b/lib/models/common/pgc_review_type.dart new file mode 100644 index 00000000..f1d056f5 --- /dev/null +++ b/lib/models/common/pgc_review_type.dart @@ -0,0 +1,13 @@ +import 'package:PiliPlus/http/api.dart'; + +enum PgcReviewType { + long(label: '长评', api: Api.pgcReviewL), + short(label: '短评', api: Api.pgcReviewS); + + final String label; + final String api; + const PgcReviewType({ + required this.label, + required this.api, + }); +} diff --git a/lib/pages/pgc_review/child/controller.dart b/lib/pages/pgc_review/child/controller.dart new file mode 100644 index 00000000..15231c6f --- /dev/null +++ b/lib/pages/pgc_review/child/controller.dart @@ -0,0 +1,89 @@ +import 'package:PiliPlus/http/bangumi.dart'; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/models/bangumi/pgc_review/data.dart'; +import 'package:PiliPlus/models/bangumi/pgc_review/list.dart'; +import 'package:PiliPlus/models/common/pgc_review_type.dart'; +import 'package:PiliPlus/pages/common/common_list_controller.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; + +class PgcReviewController + extends CommonListController { + PgcReviewController({required this.type, required this.mediaId}); + + final PgcReviewType type; + final dynamic mediaId; + + int? count; + String? next; + + @override + void onInit() { + super.onInit(); + queryData(); + } + + @override + Future onRefresh() { + count = null; + next = null; + return super.onRefresh(); + } + + @override + void checkIsEnd(int length) { + if (count != null && length >= count!) { + isEnd = true; + } + } + + @override + List? getDataList(PgcReviewData response) { + count = response.count; + next = response.next; + return response.list; + } + + @override + Future> customGetData() => BangumiHttp.pgcReview( + type: type, + mediaId: mediaId, + next: next, + ); + + Future onLike(int index, bool isLike, reviewId) async { + var res = await BangumiHttp.pgcReviewLike( + mediaId: mediaId, + reviewId: reviewId, + ); + if (res['status']) { + final item = loadingState.value.data![index]; + int likes = item.stat?.likes ?? 0; + item.stat + ?..liked = isLike ? 0 : 1 + ..likes = isLike ? likes - 1 : likes + 1; + if (!isLike) { + item.stat?.disliked = 0; + } + loadingState.refresh(); + } else { + SmartDialog.showToast(res['msg']); + } + } + + Future onDislike(int index, bool isDislike, reviewId) async { + var res = await BangumiHttp.pgcReviewDislike( + mediaId: mediaId, + reviewId: reviewId, + ); + if (res['status']) { + final item = loadingState.value.data![index]; + item.stat?.disliked = isDislike ? 0 : 1; + if (!isDislike) { + item.stat?.liked = 0; + } + loadingState.refresh(); + } else { + SmartDialog.showToast(res['msg']); + } + } +} diff --git a/lib/pages/pgc_review/child/view.dart b/lib/pages/pgc_review/child/view.dart new file mode 100644 index 00000000..dda1c0d8 --- /dev/null +++ b/lib/pages/pgc_review/child/view.dart @@ -0,0 +1,298 @@ +import 'package:PiliPlus/common/skeleton/video_reply.dart'; +import 'package:PiliPlus/common/widgets/custom_icon.dart'; +import 'package:PiliPlus/common/widgets/dialog/dialog.dart'; +import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart'; +import 'package:PiliPlus/common/widgets/refresh_indicator.dart'; +import 'package:PiliPlus/http/loading_state.dart'; +import 'package:PiliPlus/models/bangumi/pgc_review/list.dart'; +import 'package:PiliPlus/models/common/image_type.dart'; +import 'package:PiliPlus/models/common/pgc_review_type.dart'; +import 'package:PiliPlus/pages/pgc_review/child/controller.dart'; +import 'package:PiliPlus/utils/extension.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:get/get.dart'; + +class PgcReviewChildPage extends StatefulWidget { + const PgcReviewChildPage({ + super.key, + required this.type, + required this.mediaId, + }); + + final PgcReviewType type; + final dynamic mediaId; + + @override + State createState() => _PgcReviewChildPageState(); +} + +class _PgcReviewChildPageState extends State + with AutomaticKeepAliveClientMixin { + late final _controller = Get.put( + PgcReviewController(type: widget.type, mediaId: widget.mediaId), + tag: '${widget.mediaId}${widget.type.name}', + ); + late final isLongReview = widget.type == PgcReviewType.long; + + @override + void dispose() { + Get.delete( + tag: '${widget.mediaId}${widget.type.name}'); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + final theme = Theme.of(context); + return refreshIndicator( + onRefresh: _controller.onRefresh, + child: CustomScrollView( + controller: _controller.scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom + 80), + sliver: + Obx(() => _buildBody(theme, _controller.loadingState.value)), + ), + ], + ), + ); + } + + Widget _buildBody( + ThemeData theme, LoadingState?> loadingState) { + return switch (loadingState) { + Loading() => SliverToBoxAdapter( + child: ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return const VideoReplySkeleton(); + }, + itemCount: 8, + ), + ), + Success(:var response) => response?.isNotEmpty == true + ? SliverList.separated( + itemBuilder: (context, index) { + if (index == response.length - 1) { + _controller.onLoadMore(); + } + return _itemWidget(theme, index, response[index]); + }, + itemCount: response!.length, + separatorBuilder: (context, index) => Divider( + height: 1, + color: theme.colorScheme.outline.withValues(alpha: 0.1), + ), + ) + : HttpError(onReload: _controller.onReload), + Error(:var errMsg) => HttpError( + errMsg: errMsg, + onReload: _controller.onReload, + ), + }; + } + + Widget _itemWidget(ThemeData theme, int index, PgcReviewItemModel item) { + return InkWell( + onTap: isLongReview + ? () => Get.toNamed( + '/articlePage', + parameters: { + 'id': item.articleId!.toString(), + 'type': 'read', + }, + ) + : null, + onLongPress: isLongReview + ? null + : () => showConfirmDialog( + context: context, + title: '确定举报该点评?', + onConfirm: () => Get.toNamed( + '/webview', + parameters: { + 'url': + 'https://www.bilibili.com/appeal/?reviewId=${item.reviewId}&type=shortComment&mediaId=${widget.mediaId}' + }, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Get.toNamed('/member?mid=${item.author!.mid}'), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + NetworkImgLayer( + height: 34, + width: 34, + src: item.author!.avatar, + type: ImageType.avatar, + ), + const SizedBox(width: 10), + Column( + spacing: 2, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 6, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.author!.uname!, + style: TextStyle( + color: item.author?.vip?.status != null && + item.author!.vip!.status > 0 && + item.author!.vip!.type == 2 + ? context.vipColor + : theme.colorScheme.outline, + fontSize: 13, + ), + ), + Image.asset( + 'assets/images/lv/lv${item.author!.level}.png', + height: 11, + ), + ], + ), + Row( + children: [ + if (item.pushTimeStr != null) ...[ + Text( + item.pushTimeStr!, + style: TextStyle( + color: theme.colorScheme.outline, + fontSize: 12, + ), + ), + const SizedBox(width: 10), + ], + ...List.generate( + 5, + (index) { + if (index <= item.score - 1) { + return const Icon( + CustomIcon.star_favorite_solid, + size: 13, + color: Color(0xFFFFAD35), + ); + } + return const Icon( + CustomIcon.star_favorite_line, + size: 14, + color: Colors.grey, + ); + }, + ), + ], + ) + ], + ) + ], + ), + ), + const SizedBox(height: 5), + if (item.title != null) + Text( + item.title!, + style: const TextStyle( + height: 1.75, + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + if (isLongReview) + Text( + item.content!, + style: const TextStyle(height: 1.75), + ) + else + SelectableText( + item.content!, + style: const TextStyle(height: 1.75), + ), + Builder( + builder: (context) { + final Color color = theme.colorScheme.outline; + final Color primary = theme.colorScheme.primary; + final ButtonStyle style = TextButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); + final isLike = item.stat?.liked == 1; + late final isDislike = item.stat?.disliked == 1; + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (!isLongReview) + SizedBox( + height: 32, + child: TextButton( + style: style, + onPressed: () => _controller.onDislike( + index, isDislike, item.reviewId), + child: Icon( + isDislike + ? FontAwesomeIcons.solidThumbsDown + : FontAwesomeIcons.thumbsDown, + size: 16, + color: isDislike ? primary : color, + ), + ), + ), + SizedBox( + height: 32, + child: TextButton( + style: style, + onPressed: isLongReview + ? null + : () => _controller.onLike( + index, isLike, item.reviewId), + child: Row( + spacing: 4, + children: [ + Icon( + isLike + ? FontAwesomeIcons.solidThumbsUp + : FontAwesomeIcons.thumbsUp, + size: 16, + color: isLike ? primary : color, + ), + Text( + Utils.numFormat(item.stat?.likes ?? 0), + style: TextStyle( + color: isLike ? primary : color, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/pages/pgc_review/post/view.dart b/lib/pages/pgc_review/post/view.dart new file mode 100644 index 00000000..08e8f9f2 --- /dev/null +++ b/lib/pages/pgc_review/post/view.dart @@ -0,0 +1,221 @@ +import 'package:PiliPlus/common/widgets/button/icon_button.dart'; +import 'package:PiliPlus/common/widgets/custom_icon.dart'; +import 'package:PiliPlus/http/bangumi.dart'; +import 'package:PiliPlus/pages/common/common_collapse_slide_page.dart'; +import 'package:PiliPlus/utils/storage.dart' show Accounts; +import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; + +class PgcReviewPostPanel extends CommonCollapseSlidePage { + const PgcReviewPostPanel({ + super.key, + required this.name, + required this.mediaId, + }); + + final String name; + final dynamic mediaId; + + @override + State createState() => _PgcReviewPostPanelState(); +} + +class _PgcReviewPostPanelState + extends CommonCollapseSlidePageState { + final _controller = TextEditingController(); + final RxInt _score = 0.obs; + final RxBool _shareFeed = false.obs; + final RxBool _enablePost = false.obs; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget buildPage(ThemeData theme) { + final theme = Theme.of(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 45, + child: AppBar( + backgroundColor: Colors.transparent, + automaticallyImplyLeading: false, + titleSpacing: 16, + toolbarHeight: 45, + title: Text(widget.name), + actions: [ + iconButton( + context: context, + icon: Icons.clear, + onPressed: Get.back, + iconSize: 22, + bgColor: Colors.transparent, + ), + const SizedBox(width: 12), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Divider( + height: 1, + color: theme.colorScheme.outline.withValues(alpha: 0.1), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20, bottom: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + 5, + (index) { + return Obx( + () => GestureDetector( + onTap: () { + _enablePost.value = true; + _score.value = index + 1; + }, + behavior: HitTestBehavior.opaque, + child: index <= _score.value - 1 + ? const Icon( + CustomIcon.star_favorite_solid, + size: 50, + color: Color(0xFFFFAD35), + ) + : const Icon( + CustomIcon.star_favorite_line, + size: 50, + color: Colors.grey, + ), + ), + ); + }, + ), + ), + ), + SizedBox( + width: double.infinity, + child: Obx( + () => Text( + switch (_score.value) { + 1 => '很差', + 2 => '较差', + 3 => '还行', + 4 => '很好', + 5 => '佳作', + _ => '轻触评分', + }, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: _score.value == 0 + ? theme.colorScheme.outline + : const Color(0xFFFFAD35), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + maxLength: 100, + minLines: 5, + maxLines: 5, + controller: _controller, + decoration: const InputDecoration( + border: OutlineInputBorder(), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _shareFeed.value = !_shareFeed.value, + child: Obx( + () { + Color color = _shareFeed.value + ? theme.colorScheme.primary + : theme.colorScheme.outline; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + size: 22, + _shareFeed.value + ? Icons.check_box_outlined + : Icons.check_box_outline_blank_outlined, + color: color, + ), + Text( + ' 分享到动态', + style: TextStyle(color: color), + ), + ], + ); + }, + ), + ), + ), + Container( + padding: EdgeInsets.only( + left: 12, + right: 12, + top: 6, + bottom: MediaQuery.paddingOf(context).bottom + + MediaQuery.viewInsetsOf(context).bottom + + 6, + ), + width: double.infinity, + decoration: BoxDecoration( + color: theme.colorScheme.onInverseSurface, + border: Border( + top: BorderSide( + width: 0.5, + color: theme.colorScheme.outline.withValues(alpha: 0.1), + ), + ), + ), + child: Obx( + () => FilledButton.tonal( + style: FilledButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: EdgeInsets.zero, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(6)), + ), + ), + onPressed: _enablePost.value + ? () async { + if (!Accounts.main.isLogin) { + SmartDialog.showToast('账号未登录'); + return; + } + var res = await BangumiHttp.pgcReviewPost( + mediaId: widget.mediaId, + score: _score.value * 2, + content: _controller.text, + shareFeed: _shareFeed.value, + ); + if (res['status']) { + Get.back(); + SmartDialog.showToast('点评成功'); + } else { + SmartDialog.showToast(res['msg']); + } + } + : null, + child: const Text('发布'), + ), + ), + ), + ], + ); + } +} diff --git a/lib/pages/pgc_review/view.dart b/lib/pages/pgc_review/view.dart new file mode 100644 index 00000000..998dcb60 --- /dev/null +++ b/lib/pages/pgc_review/view.dart @@ -0,0 +1,160 @@ +import 'package:PiliPlus/models/common/pgc_review_type.dart'; +import 'package:PiliPlus/pages/pgc_review/child/controller.dart'; +import 'package:PiliPlus/pages/pgc_review/child/view.dart'; +import 'package:PiliPlus/pages/pgc_review/post/view.dart'; +import 'package:PiliPlus/utils/extension.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class PgcReviewPage extends StatefulWidget { + const PgcReviewPage({ + super.key, + required this.name, + required this.mediaId, + }); + + final String name; + final dynamic mediaId; + + @override + State createState() => _PgcReviewPageState(); +} + +class _PgcReviewPageState extends State + with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { + late final _tabController = + TabController(length: PgcReviewType.values.length, vsync: this); + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + final theme = Theme.of(context); + return Stack( + clipBehavior: Clip.none, + children: [ + Column( + children: [ + SizedBox( + width: double.infinity, + child: TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + dividerHeight: 0, + indicatorWeight: 0, + overlayColor: WidgetStateProperty.all(Colors.transparent), + splashFactory: NoSplash.splashFactory, + indicatorPadding: + const EdgeInsets.symmetric(horizontal: 3, vertical: 8), + indicator: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: const BorderRadius.all(Radius.circular(20)), + ), + indicatorSize: TabBarIndicatorSize.tab, + labelColor: theme.colorScheme.onSecondaryContainer, + unselectedLabelColor: theme.colorScheme.outline, + labelStyle: TabBarTheme.of(context) + .labelStyle + ?.copyWith(fontSize: 13) ?? + const TextStyle(fontSize: 13), + dividerColor: Colors.transparent, + tabs: PgcReviewType.values + .map((e) => Tab(text: e.label)) + .toList(), + onTap: (index) { + try { + if (!_tabController.indexIsChanging) { + final item = PgcReviewType.values[index]; + Get.find( + tag: '${widget.mediaId}${item.name}') + .scrollController + .animToTop(); + } + } catch (_) {} + }, + ), + ), + Expanded( + child: Material( + color: Colors.transparent, + child: TabBarView( + controller: _tabController, + physics: const NeverScrollableScrollPhysics(), + children: PgcReviewType.values + .map((e) => + PgcReviewChildPage(type: e, mediaId: widget.mediaId)) + .toList(), + ), + ), + ), + ], + ), + Positioned( + right: 16, + bottom: MediaQuery.paddingOf(context).bottom + 16, + child: FloatingActionButton( + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog( + clipBehavior: Clip.hardEdge, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + dense: true, + title: const Text( + '写短评', + style: TextStyle(fontSize: 14), + ), + onTap: () { + Get.back(); + showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + builder: (context) { + return PgcReviewPostPanel( + name: widget.name, + mediaId: widget.mediaId, + ); + }, + ); + }, + ), + ListTile( + dense: true, + title: const Text( + '写长评', + style: TextStyle(fontSize: 14), + ), + onTap: () => Get + ..back() + ..toNamed( + '/webview', + parameters: { + 'url': + 'https://member.bilibili.com/article-text/mobile?theme=${Get.isDarkMode ? 1 : 0}&media_id=${widget.mediaId}' + }, + ), + ), + ], + ), + ), + ), + child: const Icon(Icons.edit), + ), + ), + ], + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/pages/video/introduction/pgc/widgets/intro_detail.dart b/lib/pages/video/introduction/pgc/widgets/intro_detail.dart index 7bdde455..d042359d 100644 --- a/lib/pages/video/introduction/pgc/widgets/intro_detail.dart +++ b/lib/pages/video/introduction/pgc/widgets/intro_detail.dart @@ -1,6 +1,9 @@ +import 'package:PiliPlus/common/widgets/button/icon_button.dart'; +import 'package:PiliPlus/common/widgets/scroll_physics.dart'; import 'package:PiliPlus/common/widgets/stat/stat.dart'; import 'package:PiliPlus/models/bangumi/info.dart'; import 'package:PiliPlus/pages/common/common_collapse_slide_page.dart'; +import 'package:PiliPlus/pages/pgc_review/view.dart'; import 'package:PiliPlus/pages/search/widgets/search_text.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart'; @@ -13,6 +16,7 @@ class IntroDetail extends CommonCollapseSlidePage { const IntroDetail({ super.key, required this.bangumiDetail, + super.enableSlide = false, this.videoTags, }); @@ -21,32 +25,54 @@ class IntroDetail extends CommonCollapseSlidePage { } class _IntroDetailState extends CommonCollapseSlidePageState { + late final _tabController = TabController(length: 2, vsync: this); + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + @override Widget buildPage(ThemeData theme) { return Material( color: theme.colorScheme.surface, child: Column( children: [ - GestureDetector( - onTap: Get.back, - behavior: HitTestBehavior.opaque, - child: Container( - height: 35, - alignment: Alignment.center, - padding: const EdgeInsets.only(bottom: 2), - child: Container( - width: 32, - height: 3, - decoration: BoxDecoration( - color: theme.colorScheme.onSecondaryContainer - .withValues(alpha: 0.5), - borderRadius: const BorderRadius.all(Radius.circular(3))), + Row( + children: [ + Expanded( + child: TabBar( + controller: _tabController, + dividerHeight: 0, + isScrollable: true, + tabAlignment: TabAlignment.start, + dividerColor: Colors.transparent, + tabs: const [Tab(text: '详情'), Tab(text: '点评')], + ), ), - ), + iconButton( + context: context, + icon: Icons.clear, + onPressed: Get.back, + iconSize: 22, + bgColor: Colors.transparent, + ), + const SizedBox(width: 12), + ], ), Expanded( - child: enableSlide ? slideList(theme) : buildList(theme), - ) + child: tabBarView( + controller: _tabController, + children: [ + buildList(theme), + PgcReviewPage( + name: widget.bangumiDetail.title!, + mediaId: widget.bangumiDetail.mediaId, + ), + ], + ), + ), ], ), ); @@ -64,6 +90,7 @@ class _IntroDetailState extends CommonCollapseSlidePageState { padding: EdgeInsets.only( left: 14, right: 14, + top: 14, bottom: MediaQuery.paddingOf(context).bottom + 80, ), children: [ diff --git a/lib/pages/video/note/view.dart b/lib/pages/video/note/view.dart index 31d89e08..d6d53e6b 100644 --- a/lib/pages/video/note/view.dart +++ b/lib/pages/video/note/view.dart @@ -174,7 +174,7 @@ class _NoteListPageState extends CommonSlidePageState { if (index == response.length - 1) { _controller.onLoadMore(); } - return _itemWidget(context, theme, response[index]); + return _itemWidget(theme, response[index]); }, itemCount: response!.length, separatorBuilder: (context, index) => Divider( @@ -189,91 +189,92 @@ class _NoteListPageState extends CommonSlidePageState { ), }; } -} -Widget _itemWidget(BuildContext context, ThemeData theme, dynamic item) { - return InkWell( - onTap: () { - if (item['web_url'] != null && item['web_url'] != '') { - PiliScheme.routePushFromUrl(item['web_url']); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: () => Get.toNamed('/member?mid=${item['author']['mid']}'), - child: NetworkImgLayer( - height: 34, - width: 34, - src: item['author']['face'], - type: ImageType.avatar, + Widget _itemWidget(ThemeData theme, dynamic item) { + return InkWell( + onTap: () { + if (item['web_url'] != null && item['web_url'] != '') { + PiliScheme.routePushFromUrl(item['web_url']); + } + }, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => Get.toNamed('/member?mid=${item['author']['mid']}'), + child: NetworkImgLayer( + height: 34, + width: 34, + src: item['author']['face'], + type: ImageType.avatar, + ), ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: () => - Get.toNamed('/member?mid=${item['author']['mid']}'), - child: Row( - children: [ - Text( - item['author']['name'], - style: TextStyle( - color: (item['author']?['vip_info']?['status'] ?? 0) > - 0 && - item['author']?['vip_info']?['type'] == 2 - ? context.vipColor - : theme.colorScheme.outline, - fontSize: 13, + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () => + Get.toNamed('/member?mid=${item['author']['mid']}'), + child: Row( + children: [ + Text( + item['author']['name'], + style: TextStyle( + color: (item['author']?['vip_info']?['status'] ?? + 0) > + 0 && + item['author']?['vip_info']?['type'] == 2 + ? context.vipColor + : theme.colorScheme.outline, + fontSize: 13, + ), ), - ), - const SizedBox(width: 6), - Image.asset( - 'assets/images/lv/lv${item['author']['level']}.png', - height: 11, - ), - ], - ), - ), - const SizedBox(height: 4), - Text( - item['pubtime'], - style: TextStyle( - color: theme.colorScheme.outline, - fontSize: 12, - ), - ), - if (item['summary'] != null) ...[ - const SizedBox(height: 5), - Text( - item['summary'], - style: TextStyle( - height: 1.75, - fontSize: theme.textTheme.bodyMedium!.fontSize, + const SizedBox(width: 6), + Image.asset( + 'assets/images/lv/lv${item['author']['level']}.png', + height: 11, + ), + ], ), ), - if (item['web_url'] != null && item['web_url'] != '') + const SizedBox(height: 4), + Text( + item['pubtime'], + style: TextStyle( + color: theme.colorScheme.outline, + fontSize: 12, + ), + ), + if (item['summary'] != null) ...[ + const SizedBox(height: 5), Text( - '查看全部', + item['summary'], style: TextStyle( - color: theme.colorScheme.primary, height: 1.75, fontSize: theme.textTheme.bodyMedium!.fontSize, ), ), + if (item['web_url'] != null && item['web_url'] != '') + Text( + '查看全部', + style: TextStyle( + color: theme.colorScheme.primary, + height: 1.75, + fontSize: theme.textTheme.bodyMedium!.fontSize, + ), + ), + ], ], - ], + ), ), - ), - ], + ], + ), ), - ), - ); + ); + } } diff --git a/lib/pages/video/reply/widgets/zan_grpc.dart b/lib/pages/video/reply/widgets/zan_grpc.dart index 8c1b3bc1..4eaa094d 100644 --- a/lib/pages/video/reply/widgets/zan_grpc.dart +++ b/lib/pages/video/reply/widgets/zan_grpc.dart @@ -94,24 +94,23 @@ class _ZanButtonGrpcState extends State { } } - ButtonStyle get _style => TextButton.styleFrom( - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ); - @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); final Color color = theme.colorScheme.outline; final Color primary = theme.colorScheme.primary; + final ButtonStyle style = TextButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); return Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 32, child: TextButton( - style: _style, + style: style, onPressed: () => handleState(onHateReply), child: Icon( isDislike @@ -126,7 +125,7 @@ class _ZanButtonGrpcState extends State { SizedBox( height: 32, child: TextButton( - style: _style, + style: style, onPressed: () => handleState(onLikeReply), child: Row( children: [ diff --git a/lib/pages/video/view.dart b/lib/pages/video/view.dart index ea2ad5a2..9933de71 100644 --- a/lib/pages/video/view.dart +++ b/lib/pages/video/view.dart @@ -2071,7 +2071,7 @@ class _VideoDetailPageVState extends State ); } - void showIntroDetail(videoDetail, videoTags) { + void showIntroDetail(bangumi.BangumiInfoModel videoDetail, videoTags) { videoDetailController.childKey.currentState?.showBottomSheet( backgroundColor: Colors.transparent, (context) => bangumi.IntroDetail( diff --git a/lib/utils/request_utils.dart b/lib/utils/request_utils.dart index 3647f678..1c070d54 100644 --- a/lib/utils/request_utils.dart +++ b/lib/utils/request_utils.dart @@ -157,7 +157,8 @@ class RequestUtils { context: context, useSafeArea: true, isScrollControlled: true, - sheetAnimationStyle: const AnimationStyle(curve: Curves.ease), + sheetAnimationStyle: + const AnimationStyle(curve: Curves.ease), constraints: BoxConstraints( maxWidth: min(640, min(Get.width, Get.height)), ),