feat(app): 客户端展示多语言并支持筛选分页
This commit is contained in:
1 parent
8481611372
commit
8b50b5bd06
68 files changed
+2742
-1120
No files matched your search
@@ -1,11 +1,20 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
class DramaGridSkeleton extends StatelessWidget {
|
||||
const Duration _shimmerPeriod = Duration(milliseconds: 1400);
|
||||
const double _shimmerTravel = 2.4;
|
||||
const double _shimmerStart = -1.2;
|
||||
const double _subtitleWidthFactor = 0.62;
|
||||
const Color _shimmerHighlight = Color(0x38FFFFFF);
|
||||
|
||||
/// 首页短剧网格的占位。行数至少铺满可视高度,并带一道扫光。
|
||||
class DramaGridSkeleton extends StatefulWidget {
|
||||
const DramaGridSkeleton({
|
||||
super.key,
|
||||
required this.columns,
|
||||
@@ -23,61 +32,133 @@ class DramaGridSkeleton extends StatelessWidget {
|
||||
final int itemCount;
|
||||
final ScrollPhysics physics;
|
||||
|
||||
@override
|
||||
State<DramaGridSkeleton> createState() => _DramaGridSkeletonState();
|
||||
}
|
||||
|
||||
class _DramaGridSkeletonState extends State<DramaGridSkeleton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _shimmer = AnimationController(
|
||||
vsync: this,
|
||||
duration: _shimmerPeriod,
|
||||
)..repeat();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shimmer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final itemWidth =
|
||||
(constraints.maxWidth -
|
||||
horizontalPadding * 2 -
|
||||
crossAxisSpacing * (columns - 1)) /
|
||||
columns;
|
||||
final itemExtent =
|
||||
itemWidth / AppDimens.cardCoverAspectRatio +
|
||||
AppDimens.skeletonBodyExtent;
|
||||
return GridView.builder(
|
||||
physics: physics,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
horizontalPadding,
|
||||
AppDimens.spaceMd,
|
||||
horizontalPadding,
|
||||
AppDimens.spaceXl,
|
||||
),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
crossAxisSpacing: crossAxisSpacing,
|
||||
mainAxisSpacing: mainAxisSpacing,
|
||||
mainAxisExtent: itemExtent,
|
||||
),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) => Column(
|
||||
key: Key('drama-grid-skeleton-$index'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: AppDimens.cardCoverAspectRatio,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.skeletonSurface,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.homeCardRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.cardCoverTitleGap),
|
||||
const _SkeletonBar(),
|
||||
const SizedBox(height: AppDimens.skeletonTitleBarGap),
|
||||
const FractionallySizedBox(
|
||||
widthFactor: 0.62,
|
||||
child: _SkeletonBar(),
|
||||
),
|
||||
],
|
||||
final grid = _grid(constraints.maxWidth, constraints.maxHeight);
|
||||
return AnimatedBuilder(
|
||||
animation: _shimmer,
|
||||
builder: (context, child) => ShaderMask(
|
||||
blendMode: BlendMode.srcATop,
|
||||
shaderCallback: (bounds) => _shader(bounds),
|
||||
child: child,
|
||||
),
|
||||
child: grid,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _grid(double width, double height) {
|
||||
final itemWidth = _itemWidth(width);
|
||||
final itemExtent = _itemExtent(itemWidth);
|
||||
return GridView.builder(
|
||||
physics: widget.physics,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
widget.horizontalPadding,
|
||||
AppDimens.spaceMd,
|
||||
widget.horizontalPadding,
|
||||
AppDimens.spaceXl,
|
||||
),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: widget.columns,
|
||||
crossAxisSpacing: widget.crossAxisSpacing,
|
||||
mainAxisSpacing: widget.mainAxisSpacing,
|
||||
mainAxisExtent: itemExtent,
|
||||
),
|
||||
itemCount: _fillCount(height, itemExtent),
|
||||
itemBuilder: (context, index) => _SkeletonCard(index: index),
|
||||
);
|
||||
}
|
||||
|
||||
double _itemWidth(double width) {
|
||||
final gaps = widget.crossAxisSpacing * (widget.columns - 1);
|
||||
return (width - widget.horizontalPadding * 2 - gaps) / widget.columns;
|
||||
}
|
||||
|
||||
double _itemExtent(double itemWidth) {
|
||||
return itemWidth / AppDimens.cardCoverAspectRatio +
|
||||
AppDimens.skeletonBodyExtent;
|
||||
}
|
||||
|
||||
int _fillCount(double height, double extent) {
|
||||
if (!height.isFinite || extent <= 0) return widget.itemCount;
|
||||
final rowHeight = extent + widget.mainAxisSpacing;
|
||||
final rows = (height / rowHeight).ceil() + 1;
|
||||
return math.max(widget.itemCount, rows * widget.columns);
|
||||
}
|
||||
|
||||
Shader _shader(Rect bounds) {
|
||||
final shift = _shimmerStart + _shimmerTravel * _shimmer.value;
|
||||
return LinearGradient(
|
||||
begin: Alignment(shift, -0.3),
|
||||
end: Alignment(shift + 1, 0.3),
|
||||
colors: const [
|
||||
AppColors.skeletonSurface,
|
||||
_shimmerHighlight,
|
||||
AppColors.skeletonSurface,
|
||||
],
|
||||
).createShader(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
class _SkeletonCard extends StatelessWidget {
|
||||
const _SkeletonCard({required this.index});
|
||||
|
||||
final int index;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
key: Key('drama-grid-skeleton-$index'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: AppDimens.cardCoverAspectRatio,
|
||||
child: const _SkeletonBlock(),
|
||||
),
|
||||
const SizedBox(height: AppDimens.cardCoverTitleGap),
|
||||
const _SkeletonBar(),
|
||||
const SizedBox(height: AppDimens.skeletonTitleBarGap),
|
||||
const FractionallySizedBox(
|
||||
widthFactor: _subtitleWidthFactor,
|
||||
child: _SkeletonBar(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SkeletonBlock extends StatelessWidget {
|
||||
const _SkeletonBlock();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.skeletonSurface,
|
||||
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SkeletonBar extends StatelessWidget {
|
||||
|
||||
@@ -8,19 +8,27 @@ import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// 网络分页列表底部状态:仅保留加载圈与错误重试。
|
||||
/// 网络分页列表底部:加载中、失败重试、没有下一页。
|
||||
class PagedListFooter extends StatelessWidget {
|
||||
const PagedListFooter({
|
||||
super.key,
|
||||
required this.loading,
|
||||
required this.onRetry,
|
||||
this.errorMessage,
|
||||
this.hasMore = true,
|
||||
this.hasItems = false,
|
||||
});
|
||||
|
||||
final bool loading;
|
||||
final String? errorMessage;
|
||||
final Future<void> Function() onRetry;
|
||||
|
||||
/// 还有下一页时不显示结束文案。
|
||||
final bool hasMore;
|
||||
|
||||
/// 列表为空时不显示「没有更多了」。
|
||||
final bool hasItems;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -58,6 +66,17 @@ class PagedListFooter extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (hasItems && !hasMore) {
|
||||
return Padding(
|
||||
key: const Key('paged-list-no-more'),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
l10n.noMoreItems,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textHint, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
|
||||
@@ -71,10 +72,14 @@ class PagedLoadFooter extends StatelessWidget {
|
||||
super.key,
|
||||
required this.loading,
|
||||
required this.loadMoreKey,
|
||||
this.hasMore = true,
|
||||
this.hasItems = false,
|
||||
});
|
||||
|
||||
final bool loading;
|
||||
final Key loadMoreKey;
|
||||
final bool hasMore;
|
||||
final bool hasItems;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -93,6 +98,18 @@ class PagedLoadFooter extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (hasItems && !hasMore) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Padding(
|
||||
key: const Key('paged-list-no-more'),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
l10n.noMoreItems,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textHint, fontSize: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user