feat: 用户投稿

This commit is contained in:
guozhigq
2023-07-20 09:55:05 +08:00
parent 04c90830bb
commit 023e8013a5
13 changed files with 508 additions and 21 deletions

View File

@ -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: <Widget>[
Positioned(
left: 0.0,
right: 0.0,
top: top,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Expanded(
child: Container(
alignment: Alignment.centerRight,
child: RefreshImage(top),
margin: const EdgeInsets.only(right: 12.0),
),
),
Column(
children: <Widget>[
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();
},
);
}
}

View File

@ -203,4 +203,19 @@ class Api {
// 用户名片信息 // 用户名片信息
static const String memberCardInfo = '/x/web-interface/card'; 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';
} }

View File

@ -1,9 +1,23 @@
import 'package:pilipala/http/index.dart'; import 'package:pilipala/http/index.dart';
import 'package:pilipala/models/member/archive.dart';
import 'package:pilipala/models/member/info.dart'; import 'package:pilipala/models/member/info.dart';
import 'package:pilipala/utils/wbi_sign.dart';
class MemberHttp { class MemberHttp {
static Future memberInfo({String? params}) async { static Future memberInfo({
var res = await Request().get(Api.memberInfo + params!); 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) { if (res.data['code'] == 0) {
return { return {
'status': true, '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'],
};
}
}
} }

View File

@ -0,0 +1,164 @@
class MemberArchiveDataModel {
MemberArchiveDataModel({
this.list,
this.page,
});
ArchiveListModel? list;
Map? page;
MemberArchiveDataModel.fromJson(Map<String, dynamic> json) {
list = ArchiveListModel.fromJson(json['list']);
page = json['page'];
}
}
class ArchiveListModel {
ArchiveListModel({
this.tlist,
this.vlist,
});
Map<String, TListItemModel>? tlist;
List<VListItemModel>? vlist;
ArchiveListModel.fromJson(Map<String, dynamic> json) {
tlist = json['tlist'] != null
? Map.from(json['tlist']).map((k, v) =>
MapEntry<String, TListItemModel>(k, TListItemModel.fromJson(v)))
: {};
vlist = json['vlist']
.map<VListItemModel>((e) => VListItemModel.fromJson(e))
.toList();
}
}
class TListItemModel {
TListItemModel({
this.tid,
this.count,
this.name,
});
int? tid;
int? count;
String? name;
TListItemModel.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
mid = json["mid"];
name = json["author"];
face = '';
}
}

View File

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

View File

@ -0,0 +1,4 @@
library archive_panel;
export './controller.dart';
export 'index.dart';

View File

@ -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<ArchivePanel> createState() => _ArchivePanelState();
}
class _ArchivePanelState extends State<ArchivePanel>
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: <Widget>[
// 下拉刷新指示器
PullToRefreshContainer(
(PullToRefreshScrollNotificationInfo? info) {
return PullToRefreshHeader(info, lastRefreshTime);
},
),
const SizedBox(height: 4),
Expanded(
child: LoadingMoreList<VListItemModel>(
ListConfig<VListItemModel>(
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<VListItemModel> {
final ArchiveController _archiveController = Get.put(ArchiveController());
@override
Future<bool> loadData([bool isloadMoreAction = false]) {
return Future<bool>(() async {
var res = await _archiveController.getMemberArchive();
if (res['status']) {
addAll(res['data'].list.vlist);
}
return true;
});
}
}

View File

@ -1,6 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:pilipala/http/member.dart'; import 'package:pilipala/http/member.dart';
import 'package:pilipala/models/member/archive.dart';
import 'package:pilipala/models/member/info.dart'; import 'package:pilipala/models/member/info.dart';
import 'package:pilipala/utils/storage.dart'; import 'package:pilipala/utils/storage.dart';
import 'package:pilipala/utils/wbi_sign.dart'; import 'package:pilipala/utils/wbi_sign.dart';
@ -13,6 +14,8 @@ class MemberController extends GetxController {
String? heroTag; String? heroTag;
Box user = GStrorage.user; Box user = GStrorage.user;
late int ownerMid; late int ownerMid;
// 投稿列表
RxList<VListItemModel>? archiveList = [VListItemModel()].obs;
@override @override
void onInit() { void onInit() {
@ -26,14 +29,7 @@ class MemberController extends GetxController {
// 获取用户信息 // 获取用户信息
Future<Map<String, dynamic>> getInfo() async { Future<Map<String, dynamic>> getInfo() async {
await getMemberStat(); await getMemberStat();
String params = await WbiSign().makSign({ var res = await MemberHttp.memberInfo(mid: mid);
'mid': mid,
'token': '',
'platform': 'web',
'web_location': 1550101,
});
params = '?$params';
var res = await MemberHttp.memberInfo(params: params);
if (res['status']) { if (res['status']) {
memberInfo.value = res['data']; memberInfo.value = res['data'];
} }

View File

@ -1,11 +1,11 @@
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.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/common/widgets/network_img_layer.dart';
import 'package:pilipala/models/live/item.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/pages/member/index.dart';
import 'package:pilipala/utils/utils.dart'; import 'package:pilipala/utils/utils.dart';
@ -19,13 +19,15 @@ class MemberPage extends StatefulWidget {
class _MemberPageState extends State<MemberPage> class _MemberPageState extends State<MemberPage>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
final MemberController _memberController = Get.put(MemberController()); final MemberController _memberController = Get.put(MemberController());
Future? _futureBuilderFuture;
final ScrollController _extendNestCtr = ScrollController(); final ScrollController _extendNestCtr = ScrollController();
late TabController _tabController; late TabController _tabController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 3, vsync: this); _tabController = TabController(length: 3, vsync: this, initialIndex: 2);
_futureBuilderFuture = _memberController.getInfo();
} }
@override @override
@ -90,7 +92,7 @@ class _MemberPageState extends State<MemberPage>
Padding( Padding(
padding: const EdgeInsets.only(left: 18, right: 18), padding: const EdgeInsets.only(left: 18, right: 18),
child: FutureBuilder( child: FutureBuilder(
future: _memberController.getInfo(), future: _futureBuilderFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == if (snapshot.connectionState ==
ConnectionState.done) { ConnectionState.done) {
@ -264,7 +266,7 @@ class _MemberPageState extends State<MemberPage>
children: [ children: [
Text('主页'), Text('主页'),
Text('动态'), Text('动态'),
Text('投稿'), ArchivePanel(),
], ],
)) ))
], ],

View File

@ -12,6 +12,7 @@ class GStrorage {
static late final Box userInfo; static late final Box userInfo;
static late final Box hotKeyword; static late final Box hotKeyword;
static late final Box historyword; static late final Box historyword;
static late final Box localCache;
static Future<void> init() async { static Future<void> init() async {
final dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationDocumentsDirectory();
@ -28,6 +29,8 @@ class GStrorage {
hotKeyword = await Hive.openBox('hotKeyword'); hotKeyword = await Hive.openBox('hotKeyword');
// 搜索历史 // 搜索历史
historyword = await Hive.openBox('historyWord'); historyword = await Hive.openBox('historyWord');
// 本地缓存
localCache = await Hive.openBox('localCache');
} }
static regAdapter() { static regAdapter() {

View File

@ -2,11 +2,15 @@
// https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/sign/wbi.md // https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/misc/sign/wbi.md
// import md5 from 'md5' // import md5 from 'md5'
// import axios from 'axios' // import axios from 'axios'
import 'package:hive/hive.dart';
import 'package:pilipala/http/index.dart'; import 'package:pilipala/http/index.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:pilipala/utils/storage.dart';
class WbiSign { class WbiSign {
static Box localCache = GStrorage.user;
List mixinKeyEncTab = [ List mixinKeyEncTab = [
46, 46,
47, 47,
@ -83,7 +87,7 @@ class WbiSign {
} }
// 为请求参数进行 wbi 签名 // 为请求参数进行 wbi 签名
String encWbi(params, imgKey, subKey) { Map<String, dynamic> encWbi(params, imgKey, subKey) {
String mixinKey = getMixinKey(imgKey + subKey); String mixinKey = getMixinKey(imgKey + subKey);
DateTime now = DateTime.now(); DateTime now = DateTime.now();
int currTime = (now.millisecondsSinceEpoch / 1000).round(); int currTime = (now.millisecondsSinceEpoch / 1000).round();
@ -99,19 +103,25 @@ class WbiSign {
String queryStr = query.join('&'); String queryStr = query.join('&');
String wbiSign = String wbiSign =
md5.convert(utf8.encode(queryStr + mixinKey)).toString(); // 计算 w_rid md5.convert(utf8.encode(queryStr + mixinKey)).toString(); // 计算 w_rid
print('w_rid: $wbiSign'); return {'wts': currTime.toString(), 'w_rid': wbiSign};
return '$queryStr&w_rid=$wbiSign';
} }
// 获取最新的 img_key 和 sub_key // 获取最新的 img_key 和 sub_key 可以从缓存中获取
static Future<Map<String, dynamic>> getWbiKeys() async { static Future<Map<String, dynamic>> 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<String, dynamic>.from(cacheWbiKeys);
}
var resp = var resp =
await Request().get('https://api.bilibili.com/x/web-interface/nav'); await Request().get('https://api.bilibili.com/x/web-interface/nav');
var jsonContent = resp.data['data']; var jsonContent = resp.data['data'];
String imgUrl = jsonContent['wbi_img']['img_url']; String imgUrl = jsonContent['wbi_img']['img_url'];
String subUrl = jsonContent['wbi_img']['sub_url']; String subUrl = jsonContent['wbi_img']['sub_url'];
return { Map<String, dynamic> wbiKeys = {
'imgKey': imgUrl 'imgKey': imgUrl
.substring(imgUrl.lastIndexOf('/') + 1, imgUrl.length) .substring(imgUrl.lastIndexOf('/') + 1, imgUrl.length)
.split('.')[0], .split('.')[0],
@ -119,12 +129,16 @@ class WbiSign {
.substring(subUrl.lastIndexOf('/') + 1, subUrl.length) .substring(subUrl.lastIndexOf('/') + 1, subUrl.length)
.split('.')[0] .split('.')[0]
}; };
localCache.put('wbiKeys', wbiKeys);
localCache.put('timeStamp', nowDate.millisecondsSinceEpoch);
return wbiKeys;
} }
makSign(Map<String, dynamic> params) async { makSign(Map<String, dynamic> params) async {
// params 为需要加密的请求参数 // params 为需要加密的请求参数
Map<String, dynamic> wbiKeys = await getWbiKeys(); Map<String, dynamic> wbiKeys = await getWbiKeys();
String query = encWbi(params, wbiKeys['imgKey'], wbiKeys['subKey']); Map<String, dynamic> query = params
..addAll(encWbi(params, wbiKeys['imgKey'], wbiKeys['subKey']));
return query; return query;
} }
} }

View File

@ -926,6 +926,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.3" 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: rxdart:
dependency: transitive dependency: transitive
description: description:

View File

@ -87,6 +87,7 @@ dependencies:
custom_sliding_segmented_control: ^1.7.5 custom_sliding_segmented_control: ^1.7.5
loading_more_list: ^5.0.3 loading_more_list: ^5.0.3
crypto: any crypto: any
pull_to_refresh_notification: ^3.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: