diff --git a/lib/http/api.dart b/lib/http/api.dart index fb7c91133..372c2e70a 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -999,4 +999,7 @@ abstract final class Api { static const String replySubjectModify = '/x/v2/reply/subject/modify'; static const String videoshot = '/x/player/videoshot'; + + static const String liveMedalWall = + '${HttpString.liveBaseUrl}/xlive/web-ucenter/user/MedalWall'; } diff --git a/lib/http/live.dart b/lib/http/live.dart index 16fe87e4d..c7b0a154f 100644 --- a/lib/http/live.dart +++ b/lib/http/live.dart @@ -19,6 +19,7 @@ import 'package:PiliPlus/models_new/live/live_emote/data.dart'; import 'package:PiliPlus/models_new/live/live_emote/datum.dart'; import 'package:PiliPlus/models_new/live/live_feed_index/data.dart'; import 'package:PiliPlus/models_new/live/live_follow/data.dart'; +import 'package:PiliPlus/models_new/live/live_medal_wall/data.dart'; import 'package:PiliPlus/models_new/live/live_room_info_h5/data.dart'; import 'package:PiliPlus/models_new/live/live_room_play_info/data.dart'; import 'package:PiliPlus/models_new/live/live_search/data.dart'; @@ -742,4 +743,18 @@ abstract final class LiveHttp { return Error(res.data['message']); } } + + static Future> liveMedalWall({ + required Object mid, + }) async { + final res = await Request().get( + Api.liveMedalWall, + queryParameters: {'target_id': mid}, + ); + if (res.data['code'] == 0) { + return Success(MedalWallData.fromJson(res.data['data'])); + } else { + return Error(res.data['message']); + } + } } diff --git a/lib/http/user.dart b/lib/http/user.dart index 5c10958d9..d8d76743a 100644 --- a/lib/http/user.dart +++ b/lib/http/user.dart @@ -9,6 +9,7 @@ import 'package:PiliPlus/models_new/history/data.dart'; import 'package:PiliPlus/models_new/later/data.dart'; import 'package:PiliPlus/models_new/login_log/data.dart'; import 'package:PiliPlus/models_new/media_list/data.dart'; +import 'package:PiliPlus/models_new/relation/data.dart'; import 'package:PiliPlus/models_new/space_setting/data.dart'; import 'package:PiliPlus/models_new/sub/sub/data.dart'; import 'package:PiliPlus/models_new/user_real_name/data.dart'; @@ -269,7 +270,7 @@ abstract final class UserHttp { } } - static Future> hasFollow(int mid) async { + static Future> userRelation(int mid) async { final res = await Request().get( Api.relation, queryParameters: { @@ -277,7 +278,7 @@ abstract final class UserHttp { }, ); if (res.data['code'] == 0) { - return Success(res.data['data']); + return Success(RelationData.fromJson(res.data['data'])); } else { return Error(res.data['message']); } diff --git a/lib/models_new/live/live_medal_wall/data.dart b/lib/models_new/live/live_medal_wall/data.dart new file mode 100644 index 000000000..9c37865ae --- /dev/null +++ b/lib/models_new/live/live_medal_wall/data.dart @@ -0,0 +1,30 @@ +import 'package:PiliPlus/models_new/live/live_medal_wall/item.dart'; + +class MedalWallData { + List? list; + int? count; + String? name; + String? icon; + int? uid; + int? level; + + MedalWallData({ + this.list, + this.count, + this.name, + this.icon, + this.uid, + this.level, + }); + + factory MedalWallData.fromJson(Map json) => MedalWallData( + list: (json['list'] as List?) + ?.map((e) => MedalWallItem.fromJson(e as Map)) + .toList(), + count: json['count'] as int?, + name: json['name'] as String?, + icon: json['icon'] as String?, + uid: json['uid'] as int?, + level: json['level'] as int?, + ); +} diff --git a/lib/models_new/live/live_medal_wall/item.dart b/lib/models_new/live/live_medal_wall/item.dart new file mode 100644 index 000000000..5f525b827 --- /dev/null +++ b/lib/models_new/live/live_medal_wall/item.dart @@ -0,0 +1,36 @@ +import 'package:PiliPlus/models_new/live/live_medal_wall/medal_info.dart'; +import 'package:PiliPlus/models_new/live/live_medal_wall/uinfo_medal.dart'; + +class MedalWallItem { + MedalInfo? medalInfo; + String? targetName; + String? targetIcon; + String? link; + int? liveStatus; + int? official; + UinfoMedal? uinfoMedal; + + MedalWallItem({ + this.medalInfo, + this.targetName, + this.targetIcon, + this.link, + this.liveStatus, + this.official, + this.uinfoMedal, + }); + + factory MedalWallItem.fromJson(Map json) => MedalWallItem( + medalInfo: json['medal_info'] == null + ? null + : MedalInfo.fromJson(json['medal_info'] as Map), + targetName: json['target_name'] as String?, + targetIcon: json['target_icon'] as String?, + link: json['link'] as String?, + liveStatus: json['live_status'] as int?, + official: json['official'] as int?, + uinfoMedal: json['uinfo_medal'] == null + ? null + : UinfoMedal.fromJson(json['uinfo_medal'] as Map), + ); +} diff --git a/lib/models_new/live/live_medal_wall/medal_info.dart b/lib/models_new/live/live_medal_wall/medal_info.dart new file mode 100644 index 000000000..40185a968 --- /dev/null +++ b/lib/models_new/live/live_medal_wall/medal_info.dart @@ -0,0 +1,11 @@ +class MedalInfo { + int? wearingStatus; + + MedalInfo({ + this.wearingStatus, + }); + + factory MedalInfo.fromJson(Map json) => MedalInfo( + wearingStatus: json['wearing_status'] as int?, + ); +} diff --git a/lib/models_new/live/live_medal_wall/uinfo_medal.dart b/lib/models_new/live/live_medal_wall/uinfo_medal.dart new file mode 100644 index 000000000..304cb489f --- /dev/null +++ b/lib/models_new/live/live_medal_wall/uinfo_medal.dart @@ -0,0 +1,41 @@ +class UinfoMedal { + String? name; + int? level; + int? colorBorder; + int? color; + int? id; + int? ruid; + String? v2MedalColorStart; + String? v2MedalColorEnd; + String? v2MedalColorBorder; + String? v2MedalColorText; + String? v2MedalColorLevel; + + UinfoMedal({ + this.name, + this.level, + this.colorBorder, + this.color, + this.id, + this.ruid, + this.v2MedalColorStart, + this.v2MedalColorEnd, + this.v2MedalColorBorder, + this.v2MedalColorText, + this.v2MedalColorLevel, + }); + + factory UinfoMedal.fromJson(Map json) => UinfoMedal( + name: json['name'] as String?, + level: json['level'] as int?, + colorBorder: json['color_border'] as int?, + color: json['color'] as int?, + id: json['id'] as int?, + ruid: json['ruid'] as int?, + v2MedalColorStart: json['v2_medal_color_start'] as String?, + v2MedalColorEnd: json['v2_medal_color_end'] as String?, + v2MedalColorBorder: json['v2_medal_color_border'] as String?, + v2MedalColorText: json['v2_medal_color_text'] as String?, + v2MedalColorLevel: json['v2_medal_color_level'] as String?, + ); +} diff --git a/lib/models_new/relation/data.dart b/lib/models_new/relation/data.dart new file mode 100644 index 000000000..1a061965e --- /dev/null +++ b/lib/models_new/relation/data.dart @@ -0,0 +1,25 @@ +import 'package:PiliPlus/utils/extension/iterable_ext.dart'; + +class RelationData { + int? mid; + int? attribute; + int? mtime; + List? tag; + int? special; + + RelationData({ + this.mid, + this.attribute, + this.mtime, + this.tag, + this.special, + }); + + factory RelationData.fromJson(Map json) => RelationData( + mid: json['mid'] as int?, + attribute: json['attribute'] as int?, + mtime: json['mtime'] as int?, + tag: (json['tag'] as List?)?.fromCast(), + special: json['special'] as int?, + ); +} diff --git a/lib/models_new/space/space/live_fans_wearing.dart b/lib/models_new/space/space/live_fans_wearing.dart index 89c39797f..b2d0ded58 100644 --- a/lib/models_new/space/space/live_fans_wearing.dart +++ b/lib/models_new/space/space/live_fans_wearing.dart @@ -1,28 +1,50 @@ class LiveFansWearing { - int? level; - String? medalName; - int? medalColorStart; - int? medalColorEnd; - int? medalColorBorder; - String? medalJumpUrl; + DetailV2? detailV2; LiveFansWearing({ - this.level, - this.medalName, - this.medalColorStart, - this.medalColorEnd, - this.medalColorBorder, - this.medalJumpUrl, + this.detailV2, }); factory LiveFansWearing.fromJson(Map json) { return LiveFansWearing( - level: json['level'] as int?, - medalName: json['medal_name'] as String?, - medalColorStart: json['medal_color_start'] as int?, - medalColorEnd: json['medal_color_end'] as int?, - medalColorBorder: json['medal_color_border'] as int?, - medalJumpUrl: json['medal_jump_url'] as String?, + detailV2: json['detail_v2'] == null + ? null + : DetailV2.fromJson(json['detail_v2']), + ); + } +} + +class DetailV2 { + int? uid; + int? level; + String? medalColorLevel; + String? medalColorName; + String? medalName; + int? medalId; + String? medalColor; + String? medalColorBorder; + + DetailV2({ + this.uid, + this.level, + this.medalColorLevel, + this.medalColorName, + this.medalName, + this.medalId, + this.medalColor, + this.medalColorBorder, + }); + + factory DetailV2.fromJson(Map json) { + return DetailV2( + uid: json["uid"], + level: json["level"], + medalColorLevel: json["medal_color_level"], + medalColorName: json["medal_color_name"], + medalName: json["medal_name"], + medalId: json["medal_id"], + medalColor: json["medal_color"], + medalColorBorder: json["medal_color_border"], ); } } diff --git a/lib/models_new/space/space/top.dart b/lib/models_new/space/space/top.dart index 8bbc252e0..d87410d40 100644 --- a/lib/models_new/space/space/top.dart +++ b/lib/models_new/space/space/top.dart @@ -1,3 +1,4 @@ +import 'package:PiliPlus/utils/extension/iterable_ext.dart'; import 'package:PiliPlus/utils/parse_string.dart'; class Top { @@ -20,16 +21,18 @@ class TopImage { late final String fullCover; String get header => _defaultImage ?? fullCover; late final double dy; + TopTitle? title; @pragma('vm:notify-debugger-on-exception') TopImage.fromJson(Map json) { - _defaultImage = noneNullOrEmptyString( - json['item']['image']?['default_image'], - ); + final item = json['item']; + final img = item['image']; + title = json['title'] == null ? null : TopTitle.fromJson(json['title']); + _defaultImage = noneNullOrEmptyString(img?['default_image']); fullCover = json['cover']; double dy = 0; try { - final Map image = json['item']['image'] ?? json['item']['animation']; + final Map image = img ?? item['animation']; if (image['location'] case String locStr when (locStr.isNotEmpty)) { final location = locStr .split('-') @@ -48,3 +51,36 @@ class TopImage { this.dy = dy; } } + +class TopTitle { + String? title; + String? subTitle; + SubTitleColorFormat? subTitleColorFormat; + + TopTitle({ + this.title, + this.subTitle, + this.subTitleColorFormat, + }); + + factory TopTitle.fromJson(Map json) => TopTitle( + title: json["title"], + subTitle: json["sub_title"], + subTitleColorFormat: json["sub_title_color_format"] == null + ? null + : SubTitleColorFormat.fromJson(json["sub_title_color_format"]), + ); +} + +class SubTitleColorFormat { + List? colors; + + SubTitleColorFormat({ + this.colors, + }); + + factory SubTitleColorFormat.fromJson(Map json) => + SubTitleColorFormat( + colors: (json["colors"] as List?)?.fromCast(), + ); +} diff --git a/lib/pages/group_panel/view.dart b/lib/pages/group_panel/view.dart index d100de257..4dab7f44e 100644 --- a/lib/pages/group_panel/view.dart +++ b/lib/pages/group_panel/view.dart @@ -11,7 +11,7 @@ import 'package:get/get.dart'; class GroupPanel extends StatefulWidget { final int mid; - final List? tags; + final List? tags; final ScrollController? scrollController; const GroupPanel({ super.key, diff --git a/lib/pages/member/view.dart b/lib/pages/member/view.dart index db2328cac..67deb5d7e 100644 --- a/lib/pages/member/view.dart +++ b/lib/pages/member/view.dart @@ -4,13 +4,17 @@ 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/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/pages/coin_log/controller.dart'; import 'package:PiliPlus/pages/exp_log/controller.dart'; import 'package:PiliPlus/pages/log_table/view.dart'; 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/user_info_card.dart'; import 'package:PiliPlus/pages/member_cheese/view.dart'; import 'package:PiliPlus/pages/member_contribute/view.dart'; @@ -19,11 +23,13 @@ import 'package:PiliPlus/pages/member_favorite/view.dart'; import 'package:PiliPlus/pages/member_home/view.dart'; import 'package:PiliPlus/pages/member_pgc/view.dart'; import 'package:PiliPlus/pages/member_shop/view.dart'; +import 'package:PiliPlus/utils/date_utils.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'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; @@ -57,6 +63,8 @@ class _MemberPageState extends State { void dispose() { _headerController?.dispose(); _headerController = null; + _cacheFollowTime = null; + _cacheMedalData = null; super.dispose(); } @@ -91,6 +99,7 @@ class _MemberPageState extends State { live: _userController.live, silence: _userController.silence, headerControllerBuilder: getHeaderController, + showLiveMedalWall: _showLiveMedalWall, ), ), ), @@ -309,6 +318,18 @@ class _MemberPageState extends State { ), ), ] else ...[ + if (_userController.isFollow) + PopupMenuItem( + onTap: _showFollowTime, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.more_time_outlined, size: 19), + SizedBox(width: 10), + Text('关注时间'), + ], + ), + ), const PopupMenuDivider(), PopupMenuItem( onTap: () => showMemberReportDialog( @@ -371,4 +392,68 @@ class _MemberPageState extends State { }; }).toList(), ); + + String? _cacheFollowTime; + Future _showFollowTime() async { + void onShow() { + if (!mounted) return; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(_userController.username ?? ''), + content: Text(_cacheFollowTime!), + actions: [ + TextButton( + onPressed: Get.back, + child: Text( + '关闭', + style: TextStyle(color: ColorScheme.of(context).outline), + ), + ), + ], + ), + ); + } + + if (_cacheFollowTime != null) { + onShow(); + return; + } + final res = await UserHttp.userRelation(_mid); + if (res case Success(:final response)) { + if (response.mtime == null) return; + _cacheFollowTime = + '关注时间: ${DateFormatUtils.longFormatDs.format( + DateTime.fromMillisecondsSinceEpoch(response.mtime! * 1000), + )}'; + onShow(); + } else { + res.toast(); + } + } + + MedalWallData? _cacheMedalData; + Future _showLiveMedalWall() async { + void onShow() { + if (!mounted) return; + showDialog( + context: context, + builder: (context) => MedalWall(response: _cacheMedalData!), + ); + } + + if (_cacheMedalData != null) { + onShow(); + return; + } + SmartDialog.showLoading(); + final res = await LiveHttp.liveMedalWall(mid: _mid); + SmartDialog.dismiss(); + if (res case Success(:final response)) { + _cacheMedalData = response; + onShow(); + } else { + res.toast(); + } + } } diff --git a/lib/pages/member/widget/medal_wall.dart b/lib/pages/member/widget/medal_wall.dart new file mode 100644 index 000000000..82621e6cd --- /dev/null +++ b/lib/pages/member/widget/medal_wall.dart @@ -0,0 +1,177 @@ +import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; +import 'package:PiliPlus/common/widgets/pendant_avatar.dart'; +import 'package:PiliPlus/models_new/live/live_medal_wall/data.dart'; +import 'package:PiliPlus/pages/member/widget/medal_widget.dart'; +import 'package:PiliPlus/utils/app_scheme.dart'; +import 'package:PiliPlus/utils/extension/num_ext.dart'; +import 'package:PiliPlus/utils/extension/theme_ext.dart'; +import 'package:PiliPlus/utils/page_utils.dart'; +import 'package:PiliPlus/utils/utils.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:flutter/material.dart'; + +class MedalWall extends StatelessWidget { + const MedalWall({super.key, required this.response}); + + final MedalWallData response; + + @override + Widget build(BuildContext context) { + final colorScheme = ColorScheme.of(context); + return AlertDialog( + clipBehavior: .hardEdge, + title: const Text('粉丝勋章墙'), + contentPadding: const .symmetric(vertical: 16), + constraints: const BoxConstraints.tightFor(width: 380), + content: CustomScrollView( + shrinkWrap: true, + slivers: [ + SliverToBoxAdapter( + child: Center( + child: NetworkImgLayer( + src: response.icon, + width: 50, + height: 50, + type: .avatar, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const .only(top: 5), + child: Center(child: Text(response.name!)), + ), + ), + SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const .only(top: 5, bottom: 8), + child: Text.rich( + style: TextStyle(fontSize: 12, color: colorScheme.outline), + TextSpan( + children: [ + const TextSpan(text: '共拥有 '), + TextSpan( + text: response.count.toString(), + style: TextStyle( + fontSize: 13, + color: colorScheme.primary, + ), + ), + const TextSpan(text: ' 枚粉丝勋章'), + ], + ), + ), + ), + ), + ), + SliverList.builder( + itemCount: response.list!.length, + itemBuilder: (context, index) { + final item = response.list![index]; + final uinfoMedal = item.uinfoMedal!; + final isLiving = item.liveStatus == 1; + Color? nameColor; + Color? backgroundColor; + try { + nameColor = Utils.parseColor(uinfoMedal.v2MedalColorText!); + backgroundColor = Utils.parseMedalColor( + uinfoMedal.v2MedalColorStart!, + ); + } catch (e, s) { + if (kDebugMode) { + Utils.reportError(e, s); + } + } + final medal = MedalWidget( + medalName: uinfoMedal.name!, + level: uinfoMedal.level!, + backgroundColor: + backgroundColor ?? colorScheme.secondaryContainer, + nameColor: nameColor ?? colorScheme.onSecondaryContainer, + levelColor: nameColor ?? colorScheme.onSecondaryContainer, + ); + Widget avatar = PendantAvatar( + avatar: item.targetIcon, + size: 38, + officialType: switch (item.official) { + 1 => 0, + 2 => 1, + _ => null, + }, + ); + if (isLiving) { + avatar = GestureDetector( + onTap: () => + PageUtils.toDupNamed('/member?mid=${uinfoMedal.ruid}'), + child: avatar, + ); + } + return ListTile( + onTap: () { + if (isLiving) { + PiliScheme.routePushFromUrl(item.link!); + } else { + PageUtils.toDupNamed('/member?mid=${uinfoMedal.ruid}'); + } + }, + visualDensity: VisualDensity.comfortable, + leading: avatar, + title: Row( + children: [ + Flexible( + child: Text( + item.targetName!, + maxLines: 1, + overflow: .ellipsis, + style: const TextStyle(height: 1, fontSize: 14), + ), + ), + if (isLiving) + Padding( + padding: const .only(left: 4), + child: Image.asset( + 'assets/images/live.gif', + height: 16, + cacheHeight: 16.cacheSize(context), + color: colorScheme.primary, + ), + ), + Padding( + padding: const .only(left: 8), + child: medal, + ), + ], + ), + trailing: item.medalInfo!.wearingStatus == 1 + ? Container( + padding: const .symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + borderRadius: const .all(.circular(3)), + color: colorScheme.isDark + ? const Color(0xFF8F0030) + : const Color(0xFFFF6699), + ), + child: const Text( + '佩戴中', + style: TextStyle( + height: 1, + fontSize: 10, + color: Colors.white, + ), + strutStyle: StrutStyle( + height: 1, + leading: 0, + fontSize: 10, + ), + ), + ) + : null, + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/pages/member/widget/medal_widget.dart b/lib/pages/member/widget/medal_widget.dart new file mode 100644 index 000000000..6693331c7 --- /dev/null +++ b/lib/pages/member/widget/medal_widget.dart @@ -0,0 +1,58 @@ +import 'package:PiliPlus/common/constants.dart'; +import 'package:flutter/material.dart'; + +class MedalWidget extends StatelessWidget { + const MedalWidget({ + super.key, + required this.medalName, + required this.level, + required this.backgroundColor, + required this.nameColor, + required this.levelColor, + }); + + final String medalName; + final int level; + final Color backgroundColor; + final Color nameColor; + final Color levelColor; + + @override + Widget build(BuildContext context) { + return Container( + padding: const .symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + borderRadius: StyleString.mdRadius, + color: backgroundColor, + ), + child: Text.rich( + strutStyle: const StrutStyle( + height: 1, + leading: 0, + fontSize: 10, + ), + TextSpan( + children: [ + TextSpan( + text: medalName, + style: TextStyle( + height: 1, + fontSize: 10, + color: nameColor, + ), + ), + TextSpan( + text: ' $level', + style: TextStyle( + height: 1, + fontSize: 10, + fontWeight: .bold, + color: levelColor, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/member/widget/user_info_card.dart b/lib/pages/member/widget/user_info_card.dart index 83a57d42c..5e5a3fdb5 100644 --- a/lib/pages/member/widget/user_info_card.dart +++ b/lib/pages/member/widget/user_info_card.dart @@ -16,6 +16,7 @@ import 'package:PiliPlus/pages/fan/view.dart'; import 'package:PiliPlus/pages/follow/view.dart'; import 'package:PiliPlus/pages/follow_type/followed/view.dart'; import 'package:PiliPlus/pages/member/widget/header_layout_widget.dart'; +import 'package:PiliPlus/pages/member/widget/medal_widget.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/app_scheme.dart'; import 'package:PiliPlus/utils/extension/context_ext.dart'; @@ -25,9 +26,12 @@ import 'package:PiliPlus/utils/extension/theme_ext.dart'; import 'package:PiliPlus/utils/image_utils.dart'; import 'package:PiliPlus/utils/num_utils.dart'; import 'package:PiliPlus/utils/page_utils.dart'; +import 'package:PiliPlus/utils/platform_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; class UserInfoCard extends StatelessWidget { @@ -41,6 +45,7 @@ class UserInfoCard extends StatelessWidget { this.live, this.silence, required this.headerControllerBuilder, + required this.showLiveMedalWall, }); final bool isOwner; @@ -51,6 +56,7 @@ class UserInfoCard extends StatelessWidget { final Live? live; final int? silence; final ValueGetter headerControllerBuilder; + final VoidCallback showLiveMedalWall; @override Widget build(BuildContext context) { @@ -88,9 +94,15 @@ class UserInfoCard extends StatelessWidget { case UserInfoType.like: count = card.likes?.likeNum; } + void onShowCount() => SmartDialog.showToast( + '${type.title}: $count', + alignment: const Alignment(0.0, -0.8), + ); return GestureDetector( behavior: .opaque, onTap: onTap, + onLongPress: PlatformUtils.isMobile ? onShowCount : null, + onSecondaryTap: PlatformUtils.isDesktop ? onShowCount : null, child: Align( alignment: type.alignment, widthFactor: 1.0, @@ -119,206 +131,241 @@ class UserInfoCard extends StatelessWidget { BuildContext context, ColorScheme colorScheme, bool isLight, - ) => [ - Padding( - padding: const EdgeInsets.only(left: 20, right: 20), - child: Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - GestureDetector( - onTap: () => Utils.copyText(card.name!), - child: Text( - card.name!, - strutStyle: const StrutStyle( - height: 1, - leading: 0, - fontSize: 17, - fontWeight: FontWeight.bold, - ), - style: TextStyle( - height: 1, - fontSize: 17, - fontWeight: FontWeight.bold, - color: (card.vip?.status ?? -1) > 0 && card.vip?.type == 2 - ? colorScheme.vipColor - : null, - ), - ), + ) { + Widget? liveMedal; + if (card.liveFansWearing?.detailV2 case final detailV2?) { + Color? nameColor; + Color? levelColor; + Color? backgroundColor; + try { + nameColor = Utils.parseColor(detailV2.medalColorName!); + levelColor = Utils.parseColor(detailV2.medalColorLevel!); + backgroundColor = Utils.parseColor(detailV2.medalColor!); + } catch (e, s) { + if (kDebugMode) { + Utils.reportError(e, s); + } + } + try { + liveMedal = GestureDetector( + onTap: showLiveMedalWall, + child: MedalWidget( + medalName: detailV2.medalName!, + level: detailV2.level!, + backgroundColor: backgroundColor ?? colorScheme.secondaryContainer, + nameColor: nameColor ?? colorScheme.onSecondaryContainer, + levelColor: levelColor ?? colorScheme.onSecondaryContainer, ), - Image.asset( - Utils.levelName( - card.levelInfo!.currentLevel!, - isSeniorMember: card.levelInfo?.identity == 2, - ), - height: 11, - cacheHeight: 11.cacheSize(context), - semanticLabel: '等级${card.levelInfo?.currentLevel}', - ), - if (card.vip?.status == 1) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - borderRadius: StyleString.mdRadius, - color: colorScheme.vipColor, - ), + ); + } catch (e, s) { + if (kDebugMode) { + Utils.reportError(e, s); + } + } + } + return [ + Padding( + padding: const EdgeInsets.only(left: 20, right: 20), + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + GestureDetector( + onTap: () => Utils.copyText(card.name!), child: Text( - card.vip?.label?.text ?? '大会员', + card.name!, strutStyle: const StrutStyle( height: 1, - fontSize: 10, + leading: 0, + fontSize: 17, fontWeight: FontWeight.bold, ), - style: const TextStyle( + style: TextStyle( height: 1, + fontSize: 17, fontWeight: FontWeight.bold, - fontSize: 10, - color: Colors.white, + color: (card.vip?.status ?? -1) > 0 && card.vip?.type == 2 + ? colorScheme.vipColor + : null, ), ), ), - // if (card.nameplate?.imageSmall?.isNotEmpty == true) - // CachedNetworkImage( - // imageUrl: ImageUtils.thumbnailUrl(card.nameplate!.imageSmall!), - // height: 20, - // placeholder: (context, url) { - // return const SizedBox.shrink(); - // }, - // ), - ], - ), - ), - if (card.officialVerify?.desc?.isNotEmpty == true) - Container( - margin: const EdgeInsets.only(left: 20, top: 8, right: 20), - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(12)), - color: colorScheme.onInverseSurface, - ), - child: Text.rich( - TextSpan( - children: [ - if (card.officialVerify?.icon?.isNotEmpty == true) ...[ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.surface, - ), - child: Icon( - Icons.offline_bolt, - color: card.officialVerify?.type == 0 - ? const Color(0xFFFFCC00) - : Colors.lightBlueAccent, - size: 18, - ), + Image.asset( + Utils.levelName( + card.levelInfo!.currentLevel!, + isSeniorMember: card.levelInfo?.identity == 2, + ), + height: 11, + cacheHeight: 11.cacheSize(context), + semanticLabel: '等级${card.levelInfo?.currentLevel}', + ), + if (card.vip?.status == 1) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + borderRadius: StyleString.mdRadius, + color: colorScheme.vipColor, + ), + child: Text( + card.vip?.label?.text ?? '大会员', + strutStyle: const StrutStyle( + height: 1, + leading: 0, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + style: const TextStyle( + height: 1, + fontSize: 10, + color: Colors.white, + fontWeight: FontWeight.bold, ), ), - const TextSpan( - text: ' ', + ), + // if (card.nameplate?.imageSmall?.isNotEmpty == true) + // CachedNetworkImage( + // imageUrl: ImageUtils.thumbnailUrl(card.nameplate!.imageSmall!), + // height: 20, + // placeholder: (context, url) { + // return const SizedBox.shrink(); + // }, + // ), + ?liveMedal, + ], + ), + ), + if (card.officialVerify?.desc?.isNotEmpty == true) + Container( + margin: const EdgeInsets.only(left: 20, top: 8, right: 20), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(12)), + color: colorScheme.onInverseSurface, + ), + child: Text.rich( + TextSpan( + children: [ + if (card.officialVerify?.icon?.isNotEmpty == true) ...[ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.surface, + ), + child: Icon( + Icons.offline_bolt, + color: card.officialVerify?.type == 0 + ? const Color(0xFFFFCC00) + : Colors.lightBlueAccent, + size: 18, + ), + ), + ), + const TextSpan( + text: ' ', + ), + ], + TextSpan( + text: card.officialVerify!.spliceTitle!, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), ), ], - TextSpan( - text: card.officialVerify!.spliceTitle!, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ], - ), - ), - ), - if (card.sign?.isNotEmpty == true) - Padding( - padding: const EdgeInsets.only(left: 20, top: 6, right: 20), - child: SelectableText( - card.sign!.trim().replaceAll(RegExp(r'\n{2,}'), '\n'), - style: const TextStyle(fontSize: 14), - ), - ), - if (card.followingsFollowedUpper?.items?.isNotEmpty == true) ...[ - const SizedBox(height: 6), - _buildFollowedUp(colorScheme, card.followingsFollowedUpper!), - ], - Padding( - padding: const EdgeInsets.only(left: 20, top: 6, right: 20), - child: Wrap( - spacing: 10, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - GestureDetector( - onTap: () => Utils.copyText(card.mid.toString()), - child: Text( - 'UID: ${card.mid}', - style: TextStyle( - fontSize: 12, - color: colorScheme.outline, - ), ), ), - ...?card.spaceTag?.map( - (item) { - final hasUri = item.uri?.isNotEmpty == true; - final child = Text( - item.title ?? '', + ), + if (card.sign?.isNotEmpty == true) + Padding( + padding: const EdgeInsets.only(left: 20, top: 6, right: 20), + child: SelectableText( + card.sign!.trim().replaceAll(RegExp(r'\n{2,}'), '\n'), + style: const TextStyle(fontSize: 14), + ), + ), + if (card.followingsFollowedUpper?.items?.isNotEmpty == true) ...[ + const SizedBox(height: 6), + _buildFollowedUp(colorScheme, card.followingsFollowedUpper!), + ], + Padding( + padding: const EdgeInsets.only(left: 20, top: 6, right: 20), + child: Wrap( + spacing: 10, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + GestureDetector( + onTap: () => Utils.copyText(card.mid.toString()), + child: Text( + 'UID: ${card.mid}', style: TextStyle( fontSize: 12, - color: hasUri ? colorScheme.secondary : colorScheme.outline, + color: colorScheme.outline, ), - ); - if (hasUri) { - return GestureDetector( - onTap: () => PiliScheme.routePushFromUrl(item.uri!), - child: child, + ), + ), + ...?card.spaceTag?.map( + (item) { + final hasUri = item.uri?.isNotEmpty == true; + final child = Text( + item.title ?? '', + style: TextStyle( + fontSize: 12, + color: hasUri ? colorScheme.secondary : colorScheme.outline, + ), ); - } - return child; - }, - ), - ], - ), - ), - if (silence == 1) - Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(6)), - color: isLight ? colorScheme.errorContainer : colorScheme.error, - ), - margin: const EdgeInsets.only(left: 20, top: 8, right: 20), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Text.rich( - TextSpan( - children: [ - WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: Icon( - Icons.info, - size: 17, - color: isLight - ? colorScheme.onErrorContainer - : colorScheme.onError, - ), - ), - TextSpan( - text: ' 该账号封禁中', - style: TextStyle( - color: isLight - ? colorScheme.onErrorContainer - : colorScheme.onError, - ), - ), - ], - ), + if (hasUri) { + return GestureDetector( + onTap: () => PiliScheme.routePushFromUrl(item.uri!), + child: child, + ); + } + return child; + }, + ), + ], ), ), - ]; + if (silence == 1) + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(6)), + color: isLight ? colorScheme.errorContainer : colorScheme.error, + ), + margin: const EdgeInsets.only(left: 20, top: 8, right: 20), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text.rich( + TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + Icons.info, + size: 17, + color: isLight + ? colorScheme.onErrorContainer + : colorScheme.onError, + ), + ), + TextSpan( + text: ' 该账号封禁中', + style: TextStyle( + color: isLight + ? colorScheme.onErrorContainer + : colorScheme.onError, + ), + ), + ], + ), + ), + ), + ]; + } Column _buildRight(ColorScheme colorScheme) => Column( mainAxisSize: MainAxisSize.min, @@ -541,6 +588,34 @@ class UserInfoCard extends StatelessWidget { }, ), ), + Positioned( + right: 0, + bottom: 3.5, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 125), + child: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: .centerLeft, + end: .centerRight, + colors: [ + Colors.transparent, + Colors.black12, + Colors.black38, + Colors.black45, + ], + ), + ), + child: Padding( + padding: const .only(left: 15, right: 5, bottom: 2), + child: HeaderTitle( + images: imgUrls, + pageController: controller, + ), + ), + ), + ), + ), Positioned( left: 0, right: 0, @@ -788,3 +863,79 @@ class _HeaderIndicatorState extends State { ); } } + +class HeaderTitle extends StatefulWidget { + const HeaderTitle({ + super.key, + required this.images, + required this.pageController, + }); + + final List images; + final PageController pageController; + + @override + State createState() => _HeaderTitleState(); +} + +class _HeaderTitleState extends State { + late int _index; + + @override + void initState() { + super.initState(); + _updateIndex(); + widget.pageController.addListener(_listener); + } + + void _listener() { + _updateIndex(); + setState(() {}); + } + + void _updateIndex() { + _index = widget.pageController.page?.round() ?? 0; + } + + @override + void dispose() { + widget.pageController.removeListener(_listener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final title = widget.images[_index].title; + if (title == null) return const SizedBox.shrink(); + try { + return Column( + crossAxisAlignment: .end, + children: [ + Text( + title.title!, + maxLines: 1, + overflow: .ellipsis, + style: const TextStyle(fontSize: 12, color: Colors.white), + ), + Text( + title.subTitle!, + style: TextStyle( + fontSize: 12, + fontFamily: 'digital_id_num', + color: title.subTitleColorFormat?.colors?.isNotEmpty == true + ? Utils.parseMedalColor( + title.subTitleColorFormat!.colors!.last, + ) + : Colors.white, + ), + ), + ], + ); + } catch (e, s) { + if (kDebugMode) { + Utils.reportError(e, s); + } + return const SizedBox.shrink(); + } + } +} diff --git a/lib/pages/rcmd/view.dart b/lib/pages/rcmd/view.dart index 469d2b49d..6ddd24077 100644 --- a/lib/pages/rcmd/view.dart +++ b/lib/pages/rcmd/view.dart @@ -27,6 +27,7 @@ class _RcmdPageState extends State @override Widget build(BuildContext context) { super.build(context); + final colorScheme = ColorScheme.of(context); return Container( clipBehavior: .hardEdge, margin: const .symmetric(horizontal: StyleString.safeSpace), @@ -39,7 +40,9 @@ class _RcmdPageState extends State slivers: [ SliverPadding( padding: const .only(top: StyleString.cardSpace, bottom: 100), - sliver: Obx(() => _buildBody(controller.loadingState.value)), + sliver: Obx( + () => _buildBody(colorScheme, controller.loadingState.value), + ), ), ], ), @@ -55,7 +58,10 @@ class _RcmdPageState extends State mainAxisExtent: MediaQuery.textScalerOf(context).scale(90), ); - Widget _buildBody(LoadingState?> loadingState) { + Widget _buildBody( + ColorScheme colorScheme, + LoadingState?> loadingState, + ) { return switch (loadingState) { Loading() => _buildSkeleton, Success(:final response) => @@ -80,9 +86,7 @@ class _RcmdPageState extends State '上次看到这里\n点击刷新', textAlign: .center, style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, + color: colorScheme.onSurfaceVariant, ), ), ), diff --git a/lib/pages/video/introduction/ugc/controller.dart b/lib/pages/video/introduction/ugc/controller.dart index 80765dce5..9af3f0fa3 100644 --- a/lib/pages/video/introduction/ugc/controller.dart +++ b/lib/pages/video/introduction/ugc/controller.dart @@ -13,6 +13,7 @@ import 'package:PiliPlus/http/user.dart'; import 'package:PiliPlus/http/video.dart'; import 'package:PiliPlus/models/common/video/source_type.dart'; import 'package:PiliPlus/models_new/member_card_info/data.dart'; +import 'package:PiliPlus/models_new/relation/data.dart'; import 'package:PiliPlus/models_new/video/video_ai_conclusion/model_result.dart'; import 'package:PiliPlus/models_new/video/video_detail/episode.dart'; import 'package:PiliPlus/models_new/video/video_detail/page.dart'; @@ -52,7 +53,7 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { // up主粉丝数 final Rx userStat = MemberCardInfoData().obs; // 关注状态 默认未关注 - late final RxMap followStatus = {}.obs; + late final Rx followStatus = Rx(RelationData()); late final RxMap staffRelations = {}.obs; // 是否点踩 @@ -426,9 +427,9 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { if (videoDetail.owner == null || videoDetail.staff?.isNotEmpty == true) { return; } - final res = await UserHttp.hasFollow(videoDetail.owner!.mid!); + final res = await UserHttp.userRelation(videoDetail.owner!.mid!); if (res case Success(:final response)) { - if (response['special'] == 1) response['attribute'] = -10; + if (response.special == 1) response.attribute = -10; followStatus.value = response; } } @@ -447,7 +448,7 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { if (mid == null) { return; } - int attr = followStatus['attribute'] ?? 0; + int attr = followStatus.value.attribute ?? 0; if (attr == 128) { final res = await VideoHttp.relationMod( mid: mid, @@ -455,7 +456,9 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { reSrc: 11, ); if (res.isSuccess) { - followStatus['attribute'] = 0; + followStatus + ..value.attribute = 0 + ..refresh(); } return; } else { @@ -463,9 +466,11 @@ class UgcIntroController extends CommonIntroController with ReloadMixin { context: context, mid: mid, isFollow: attr != 0, - followStatus: followStatus, + followStatus: followStatus.value, afterMod: (attribute) { - followStatus['attribute'] = attribute; + followStatus + ..value.attribute = attribute + ..refresh(); Future.delayed(const Duration(milliseconds: 500), queryFollowStatus); }, ); diff --git a/lib/pages/video/introduction/ugc/view.dart b/lib/pages/video/introduction/ugc/view.dart index 38dec5e64..d42a0fcfc 100644 --- a/lib/pages/video/introduction/ugc/view.dart +++ b/lib/pages/video/introduction/ugc/view.dart @@ -488,7 +488,7 @@ class _UgcIntroPanelState extends State { Widget followButton(BuildContext context, ThemeData t) { return Obx( () { - int attr = introController.followStatus['attribute'] ?? 0; + int attr = introController.followStatus.value.attribute ?? 0; return TextButton( onPressed: () => introController.actionRelationMod(context), style: TextButton.styleFrom( diff --git a/lib/utils/page_utils.dart b/lib/utils/page_utils.dart index 647cf604e..b32c336e2 100644 --- a/lib/utils/page_utils.dart +++ b/lib/utils/page_utils.dart @@ -537,7 +537,7 @@ abstract final class PageUtils { if (off) { Get.offNamed('/liveRoom', arguments: roomId); } else { - Get.toNamed('/liveRoom', arguments: roomId); + PageUtils.toDupNamed('/liveRoom', arguments: roomId); } } diff --git a/lib/utils/request_utils.dart b/lib/utils/request_utils.dart index b5ae32b3b..214c011e6 100644 --- a/lib/utils/request_utils.dart +++ b/lib/utils/request_utils.dart @@ -18,6 +18,7 @@ import 'package:PiliPlus/models/dynamics/result.dart'; import 'package:PiliPlus/models/login/model.dart'; import 'package:PiliPlus/models_new/fav/fav_detail/media.dart'; import 'package:PiliPlus/models_new/later/list.dart'; +import 'package:PiliPlus/models_new/relation/data.dart'; import 'package:PiliPlus/pages/common/multi_select/base.dart'; import 'package:PiliPlus/pages/dynamics_tab/controller.dart'; import 'package:PiliPlus/pages/fav_detail/controller.dart' @@ -103,7 +104,7 @@ abstract final class RequestUtils { required dynamic mid, required bool isFollow, required ValueChanged? afterMod, - Map? followStatus, + RelationData? followStatus, }) async { if (mid == null) { return; @@ -122,8 +123,8 @@ abstract final class RequestUtils { res.toast(); } } else { - if (followStatus?['tag'] == null) { - final res = await UserHttp.hasFollow(mid); + if (followStatus?.tag == null) { + final res = await UserHttp.userRelation(mid); if (res case Success(:final response)) { followStatus = response; } else { @@ -133,7 +134,7 @@ abstract final class RequestUtils { } if (context.mounted) { - bool isSpecialFollowed = followStatus!['special'] == 1; + bool isSpecialFollowed = followStatus!.special == 1; String text = isSpecialFollowed ? '移除特别关注' : '加入特别关注'; showDialog( context: context, @@ -194,15 +195,15 @@ abstract final class RequestUtils { ) { return GroupPanel( mid: mid, - tags: followStatus!['tag'], + tags: followStatus!.tag, scrollController: scrollController, ); }, ); }, ); - followStatus!['tag'] = result?.toList(); if (result != null) { + followStatus!.tag = result.toList(); afterMod?.call(result.contains(-10) ? -10 : 2); } }, diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 8719cc5d0..2e4aac881 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -83,6 +83,10 @@ abstract final class Utils { static Color parseColor(String color) => Color(int.parse(color.replaceFirst('#', 'FF'), radix: 16)); + static Color parseMedalColor(String color) => Color( + int.parse('${color.substring(7)}${color.substring(1, 7)}', radix: 16), + ); + static int? _sdkInt; static Future get sdkInt async { return _sdkInt ??= (await DeviceInfoPlugin().androidInfo).version.sdkInt;