feat: pgc review

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-05-24 18:17:57 +08:00
parent 8e1b2be073
commit 70164fa3f7
18 changed files with 1140 additions and 108 deletions

Binary file not shown.

View File

@@ -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 {

View File

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

View File

@@ -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<LoadingState> pgcIndexResult({
@@ -107,4 +111,93 @@ class BangumiHttp {
return Error(res.data['message']);
}
}
static Future<LoadingState<PgcReviewData>> 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']};
}
}
}

View File

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

View File

@@ -0,0 +1,17 @@
import 'package:PiliPlus/models/bangumi/pgc_review/list.dart';
class PgcReviewData {
List<PgcReviewItemModel>? list;
String? next;
int? count;
PgcReviewData({this.list, this.next, this.count});
factory PgcReviewData.fromJson(Map<String, dynamic> json) => PgcReviewData(
list: (json['list'] as List<dynamic>?)
?.map((e) => PgcReviewItemModel.fromJson(e as Map<String, dynamic>))
.toList(),
next: json['next'] as String?,
count: json['count'] as int?,
);
}

View File

@@ -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<String, dynamic> json) =>
PgcReviewItemModel(
articleId: json['article_id'],
author: json['author'] == null
? null
: Author.fromJson(json['author'] as Map<String, dynamic>),
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<String, dynamic>),
);
}

View File

@@ -0,0 +1,19 @@
class Stat {
int? disliked;
int? liked;
int? likes;
Stat({this.disliked, this.liked, this.likes});
factory Stat.fromJson(Map<String, dynamic> json) => Stat(
disliked: json['disliked'] as int?,
liked: json['liked'] as int?,
likes: json['likes'] as int?,
);
Map<String, dynamic> toJson() => {
'disliked': disliked,
'liked': liked,
'likes': likes,
};
}

View File

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

View File

@@ -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<PgcReviewData, PgcReviewItemModel> {
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<void> onRefresh() {
count = null;
next = null;
return super.onRefresh();
}
@override
void checkIsEnd(int length) {
if (count != null && length >= count!) {
isEnd = true;
}
}
@override
List<PgcReviewItemModel>? getDataList(PgcReviewData response) {
count = response.count;
next = response.next;
return response.list;
}
@override
Future<LoadingState<PgcReviewData>> customGetData() => BangumiHttp.pgcReview(
type: type,
mediaId: mediaId,
next: next,
);
Future<void> 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<void> 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']);
}
}
}

View File

@@ -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<PgcReviewChildPage> createState() => _PgcReviewChildPageState();
}
class _PgcReviewChildPageState extends State<PgcReviewChildPage>
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<PgcReviewController>(
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<List<PgcReviewItemModel>?> 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;
}

View File

@@ -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<PgcReviewPostPanel> createState() => _PgcReviewPostPanelState();
}
class _PgcReviewPostPanelState
extends CommonCollapseSlidePageState<PgcReviewPostPanel> {
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('发布'),
),
),
),
],
);
}
}

View File

@@ -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<PgcReviewPage> createState() => _PgcReviewPageState();
}
class _PgcReviewPageState extends State<PgcReviewPage>
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<PgcReviewController>(
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;
}

View File

@@ -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<IntroDetail> {
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<IntroDetail> {
padding: EdgeInsets.only(
left: 14,
right: 14,
top: 14,
bottom: MediaQuery.paddingOf(context).bottom + 80,
),
children: [

View File

@@ -174,7 +174,7 @@ class _NoteListPageState extends CommonSlidePageState<NoteListPage> {
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<NoteListPage> {
),
};
}
}
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,
),
),
],
],
],
),
),
),
],
],
),
),
),
);
);
}
}

View File

@@ -94,24 +94,23 @@ class _ZanButtonGrpcState extends State<ZanButtonGrpc> {
}
}
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<ZanButtonGrpc> {
SizedBox(
height: 32,
child: TextButton(
style: _style,
style: style,
onPressed: () => handleState(onLikeReply),
child: Row(
children: [

View File

@@ -2071,7 +2071,7 @@ class _VideoDetailPageVState extends State<VideoDetailPageV>
);
}
void showIntroDetail(videoDetail, videoTags) {
void showIntroDetail(bangumi.BangumiInfoModel videoDetail, videoTags) {
videoDetailController.childKey.currentState?.showBottomSheet(
backgroundColor: Colors.transparent,
(context) => bangumi.IntroDetail(

View File

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