- 清理大量废弃的 barrel 导出文件,移除冗余的中间导出层 - 修复所有相对路径导入错误,统一调整为扁平化模块引用 - 更新多平台 pubspec 版本号与依赖库版本 - 补充后端功能问题管理后台与脚本工具 - 调整部分页面的快捷方式文案适配新功能 - 更新部分翻译覆盖率与API文档
425 lines
12 KiB
Dart
425 lines
12 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 壁纸瀑布流网格组件
|
||
/// 创建时间: 2026-05-04
|
||
/// 更新时间: 2026-06-12
|
||
/// 作用: 瀑布流布局展示壁纸 + URL三级回退 + 已加载优先排序
|
||
/// + CachedNetworkImage + shimmer骨架屏 + 选择态
|
||
/// 上次更新: 从shared/widgets迁移至features/template模块,简化template导入路径,调整shared/widgets路径
|
||
/// ============================================================
|
||
|
||
import 'dart:async';
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||
import 'package:shimmer/shimmer.dart';
|
||
|
||
import '../../../core/services/data/image_cache_manager.dart';
|
||
import '../../../core/theme/app_theme.dart';
|
||
import '../../../core/theme/app_radius.dart';
|
||
import '../../../core/utils/logger.dart';
|
||
import '../../../shared/widgets/media/safe_cached_image.dart';
|
||
import '../models/template_models.dart';
|
||
import '../services/wallpaper_favorite_service.dart';
|
||
|
||
class WallpaperMasonryGrid extends StatelessWidget {
|
||
const WallpaperMasonryGrid({
|
||
super.key,
|
||
required this.items,
|
||
required this.isLoading,
|
||
required this.hasMore,
|
||
required this.selectedId,
|
||
required this.onSelect,
|
||
required this.onLoadMore,
|
||
required this.onItemTap,
|
||
this.onImageLoaded,
|
||
this.crossAxisCount = 2,
|
||
});
|
||
|
||
final List<WallpaperItem> items;
|
||
final bool isLoading;
|
||
final bool hasMore;
|
||
final String? selectedId;
|
||
final ValueChanged<String> onSelect;
|
||
final VoidCallback onLoadMore;
|
||
final ValueChanged<WallpaperItem> onItemTap;
|
||
final ValueChanged<String>? onImageLoaded;
|
||
final int crossAxisCount;
|
||
|
||
/// 三级回退获取最佳图片URL: thumbnailUrl → previewUrl → imageUrl
|
||
static String resolveImageUrl(WallpaperItem item) {
|
||
if (item.thumbnailUrl.isNotEmpty) return item.thumbnailUrl;
|
||
if (item.previewUrl.isNotEmpty) return item.previewUrl;
|
||
return item.imageUrl;
|
||
}
|
||
|
||
/// 将已加载(有缓存)的项排到前面
|
||
static List<WallpaperItem> sortLoadedFirst(
|
||
List<WallpaperItem> items,
|
||
Set<String> cachedIds,
|
||
) {
|
||
final cached = <WallpaperItem>[];
|
||
final uncached = <WallpaperItem>[];
|
||
for (final item in items) {
|
||
if (cachedIds.contains(item.id)) {
|
||
cached.add(item);
|
||
} else {
|
||
uncached.add(item);
|
||
}
|
||
}
|
||
return [...cached, ...uncached];
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ext = AppTheme.ext(context);
|
||
|
||
if (isLoading && items.isEmpty) {
|
||
return Center(
|
||
child: CupertinoActivityIndicator(radius: 14, color: ext.textSecondary),
|
||
);
|
||
}
|
||
|
||
if (items.isEmpty) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(CupertinoIcons.photo, size: 48, color: ext.textHint),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
'暂无壁纸',
|
||
style: TextStyle(color: ext.textSecondary, fontSize: 16),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return MasonryGridView.count(
|
||
crossAxisCount: crossAxisCount,
|
||
mainAxisSpacing: 8,
|
||
crossAxisSpacing: 8,
|
||
itemCount: items.length + (hasMore ? 1 : 0),
|
||
itemBuilder: (context, index) {
|
||
if (index >= items.length) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => onLoadMore());
|
||
return const Padding(
|
||
padding: EdgeInsets.all(16),
|
||
child: Center(child: CupertinoActivityIndicator(radius: 12)),
|
||
);
|
||
}
|
||
|
||
final item = items[index];
|
||
final isSelected = selectedId == item.id;
|
||
|
||
return _WallpaperCard(
|
||
item: item,
|
||
isSelected: isSelected,
|
||
ext: ext,
|
||
onSelect: () => onSelect(item.id),
|
||
onTap: () => onItemTap(item),
|
||
onImageLoaded: onImageLoaded,
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _WallpaperCard extends StatefulWidget {
|
||
const _WallpaperCard({
|
||
required this.item,
|
||
required this.isSelected,
|
||
required this.ext,
|
||
required this.onSelect,
|
||
required this.onTap,
|
||
this.onImageLoaded,
|
||
});
|
||
|
||
final WallpaperItem item;
|
||
final bool isSelected;
|
||
final AppThemeExtension ext;
|
||
final VoidCallback onSelect;
|
||
final VoidCallback onTap;
|
||
final ValueChanged<String>? onImageLoaded;
|
||
|
||
@override
|
||
State<_WallpaperCard> createState() => _WallpaperCardState();
|
||
}
|
||
|
||
class _WallpaperCardState extends State<_WallpaperCard> {
|
||
bool _isCached = false;
|
||
bool _checkedCache = false;
|
||
bool _imageLoadedNotified = false;
|
||
late bool _isFavorite;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_isFavorite = WallpaperFavoriteService.isFavorite(widget.item.id);
|
||
_checkCache();
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant _WallpaperCard oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
if (oldWidget.item.id != widget.item.id) {
|
||
_isFavorite = WallpaperFavoriteService.isFavorite(widget.item.id);
|
||
}
|
||
}
|
||
|
||
// 修复9: 缓存检查添加超时保护,防止文件I/O阻塞
|
||
Future<void> _checkCache() async {
|
||
final url = WallpaperMasonryGrid.resolveImageUrl(widget.item);
|
||
if (url.isEmpty) return;
|
||
try {
|
||
final file = await CustomCacheManager.instance
|
||
.getFileFromCache(url)
|
||
.timeout(const Duration(seconds: 2));
|
||
if (mounted) {
|
||
setState(() {
|
||
_isCached = file != null;
|
||
_checkedCache = true;
|
||
});
|
||
if (file != null) {
|
||
_notifyImageLoaded(url);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
Log.w('壁纸缓存检查失败: $e');
|
||
if (mounted) setState(() => _checkedCache = true);
|
||
}
|
||
}
|
||
|
||
void _notifyImageLoaded(String url) {
|
||
if (_imageLoadedNotified || url.isEmpty) return;
|
||
_imageLoadedNotified = true;
|
||
widget.onImageLoaded?.call(url);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ext = widget.ext;
|
||
final item = widget.item;
|
||
final imageUrl = WallpaperMasonryGrid.resolveImageUrl(item);
|
||
|
||
return GestureDetector(
|
||
onTap: () {
|
||
widget.onSelect();
|
||
widget.onTap();
|
||
},
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
decoration: BoxDecoration(
|
||
borderRadius: AppRadius.mdBorder,
|
||
border: widget.isSelected
|
||
? Border.all(color: ext.accent, width: 2.5)
|
||
: Border.all(
|
||
color: ext.bgCard.withValues(alpha: 0.3),
|
||
width: 0.5,
|
||
),
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Stack(
|
||
children: [
|
||
_buildImage(imageUrl, ext),
|
||
if (_isCached && _checkedCache)
|
||
Positioned(top: 6, left: 6, child: _buildCachedBadge(ext)),
|
||
Positioned(top: 6, right: 6, child: _buildFavoriteButton(ext)),
|
||
if (widget.isSelected)
|
||
Positioned(top: 6, right: 6, child: _buildSelectCheck(ext)),
|
||
Positioned.fill(child: _buildGradientOverlay()),
|
||
Positioned(left: 0, right: 0, bottom: 0, child: _buildInfo(ext)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildImage(String imageUrl, AppThemeExtension ext) {
|
||
if (imageUrl.isEmpty) {
|
||
return Container(
|
||
height: 160,
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: [ext.accent.withValues(alpha: 0.3), ext.bgCard],
|
||
),
|
||
),
|
||
child: Center(
|
||
child: Icon(CupertinoIcons.photo, color: ext.textHint, size: 32),
|
||
),
|
||
);
|
||
}
|
||
|
||
final aspectRatio = widget.item.width > 0 && widget.item.height > 0
|
||
? widget.item.width / widget.item.height
|
||
: 16 / 9;
|
||
final height = aspectRatio > 0 ? 200.0 / aspectRatio : 200.0;
|
||
final clampedHeight = height.clamp(120.0, 320.0);
|
||
|
||
return SizedBox(
|
||
height: clampedHeight,
|
||
child: SafeCachedImage(
|
||
url: imageUrl,
|
||
fit: BoxFit.cover,
|
||
httpHeaders: const {'User-Agent': 'Xianyan/1.0'},
|
||
fadeInDuration: const Duration(milliseconds: 300),
|
||
onLoaded: () => _notifyImageLoaded(imageUrl),
|
||
placeholder: (_, __) => Shimmer.fromColors(
|
||
baseColor: ext.bgCard,
|
||
highlightColor: ext.bgElevated,
|
||
child: Container(
|
||
height: clampedHeight,
|
||
decoration: BoxDecoration(
|
||
color: ext.bgCard,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
),
|
||
),
|
||
errorWidget: (_, __, ___) => Container(
|
||
height: clampedHeight,
|
||
color: ext.bgCard,
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(CupertinoIcons.photo, color: ext.textHint, size: 24),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'加载失败',
|
||
style: TextStyle(fontSize: 10, color: ext.textHint),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCachedBadge(AppThemeExtension ext) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF34C759).withValues(alpha: 0.9),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
child: const Text(
|
||
'已缓存',
|
||
style: TextStyle(
|
||
color: CupertinoColors.white,
|
||
fontSize: 8,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 收藏心形按钮
|
||
Widget _buildFavoriteButton(AppThemeExtension ext) {
|
||
return GestureDetector(
|
||
onTap: () async {
|
||
await WallpaperFavoriteService.toggle(widget.item);
|
||
if (mounted) {
|
||
setState(() {
|
||
_isFavorite = !_isFavorite;
|
||
});
|
||
}
|
||
},
|
||
child: Container(
|
||
width: 26,
|
||
height: 26,
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withValues(alpha: 0.35),
|
||
shape: BoxShape.circle,
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Icon(
|
||
_isFavorite ? CupertinoIcons.heart_fill : CupertinoIcons.heart,
|
||
size: 14,
|
||
color: _isFavorite ? const Color(0xFFFF3B30) : CupertinoColors.white,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSelectCheck(AppThemeExtension ext) {
|
||
return Container(
|
||
width: 22,
|
||
height: 22,
|
||
decoration: BoxDecoration(color: ext.accent, shape: BoxShape.circle),
|
||
child: const Icon(
|
||
CupertinoIcons.checkmark,
|
||
size: 12,
|
||
color: CupertinoColors.white,
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildGradientOverlay() {
|
||
return const DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Colors.transparent, Color(0x80000000)],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInfo(AppThemeExtension ext) {
|
||
final item = widget.item;
|
||
return Padding(
|
||
padding: const EdgeInsets.all(8),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (item.title.isNotEmpty)
|
||
Text(
|
||
item.title.length > 12
|
||
? '${item.title.substring(0, 12)}…'
|
||
: item.title,
|
||
style: const TextStyle(
|
||
color: Color(0xCCFFFFFF),
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 2),
|
||
Row(
|
||
children: [
|
||
if (item.resolution.isNotEmpty) _buildBadge(item.resolution),
|
||
const SizedBox(width: 4),
|
||
if (item.views > 0) _buildBadge(_formatCount(item.views)),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildBadge(String text) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black.withValues(alpha: 0.4),
|
||
borderRadius: BorderRadius.circular(3),
|
||
),
|
||
child: Text(
|
||
text,
|
||
style: const TextStyle(color: Color(0xB3FFFFFF), fontSize: 8),
|
||
),
|
||
);
|
||
}
|
||
|
||
String _formatCount(int count) {
|
||
if (count >= 10000) return '${(count / 10000).toStringAsFixed(1)}w';
|
||
if (count >= 1000) return '${(count / 1000).toStringAsFixed(1)}k';
|
||
return '$count';
|
||
}
|
||
}
|