diff --git a/lib/http/api.dart b/lib/http/api.dart index 5a33e860..32ed44a9 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -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'; } diff --git a/lib/http/dynamics.dart b/lib/http/dynamics.dart index 17552d70..1eaf1565 100644 --- a/lib/http/dynamics.dart +++ b/lib/http/dynamics.dart @@ -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> 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']); + } + } } diff --git a/lib/models/dynamics/article_content_model.dart b/lib/models/dynamics/article_content_model.dart index e60664d9..7c616ba9 100644 --- a/lib/models/dynamics/article_content_model.dart +++ b/lib/models/dynamics/article_content_model.dart @@ -34,8 +34,6 @@ class Pic { num? size; String? liveUrl; - double? calHeight; - Pic.fromJson(Map 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 { diff --git a/lib/models/dynamics/article_list/article.dart b/lib/models/dynamics/article_list/article.dart new file mode 100644 index 00000000..1771b814 --- /dev/null +++ b/lib/models/dynamics/article_list/article.dart @@ -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? 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 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), + categories: (json['categories'] as List?) + ?.map((e) => Category.fromJson(e as Map)) + .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), + likeState: json['like_state'] as int?, + ); + + Map 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, + }; +} diff --git a/lib/models/dynamics/article_list/author.dart b/lib/models/dynamics/article_list/author.dart new file mode 100644 index 00000000..2808a0de --- /dev/null +++ b/lib/models/dynamics/article_list/author.dart @@ -0,0 +1,23 @@ +class Author { + int? mid; + String? name; + String? face; + + Author({ + this.mid, + this.name, + this.face, + }); + + factory Author.fromJson(Map json) => Author( + mid: json['mid'] as int?, + name: json['name'] as String?, + face: json['face'] as String?, + ); + + Map toJson() => { + 'mid': mid, + 'name': name, + 'face': face, + }; +} diff --git a/lib/models/dynamics/article_list/category.dart b/lib/models/dynamics/article_list/category.dart new file mode 100644 index 00000000..ea85d9c4 --- /dev/null +++ b/lib/models/dynamics/article_list/category.dart @@ -0,0 +1,19 @@ +class Category { + int? id; + int? parentId; + String? name; + + Category({this.id, this.parentId, this.name}); + + factory Category.fromJson(Map json) => Category( + id: json['id'] as int?, + parentId: json['parent_id'] as int?, + name: json['name'] as String?, + ); + + Map toJson() => { + 'id': id, + 'parent_id': parentId, + 'name': name, + }; +} diff --git a/lib/models/dynamics/article_list/data.dart b/lib/models/dynamics/article_list/data.dart new file mode 100644 index 00000000..19a03c40 --- /dev/null +++ b/lib/models/dynamics/article_list/data.dart @@ -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
? articles; + Author? author; + bool? attention; + + ArticleListData({ + this.list, + this.articles, + this.author, + this.attention, + }); + + factory ArticleListData.fromJson(Map json) => + ArticleListData( + list: json['list'] == null + ? null + : ArticleList.fromJson(json['list'] as Map), + articles: (json['articles'] as List?) + ?.map((e) => Article.fromJson(e as Map)) + .toList(), + author: json['author'] == null + ? null + : Author.fromJson(json['author'] as Map), + attention: json['attention'] as bool?, + ); + + Map toJson() => { + 'list': list?.toJson(), + 'articles': articles?.map((e) => e.toJson()).toList(), + 'author': author?.toJson(), + 'attention': attention, + }; +} diff --git a/lib/models/dynamics/article_list/list.dart b/lib/models/dynamics/article_list/list.dart new file mode 100644 index 00000000..0c73240c --- /dev/null +++ b/lib/models/dynamics/article_list/list.dart @@ -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 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 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, + }; +} diff --git a/lib/models/dynamics/article_list/stats.dart b/lib/models/dynamics/article_list/stats.dart new file mode 100644 index 00000000..ba12f9dc --- /dev/null +++ b/lib/models/dynamics/article_list/stats.dart @@ -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 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 toJson() => { + 'view': view, + 'favorite': favorite, + 'like': like, + 'dislike': dislike, + 'reply': reply, + 'share': share, + 'coin': coin, + 'dynamic': dynam1c, + }; +} diff --git a/lib/models/dynamics/result.dart b/lib/models/dynamics/result.dart index 1bdd1c32..6514aa2e 100644 --- a/lib/models/dynamics/result.dart +++ b/lib/models/dynamics/result.dart @@ -98,6 +98,7 @@ class ItemModulesModel { // 专栏 ModuleTop? moduleTop; + ModuleCollection? moduleCollection; List? moduleExtend; // opus的tag List? 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 json) { + count = json['count']; + id = json['id']; + name = json['name']; + title = json['title']; + } +} + class ModuleTop { ModuleTopDisplay? display; diff --git a/lib/pages/article/view.dart b/lib/pages/article/view.dart index b6b33fb4..89e4b476 100644 --- a/lib/pages/article/view.dart +++ b/lib/pages/article/view.dart @@ -516,6 +516,14 @@ class _ArticlePageState extends State ), ), ), + if (_articleCtr.type != 'read' && + _articleCtr.opusData?.modules.moduleCollection != null) + SliverToBoxAdapter( + child: opusCollection( + theme, + _articleCtr.opusData!.modules.moduleCollection!, + ), + ), content, ], ); diff --git a/lib/pages/article/widgets/opus_content.dart b/lib/pages/article/widgets/opus_content.dart index 1ff54225..dc1d05be 100644 --- a/lib/pages/article/widgets/opus_content.dart +++ b/lib/pages/article/widgets/opus_content.dart @@ -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, + ), + ], + ), + ), + ), + ), + ); +} diff --git a/lib/pages/article_list/controller.dart b/lib/pages/article_list/controller.dart new file mode 100644 index 00000000..7c24b99d --- /dev/null +++ b/lib/pages/article_list/controller.dart @@ -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 { + final id = Get.parameters['id']; + + @override + void onInit() { + super.onInit(); + queryData(); + } + + ArticleList? list; + Author? author; + + @override + List
? getDataList(ArticleListData response) { + list = response.list; + author = response.author; + return response.articles; + } + + @override + Future> customGetData() => + DynamicsHttp.articleList(id: id); +} diff --git a/lib/pages/article_list/view.dart b/lib/pages/article_list/view.dart new file mode 100644 index 00000000..35b85810 --- /dev/null +++ b/lib/pages/article_list/view.dart @@ -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 createState() => _ArticleListPageState(); +} + +class _ArticleListPageState extends State { + 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?> 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, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/article_list/widgets/item.dart b/lib/pages/article_list/widgets/item.dart new file mode 100644 index 00000000..dd6a7008 --- /dev/null +++ b/lib/pages/article_list/widgets/item.dart @@ -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, + ); + }, + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index 47380f47..c01dd13b 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -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()), ]; }