feat: article list

Closes #841

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-05-10 12:39:17 +08:00
parent 024a249e6b
commit 91af974bd4
16 changed files with 740 additions and 14 deletions

View File

@@ -829,4 +829,6 @@ class Api {
static const String topicFeed = '/x/polymer/web-dynamic/v1/feed/topic';
static const String spaceOpus = '/x/polymer/web-dynamic/v1/opus/feed/space';
static const String articleList = '/x/article/list/web/articles';
}

View File

@@ -3,6 +3,7 @@ import 'package:PiliPlus/http/constants.dart';
import 'package:PiliPlus/http/init.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/common/dynamic/dynamics_type.dart';
import 'package:PiliPlus/models/dynamics/article_list/data.dart';
import 'package:PiliPlus/models/dynamics/dyn_topic_feed/topic_card_list.dart';
import 'package:PiliPlus/models/dynamics/dyn_topic_top/top_details.dart';
import 'package:PiliPlus/models/dynamics/result.dart';
@@ -304,4 +305,21 @@ class DynamicsHttp {
return LoadingState.error(res.data['message']);
}
}
static Future<LoadingState<ArticleListData>> articleList({
required id,
}) async {
final res = await Request().get(
Api.articleList,
queryParameters: {
'id': id,
'web_location': 333.1400,
},
);
if (res.data['code'] == 0) {
return LoadingState.success(ArticleListData.fromJson(res.data['data']));
} else {
return LoadingState.error(res.data['message']);
}
}
}

View File

@@ -34,8 +34,6 @@ class Pic {
num? size;
String? liveUrl;
double? calHeight;
Pic.fromJson(Map<String, dynamic> json) {
url = json['url'];
width = json['width'];
@@ -45,12 +43,6 @@ class Pic {
style = json['style'];
liveUrl = json['live_url'];
}
void onCalHeight(double maxWidth) {
if (calHeight == null && height != null && width != null) {
calHeight = maxWidth * height! / width!;
}
}
}
class Line {

View File

@@ -0,0 +1,84 @@
import 'package:PiliPlus/models/dynamics/article_list/category.dart';
import 'package:PiliPlus/models/dynamics/article_list/stats.dart';
class Article {
int? id;
String? title;
int? state;
int? publishTime;
int? words;
List? imageUrls;
Category? category;
List<Category>? categories;
String? summary;
int? type;
String? dynIdStr;
int? attributes;
int? authorUid;
int? onlyFans;
Stats? stats;
int? likeState;
Article({
this.id,
this.title,
this.state,
this.publishTime,
this.words,
this.imageUrls,
this.category,
this.categories,
this.summary,
this.type,
this.dynIdStr,
this.attributes,
this.authorUid,
this.onlyFans,
this.stats,
this.likeState,
});
factory Article.fromJson(Map<String, dynamic> json) => Article(
id: json['id'] as int?,
title: json['title'] as String?,
state: json['state'] as int?,
publishTime: json['publish_time'] as int?,
words: json['words'] as int?,
imageUrls: json['image_urls'],
category: json['category'] == null
? null
: Category.fromJson(json['category'] as Map<String, dynamic>),
categories: (json['categories'] as List<dynamic>?)
?.map((e) => Category.fromJson(e as Map<String, dynamic>))
.toList(),
summary: json['summary'] as String?,
type: json['type'] as int?,
dynIdStr: json['dyn_id_str'] as String?,
attributes: json['attributes'] as int?,
authorUid: json['author_uid'] as int?,
onlyFans: json['only_fans'] as int?,
stats: json['stats'] == null
? null
: Stats.fromJson(json['stats'] as Map<String, dynamic>),
likeState: json['like_state'] as int?,
);
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'state': state,
'publish_time': publishTime,
'words': words,
'image_urls': imageUrls,
'category': category?.toJson(),
'categories': categories?.map((e) => e.toJson()).toList(),
'summary': summary,
'type': type,
'dyn_id_str': dynIdStr,
'attributes': attributes,
'author_uid': authorUid,
'only_fans': onlyFans,
'stats': stats?.toJson(),
'like_state': likeState,
};
}

View File

@@ -0,0 +1,23 @@
class Author {
int? mid;
String? name;
String? face;
Author({
this.mid,
this.name,
this.face,
});
factory Author.fromJson(Map<String, dynamic> json) => Author(
mid: json['mid'] as int?,
name: json['name'] as String?,
face: json['face'] as String?,
);
Map<String, dynamic> toJson() => {
'mid': mid,
'name': name,
'face': face,
};
}

View File

@@ -0,0 +1,19 @@
class Category {
int? id;
int? parentId;
String? name;
Category({this.id, this.parentId, this.name});
factory Category.fromJson(Map<String, dynamic> json) => Category(
id: json['id'] as int?,
parentId: json['parent_id'] as int?,
name: json['name'] as String?,
);
Map<String, dynamic> toJson() => {
'id': id,
'parent_id': parentId,
'name': name,
};
}

View File

@@ -0,0 +1,38 @@
import 'package:PiliPlus/models/dynamics/article_list/article.dart';
import 'package:PiliPlus/models/dynamics/article_list/author.dart';
import 'package:PiliPlus/models/dynamics/article_list/list.dart';
class ArticleListData {
ArticleList? list;
List<Article>? articles;
Author? author;
bool? attention;
ArticleListData({
this.list,
this.articles,
this.author,
this.attention,
});
factory ArticleListData.fromJson(Map<String, dynamic> json) =>
ArticleListData(
list: json['list'] == null
? null
: ArticleList.fromJson(json['list'] as Map<String, dynamic>),
articles: (json['articles'] as List<dynamic>?)
?.map((e) => Article.fromJson(e as Map<String, dynamic>))
.toList(),
author: json['author'] == null
? null
: Author.fromJson(json['author'] as Map<String, dynamic>),
attention: json['attention'] as bool?,
);
Map<String, dynamic> toJson() => {
'list': list?.toJson(),
'articles': articles?.map((e) => e.toJson()).toList(),
'author': author?.toJson(),
'attention': attention,
};
}

View File

@@ -0,0 +1,71 @@
class ArticleList {
int? id;
int? mid;
String? name;
String? imageUrl;
int? updateTime;
int? ctime;
int? publishTime;
String? summary;
int? words;
int? read;
int? articlesCount;
int? state;
String? reason;
String? applyTime;
String? checkTime;
ArticleList({
this.id,
this.mid,
this.name,
this.imageUrl,
this.updateTime,
this.ctime,
this.publishTime,
this.summary,
this.words,
this.read,
this.articlesCount,
this.state,
this.reason,
this.applyTime,
this.checkTime,
});
factory ArticleList.fromJson(Map<String, dynamic> json) => ArticleList(
id: json['id'] as int?,
mid: json['mid'] as int?,
name: json['name'] as String?,
imageUrl: json['image_url'] as String?,
updateTime: json['update_time'] as int?,
ctime: json['ctime'] as int?,
publishTime: json['publish_time'] as int?,
summary: json['summary'] as String?,
words: json['words'] as int?,
read: json['read'] as int?,
articlesCount: json['articles_count'] as int?,
state: json['state'] as int?,
reason: json['reason'] as String?,
applyTime: json['apply_time'] as String?,
checkTime: json['check_time'] as String?,
);
Map<String, dynamic> toJson() => {
'id': id,
'mid': mid,
'name': name,
'image_url': imageUrl,
'update_time': updateTime,
'ctime': ctime,
'publish_time': publishTime,
'summary': summary,
'words': words,
'read': read,
'articles_count': articlesCount,
'state': state,
'reason': reason,
'apply_time': applyTime,
'check_time': checkTime,
};
}

View File

@@ -0,0 +1,43 @@
class Stats {
int? view;
int? favorite;
int? like;
int? dislike;
int? reply;
int? share;
int? coin;
int? dynam1c;
Stats({
this.view,
this.favorite,
this.like,
this.dislike,
this.reply,
this.share,
this.coin,
this.dynam1c,
});
factory Stats.fromJson(Map<String, dynamic> json) => Stats(
view: json['view'] as int?,
favorite: json['favorite'] as int?,
like: json['like'] as int?,
dislike: json['dislike'] as int?,
reply: json['reply'] as int?,
share: json['share'] as int?,
coin: json['coin'] as int?,
dynam1c: json['dynamic'] as int?,
);
Map<String, dynamic> toJson() => {
'view': view,
'favorite': favorite,
'like': like,
'dislike': dislike,
'reply': reply,
'share': share,
'coin': coin,
'dynamic': dynam1c,
};
}

View File

@@ -98,6 +98,7 @@ class ItemModulesModel {
// 专栏
ModuleTop? moduleTop;
ModuleCollection? moduleCollection;
List<ModuleTag>? moduleExtend; // opus的tag
List<ArticleContentModel>? moduleContent;
ModuleBlocked? moduleBlocked;
@@ -133,6 +134,11 @@ class ItemModulesModel {
? null
: ModuleTag.fromJson(i['module_title']);
break;
case 'MODULE_TYPE_COLLECTION':
moduleCollection = i['module_collection'] == null
? null
: ModuleCollection.fromJson(i['module_collection']);
break;
case 'MODULE_TYPE_AUTHOR':
moduleAuthor = i['module_author'] == null
? null
@@ -167,6 +173,20 @@ class ItemModulesModel {
}
}
class ModuleCollection {
String? count;
int? id;
String? name;
String? title;
ModuleCollection.fromJson(Map<String, dynamic> json) {
count = json['count'];
id = json['id'];
name = json['name'];
title = json['title'];
}
}
class ModuleTop {
ModuleTopDisplay? display;

View File

@@ -516,6 +516,14 @@ class _ArticlePageState extends State<ArticlePage>
),
),
),
if (_articleCtr.type != 'read' &&
_articleCtr.opusData?.modules.moduleCollection != null)
SliverToBoxAdapter(
child: opusCollection(
theme,
_articleCtr.opusData!.modules.moduleCollection!,
),
),
content,
],
);

View File

@@ -126,7 +126,6 @@ class OpusContent extends StatelessWidget {
return widget;
case 2 when (element.pic != null):
if (element.pic!.pics!.length == 1) {
element.pic!.pics!.first.onCalHeight(maxWidth);
return Hero(
tag: element.pic!.pics!.first.url!,
child: GestureDetector(
@@ -142,11 +141,20 @@ class OpusContent extends StatelessWidget {
);
}
},
child: NetworkImgLayer(
width: maxWidth,
height: element.pic!.pics!.first.calHeight,
src: element.pic!.pics!.first.url!,
quality: 60,
child: Center(
child: ClipRRect(
borderRadius: StyleString.mdRadius,
child: CachedNetworkImage(
imageUrl: Utils.thumbnailImgUrl(
element.pic!.pics!.first.url!,
60,
),
fadeInDuration: const Duration(milliseconds: 120),
fadeOutDuration: const Duration(milliseconds: 120),
placeholder: (context, url) =>
Image.asset('assets/images/loading.png'),
),
),
),
),
);
@@ -670,3 +678,63 @@ Widget moduleBlockedItem(
),
);
}
Widget opusCollection(ThemeData theme, ModuleCollection item) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Material(
borderRadius: const BorderRadius.all(Radius.circular(6)),
color: theme.colorScheme.onInverseSurface,
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(6)),
onTap: () {
Get.toNamed(
'/articleList',
parameters: {'id': '${item.id}'},
);
},
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.title!),
Text.rich(
TextSpan(
children: [
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Icon(
size: 18,
Icons.article_outlined,
color: theme.colorScheme.outline,
),
),
TextSpan(
text: '${item.name} · ${item.count}',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.outline,
),
),
],
),
),
],
),
),
Icon(
Icons.keyboard_arrow_right,
color: theme.colorScheme.outline,
),
],
),
),
),
),
);
}

View File

@@ -0,0 +1,33 @@
import 'package:PiliPlus/http/dynamics.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/dynamics/article_list/article.dart';
import 'package:PiliPlus/models/dynamics/article_list/author.dart';
import 'package:PiliPlus/models/dynamics/article_list/data.dart';
import 'package:PiliPlus/models/dynamics/article_list/list.dart';
import 'package:PiliPlus/pages/common/common_list_controller.dart';
import 'package:get/get.dart';
class ArticleListController
extends CommonListController<ArticleListData, Article> {
final id = Get.parameters['id'];
@override
void onInit() {
super.onInit();
queryData();
}
ArticleList? list;
Author? author;
@override
List<Article>? getDataList(ArticleListData response) {
list = response.list;
author = response.author;
return response.articles;
}
@override
Future<LoadingState<ArticleListData>> customGetData() =>
DynamicsHttp.articleList(id: id);
}

View File

@@ -0,0 +1,192 @@
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/skeleton/video_card_h.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/loading_widget/http_error.dart';
import 'package:PiliPlus/common/widgets/refresh_indicator.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/dynamics/article_list/article.dart';
import 'package:PiliPlus/models/dynamics/article_list/list.dart';
import 'package:PiliPlus/pages/article_list/controller.dart';
import 'package:PiliPlus/pages/article_list/widgets/item.dart';
import 'package:PiliPlus/utils/grid.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class ArticleListPage extends StatefulWidget {
const ArticleListPage({super.key});
@override
State<ArticleListPage> createState() => _ArticleListPageState();
}
class _ArticleListPageState extends State<ArticleListPage> {
final _controller =
Get.put(ArticleListController(), tag: Utils.generateRandomString(8));
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Scaffold(
body: SafeArea(
top: false,
bottom: false,
child: refreshIndicator(
onRefresh: _controller.onRefresh,
child: CustomScrollView(
slivers: [
SliverPadding(
padding: EdgeInsets.only(
bottom: MediaQuery.paddingOf(context).bottom + 80),
sliver: Obx(
() => _buildBody(theme, _controller.loadingState.value)),
),
],
),
),
),
);
}
Widget _buildBody(
ThemeData theme, LoadingState<List<Article>?> loadingState) {
return switch (loadingState) {
Loading() => SliverPadding(
padding: EdgeInsets.only(
top: MediaQuery.paddingOf(context).top + kToolbarHeight + 120),
sliver: SliverGrid(
gridDelegate: Grid.videoCardHDelegate(context),
delegate: SliverChildBuilderDelegate(
(context, index) {
return const VideoCardHSkeleton();
},
childCount: 10,
),
),
),
Success() => SliverMainAxisGroup(
slivers: [
if (_controller.list != null)
_buildHeader(theme, _controller.list!),
if (loadingState.response?.isNotEmpty == true)
SliverGrid(
gridDelegate: SliverGridDelegateWithExtentAndRatio(
mainAxisSpacing: 2,
maxCrossAxisExtent: Grid.smallCardWidth * 2,
childAspectRatio: StyleString.aspectRatio * 2.6,
minHeight: MediaQuery.textScalerOf(context).scale(90),
),
delegate: SliverChildBuilderDelegate(
(context, index) {
return ArticleListItem(
item: loadingState.response![index],
);
},
childCount: loadingState.response!.length,
),
)
else
HttpError(onReload: _controller.onReload),
],
),
Error() => HttpError(
errMsg: loadingState.errMsg,
onReload: _controller.onReload,
),
};
}
Widget _buildHeader(ThemeData theme, ArticleList item) {
late final style = TextStyle(color: theme.colorScheme.onSurfaceVariant);
late final divider = TextSpan(
text: ' | ',
style: TextStyle(color: theme.colorScheme.outline.withOpacity(0.7)),
);
final padding = MediaQuery.paddingOf(context).top + kToolbarHeight;
return SliverAppBar.medium(
title: Text(item.name!),
expandedHeight: kToolbarHeight + 130,
flexibleSpace: FlexibleSpaceBar(
background: Container(
height: 120,
margin: EdgeInsets.only(
left: 12,
right: 12,
top: padding,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.imageUrl?.isNotEmpty == true)
NetworkImgLayer(
width: 91,
height: 120,
src: item.imageUrl,
radius: 6,
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
if (_controller.author != null) ...[
const SizedBox(height: 10),
GestureDetector(
onTap: () {
Get.toNamed('/member?mid=${_controller.author!.mid}');
},
child: Row(
children: [
NetworkImgLayer(
width: 30,
height: 30,
src: _controller.author!.face,
),
const SizedBox(width: 10),
Text(_controller.author!.name!),
],
),
),
],
const SizedBox(height: 10),
Text.rich(
TextSpan(
children: [
TextSpan(text: '${item.articlesCount}篇专栏'),
divider,
TextSpan(text: '${item.words}个字'),
divider,
TextSpan(text: '${item.read}次阅读'),
],
style: style,
),
),
Text.rich(
TextSpan(
children: [
TextSpan(
text:
'${Utils.dateFormat(item.updateTime, formatType: 'day')}更新'),
divider,
TextSpan(text: '文集号: ${item.id}'),
],
style: style,
),
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/image/network_img_layer.dart';
import 'package:PiliPlus/common/widgets/stat/stat.dart';
import 'package:PiliPlus/models/dynamics/article_list/article.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class ArticleListItem extends StatelessWidget {
const ArticleListItem({
super.key,
required this.item,
});
final Article item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
onTap: () {
final dynIdStr = item.dynIdStr;
Get.toNamed(
'/articlePage',
parameters: {
'id': dynIdStr ?? item.id!.toString(),
'type': dynIdStr != null ? 'opus' : 'read',
},
);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: StyleString.safeSpace,
vertical: 5,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title!,
style: const TextStyle(
fontSize: 15,
height: 1.42,
letterSpacing: 0.3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
if (item.summary != null)
Text(
item.summary!,
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.outline,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Spacer(),
Row(
children: [
StatView(
context: context,
value: item.stats?.view ?? 0,
goto: 'picture',
textColor: theme.colorScheme.outline,
),
const SizedBox(width: 16),
StatView(
context: context,
goto: 'like',
value: item.stats?.like ?? 0,
textColor: theme.colorScheme.outline,
),
const SizedBox(width: 16),
StatView(
context: context,
goto: 'reply',
value: item.stats?.reply ?? 0,
textColor: theme.colorScheme.outline,
),
],
),
],
),
),
if (item.imageUrls?.isNotEmpty == true) ...[
const SizedBox(width: 10),
AspectRatio(
aspectRatio: StyleString.aspectRatio,
child: LayoutBuilder(
builder: (context, boxConstraints) {
return NetworkImgLayer(
src: item.imageUrls!.first,
width: boxConstraints.maxWidth,
height: boxConstraints.maxHeight,
);
},
),
),
],
],
),
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:PiliPlus/pages/about/view.dart';
import 'package:PiliPlus/pages/article/view.dart';
import 'package:PiliPlus/pages/article_list/view.dart';
import 'package:PiliPlus/pages/blacklist/view.dart';
import 'package:PiliPlus/pages/danmaku_block/view.dart';
import 'package:PiliPlus/pages/dynamics/view.dart';
@@ -174,6 +175,7 @@ class Routes {
CustomGetPage(
name: '/searchTrending', page: () => const SearchTrendingPage()),
CustomGetPage(name: '/dynTopic', page: () => const DynTopicPage()),
CustomGetPage(name: '/articleList', page: () => const ArticleListPage()),
];
}