feat: pgc index page

Closes #216

Signed-off-by: bggRGjQaUbCoE <githubaccount56556@proton.me>
This commit is contained in:
bggRGjQaUbCoE
2025-02-16 11:43:12 +08:00
parent 8c408e59f6
commit 74bf78b9cd
8 changed files with 617 additions and 2 deletions

View File

@@ -39,7 +39,13 @@ class _SelfSizedHorizontalListState extends State<SelfSizedHorizontalList> {
WidgetsBinding.instance.addPostFrameCallback((v) => setState(() {})); WidgetsBinding.instance.addPostFrameCallback((v) => setState(() {}));
} }
if (widget.itemCount == 0) return const SizedBox(); if (widget.itemCount == 0) return const SizedBox();
if (isInit) return Container(key: infoKey, child: widget.childBuilder(0)); if (isInit) {
return Container(
key: infoKey,
padding: widget.padding,
child: widget.childBuilder(0),
);
}
return SizedBox( return SizedBox(
height: height, height: height,

View File

@@ -718,4 +718,8 @@ class Api {
/// 我的关注 - 正在直播 /// 我的关注 - 正在直播
static const String getFollowingLive = static const String getFollowingLive =
'${HttpString.liveBaseUrl}/xlive/web-ucenter/user/following'; '${HttpString.liveBaseUrl}/xlive/web-ucenter/user/following';
static const String pgcIndexCondition = '/pgc/season/index/condition';
static const String pgcIndexResult = '/pgc/season/index/result';
} }

View File

@@ -1,9 +1,55 @@
import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/loading_state.dart';
import '../models/bangumi/list.dart'; import '../models/bangumi/list.dart';
import '../models/bangumi/pgc_index/condition.dart';
import 'index.dart'; import 'index.dart';
class BangumiHttp { class BangumiHttp {
static Future<LoadingState> pgcIndexResult({
required int page,
required Map<String, dynamic> params,
seasonType,
type,
indexType,
}) async {
dynamic res = await Request().get(
Api.pgcIndexResult,
queryParameters: {
...params,
if (seasonType != null) 'season_type': seasonType,
if (type != null) 'type': type,
if (indexType != null) 'index_type': indexType,
'page': page,
'pagesize': 21,
},
);
if (res.data['code'] == 0) {
return LoadingState.success(res.data['data']);
} else {
return LoadingState.error(res.data['message']);
}
}
static Future<LoadingState> pgcIndexCondition({
seasonType,
type,
indexType,
}) async {
dynamic res = await Request().get(
Api.pgcIndexCondition,
queryParameters: {
if (seasonType != null) 'season_type': seasonType,
if (type != null) 'type': type,
if (indexType != null) 'index_type': indexType,
},
);
if (res.data['code'] == 0) {
return LoadingState.success(Condition.fromJson(res.data['data']));
} else {
return LoadingState.error(res.data['message']);
}
}
static Future<LoadingState> bangumiList({ static Future<LoadingState> bangumiList({
int? page, int? page,
int? indexType, int? indexType,

View File

@@ -0,0 +1,70 @@
class Condition {
List<Filter>? filter;
List<Order>? order;
Condition({
this.filter,
this.order,
});
Condition.fromJson(Map json) {
filter = (json['filter'] as List?)
?.map((item) => Filter.fromJson(item))
.toList();
order =
(json['order'] as List?)?.map((item) => Order.fromJson(item)).toList();
}
}
class Order {
String? field;
String? name;
String? sort;
Order({
this.field,
this.name,
this.sort,
});
Order.fromJson(Map json) {
field = json['field'];
name = json['name'];
sort = json['sort'];
}
}
class Filter {
String? field;
String? name;
List<Values>? values;
Filter({
this.field,
this.name,
this.values,
});
Filter.fromJson(Map json) {
field = json['field'];
name = json['name'];
values = (json['values'] as List?)
?.map((item) => Values.fromJson(item))
.toList();
}
}
class Values {
String? keyword;
String? name;
Values({
this.keyword,
this.name,
});
Values.fromJson(Map json) {
keyword = json['keyword'];
name = json['name'];
}
}

View File

@@ -0,0 +1,69 @@
import 'package:PiliPlus/http/bangumi.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/bangumi/pgc_index/condition.dart';
import 'package:PiliPlus/pages/common/common_controller.dart';
import 'package:get/get.dart' hide Condition;
class PgcIndexController extends CommonController {
PgcIndexController(this.indexType);
int? indexType;
Rx<LoadingState> conditionState = LoadingState.loading().obs;
late final RxBool isExpand = false.obs;
RxMap<String, dynamic> indexParams = <String, dynamic>{}.obs;
@override
void onInit() {
super.onInit();
getPgcIndexCondition();
}
Future getPgcIndexCondition() async {
dynamic res = await BangumiHttp.pgcIndexCondition(
seasonType: indexType == null ? 1 : null,
type: 0,
indexType: indexType,
);
if (res is Success) {
Condition data = res.response;
if (data.order?.isNotEmpty == true) {
indexParams['order'] = data.order!.first.field;
}
if (data.filter?.isNotEmpty == true) {
for (Filter item in data.filter!) {
indexParams['${item.field}'] = item.values?.firstOrNull?.keyword;
}
}
queryData();
}
conditionState.value = res;
}
@override
Future<LoadingState> customGetData() => BangumiHttp.pgcIndexResult(
page: currentPage,
params: indexParams,
seasonType: indexType == null ? 1 : null,
type: 0,
indexType: indexType,
);
@override
bool customHandleResponse(Success response) {
if (response.response['has_next'] == null ||
response.response['has_next'] == 0) {
isEnd = true;
}
if (response.response['list'] == null ||
(response.response['list'] as List?)?.isEmpty == true) {
isEnd = true;
}
if (currentPage != 1 && loadingState.value is Success) {
response.response['list']
?.insertAll(0, (loadingState.value as Success).response);
}
loadingState.value = LoadingState.success(response.response['list']);
return true;
}
}

View File

@@ -0,0 +1,237 @@
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/http_error.dart';
import 'package:PiliPlus/common/widgets/loading_widget.dart';
import 'package:PiliPlus/common/widgets/self_sized_horizontal_list.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/pages/bangumi/pgc_index/pgc_index_controller.dart';
import 'package:PiliPlus/pages/bangumi/widgets/bangumi_card_v_pgc_index.dart';
import 'package:PiliPlus/pages/search/widgets/search_text.dart';
import 'package:PiliPlus/utils/extension.dart';
import 'package:PiliPlus/utils/grid.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart' hide Condition;
import '../../../models/bangumi/pgc_index/condition.dart';
class PgcIndexPage extends StatefulWidget {
const PgcIndexPage({super.key, this.indexType});
final int? indexType;
@override
State<PgcIndexPage> createState() => _PgcIndexPageState();
}
class _PgcIndexPageState extends State<PgcIndexPage> {
late final _ctr = Get.put(
PgcIndexController(widget.indexType),
tag: '${widget.indexType}',
);
@override
Widget build(BuildContext context) {
return widget.indexType == null
? Scaffold(
appBar: AppBar(title: const Text('索引')),
body: Obx(() => _buildBody(_ctr.conditionState.value)),
)
: Obx(() => _buildBody(_ctr.conditionState.value));
}
Widget _buildBody(LoadingState loadingState) {
return switch (loadingState) {
Loading() => loadingWidget,
Success() => Builder(builder: (context) {
Condition data = loadingState.response;
int count = (data.order?.isNotEmpty == true ? 1 : 0) +
(data.filter?.length ?? 0);
if (count == 0) return const SizedBox.shrink();
return CustomScrollView(
slivers: [
if (widget.indexType != null)
SliverToBoxAdapter(child: const SizedBox(height: 12)),
SliverToBoxAdapter(
child: AnimatedSize(
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
duration: const Duration(milliseconds: 200),
child: count > 5
? Obx(() => _buildSortWidget(count, data))
: _buildSortWidget(count, data),
),
),
SliverPadding(
padding: EdgeInsets.only(
left: StyleString.safeSpace,
right: StyleString.safeSpace,
top: 12,
bottom: MediaQuery.paddingOf(context).bottom + 80,
),
sliver: Obx(() => _buildList(_ctr.loadingState.value)),
),
],
);
}),
Error() => scrollErrorWidget(
errMsg: loadingState.errMsg,
callback: () {
_ctr.conditionState.value = LoadingState.loading();
_ctr.getPgcIndexCondition();
},
),
LoadingState() => throw UnimplementedError(),
};
}
Widget _buildSortWidget(count, data) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...List.generate(
count > 5
? _ctr.isExpand.value
? count
: count ~/ 2
: count,
(index) {
List? item = data.order?.isNotEmpty == true
? index == 0
? data.order
: data.filter![index - 1].values
: data.filter![index].values;
return item?.isNotEmpty == true
? Padding(
padding: EdgeInsets.only(
top: index == 0 ? 0 : 10,
),
child: SelfSizedHorizontalList(
gapSize: 12,
padding: const EdgeInsets.symmetric(
horizontal: 12,
),
childBuilder: (childIndex) => Obx(
() => SearchText(
bgColor: (item[childIndex] is Order
? _ctr.indexParams['order']
: _ctr.indexParams[data
.filter![
data.order?.isNotEmpty == true
? index - 1
: index]
.field]) ==
(item[childIndex] is Order
? item[childIndex].field
: item[childIndex].keyword)
? Theme.of(context)
.colorScheme
.secondaryContainer
: Colors.transparent,
textColor: (item[childIndex] is Order
? _ctr.indexParams['order']
: _ctr.indexParams[data
.filter![
data.order?.isNotEmpty == true
? index - 1
: index]
.field]) ==
(item[childIndex] is Order
? item[childIndex].field
: item[childIndex].keyword)
? Theme.of(context)
.colorScheme
.onSecondaryContainer
: Theme.of(context)
.colorScheme
.onSurfaceVariant,
text: item[childIndex].name,
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
onTap: (_) {
String name = item[childIndex] is Order
? 'order'
: data
.filter![data.order?.isNotEmpty == true
? index - 1
: index]
.field!;
_ctr.indexParams[name] =
(item[childIndex] is Order
? item[childIndex].field
: item[childIndex].keyword);
_ctr.onReload();
},
),
),
itemCount: item!.length,
),
)
: const SizedBox.shrink();
},
),
if (count > 5) ...[
const SizedBox(height: 8),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
_ctr.isExpand.value = _ctr.isExpand.value.not;
},
child: Container(
width: double.infinity,
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_ctr.isExpand.value ? '收起' : '展开',
style: TextStyle(
color: Theme.of(context).colorScheme.outline,
),
),
Icon(
_ctr.isExpand.value
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.outline,
),
],
),
),
),
],
],
);
Widget _buildList(LoadingState loadingState) {
return switch (loadingState) {
Loading() => HttpError(errMsg: '加载中'),
Success() => (loadingState.response as List?)?.isNotEmpty == true
? SliverGrid(
gridDelegate: SliverGridDelegateWithExtentAndRatio(
mainAxisSpacing: StyleString.cardSpace,
crossAxisSpacing: StyleString.cardSpace,
maxCrossAxisExtent: Grid.smallCardWidth / 3 * 2,
childAspectRatio: 0.75,
mainAxisExtent: MediaQuery.textScalerOf(context).scale(50),
),
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
if (index == loadingState.response.length - 1) {
_ctr.onLoadMore();
}
return BangumiCardVPgcIndex(
bangumiItem: loadingState.response[index]);
},
childCount: loadingState.response.length,
),
)
: HttpError(callback: _ctr.onReload),
Error() => HttpError(
errMsg: loadingState.errMsg,
callback: _ctr.onReload,
),
LoadingState() => throw UnimplementedError(),
};
}
}

View File

@@ -4,6 +4,7 @@ import 'package:PiliPlus/common/widgets/loading_widget.dart';
import 'package:PiliPlus/common/widgets/refresh_indicator.dart'; import 'package:PiliPlus/common/widgets/refresh_indicator.dart';
import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/models/common/tab_type.dart'; import 'package:PiliPlus/models/common/tab_type.dart';
import 'package:PiliPlus/pages/bangumi/pgc_index/pgc_index_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@@ -128,7 +129,12 @@ class _BangumiPageState extends State<BangumiPage>
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 10, bottom: 10, left: 16), padding: const EdgeInsets.only(
top: 10,
bottom: 10,
left: 16,
right: 10,
),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -136,6 +142,56 @@ class _BangumiPageState extends State<BangumiPage>
'推荐', '推荐',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
GestureDetector(
onTap: () {
if (widget.tabType == TabType.bangumi) {
Get.to(PgcIndexPage());
} else {
List titles = const ['全部', '电影', '电视剧', '纪录片', '综艺'];
List types = const [102, 2, 5, 3, 7];
Get.to(
Scaffold(
appBar: AppBar(title: const Text('索引')),
body: DefaultTabController(
length: types.length,
child: Column(
children: [
TabBar(
tabs: titles
.map((title) => Tab(text: title))
.toList()),
Expanded(
child: TabBarView(
children: types
.map((type) =>
PgcIndexPage(indexType: type))
.toList()),
)
],
),
),
),
);
}
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'查看更多',
strutStyle: StrutStyle(leading: 0, height: 1),
style: TextStyle(
height: 1,
color: Theme.of(context).colorScheme.secondary,
),
),
Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.secondary,
),
],
),
),
], ],
), ),
), ),

View File

@@ -0,0 +1,127 @@
import 'package:PiliPlus/common/widgets/image_save.dart';
import 'package:flutter/material.dart';
import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/badge.dart';
import 'package:PiliPlus/utils/utils.dart';
import 'package:PiliPlus/common/widgets/network_img_layer.dart';
// 视频卡片 - 垂直布局
class BangumiCardVPgcIndex extends StatelessWidget {
const BangumiCardVPgcIndex({
super.key,
required this.bangumiItem,
});
final dynamic bangumiItem;
@override
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.hardEdge,
margin: EdgeInsets.zero,
child: InkWell(
onLongPress: () => imageSaveDialog(
context: context,
title: bangumiItem['title'],
cover: bangumiItem['cover'],
),
onTap: () {
Utils.viewBangumi(seasonId: bangumiItem['season_id']);
},
child: Column(
children: [
ClipRRect(
borderRadius: StyleString.mdRadius,
child: AspectRatio(
aspectRatio: 0.75,
child: LayoutBuilder(builder: (context, boxConstraints) {
final double maxWidth = boxConstraints.maxWidth;
final double maxHeight = boxConstraints.maxHeight;
return Stack(
children: [
NetworkImgLayer(
src: bangumiItem['cover'],
width: maxWidth,
height: maxHeight,
),
if (bangumiItem['badge'] != null &&
bangumiItem['badge'] != '')
PBadge(
text: bangumiItem['badge'],
top: 6,
right: 6,
bottom: null,
left: null,
),
if (bangumiItem['order'] != null &&
bangumiItem['order'] != '')
PBadge(
text: bangumiItem['order'],
top: null,
right: null,
bottom: 6,
left: 6,
type: 'gray',
),
],
);
}),
),
),
bagumiContent(context)
],
),
),
);
}
Widget bagumiContent(context) {
return Expanded(
child: Padding(
// 多列
padding: const EdgeInsets.fromLTRB(4, 5, 0, 3),
// 单列
// padding: const EdgeInsets.fromLTRB(14, 10, 4, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Expanded(
child: Text(
bangumiItem['title'],
textAlign: TextAlign.start,
style: const TextStyle(
letterSpacing: 0.3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)),
],
),
const SizedBox(height: 1),
if (bangumiItem['index_show'] != null)
Text(
bangumiItem['index_show'],
maxLines: 1,
style: TextStyle(
fontSize: Theme.of(context).textTheme.labelMedium!.fontSize,
color: Theme.of(context).colorScheme.outline,
),
),
// if (bangumiItem.progress != null)
// Text(
// bangumiItem.progress,
// maxLines: 1,
// style: TextStyle(
// fontSize: Theme.of(context).textTheme.labelMedium!.fontSize,
// color: Theme.of(context).colorScheme.outline,
// ),
// ),
],
),
),
);
}
}