feat(media): 图片占位改为纯色块并去掉扫光
This commit is contained in:
1 parent
613147c20c
commit
7f65fe4d48
20 files changed
+47
-290
No files matched your search
@@ -1,86 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
// 职责:给默认图片加载占位叠加可暂停、局部重绘的轻量扫光。
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
const _sweepDuration = Duration(milliseconds: 1400);
|
|
||||||
const double _bandWidthRatio = 0.65;
|
|
||||||
const _highlight = Color(0x12FFFFFF);
|
|
||||||
const _clear = Color(0x00FFFFFF);
|
|
||||||
|
|
||||||
/// 只用于等待真实图片的占位;减少动画或页面离屏时不运行扫光。
|
|
||||||
class ImagePlaceholderShimmer extends StatefulWidget {
|
|
||||||
/// 在 [child] 上方绘制不影响布局与点击区域的扫光。
|
|
||||||
const ImagePlaceholderShimmer({super.key, required this.child});
|
|
||||||
|
|
||||||
final Widget child;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ImagePlaceholderShimmer> createState() =>
|
|
||||||
_ImagePlaceholderShimmerState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ImagePlaceholderShimmerState extends State<ImagePlaceholderShimmer>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late final AnimationController _controller = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: _sweepDuration,
|
|
||||||
);
|
|
||||||
bool _active = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
_active =
|
|
||||||
TickerMode.valuesOf(context).enabled &&
|
|
||||||
!MediaQuery.disableAnimationsOf(context);
|
|
||||||
if (_active) {
|
|
||||||
_controller.repeat();
|
|
||||||
} else {
|
|
||||||
_controller.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (!_active) return widget.child;
|
|
||||||
return RepaintBoundary(
|
|
||||||
child: ClipRect(
|
|
||||||
child: CustomPaint(
|
|
||||||
foregroundPainter: _SweepPainter(_controller),
|
|
||||||
child: widget.child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SweepPainter extends CustomPainter {
|
|
||||||
_SweepPainter(this.progress) : super(repaint: progress);
|
|
||||||
|
|
||||||
final Animation<double> progress;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void paint(Canvas canvas, Size size) {
|
|
||||||
if (size.isEmpty) return;
|
|
||||||
final bandWidth = size.width * _bandWidthRatio;
|
|
||||||
final left = -bandWidth + (size.width + bandWidth) * progress.value;
|
|
||||||
final rect = Rect.fromLTWH(left, 0, bandWidth, size.height);
|
|
||||||
final paint = Paint()
|
|
||||||
..shader = const LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [_clear, _highlight, _clear],
|
|
||||||
).createShader(rect);
|
|
||||||
canvas.drawRect(rect, paint);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool shouldRepaint(covariant _SweepPainter oldDelegate) =>
|
|
||||||
oldDelegate.progress != progress;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
// 职责:统一网络图片入口,补服务地址,并在加载中/失败时显示纯色占位块。
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import 'image_placeholder_shimmer.dart';
|
|
||||||
|
|
||||||
const int _minImageDecodePixels = 96;
|
const int _minImageDecodePixels = 96;
|
||||||
const int _maxImageDecodePixels = 2048;
|
const int _maxImageDecodePixels = 2048;
|
||||||
@@ -32,8 +32,6 @@ class RemoteImage extends StatelessWidget {
|
|||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
this.fit = BoxFit.cover,
|
this.fit = BoxFit.cover,
|
||||||
this.showFallbackIcon = false,
|
|
||||||
this.fallbackIconKey,
|
|
||||||
this.fallbackAspectRatio,
|
this.fallbackAspectRatio,
|
||||||
this.fallbackColor = AppColors.raised,
|
this.fallbackColor = AppColors.raised,
|
||||||
this.fallbackWidget,
|
this.fallbackWidget,
|
||||||
@@ -45,12 +43,10 @@ class RemoteImage extends StatelessWidget {
|
|||||||
final double? width;
|
final double? width;
|
||||||
final double? height;
|
final double? height;
|
||||||
final BoxFit fit;
|
final BoxFit fit;
|
||||||
final bool showFallbackIcon;
|
|
||||||
final Key? fallbackIconKey;
|
|
||||||
final double? fallbackAspectRatio;
|
final double? fallbackAspectRatio;
|
||||||
final Color fallbackColor;
|
final Color fallbackColor;
|
||||||
|
|
||||||
/// 自定义兜底:地址为空、加载中、加载失败时都显示它(优先于 [fallbackColor])。
|
/// 自定义占位:地址为空、加载中、加载失败时都显示它(优先于 [fallbackColor])。
|
||||||
final Widget? fallbackWidget;
|
final Widget? fallbackWidget;
|
||||||
final int? decodeWidth;
|
final int? decodeWidth;
|
||||||
final int? decodeHeight;
|
final int? decodeHeight;
|
||||||
@@ -58,7 +54,7 @@ class RemoteImage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final url = AppConfig.mediaUrl(path);
|
final url = AppConfig.mediaUrl(path);
|
||||||
if (url.isEmpty) return _fallback(brand: showFallbackIcon);
|
if (url.isEmpty) return _fallback();
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
|
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||||
@@ -100,51 +96,23 @@ class RemoteImage extends StatelessWidget {
|
|||||||
?.clamp(_minImageDecodePixels, _maxImageDecodePixels)
|
?.clamp(_minImageDecodePixels, _maxImageDecodePixels)
|
||||||
.toInt(),
|
.toInt(),
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
|
errorBuilder: (_, _, _) => _fallback(),
|
||||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||||
return _loadingFallback();
|
return _fallback();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _loadingFallback() {
|
Widget _fallback() {
|
||||||
final custom = fallbackWidget;
|
final custom = fallbackWidget;
|
||||||
if (custom != null) return custom;
|
final content = custom != null
|
||||||
return ImagePlaceholderShimmer(child: _fallback(brand: showFallbackIcon));
|
? SizedBox(width: width, height: height, child: custom)
|
||||||
}
|
: Container(width: width, height: height, color: fallbackColor);
|
||||||
|
|
||||||
Widget _fallback({required bool brand}) {
|
|
||||||
final custom = fallbackWidget;
|
|
||||||
if (custom != null) return custom;
|
|
||||||
final mark = brand
|
|
||||||
? LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final width = constraints.maxWidth.isFinite
|
|
||||||
? constraints.maxWidth
|
|
||||||
: 120.0;
|
|
||||||
return Center(
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/icon/splash_logo.png',
|
|
||||||
key: fallbackIconKey,
|
|
||||||
width: width * 0.72,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
final fallback = Container(
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
color: brand ? AppColors.mediaBlack : fallbackColor,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: mark,
|
|
||||||
);
|
|
||||||
final ratio = fallbackAspectRatio;
|
final ratio = fallbackAspectRatio;
|
||||||
if (ratio == null) return fallback;
|
if (ratio == null) return content;
|
||||||
return AspectRatio(aspectRatio: ratio, child: fallback);
|
return AspectRatio(aspectRatio: ratio, child: content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,7 +176,7 @@ class _FeedBannerCarouselState extends State<FeedBannerCarousel> {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
RemoteImage(banner.imageUrl, showFallbackIcon: true),
|
RemoteImage(banner.imageUrl),
|
||||||
DecoratedBox(
|
DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ class SearchResultCard extends StatelessWidget {
|
|||||||
RemoteImage(
|
RemoteImage(
|
||||||
drama.posterUrl,
|
drama.posterUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackColor: AppColors.contentSurface,
|
fallbackColor: AppColors.contentSurface,
|
||||||
),
|
),
|
||||||
const Positioned(
|
const Positioned(
|
||||||
@@ -60,7 +59,9 @@ class SearchResultCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppDimens.searchResultTitleGap),
|
const SizedBox(height: AppDimens.searchResultTitleGap),
|
||||||
Text(
|
Text(
|
||||||
drama.localizedTitle(Localizations.localeOf(context).languageCode),
|
drama.localizedTitle(
|
||||||
|
Localizations.localeOf(context).languageCode,
|
||||||
|
),
|
||||||
key: Key('search-result-title-${drama.id}'),
|
key: Key('search-result-title-${drama.id}'),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -93,7 +94,9 @@ class SearchResultCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> _tags(BuildContext context) {
|
List<String> _tags(BuildContext context) {
|
||||||
final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
|
final localized = drama.localizedTypeLabels(
|
||||||
|
Localizations.localeOf(context).languageCode,
|
||||||
|
);
|
||||||
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
|
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
return [drama.genre, drama.category]
|
return [drama.genre, drama.category]
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ class DramaDetailHeader extends StatelessWidget {
|
|||||||
child: RemoteImage(
|
child: RemoteImage(
|
||||||
drama.posterUrl,
|
drama.posterUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackAspectRatio: 3 / 4,
|
fallbackAspectRatio: 3 / 4,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ class FilterResultCard extends StatelessWidget {
|
|||||||
RemoteImage(
|
RemoteImage(
|
||||||
drama.posterUrl,
|
drama.posterUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackAspectRatio: 3 / 4,
|
fallbackAspectRatio: 3 / 4,
|
||||||
),
|
),
|
||||||
if (drama.isHot)
|
if (drama.isHot)
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ class FollowingGridCard extends StatelessWidget {
|
|||||||
child: RemoteImage(
|
child: RemoteImage(
|
||||||
posterUrl,
|
posterUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackColor: AppColors.contentSurface,
|
fallbackColor: AppColors.contentSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ class FollowingHistoryCard extends StatelessWidget {
|
|||||||
child: RemoteImage(
|
child: RemoteImage(
|
||||||
posterUrl,
|
posterUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackColor: AppColors.contentSurface,
|
fallbackColor: AppColors.contentSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ class ContinueWatchingBanner extends StatelessWidget {
|
|||||||
RemoteImage(
|
RemoteImage(
|
||||||
record.displayLandscapeCover,
|
record.displayLandscapeCover,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackColor: AppColors.contentSurface,
|
fallbackColor: AppColors.contentSurface,
|
||||||
),
|
),
|
||||||
const DecoratedBox(
|
const DecoratedBox(
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ class HomeDramaCard extends StatelessWidget {
|
|||||||
drama.posterUrl,
|
drama.posterUrl,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
fit: BoxFit.fitWidth,
|
fit: BoxFit.fitWidth,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackAspectRatio: _coverAspectRatio,
|
fallbackAspectRatio: _coverAspectRatio,
|
||||||
),
|
),
|
||||||
Positioned(top: 8, left: 8, child: _tagBadge()),
|
Positioned(top: 8, left: 8, child: _tagBadge()),
|
||||||
@@ -226,7 +225,9 @@ class HomeDramaCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _genre(BuildContext context) {
|
Widget _genre(BuildContext context) {
|
||||||
final labels = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
|
final labels = drama.localizedTypeLabels(
|
||||||
|
Localizations.localeOf(context).languageCode,
|
||||||
|
);
|
||||||
final genre = labels.isEmpty
|
final genre = labels.isEmpty
|
||||||
? (drama.genre.isEmpty ? drama.category : drama.genre)
|
? (drama.genre.isEmpty ? drama.category : drama.genre)
|
||||||
: labels.join(' / ');
|
: labels.join(' / ');
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ import '../../../core/widgets/remote_image.dart';
|
|||||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||||
|
|
||||||
class HomeFeedCard extends StatelessWidget {
|
class HomeFeedCard extends StatelessWidget {
|
||||||
const HomeFeedCard({
|
const HomeFeedCard({super.key, required this.drama, required this.onTap});
|
||||||
super.key,
|
|
||||||
required this.drama,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Drama drama;
|
final Drama drama;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
@@ -44,7 +40,9 @@ class HomeFeedCard extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
drama.localizedTitle(Localizations.localeOf(context).languageCode),
|
drama.localizedTitle(
|
||||||
|
Localizations.localeOf(context).languageCode,
|
||||||
|
),
|
||||||
key: Key('home-feed-title-${drama.id}'),
|
key: Key('home-feed-title-${drama.id}'),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -69,7 +67,9 @@ class HomeFeedCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _cover(BuildContext context) {
|
Widget _cover(BuildContext context) {
|
||||||
final label = drama.localizedTag(Localizations.localeOf(context).languageCode);
|
final label = drama.localizedTag(
|
||||||
|
Localizations.localeOf(context).languageCode,
|
||||||
|
);
|
||||||
return ClipRRect(
|
return ClipRRect(
|
||||||
key: Key('home-feed-cover-clip-${drama.id}'),
|
key: Key('home-feed-cover-clip-${drama.id}'),
|
||||||
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
|
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
|
||||||
@@ -82,7 +82,6 @@ class HomeFeedCard extends StatelessWidget {
|
|||||||
drama.posterUrl,
|
drama.posterUrl,
|
||||||
key: Key('home-feed-cover-${drama.id}'),
|
key: Key('home-feed-cover-${drama.id}'),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackColor: AppColors.contentSurface,
|
fallbackColor: AppColors.contentSurface,
|
||||||
),
|
),
|
||||||
if (label.isNotEmpty)
|
if (label.isNotEmpty)
|
||||||
@@ -133,7 +132,9 @@ class HomeFeedCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> _cardTags(BuildContext context) {
|
List<String> _cardTags(BuildContext context) {
|
||||||
final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
|
final localized = drama.localizedTypeLabels(
|
||||||
|
Localizations.localeOf(context).languageCode,
|
||||||
|
);
|
||||||
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
|
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
return [drama.genre, drama.category]
|
return [drama.genre, drama.category]
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ class MineContinueWatching extends StatelessWidget {
|
|||||||
child: RemoteImage(
|
child: RemoteImage(
|
||||||
record.dramaCover ?? '',
|
record.dramaCover ?? '',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackColor: AppColors.contentSurface,
|
fallbackColor: AppColors.contentSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -276,7 +276,6 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
|||||||
RemoteImage(
|
RemoteImage(
|
||||||
drama.posterUrl,
|
drama.posterUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackAspectRatio: 16 / 9,
|
fallbackAspectRatio: 16 / 9,
|
||||||
),
|
),
|
||||||
const DecoratedBox(
|
const DecoratedBox(
|
||||||
@@ -448,7 +447,6 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
|||||||
RemoteImage(
|
RemoteImage(
|
||||||
drama.posterUrl,
|
drama.posterUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackAspectRatio: 3 / 4,
|
fallbackAspectRatio: 3 / 4,
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|||||||
@@ -255,11 +255,7 @@ class _DramaInfoSheetState extends State<DramaInfoSheet> {
|
|||||||
width: _posterWidth,
|
width: _posterWidth,
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 3 / 4,
|
aspectRatio: 3 / 4,
|
||||||
child: RemoteImage(
|
child: RemoteImage(drama.posterUrl, fit: BoxFit.cover),
|
||||||
drama.posterUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
showFallbackIcon: true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -466,11 +462,7 @@ class _DramaInfoSheetState extends State<DramaInfoSheet> {
|
|||||||
width: _relatedWidth,
|
width: _relatedWidth,
|
||||||
child: AspectRatio(
|
child: AspectRatio(
|
||||||
aspectRatio: 3 / 4,
|
aspectRatio: 3 / 4,
|
||||||
child: RemoteImage(
|
child: RemoteImage(drama.posterUrl, fit: BoxFit.cover),
|
||||||
drama.posterUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
showFallbackIcon: true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
|||||||
RemoteImage(
|
RemoteImage(
|
||||||
banner.imageUrl,
|
banner.imageUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackColor: AppColors.contentSurface,
|
fallbackColor: AppColors.contentSurface,
|
||||||
),
|
),
|
||||||
const DecoratedBox(
|
const DecoratedBox(
|
||||||
@@ -210,10 +209,7 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
|||||||
left: 28,
|
left: 28,
|
||||||
right: 28,
|
right: 28,
|
||||||
bottom: _copyBottomInset,
|
bottom: _copyBottomInset,
|
||||||
child: _HeroCopy(
|
child: _HeroCopy(key: Key('home-hero-copy-$index'), drama: drama),
|
||||||
key: Key('home-hero-copy-$index'),
|
|
||||||
drama: drama,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (drama != null)
|
if (drama != null)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ class HomePosterCard extends StatelessWidget {
|
|||||||
RemoteImage(
|
RemoteImage(
|
||||||
drama.posterUrl,
|
drama.posterUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackColor: AppColors.contentSurface,
|
fallbackColor: AppColors.contentSurface,
|
||||||
),
|
),
|
||||||
if (tagLabel.isNotEmpty)
|
if (tagLabel.isNotEmpty)
|
||||||
@@ -189,7 +188,6 @@ class HomeEditorPickBanner extends StatelessWidget {
|
|||||||
RemoteImage(
|
RemoteImage(
|
||||||
drama.posterUrl,
|
drama.posterUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackColor: AppColors.contentSurface,
|
fallbackColor: AppColors.contentSurface,
|
||||||
),
|
),
|
||||||
const DecoratedBox(
|
const DecoratedBox(
|
||||||
|
|||||||
@@ -310,7 +310,6 @@ class _PlaylistCollage extends StatelessWidget {
|
|||||||
child: RemoteImage(
|
child: RemoteImage(
|
||||||
drama.posterUrl,
|
drama.posterUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackColor: _filmBody,
|
fallbackColor: _filmBody,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ class RecommendSectionCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tagLabel = drama.localizedTag(Localizations.localeOf(context).languageCode);
|
final tagLabel = drama.localizedTag(
|
||||||
|
Localizations.localeOf(context).languageCode,
|
||||||
|
);
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: AppDimens.recommendCardWidth,
|
width: AppDimens.recommendCardWidth,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@@ -47,7 +49,6 @@ class RecommendSectionCard extends StatelessWidget {
|
|||||||
RemoteImage(
|
RemoteImage(
|
||||||
drama.posterUrl,
|
drama.posterUrl,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
showFallbackIcon: true,
|
|
||||||
fallbackColor: AppColors.contentSurface,
|
fallbackColor: AppColors.contentSurface,
|
||||||
),
|
),
|
||||||
if (tagLabel.isNotEmpty)
|
if (tagLabel.isNotEmpty)
|
||||||
@@ -80,7 +81,9 @@ class RecommendSectionCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: AppDimens.cardCoverTitleGap),
|
const SizedBox(height: AppDimens.cardCoverTitleGap),
|
||||||
Text(
|
Text(
|
||||||
drama.localizedTitle(Localizations.localeOf(context).languageCode),
|
drama.localizedTitle(
|
||||||
|
Localizations.localeOf(context).languageCode,
|
||||||
|
),
|
||||||
key: Key('recommend-title-$sectionKey-${drama.id}'),
|
key: Key('recommend-title-$sectionKey-${drama.id}'),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -116,7 +119,9 @@ class RecommendSectionCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<String> _cardTags(BuildContext context) {
|
List<String> _cardTags(BuildContext context) {
|
||||||
final localized = drama.localizedTypeLabels(Localizations.localeOf(context).languageCode);
|
final localized = drama.localizedTypeLabels(
|
||||||
|
Localizations.localeOf(context).languageCode,
|
||||||
|
);
|
||||||
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
|
if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
return [drama.genre, drama.category]
|
return [drama.genre, drama.category]
|
||||||
|
|||||||
@@ -70,10 +70,12 @@ class WatchRecordCard extends StatelessWidget {
|
|||||||
key: ValueKey('watch-record-cover-${record.dramaId}'),
|
key: ValueKey('watch-record-cover-${record.dramaId}'),
|
||||||
width: _coverWidth,
|
width: _coverWidth,
|
||||||
height: _coverHeight,
|
height: _coverHeight,
|
||||||
showFallbackIcon: true,
|
fallbackWidget: ColoredBox(
|
||||||
fallbackIconKey: ValueKey(
|
key: ValueKey(
|
||||||
'watch-record-cover-placeholder-${record.dramaId}',
|
'watch-record-cover-placeholder-${record.dramaId}',
|
||||||
),
|
),
|
||||||
|
color: AppColors.raised,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
// Author: fruit.com.cn
|
|
||||||
// 职责:验证图片占位扫光生命周期及默认兜底接入。
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:fruitshorts/core/widgets/image_placeholder_shimmer.dart';
|
|
||||||
import 'package:fruitshorts/core/widgets/remote_image.dart';
|
|
||||||
|
|
||||||
const _frame = Duration(milliseconds: 350);
|
|
||||||
const double _width = 120;
|
|
||||||
const double _height = 160;
|
|
||||||
const _size = Size(_width, _height);
|
|
||||||
|
|
||||||
Widget _placeholder({bool reduceMotion = false, bool active = true}) {
|
|
||||||
return MaterialApp(
|
|
||||||
home: MediaQuery(
|
|
||||||
data: MediaQueryData(disableAnimations: reduceMotion),
|
|
||||||
child: TickerMode(
|
|
||||||
enabled: active,
|
|
||||||
child: const Center(
|
|
||||||
child: SizedBox(
|
|
||||||
width: _width,
|
|
||||||
height: _height,
|
|
||||||
child: ImagePlaceholderShimmer(
|
|
||||||
child: ColoredBox(color: Colors.black),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
testWidgets('should_paint_clipped_sweep_when_placeholder_is_loading', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(_placeholder());
|
|
||||||
final shimmer = find.byType(ImagePlaceholderShimmer);
|
|
||||||
final paintFinder = find.descendant(
|
|
||||||
of: shimmer,
|
|
||||||
matching: find.byType(CustomPaint),
|
|
||||||
);
|
|
||||||
final paint = tester.widget<CustomPaint>(paintFinder);
|
|
||||||
var repaints = 0;
|
|
||||||
paint.foregroundPainter!.addListener(() => repaints++);
|
|
||||||
await tester.pump(_frame);
|
|
||||||
expect(repaints, greaterThan(0));
|
|
||||||
expect(tester.getSize(shimmer), _size);
|
|
||||||
expect(
|
|
||||||
find.descendant(of: shimmer, matching: find.byType(ClipRect)),
|
|
||||||
findsOneWidget,
|
|
||||||
);
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
|
||||||
await tester.pump(_frame);
|
|
||||||
expect(tester.binding.hasScheduledFrame, isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('should_pause_sweep_when_motion_is_disabled_or_page_is_hidden', (
|
|
||||||
tester,
|
|
||||||
) async {
|
|
||||||
await tester.pumpWidget(_placeholder());
|
|
||||||
await tester.pump(_frame);
|
|
||||||
await tester.pumpWidget(_placeholder(reduceMotion: true));
|
|
||||||
await tester.pump(_frame);
|
|
||||||
expect(tester.binding.hasScheduledFrame, isFalse);
|
|
||||||
await tester.pumpWidget(_placeholder(active: false));
|
|
||||||
await tester.pump(_frame);
|
|
||||||
expect(tester.binding.hasScheduledFrame, isFalse);
|
|
||||||
await tester.pumpWidget(_placeholder());
|
|
||||||
await tester.pump(_frame);
|
|
||||||
expect(tester.binding.hasScheduledFrame, isTrue);
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets(
|
|
||||||
'should_sweep_only_default_loading_fallback_when_image_is_pending',
|
|
||||||
(tester) async {
|
|
||||||
await tester.pumpWidget(
|
|
||||||
const MaterialApp(
|
|
||||||
home: SizedBox(
|
|
||||||
width: _width,
|
|
||||||
height: _height,
|
|
||||||
child: RemoteImage('/files/pending-cover.jpg'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final image = tester.widget<Image>(find.byType(Image));
|
|
||||||
final context = tester.element(find.byType(RemoteImage));
|
|
||||||
const loaded = SizedBox(key: Key('loaded'));
|
|
||||||
expect(
|
|
||||||
image.frameBuilder!(context, loaded, null, false),
|
|
||||||
isA<ImagePlaceholderShimmer>(),
|
|
||||||
);
|
|
||||||
expect(image.frameBuilder!(context, loaded, 0, false), same(loaded));
|
|
||||||
expect(
|
|
||||||
image.errorBuilder!(context, Exception('failed'), null),
|
|
||||||
isNot(isA<ImagePlaceholderShimmer>()),
|
|
||||||
);
|
|
||||||
await tester.pumpWidget(const MaterialApp(home: RemoteImage('')));
|
|
||||||
expect(find.byType(ImagePlaceholderShimmer), findsNothing);
|
|
||||||
await tester.pumpWidget(
|
|
||||||
const MaterialApp(
|
|
||||||
home: RemoteImage(
|
|
||||||
'/files/custom-cover.jpg',
|
|
||||||
fallbackWidget: SizedBox(key: Key('custom')),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(find.byKey(const Key('custom')), findsOneWidget);
|
|
||||||
expect(find.byType(ImagePlaceholderShimmer), findsNothing);
|
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user