diff --git a/lib/common/widgets/pull_to_refresh_header.dart b/lib/common/widgets/pull_to_refresh_header.dart new file mode 100644 index 00000000..eaf67210 --- /dev/null +++ b/lib/common/widgets/pull_to_refresh_header.dart @@ -0,0 +1,129 @@ +import 'dart:math'; +import 'dart:ui' as ui show Image; + +import 'package:extended_image/extended_image.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:pull_to_refresh_notification/pull_to_refresh_notification.dart'; + +double get maxDragOffset => 100; +double hideHeight = maxDragOffset / 2.3; +double refreshHeight = maxDragOffset / 1.5; + +class PullToRefreshHeader extends StatelessWidget { + const PullToRefreshHeader( + this.info, + this.lastRefreshTime, { + this.color, + }); + + final PullToRefreshScrollNotificationInfo? info; + final DateTime? lastRefreshTime; + final Color? color; + + @override + Widget build(BuildContext context) { + final PullToRefreshScrollNotificationInfo? _info = info; + if (_info == null) { + return Container(); + } + String text = ''; + if (_info.mode == PullToRefreshIndicatorMode.armed) { + text = 'Release to refresh'; + } else if (_info.mode == PullToRefreshIndicatorMode.refresh || + _info.mode == PullToRefreshIndicatorMode.snap) { + text = 'Loading...'; + } else if (_info.mode == PullToRefreshIndicatorMode.done) { + text = 'Refresh completed.'; + } else if (_info.mode == PullToRefreshIndicatorMode.drag) { + text = 'Pull to refresh'; + } else if (_info.mode == PullToRefreshIndicatorMode.canceled) { + text = 'Cancel refresh'; + } + + final TextStyle ts = const TextStyle( + color: Colors.grey, + ).copyWith(fontSize: 14); + + final double dragOffset = info?.dragOffset ?? 0.0; + + final DateTime time = lastRefreshTime ?? DateTime.now(); + final double top = -hideHeight + dragOffset; + return Container( + height: dragOffset, + color: color ?? Colors.transparent, + // padding: EdgeInsets.only(top: dragOffset / 3), + // padding: EdgeInsets.only(bottom: 5.0), + child: Stack( + children: [ + Positioned( + left: 0.0, + right: 0.0, + top: top, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Container( + alignment: Alignment.centerRight, + child: RefreshImage(top), + margin: const EdgeInsets.only(right: 12.0), + ), + ), + Column( + children: [ + Text(text, style: ts), + Text( + 'Last updated:' + + DateFormat('yyyy-MM-dd hh:mm').format(time), + style: ts.copyWith(fontSize: 14), + ) + ], + ), + const Spacer(), + ], + ), + ) + ], + ), + ); + } +} + +class RefreshImage extends StatelessWidget { + const RefreshImage(this.top); + + final double top; + + @override + Widget build(BuildContext context) { + const double imageSize = 30; + return ExtendedImage.asset( + 'assets/flutterCandies_grey.png', + width: imageSize, + height: imageSize, + afterPaintImage: (Canvas canvas, Rect rect, ui.Image image, Paint paint) { + final double imageHeight = image.height.toDouble(); + final double imageWidth = image.width.toDouble(); + final Size size = rect.size; + final double y = + (1 - min(top / (refreshHeight - hideHeight), 1)) * imageHeight; + + canvas.drawImageRect( + image, + Rect.fromLTWH(0.0, y, imageWidth, imageHeight - y), + Rect.fromLTWH(rect.left, rect.top + y / imageHeight * size.height, + size.width, (imageHeight - y) / imageHeight * size.height), + Paint() + ..colorFilter = + const ColorFilter.mode(Color(0xFFea5504), BlendMode.srcIn) + ..isAntiAlias = false + ..filterQuality = FilterQuality.low, + ); + + //canvas.restore(); + }, + ); + } +} diff --git a/lib/http/api.dart b/lib/http/api.dart index b8805cc4..e4356840 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -203,4 +203,19 @@ class Api { // 用户名片信息 static const String memberCardInfo = '/x/web-interface/card'; + + // 用户投稿 + // https://api.bilibili.com/x/space/wbi/arc/search? + // mid=85754245& + // ps=30& + // tid=0& + // pn=1& + // keyword=& + // order=pubdate& + // platform=web& + // web_location=1550101& + // order_avoided=true& + // w_rid=d893cf98a4e010cf326373194a648360& + // wts=1689767832 + static const String memberArchive = '/x/space/wbi/arc/search'; } diff --git a/lib/http/member.dart b/lib/http/member.dart index d9f33bcc..63442b04 100644 --- a/lib/http/member.dart +++ b/lib/http/member.dart @@ -1,9 +1,23 @@ import 'package:pilipala/http/index.dart'; +import 'package:pilipala/models/member/archive.dart'; import 'package:pilipala/models/member/info.dart'; +import 'package:pilipala/utils/wbi_sign.dart'; class MemberHttp { - static Future memberInfo({String? params}) async { - var res = await Request().get(Api.memberInfo + params!); + static Future memberInfo({ + int? mid, + String token = '', + }) async { + Map params = await WbiSign().makSign({ + 'mid': mid, + 'token': token, + 'platform': 'web', + 'web_location': 1550101, + }); + var res = await Request().get( + Api.memberInfo, + data: params, + ); if (res.data['code'] == 0) { return { 'status': true, @@ -44,4 +58,42 @@ class MemberHttp { }; } } + + static Future memberArchive({ + int? mid, + int ps = 30, + int tid = 0, + int? pn, + String keyword = '', + String order = 'pubdate', + bool orderAvoided = true, + }) async { + Map params = await WbiSign().makSign({ + 'mid': mid, + 'ps': ps, + 'tid': tid, + 'pn': pn, + 'keyword': keyword, + 'order': order, + 'platform': 'web', + 'web_location': 1550101, + 'order_avoided': orderAvoided + }); + var res = await Request().get( + Api.memberArchive, + data: params, + ); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': MemberArchiveDataModel.fromJson(res.data['data']) + }; + } else { + return { + 'status': false, + 'data': [], + 'msg': res.data['message'], + }; + } + } } diff --git a/lib/models/member/archive.dart b/lib/models/member/archive.dart new file mode 100644 index 00000000..5d2ea77e --- /dev/null +++ b/lib/models/member/archive.dart @@ -0,0 +1,164 @@ +class MemberArchiveDataModel { + MemberArchiveDataModel({ + this.list, + this.page, + }); + + ArchiveListModel? list; + Map? page; + + MemberArchiveDataModel.fromJson(Map json) { + list = ArchiveListModel.fromJson(json['list']); + page = json['page']; + } +} + +class ArchiveListModel { + ArchiveListModel({ + this.tlist, + this.vlist, + }); + + Map? tlist; + List? vlist; + + ArchiveListModel.fromJson(Map json) { + tlist = json['tlist'] != null + ? Map.from(json['tlist']).map((k, v) => + MapEntry(k, TListItemModel.fromJson(v))) + : {}; + vlist = json['vlist'] + .map((e) => VListItemModel.fromJson(e)) + .toList(); + } +} + +class TListItemModel { + TListItemModel({ + this.tid, + this.count, + this.name, + }); + + int? tid; + int? count; + String? name; + + TListItemModel.fromJson(Map json) { + tid = json['tid']; + count = json['count']; + name = json['name']; + } +} + +class VListItemModel { + VListItemModel({ + this.comment, + this.typeid, + this.play, + this.pic, + this.subtitle, + this.description, + this.copyright, + this.title, + this.review, + this.author, + this.mid, + this.created, + this.pubdate, + this.length, + this.duration, + this.videoReview, + this.aid, + this.bvid, + this.cid, + this.hideClick, + this.isChargingSrc, + this.rcmdReason, + this.owner, + }); + + int? comment; + int? typeid; + int? play; + String? pic; + String? subtitle; + String? description; + String? copyright; + String? title; + int? review; + String? author; + int? mid; + int? created; + int? pubdate; + String? length; + String? duration; + int? videoReview; + int? aid; + String? bvid; + int? cid; + bool? hideClick; + bool? isChargingSrc; + Stat? stat; + String? rcmdReason; + Owner? owner; + + VListItemModel.fromJson(Map json) { + comment = json['comment']; + typeid = json['typeid']; + play = json['play']; + pic = json['pic']; + subtitle = json['subtitle']; + description = json['description']; + copyright = json['copyright']; + title = json['title']; + review = json['review']; + author = json['author']; + mid = json['mid']; + created = json['created']; + pubdate = json['created']; + length = json['length']; + duration = json['length']; + videoReview = json['video_review']; + aid = json['aid']; + bvid = json['bvid']; + cid = null; + hideClick = json['hide_click']; + isChargingSrc = json['is_charging_arc']; + stat = Stat.fromJson(json); + rcmdReason = null; + owner = Owner.fromJson(json); + } +} + +class Stat { + Stat({ + this.view, + this.danmaku, + }); + + int? view; + int? danmaku; + + Stat.fromJson(Map json) { + view = json["play"]; + danmaku = json['comment']; + } +} + +class Owner { + Owner({ + this.mid, + this.name, + this.face, + }); + int? mid; + String? name; + String? face; + + Owner.fromJson(Map json) { + mid = json["mid"]; + name = json["author"]; + face = ''; + } +} diff --git a/lib/pages/member/archive/controller.dart b/lib/pages/member/archive/controller.dart new file mode 100644 index 00000000..52400893 --- /dev/null +++ b/lib/pages/member/archive/controller.dart @@ -0,0 +1,22 @@ +import 'package:get/get.dart'; +import 'package:pilipala/http/member.dart'; + +class ArchiveController extends GetxController { + int? mid; + int pn = 1; + + @override + void onInit() { + super.onInit(); + mid = int.parse(Get.parameters['mid']!); + } + + // 获取用户投稿 + Future getMemberArchive() async { + var res = await MemberHttp.memberArchive(mid: mid, pn: pn); + if (res['status']) { + pn += 1; + } + return res; + } +} diff --git a/lib/pages/member/archive/index.dart b/lib/pages/member/archive/index.dart new file mode 100644 index 00000000..8be45429 --- /dev/null +++ b/lib/pages/member/archive/index.dart @@ -0,0 +1,4 @@ +library archive_panel; + +export './controller.dart'; +export 'index.dart'; diff --git a/lib/pages/member/archive/view.dart b/lib/pages/member/archive/view.dart new file mode 100644 index 00000000..4ec77775 --- /dev/null +++ b/lib/pages/member/archive/view.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:loading_more_list/loading_more_list.dart'; +import 'package:pilipala/common/widgets/pull_to_refresh_header.dart'; +import 'package:pilipala/common/widgets/video_card_h.dart'; +import 'package:pilipala/models/member/archive.dart'; +import 'package:pilipala/pages/member/archive/index.dart'; +import 'package:pull_to_refresh_notification/pull_to_refresh_notification.dart'; + +class ArchivePanel extends StatefulWidget { + const ArchivePanel({super.key}); + + @override + State createState() => _ArchivePanelState(); +} + +class _ArchivePanelState extends State + with AutomaticKeepAliveClientMixin { + DateTime lastRefreshTime = DateTime.now(); + late final LoadMoreListSource source = LoadMoreListSource(); + + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + return PullToRefreshNotification( + onRefresh: () async { + await Future.delayed(const Duration(seconds: 1)); + return true; + }, + maxDragOffset: 50, + child: GlowNotificationWidget( + Column( + children: [ + // 下拉刷新指示器 + PullToRefreshContainer( + (PullToRefreshScrollNotificationInfo? info) { + return PullToRefreshHeader(info, lastRefreshTime); + }, + ), + const SizedBox(height: 4), + Expanded( + child: LoadingMoreList( + ListConfig( + sourceList: source, + itemBuilder: + (BuildContext c, VListItemModel item, int index) { + return VideoCardH(videoItem: item); + }, + indicatorBuilder: (context, status) { + return const Center(child: Text('加载中')); + }, + ), + ), + ) + ], + ), + showGlowLeading: false, + ), + ); + } +} + +class LoadMoreListSource extends LoadingMoreBase { + final ArchiveController _archiveController = Get.put(ArchiveController()); + @override + Future loadData([bool isloadMoreAction = false]) { + return Future(() async { + var res = await _archiveController.getMemberArchive(); + if (res['status']) { + addAll(res['data'].list.vlist); + } + return true; + }); + } +} diff --git a/lib/pages/member/controller.dart b/lib/pages/member/controller.dart index 54c23698..194c1922 100644 --- a/lib/pages/member/controller.dart +++ b/lib/pages/member/controller.dart @@ -1,6 +1,7 @@ import 'package:get/get.dart'; import 'package:hive/hive.dart'; import 'package:pilipala/http/member.dart'; +import 'package:pilipala/models/member/archive.dart'; import 'package:pilipala/models/member/info.dart'; import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/wbi_sign.dart'; @@ -13,6 +14,8 @@ class MemberController extends GetxController { String? heroTag; Box user = GStrorage.user; late int ownerMid; + // 投稿列表 + RxList? archiveList = [VListItemModel()].obs; @override void onInit() { @@ -26,14 +29,7 @@ class MemberController extends GetxController { // 获取用户信息 Future> getInfo() async { await getMemberStat(); - String params = await WbiSign().makSign({ - 'mid': mid, - 'token': '', - 'platform': 'web', - 'web_location': 1550101, - }); - params = '?$params'; - var res = await MemberHttp.memberInfo(params: params); + var res = await MemberHttp.memberInfo(mid: mid); if (res['status']) { memberInfo.value = res['data']; } diff --git a/lib/pages/member/view.dart b/lib/pages/member/view.dart index 3025416e..ee33b954 100644 --- a/lib/pages/member/view.dart +++ b/lib/pages/member/view.dart @@ -1,11 +1,11 @@ import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; +import 'package:loading_more_list/loading_more_list.dart'; import 'package:pilipala/common/widgets/network_img_layer.dart'; import 'package:pilipala/models/live/item.dart'; -import 'package:pilipala/models/user/stat.dart'; +import 'package:pilipala/pages/member/archive/view.dart'; import 'package:pilipala/pages/member/index.dart'; import 'package:pilipala/utils/utils.dart'; @@ -19,13 +19,15 @@ class MemberPage extends StatefulWidget { class _MemberPageState extends State with SingleTickerProviderStateMixin { final MemberController _memberController = Get.put(MemberController()); + Future? _futureBuilderFuture; final ScrollController _extendNestCtr = ScrollController(); late TabController _tabController; @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this); + _tabController = TabController(length: 3, vsync: this, initialIndex: 2); + _futureBuilderFuture = _memberController.getInfo(); } @override @@ -90,7 +92,7 @@ class _MemberPageState extends State Padding( padding: const EdgeInsets.only(left: 18, right: 18), child: FutureBuilder( - future: _memberController.getInfo(), + future: _futureBuilderFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { @@ -264,7 +266,7 @@ class _MemberPageState extends State children: [ Text('主页'), Text('动态'), - Text('投稿'), + ArchivePanel(), ], )) ], diff --git a/lib/utils/storage.dart b/lib/utils/storage.dart index 0bbd4136..df3c92b8 100644 --- a/lib/utils/storage.dart +++ b/lib/utils/storage.dart @@ -12,6 +12,7 @@ class GStrorage { static late final Box userInfo; static late final Box hotKeyword; static late final Box historyword; + static late final Box localCache; static Future init() async { final dir = await getApplicationDocumentsDirectory(); @@ -28,6 +29,8 @@ class GStrorage { hotKeyword = await Hive.openBox('hotKeyword'); // 搜索历史 historyword = await Hive.openBox('historyWord'); + // 本地缓存 + localCache = await Hive.openBox('localCache'); } static regAdapter() { diff --git a/lib/utils/wbi_sign.dart b/lib/utils/wbi_sign.dart index 419884ce..39c88389 100644 --- a/lib/utils/wbi_sign.dart +++ b/lib/utils/wbi_sign.dart @@ -2,11 +2,15 @@ // https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/sign/wbi.md // import md5 from 'md5' // import axios from 'axios' +import 'package:hive/hive.dart'; import 'package:pilipala/http/index.dart'; import 'package:crypto/crypto.dart'; import 'dart:convert'; +import 'package:pilipala/utils/storage.dart'; + class WbiSign { + static Box localCache = GStrorage.user; List mixinKeyEncTab = [ 46, 47, @@ -83,7 +87,7 @@ class WbiSign { } // 为请求参数进行 wbi 签名 - String encWbi(params, imgKey, subKey) { + Map encWbi(params, imgKey, subKey) { String mixinKey = getMixinKey(imgKey + subKey); DateTime now = DateTime.now(); int currTime = (now.millisecondsSinceEpoch / 1000).round(); @@ -99,19 +103,25 @@ class WbiSign { String queryStr = query.join('&'); String wbiSign = md5.convert(utf8.encode(queryStr + mixinKey)).toString(); // 计算 w_rid - print('w_rid: $wbiSign'); - return '$queryStr&w_rid=$wbiSign'; + return {'wts': currTime.toString(), 'w_rid': wbiSign}; } - // 获取最新的 img_key 和 sub_key + // 获取最新的 img_key 和 sub_key 可以从缓存中获取 static Future> getWbiKeys() async { + DateTime nowDate = DateTime.now(); + if (localCache.get('wbiKeys') != null && + DateTime.fromMillisecondsSinceEpoch(localCache.get('timeStamp')).day == + nowDate.day) { + Map cacheWbiKeys = localCache.get('wbiKeys'); + return Map.from(cacheWbiKeys); + } var resp = await Request().get('https://api.bilibili.com/x/web-interface/nav'); var jsonContent = resp.data['data']; String imgUrl = jsonContent['wbi_img']['img_url']; String subUrl = jsonContent['wbi_img']['sub_url']; - return { + Map wbiKeys = { 'imgKey': imgUrl .substring(imgUrl.lastIndexOf('/') + 1, imgUrl.length) .split('.')[0], @@ -119,12 +129,16 @@ class WbiSign { .substring(subUrl.lastIndexOf('/') + 1, subUrl.length) .split('.')[0] }; + localCache.put('wbiKeys', wbiKeys); + localCache.put('timeStamp', nowDate.millisecondsSinceEpoch); + return wbiKeys; } makSign(Map params) async { // params 为需要加密的请求参数 Map wbiKeys = await getWbiKeys(); - String query = encWbi(params, wbiKeys['imgKey'], wbiKeys['subKey']); + Map query = params + ..addAll(encWbi(params, wbiKeys['imgKey'], wbiKeys['subKey'])); return query; } } diff --git a/pubspec.lock b/pubspec.lock index c8fa36f7..d96ee6ad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -926,6 +926,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.3" + pull_to_refresh_notification: + dependency: "direct main" + description: + name: pull_to_refresh_notification + sha256: "3f27b9695c98770db3f9f50550e5ab44a6d946d022311a55bbe6d5cd4c69a1ad" + url: "https://pub.dev" + source: hosted + version: "3.0.1" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 17d711cd..2f51a143 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -87,6 +87,7 @@ dependencies: custom_sliding_segmented_control: ^1.7.5 loading_more_list: ^5.0.3 crypto: any + pull_to_refresh_notification: ^3.0.1 dev_dependencies: flutter_test: