diff --git a/README.md b/README.md index 5cb6c8279..dbdb898d2 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ ## feat +- [x] 创建/修改/删除关注分组 +- [x] 移除粉丝 - [x] 直播弹幕发送表情 - [x] 收藏夹排序 - [x] 稍后再看`未看`/`未看完`/`已看完`分类 diff --git a/lib/common/widgets/dialog.dart b/lib/common/widgets/dialog.dart index 10a0565ac..fff355de5 100644 --- a/lib/common/widgets/dialog.dart +++ b/lib/common/widgets/dialog.dart @@ -4,7 +4,7 @@ import 'package:get/get.dart'; void showConfirmDialog({ required BuildContext context, required String title, - String? content, + dynamic content, required VoidCallback onConfirm, }) { showDialog( @@ -12,7 +12,11 @@ void showConfirmDialog({ builder: (context) { return AlertDialog( title: Text(title), - content: content == null ? null : Text(content), + content: content is String + ? Text(content) + : content is Widget + ? content + : null, actions: [ TextButton( onPressed: Get.back, diff --git a/lib/http/api.dart b/lib/http/api.dart index 3b670e62b..9465a0dae 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -286,10 +286,6 @@ class Api { // order_type 排序规则 最近访问传空,最常访问传 attention static const String followings = '/x/relation/followings'; - // 指定分类的关注 - // https://api.bilibili.com/x/relation/tag?mid=17340771&tagid=-10&pn=1&ps=20 - static const String tagFollowings = '/x/relation/tag'; - // 搜索follow static const followSearch = '/x/relation/followings/search'; @@ -469,6 +465,12 @@ class Api { // 获取指定分组下的up static const String followUpGroup = '/x/relation/tag'; + static const String createFollowTag = '/x/relation/tag/create'; + + static const String updateFollowTag = '/x/relation/tag/update'; + + static const String delFollowTag = '/x/relation/tag/del'; + // 获取未读私信数 // https://api.vc.bilibili.com/session_svr/v1/session_svr/single_unread static const String msgUnread = diff --git a/lib/http/follow.dart b/lib/http/follow.dart index 49b9adb35..8b1566de1 100644 --- a/lib/http/follow.dart +++ b/lib/http/follow.dart @@ -4,8 +4,12 @@ import '../models/follow/result.dart'; import 'index.dart'; class FollowHttp { - static Future followings( - {int? vmid, int? pn, int? ps, String? orderType}) async { + static Future followings({ + int? vmid, + int? pn, + int? ps, + String orderType = '', + }) async { var res = await Request().get(Api.followings, queryParameters: { 'vmid': vmid, 'pn': pn, @@ -23,8 +27,12 @@ class FollowHttp { } } - static Future?>> followingsNew( - {int? vmid, int? pn, int? ps, String? orderType}) async { + static Future> followingsNew({ + int? vmid, + int? pn, + int? ps, + String orderType = '', // ''=>最近关注,'attention'=>最常访问 + }) async { var res = await Request().get(Api.followings, queryParameters: { 'vmid': vmid, 'pn': pn, @@ -35,7 +43,8 @@ class FollowHttp { if (res.data['code'] == 0) { return LoadingState.success( - FollowDataModel.fromJson(res.data['data']).list); + FollowDataModel.fromJson(res.data['data']), + ); } else { return LoadingState.error(res.data['message']); } diff --git a/lib/http/member.dart b/lib/http/member.dart index a69558f7e..015ec6b63 100644 --- a/lib/http/member.dart +++ b/lib/http/member.dart @@ -470,8 +470,8 @@ class MemberHttp { if (res.data['code'] == 0) { return { 'status': true, - 'data': (res.data['data'] as List?) - ?.map((e) => MemberTagItemModel.fromJson(e)) + 'data': res.data['data'] + .map((e) => MemberTagItemModel.fromJson(e)) .toList() }; } else { @@ -525,7 +525,7 @@ class MemberHttp { } // 获取某分组下的up - static Future?>> followUpGroup( + static Future> followUpGroup( int? mid, int? tagid, int? pn, @@ -541,14 +541,82 @@ class MemberHttp { }, ); if (res.data['code'] == 0) { - return LoadingState.success((res.data['data'] as List?) - ?.map((e) => FollowItemModel.fromJson(e)) - .toList()); + return LoadingState.success(FollowDataModel( + list: (res.data['data'] as List?) + ?.map((e) => FollowItemModel.fromJson(e)) + .toList())); } else { return LoadingState.error(res.data['message']); } } + static Future createFollowTag(tagName) async { + var res = await Request().post( + Api.createFollowTag, + queryParameters: { + 'x-bili-device-req-json': + '{"platform":"web","device":"pc","spmid":"333.1387"}', + }, + data: { + 'tag': tagName, + '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 updateFollowTag(tagid, name) async { + var res = await Request().post( + Api.updateFollowTag, + queryParameters: { + 'x-bili-device-req-json': + '{"platform":"web","device":"pc","spmid":"333.1387"}', + }, + data: { + 'tagid': tagid, + 'name': name, + '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 delFollowTag(tagid) async { + var res = await Request().post( + Api.delFollowTag, + queryParameters: { + 'x-bili-device-req-json': + '{"platform":"web","device":"pc","spmid":"333.1387"}', + }, + data: { + 'tagid': tagid, + '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']}; + } + } + // 获取up置顶 static Future getTopVideo(String? vmid) async { var res = await Request().get(Api.getTopVideoApi); diff --git a/lib/pages/follow/child_controller.dart b/lib/pages/follow/child_controller.dart index 4c9aea72d..444dfae48 100644 --- a/lib/pages/follow/child_controller.dart +++ b/lib/pages/follow/child_controller.dart @@ -3,13 +3,25 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/member.dart'; import 'package:PiliPlus/models/follow/result.dart'; import 'package:PiliPlus/pages/common/common_list_controller.dart'; +import 'package:PiliPlus/pages/follow/controller.dart'; +import 'package:get/get.dart'; + +enum OrderType { def, attention } + +extension OrderTypeExt on OrderType { + String get type => const ['', 'attention'][index]; + String get title => const ['最近关注', '最常访问'][index]; +} class FollowChildController - extends CommonListController?, FollowItemModel> { - FollowChildController(this.mid, this.tagid); + extends CommonListController { + FollowChildController(this.controller, this.mid, this.tagid); + final FollowController controller; final int? tagid; final int mid; + late final Rx orderType = OrderType.def.obs; + @override void onInit() { super.onInit(); @@ -17,12 +29,35 @@ class FollowChildController } @override - Future?>> customGetData() { + List? getDataList(FollowDataModel response) { + return response.list; + } + + @override + bool customHandleResponse(bool isRefresh, Success response) { + try { + if (controller.isOwner && + tagid == null && + isRefresh && + controller.followState.value is Success) { + controller.tabs[0].count = response.response.total; + controller.tabs.refresh(); + } + } catch (_) {} + return false; + } + + @override + Future> customGetData() { if (tagid != null) { return MemberHttp.followUpGroup(mid, tagid, currentPage, 20); } return FollowHttp.followingsNew( - vmid: mid, pn: currentPage, ps: 20, orderType: 'attention'); + vmid: mid, + pn: currentPage, + ps: 20, + orderType: orderType.value.type, + ); } } diff --git a/lib/pages/follow/child_view.dart b/lib/pages/follow/child_view.dart index 56e91976e..1e3c3d1f4 100644 --- a/lib/pages/follow/child_view.dart +++ b/lib/pages/follow/child_view.dart @@ -4,14 +4,21 @@ import 'package:PiliPlus/common/widgets/refresh_indicator.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models/follow/result.dart'; import 'package:PiliPlus/pages/follow/child_controller.dart'; +import 'package:PiliPlus/pages/follow/controller.dart'; import 'package:PiliPlus/pages/follow/widgets/follow_item.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; class FollowChildPage extends StatefulWidget { - const FollowChildPage({super.key, required this.mid, this.tagid}); + const FollowChildPage({ + super.key, + required this.controller, + required this.mid, + this.tagid, + }); + final FollowController controller; final int mid; final int? tagid; @@ -22,30 +29,50 @@ class FollowChildPage extends StatefulWidget { class _FollowChildPageState extends State with AutomaticKeepAliveClientMixin { late final _followController = Get.put( - FollowChildController(widget.mid, widget.tagid), + FollowChildController(widget.controller, widget.mid, widget.tagid), tag: Utils.generateRandomString(8)); - late final _isOwner = widget.tagid != null; @override Widget build(BuildContext context) { super.build(context); - return refreshIndicator( - onRefresh: () async { - await _followController.onRefresh(); - }, - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverPadding( - padding: EdgeInsets.only( - bottom: MediaQuery.paddingOf(context).bottom + 80), - sliver: Obx(() => _buildBody(_followController.loadingState.value)), - ), - ], - ), - ); + if (widget.controller.isOwner && widget.tagid == null) { + return Scaffold( + backgroundColor: Colors.transparent, + body: _child, + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + _followController + ..orderType.value = + _followController.orderType.value == OrderType.def + ? OrderType.attention + : OrderType.def + ..onReload(); + }, + icon: const Icon(Icons.format_list_bulleted, size: 20), + label: Obx(() => Text(_followController.orderType.value.title)), + ), + ); + } + return _child; } + Widget get _child => refreshIndicator( + onRefresh: () async { + await _followController.onRefresh(); + }, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + bottom: MediaQuery.paddingOf(context).bottom + 80), + sliver: + Obx(() => _buildBody(_followController.loadingState.value)), + ), + ], + ), + ); + Widget _buildBody(LoadingState?> loadingState) { return switch (loadingState) { Loading() => SliverList.builder( @@ -63,7 +90,7 @@ class _FollowChildPageState extends State } return FollowItem( item: loadingState.response![index], - isOwner: _isOwner, + isOwner: widget.controller.isOwner, callback: (attr) { List list = (_followController.loadingState.value as Success) @@ -86,5 +113,5 @@ class _FollowChildPageState extends State } @override - bool get wantKeepAlive => widget.tagid != null; + bool get wantKeepAlive => widget.controller.tabController != null; } diff --git a/lib/pages/follow/controller.dart b/lib/pages/follow/controller.dart index 6f21875f4..48123ffe0 100644 --- a/lib/pages/follow/controller.dart +++ b/lib/pages/follow/controller.dart @@ -2,17 +2,17 @@ import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/member.dart'; import 'package:PiliPlus/models/member/tags.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:PiliPlus/utils/storage.dart'; -class FollowController extends GetxController - with GetSingleTickerProviderStateMixin { +class FollowController extends GetxController with GetTickerProviderStateMixin { late int mid; String? name; late bool isOwner; - late final Rx?>> followState = - LoadingState?>.loading().obs; + late final Rx followState = LoadingState.loading().obs; + late final RxList tabs = [].obs; TabController? tabController; @override @@ -33,12 +33,20 @@ class FollowController extends GetxController Future queryFollowUpTags() async { var res = await MemberHttp.followUpTags(); if (res['status']) { + tabs.clear(); + tabs.addAll(res['data']); + tabs.insert(0, MemberTagItemModel(name: '全部关注')); + int initialIndex = 0; + if (tabController != null) { + initialIndex = tabController!.index.clamp(0, tabs.length - 1); + tabController!.dispose(); + } tabController = TabController( - initialIndex: 0, - length: res['data'].length, + initialIndex: initialIndex, + length: tabs.length, vsync: this, ); - followState.value = LoadingState.success(res['data']); + followState.value = LoadingState.success(tabs.hashCode); } else { followState.value = LoadingState.error(res['msg']); } @@ -49,4 +57,37 @@ class FollowController extends GetxController tabController?.dispose(); super.onClose(); } + + Future onCreateTag(String tagName) async { + final res = await MemberHttp.createFollowTag(tagName); + if (res['status']) { + followState.value = LoadingState.loading(); + queryFollowUpTags(); + SmartDialog.showToast('创建成功'); + } else { + SmartDialog.showToast(res['msg']); + } + } + + Future onUpdateTag(int index, tagid, String tagName) async { + final res = await MemberHttp.updateFollowTag(tagid, tagName); + if (res['status']) { + tabs[index].name = tagName; + tabs.refresh(); + SmartDialog.showToast('修改成功'); + } else { + SmartDialog.showToast(res['msg']); + } + } + + Future onDelTag(tagid) async { + final res = await MemberHttp.delFollowTag(tagid); + if (res['status']) { + followState.value = LoadingState.loading(); + queryFollowUpTags(); + SmartDialog.showToast('删除成功'); + } else { + SmartDialog.showToast(res['msg']); + } + } } diff --git a/lib/pages/follow/view.dart b/lib/pages/follow/view.dart index 641211631..2dd18b666 100644 --- a/lib/pages/follow/view.dart +++ b/lib/pages/follow/view.dart @@ -1,3 +1,4 @@ +import 'package:PiliPlus/common/widgets/dialog.dart'; import 'package:PiliPlus/common/widgets/loading_widget.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart'; import 'package:PiliPlus/http/loading_state.dart'; @@ -5,6 +6,7 @@ import 'package:PiliPlus/models/member/tags.dart'; import 'package:PiliPlus/pages/follow/child_view.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'controller.dart'; @@ -16,8 +18,10 @@ class FollowPage extends StatefulWidget { } class _FollowPageState extends State { - final FollowController _followController = - Get.put(FollowController(), tag: Utils.generateRandomString(8)); + final FollowController _followController = Get.put( + FollowController(), + tag: Utils.generateRandomString(8), + ); @override Widget build(BuildContext context) { @@ -28,6 +32,11 @@ class _FollowPageState extends State { ), actions: _followController.isOwner ? [ + IconButton( + onPressed: _onCreateTag, + icon: const Icon(Icons.add), + tooltip: '新建分组', + ), IconButton( onPressed: () => Get.toNamed( '/followSearch', @@ -60,50 +69,171 @@ class _FollowPageState extends State { ), body: _followController.isOwner ? Obx(() => _buildBody(_followController.followState.value)) - : FollowChildPage(mid: _followController.mid), + : FollowChildPage( + controller: _followController, mid: _followController.mid), ); } - Widget _buildBody(LoadingState?> loadingState) { + bool _isCustomTag(tagid) { + return tagid != null && tagid != 0 && tagid != -10 && tagid != -2; + } + + Widget _buildBody(LoadingState loadingState) { return switch (loadingState) { Loading() => loadingWidget, - Success() => loadingState.response?.isNotEmpty == true - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SafeArea( - top: false, - bottom: false, - child: TabBar( - isScrollable: true, - tabAlignment: TabAlignment.start, - controller: _followController.tabController, - tabs: loadingState.response! - .map((item) => Tab(text: item.name)) - .toList(), - ), + Success() => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SafeArea( + top: false, + bottom: false, + child: TabBar( + isScrollable: true, + tabAlignment: TabAlignment.start, + controller: _followController.tabController, + tabs: List.generate(_followController.tabs.length, (index) { + return Obx(() { + final item = _followController.tabs[index]; + int? count = item.count; + if (_isCustomTag(item.tagid)) { + return GestureDetector( + onLongPress: () { + _onHandleTag(index, item); + }, + child: Tab( + child: Row( + children: [ + Text( + '${item.name}${count != null ? '($count)' : ''} ', + ), + Icon(Icons.menu, size: 18), + ], + ), + ), + ); + } + return Tab( + text: '${item.name}${count != null ? '($count)' : ''}'); + }); + }).toList(), + onTap: (value) { + if (!_followController.tabController!.indexIsChanging) { + final item = _followController.tabs[value]; + if (_isCustomTag(item.tagid)) { + _onHandleTag(value, item); + } + } + }, + ), + ), + Expanded( + child: Material( + color: Colors.transparent, + child: tabBarView( + controller: _followController.tabController, + children: _followController.tabs + .map( + (item) => FollowChildPage( + controller: _followController, + mid: _followController.mid, + tagid: item.tagid, + ), + ) + .toList(), ), - Expanded( - child: Material( - color: Colors.transparent, - child: tabBarView( - controller: _followController.tabController, - children: loadingState.response! - .map( - (item) => FollowChildPage( - mid: _followController.mid, - tagid: item.tagid, - ), - ) - .toList(), - ), - ), - ), - ], - ) - : FollowChildPage(mid: _followController.mid), - Error() => FollowChildPage(mid: _followController.mid), + ), + ), + ], + ), + Error() => FollowChildPage( + controller: _followController, mid: _followController.mid), _ => throw UnimplementedError(), }; } + + void _onHandleTag(int index, MemberTagItemModel item) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + clipBehavior: Clip.hardEdge, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + onTap: () { + Get.back(); + String tagName = item.name!; + showConfirmDialog( + context: context, + title: '编辑分组名称', + content: TextFormField( + autofocus: true, + initialValue: tagName, + onChanged: (value) => tagName = value, + inputFormatters: [ + LengthLimitingTextInputFormatter(16), + ], + decoration: + const InputDecoration(border: OutlineInputBorder()), + ), + onConfirm: () { + if (tagName.isNotEmpty) { + _followController.onUpdateTag( + index, item.tagid, tagName); + } + }, + ); + }, + dense: true, + title: const Text( + '修改名称', + style: TextStyle(fontSize: 14), + ), + ), + ListTile( + onTap: () { + Get.back(); + showConfirmDialog( + context: context, + title: '删除分组', + content: '删除后,该分组下的用户依旧保留?', + onConfirm: () { + _followController.onDelTag(item.tagid); + }, + ); + }, + dense: true, + title: const Text( + '删除分组', + style: TextStyle(fontSize: 14), + ), + ), + ], + ), + ); + }, + ); + } + + void _onCreateTag() { + String tagName = ''; + showConfirmDialog( + context: context, + title: '新建分组', + content: TextFormField( + autofocus: true, + initialValue: tagName, + onChanged: (value) => tagName = value, + inputFormatters: [ + LengthLimitingTextInputFormatter(16), + ], + decoration: const InputDecoration(border: OutlineInputBorder()), + ), + onConfirm: () { + _followController.onCreateTag(tagName); + }, + ); + } }