From b43b9549b96423c721a265894084f9ec5d4864c9 Mon Sep 17 00:00:00 2001 From: guozhigq Date: Thu, 29 Jun 2023 23:37:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=A8=E6=80=81=E7=AD=9B=E9=80=89&UP?= =?UTF-8?q?=E4=B8=BB=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/http/api.dart | 1 + lib/http/dynamics.dart | 27 ++- lib/models/common/dynamics_type.dart | 2 +- lib/models/dynamics/result.dart | 2 +- lib/models/dynamics/up.dart | 93 ++++++++ lib/pages/dynamics/controller.dart | 55 ++++- lib/pages/dynamics/deatil/controller.dart | 3 + lib/pages/dynamics/view.dart | 182 ++++++++++----- lib/pages/dynamics/widgets/up_panel.dart | 263 ++++++++++++++++++++++ 9 files changed, 557 insertions(+), 71 deletions(-) create mode 100644 lib/models/dynamics/up.dart create mode 100644 lib/pages/dynamics/widgets/up_panel.dart diff --git a/lib/http/api.dart b/lib/http/api.dart index 3c24a618..6c65c56d 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -134,6 +134,7 @@ class Api { // 正在直播的up & 关注的up // https://api.bilibili.com/x/polymer/web-dynamic/v1/portal + static const String followUp = '/x/polymer/web-dynamic/v1/portal'; // 关注的up动态 // https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/all diff --git a/lib/http/dynamics.dart b/lib/http/dynamics.dart index 51e62c40..510e0ff2 100644 --- a/lib/http/dynamics.dart +++ b/lib/http/dynamics.dart @@ -1,19 +1,26 @@ import 'package:pilipala/http/index.dart'; import 'package:pilipala/models/dynamics/result.dart'; +import 'package:pilipala/models/dynamics/up.dart'; class DynamicsHttp { static Future followDynamic({ String? type, int? page, String? offset, + int? mid, }) async { - var res = await Request().get(Api.followDynamic, data: { + Map data = { 'type': type ?? 'all', 'page': page ?? 1, 'timezone_offset': '-480', 'offset': page == 1 ? '' : offset, 'features': 'itemOpusStyle' - }); + }; + if (mid != -1) { + data['host_mid'] = mid; + data.remove('timezone_offset'); + } + var res = await Request().get(Api.followDynamic, data: data); if (res.data['code'] == 0) { return { 'status': true, @@ -27,4 +34,20 @@ class DynamicsHttp { }; } } + + static Future followUp() async { + var res = await Request().get(Api.followUp); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': FollowUpModel.fromJson(res.data['data']), + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/models/common/dynamics_type.dart b/lib/models/common/dynamics_type.dart index 85a5270a..337f6aec 100644 --- a/lib/models/common/dynamics_type.dart +++ b/lib/models/common/dynamics_type.dart @@ -7,5 +7,5 @@ enum DynamicsType { extension BusinessTypeExtension on DynamicsType { String get values => ['all', 'video', 'pgc', 'article'][index]; - String get labels => ['全部', '视频投稿', '追番追剧', '专栏'][index]; + String get labels => ['全部', '视频', '追番', '专栏'][index]; } diff --git a/lib/models/dynamics/result.dart b/lib/models/dynamics/result.dart index 0584fb21..cddf8d80 100644 --- a/lib/models/dynamics/result.dart +++ b/lib/models/dynamics/result.dart @@ -526,7 +526,7 @@ class OpusPicsModel { OpusPicsModel.fromJson(Map json) { width = json['width']; height = json['height']; - size = json['size'].toInt(); + size = json['size'] != null ? json['size'].toInt() : 0; src = json['src']; url = json['url']; } diff --git a/lib/models/dynamics/up.dart b/lib/models/dynamics/up.dart new file mode 100644 index 00000000..acc61bc1 --- /dev/null +++ b/lib/models/dynamics/up.dart @@ -0,0 +1,93 @@ +class FollowUpModel { + FollowUpModel({ + this.liveUsers, + this.upList, + }); + + LiveUsers? liveUsers; + List? upList; + + FollowUpModel.fromJson(Map json) { + liveUsers = LiveUsers.fromJson(json['live_users']); + upList = json['up_list'] != null + ? json['up_list'].map((e) => UpItem.fromJson(e)).toList() + : []; + } +} + +class LiveUsers { + LiveUsers({ + this.count, + this.group, + this.items, + }); + + int? count; + String? group; + List? items; + + LiveUsers.fromJson(Map json) { + count = json['count']; + group = json['group']; + items = json['items'] + .map((e) => LiveUserItem.fromJson(e)) + .toList(); + } +} + +class LiveUserItem { + LiveUserItem({ + this.face, + this.isReserveRecall, + this.jumpUrl, + this.mid, + this.roomId, + this.title, + this.uname, + }); + + String? face; + bool? isReserveRecall; + String? jumpUrl; + int? mid; + int? roomId; + String? title; + String? uname; + bool hasUpdate = false; + String type = 'live'; + + LiveUserItem.fromJson(Map json) { + face = json['face']; + isReserveRecall = json['is_reserve_recall']; + jumpUrl = json['jump_url']; + mid = json['mid']; + roomId = json['room_id']; + title = json['title']; + uname = json['uname']; + } +} + +class UpItem { + UpItem({ + this.face, + this.hasUpdate, + this.isReserveRecall, + this.mid, + this.uname, + }); + + String? face; + bool? hasUpdate; + bool? isReserveRecall; + int? mid; + String? uname; + String type = 'up'; + + UpItem.fromJson(Map json) { + face = json['face']; + hasUpdate = json['has_update']; + isReserveRecall = json['is_reserve_recall']; + mid = json['mid']; + uname = json['uname']; + } +} diff --git a/lib/pages/dynamics/controller.dart b/lib/pages/dynamics/controller.dart index c8903fe4..148bfc9f 100644 --- a/lib/pages/dynamics/controller.dart +++ b/lib/pages/dynamics/controller.dart @@ -3,22 +3,53 @@ import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:pilipala/http/dynamics.dart'; import 'package:pilipala/http/search.dart'; +import 'package:pilipala/models/common/dynamics_type.dart'; import 'package:pilipala/models/dynamics/result.dart'; +import 'package:pilipala/models/dynamics/up.dart'; import 'package:pilipala/utils/utils.dart'; class DynamicsController extends GetxController { int page = 1; String? offset = ''; RxList? dynamicsList = [DynamicItemModel()].obs; - RxString dynamicsType = 'all'.obs; + Rx dynamicsType = DynamicsType.values[0].obs; RxString dynamicsTypeLabel = '全部'.obs; final ScrollController scrollController = ScrollController(); + Rx upData = FollowUpModel().obs; + // 默认获取全部动态 + int mid = -1; + List filterTypeList = [ + { + 'label': DynamicsType.all.labels, + 'value': DynamicsType.all, + 'enabled': true + }, + { + 'label': DynamicsType.video.labels, + 'value': DynamicsType.video, + 'enabled': true + }, + { + 'label': DynamicsType.pgc.labels, + 'value': DynamicsType.pgc, + 'enabled': true + }, + { + 'label': DynamicsType.article.labels, + 'value': DynamicsType.article, + 'enabled': true + }, + ]; Future queryFollowDynamic({type = 'init'}) async { + // if (type == 'init') { + // dynamicsList!.value = []; + // } var res = await DynamicsHttp.followDynamic( page: type == 'init' ? 1 : page, - type: dynamicsType.value, + type: dynamicsType.value.values, offset: offset, + mid: mid, ); if (res['status']) { if (type == 'init') { @@ -32,12 +63,10 @@ class DynamicsController extends GetxController { return res; } - onSelectType(value, label) async { + onSelectType(value) async { dynamicsType.value = value; - dynamicsTypeLabel.value = label; await queryFollowDynamic(); - scrollController.animateTo(0, - duration: const Duration(milliseconds: 500), curve: Curves.easeInOut); + scrollController.jumpTo(0); } pushDetail(item, floor, {action = 'all'}) async { @@ -86,4 +115,18 @@ class DynamicsController extends GetxController { break; } } + + Future queryFollowUp() async { + var res = await DynamicsHttp.followUp(); + if (res['status']) { + upData.value = res['data']; + } + return res; + } + + onSelectUp(mid) async { + dynamicsType.value = DynamicsType.values[0]; + + queryFollowDynamic(); + } } diff --git a/lib/pages/dynamics/deatil/controller.dart b/lib/pages/dynamics/deatil/controller.dart index 41f7ef74..ab422b67 100644 --- a/lib/pages/dynamics/deatil/controller.dart +++ b/lib/pages/dynamics/deatil/controller.dart @@ -73,6 +73,9 @@ class DynamicDetailController extends GetxController { } else { replyList.addAll(replies); } + if (replyList.length == acount.value) { + noMore.value = '没有更多了'; + } } isLoadingMore = false; return res; diff --git a/lib/pages/dynamics/view.dart b/lib/pages/dynamics/view.dart index fd540f04..0002be28 100644 --- a/lib/pages/dynamics/view.dart +++ b/lib/pages/dynamics/view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:pilipala/common/skeleton/dynamic_card.dart'; import 'package:pilipala/common/widgets/http_error.dart'; @@ -7,6 +8,7 @@ import 'package:pilipala/models/dynamics/result.dart'; import 'controller.dart'; import 'widgets/dynamic_panel.dart'; +import 'widgets/up_panel.dart'; class DynamicsPage extends StatefulWidget { const DynamicsPage({super.key}); @@ -19,7 +21,6 @@ class _DynamicsPageState extends State with AutomaticKeepAliveClientMixin { final DynamicsController _dynamicsController = Get.put(DynamicsController()); Future? _futureBuilderFuture; - // final ScrollController scrollController = ScrollController(); bool _isLoadingMore = false; @override bool get wantKeepAlive => true; @@ -48,81 +49,140 @@ class _DynamicsPageState extends State Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: false, - title: const Text('动态'), - actions: [ - Obx( - () => PopupMenuButton( - initialValue: _dynamicsController.dynamicsType.value, - position: PopupMenuPosition.under, - itemBuilder: (context) => [ - for (var i in DynamicsType.values) ...[ - PopupMenuItem( - value: i.values, - onTap: () => - _dynamicsController.onSelectType(i.values, i.labels), - child: Text(i.labels), - ) - ], - ], - child: Row( + elevation: 0, + scrolledUnderElevation: 0, + titleSpacing: 0, + title: SizedBox( + height: 36, + child: Stack( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - _dynamicsController.dynamicsTypeLabel.value, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, + Obx( + () => SegmentedButton( + showSelectedIcon: false, + style: ButtonStyle( + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric( + vertical: 0, horizontal: 10)), + side: MaterialStateProperty.all( + BorderSide( + color: Theme.of(context).hintColor, width: 0.5), + ), + ), + segments: >[ + for (var i in _dynamicsController.filterTypeList) ...[ + ButtonSegment( + value: i['value'], + label: Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text(i['label']), + ), + enabled: i['enabled'], + ), + ] + ], + selected: { + _dynamicsController.dynamicsType.value + }, + onSelectionChanged: (Set newSelection) { + _dynamicsController.dynamicsType.value = + newSelection.first; + _dynamicsController.onSelectType(newSelection.first); + }, ), ), - const SizedBox(width: 10) ], ), - ), + Positioned( + right: 10, + top: 0, + bottom: 0, + child: IconButton( + padding: EdgeInsets.zero, + onPressed: () { + _dynamicsController.mid = -1; + _dynamicsController.dynamicsType.value = + DynamicsType.values[0]; + SmartDialog.showToast('还原默认加载', + alignment: Alignment.topCenter); + _dynamicsController.queryFollowDynamic(); + }, + icon: const Icon(Icons.history), + ), + ) + ], ), - const SizedBox(width: 4) - ], + ), ), body: RefreshIndicator( onRefresh: () async { + _dynamicsController.page = 1; + _dynamicsController.queryFollowUp(); await _dynamicsController.queryFollowDynamic(); }, - child: FutureBuilder( - future: _futureBuilderFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - Map data = snapshot.data; - if (data['status']) { - List list = _dynamicsController.dynamicsList!; - return Obx( - () => ListView.builder( - controller: _dynamicsController.scrollController, - shrinkWrap: true, - itemCount: list.length, - itemBuilder: (BuildContext context, index) { - return DynamicPanel(item: list[index]); - }, - ), - ); - } else { - return CustomScrollView( - slivers: [ - HttpError( + child: CustomScrollView( + controller: _dynamicsController.scrollController, + slivers: [ + FutureBuilder( + future: _dynamicsController.queryFollowUp(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data; + if (data['status']) { + return Obx(() => UpPanel(_dynamicsController.upData.value)); + } else { + return const SliverToBoxAdapter( + child: SizedBox(height: 80)); + } + } else { + return const SliverToBoxAdapter( + child: SizedBox( + height: 115, + child: UpPanelSkeleton(), + )); + } + }, + ), + FutureBuilder( + future: _futureBuilderFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + Map data = snapshot.data; + if (data['status']) { + List list = + _dynamicsController.dynamicsList!; + return Obx( + () => SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return DynamicPanel(item: list[index]); + }, childCount: list.length), + ), + ); + } else { + return HttpError( errMsg: data['msg'], fn: () => _dynamicsController.queryFollowDynamic(), - ) - ], - ); - } - } else { - // 骨架屏 - return ListView.builder( - physics: const NeverScrollableScrollPhysics(), - itemCount: 5, - itemBuilder: ((context, index) => const DynamicCardSkeleton()), - ); - } - }, + ); + } + } else { + // 骨架屏 + return skeleton(); + } + }, + ), + ], ), ), ); } + + Widget skeleton() { + return SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + return const DynamicCardSkeleton(); + }, childCount: 5), + ); + } } diff --git a/lib/pages/dynamics/widgets/up_panel.dart b/lib/pages/dynamics/widgets/up_panel.dart new file mode 100644 index 00000000..0f499694 --- /dev/null +++ b/lib/pages/dynamics/widgets/up_panel.dart @@ -0,0 +1,263 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_meedu_media_kit/meedu_player.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:pilipala/common/widgets/badge.dart'; +import 'package:pilipala/common/widgets/network_img_layer.dart'; +import 'package:pilipala/models/common/dynamics_type.dart'; +import 'package:pilipala/models/dynamics/up.dart'; +import 'package:pilipala/pages/dynamics/controller.dart'; + +class UpPanel extends StatefulWidget { + FollowUpModel? upData; + UpPanel(this.upData, {Key? key}) : super(key: key); + + @override + State createState() => _UpPanelState(); +} + +class _UpPanelState extends State { + final ScrollController scrollController = ScrollController(); + int currentMid = -1; + late double contentWidth = 56; + List upList = []; + List liveList = []; + static const itemPadding = EdgeInsets.symmetric(horizontal: 5, vertical: 0); + + @override + void initState() { + super.initState(); + upList = widget.upData!.upList!; + liveList = widget.upData!.liveUsers!.items!; + upList.insert(0, UpItem(face: '', uname: '全部动态', mid: -1)); + } + + @override + Widget build(BuildContext context) { + return SliverPersistentHeader( + floating: true, + pinned: false, + delegate: _SliverHeaderDelegate( + height: 115, + child: Container( + height: 115, + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Theme.of(context).dividerColor.withOpacity(0.1), + blurRadius: 10, + spreadRadius: 2, + ), + ], + color: Theme.of(context).colorScheme.surface, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + top: 5, left: 12, right: 12, bottom: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text( + '最常访问', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + Expanded( + child: ListView( + scrollDirection: Axis.horizontal, + controller: scrollController, + children: [ + const SizedBox(width: 10), + for (int i = 0; i < liveList.length; i++) ...[ + upItemBuild(liveList[i], i) + ], + VerticalDivider( + indent: 15, + endIndent: 35, + width: 26, + color: Theme.of(context).primaryColor.withOpacity(0.5), + ), + for (int i = 0; i < upList.length; i++) ...[ + upItemBuild(upList[i], i) + ], + const SizedBox(width: 10), + ], + ), + ) + ], + ), + ), + ), + ); + } + + Widget upItemBuild(data, i) { + bool isCurrent = currentMid == data.mid || currentMid == -1; + return InkWell( + onTap: () { + if (data.type == 'up') { + currentMid = data.mid; + Get.find().mid = data.mid; + Get.find().onSelectUp(data.mid); + int liveLen = liveList.length; + int upLen = upList.length; + double itemWidth = contentWidth + itemPadding.horizontal; + double screenWidth = MediaQuery.of(context).size.width; + double moveDistance = 0.0; + if ((upLen - i - 0.5) * itemWidth > screenWidth / 2) { + moveDistance = + (i + liveLen + 0.5) * itemWidth + 46 - screenWidth / 2; + } else { + moveDistance = (upLen + liveLen) * itemWidth + 46 - screenWidth; + } + scrollController.animateTo( + moveDistance, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + + setState(() {}); + } else if (data.type == 'live') { + SmartDialog.showToast('直播功能暂未开发'); + } + }, + child: Padding( + padding: itemPadding, + child: AnimatedOpacity( + opacity: isCurrent ? 1 : 0.3, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Badge( + smallSize: 8, + label: data.type == 'live' ? const Text('Live') : null, + textColor: Theme.of(context).colorScheme.onPrimary, + alignment: AlignmentDirectional.bottomCenter, + padding: const EdgeInsets.only(left: 4, right: 4), + isLabelVisible: data.type == 'live' || + (data.type == 'up' && (data.hasUpdate ?? false)), + backgroundColor: Theme.of(context).primaryColor, + child: NetworkImgLayer( + width: 49, + height: 49, + src: data.face, + type: 'avatar', + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: SizedBox( + width: contentWidth, + child: Text( + data.uname, + overflow: TextOverflow.fade, + softWrap: false, + textAlign: TextAlign.center, + style: TextStyle( + color: currentMid == data.mid + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.outline, + fontSize: + Theme.of(context).textTheme.labelMedium!.fontSize), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SliverHeaderDelegate extends SliverPersistentHeaderDelegate { + _SliverHeaderDelegate({required this.height, required this.child}); + + final double height; + final Widget child; + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + return child; + } + + @override + double get maxExtent => height; + + @override + double get minExtent => height; + + @override + bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => + true; +} + +class UpPanelSkeleton extends StatelessWidget { + const UpPanelSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.only(top: 5, left: 12, right: 12, bottom: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text( + '最常访问', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + itemCount: 10, + itemBuilder: ((context, index) => Padding( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 0), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 49, + height: 49, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onInverseSurface, + borderRadius: const BorderRadius.all( + Radius.circular(24), + ), + ), + ), + Container( + margin: const EdgeInsets.only(top: 6), + width: 45, + height: 12, + color: Theme.of(context).colorScheme.onInverseSurface, + ), + ], + ), + )), + ), + ) + ], + ); + } +}