Files
xianyan/lib/features/mine/settings/presentation/image_cache_widgets.dart
Developer 9ea8d3d606 chore: 汇总批量提交的功能优化与bug修复
本次提交包含多项迭代优化和问题修复:
1. 新增缩略图图片组件、数字格式化工具类,补充多语言翻译类型与本地化支持
2. 优化底部导航栏主题色统一使用动态accent色值
3. 修复多处图表动画、路由跳转、API请求相关问题
4. 简化服务器公告文案,调整默认分屏状态为关闭
5. 新增安卓/iOS桌面快捷方式配置
6. 重构多处状态管理类使用SafeNotifierInit统一异常保护
7. 替换硬编码蓝色为主题色,更新版本号获取方式为动态读取
8. 优化缓存预加载逻辑,移除无用代码
9. 调整默认设置项,优化用户体验细节
2026-05-31 12:24:05 +08:00

841 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言APP — 图片缓存概览/筛选/占比/操作区组件
/// 创建时间: 2026-05-30
/// 更新时间: 2026-05-31
/// 作用: 图片缓存页面的存储概览、分类筛选、缓存占比(饼图)、操作区、批量操作栏
/// 上次更新: 修复缓存为空时显示0B问题添加空态UI
/// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../../core/services/data/image_cache_metadata_service.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/theme/app_typography.dart';
import '../../../../l10n/translations.dart';
import '../../../../shared/widgets/containers/glass_container.dart';
import 'image_cache_models.dart';
import 'image_cache_pie_chart.dart';
// ============================================================
// 存储概览区
// ============================================================
/// 存储概览区:总缓存/文件数/Feed缓存 + 进度条(动态上限)
class StorageOverviewSection extends StatelessWidget {
StorageOverviewSection({
required this.state,
required this.ext,
required this.ct,
super.key,
});
final ImageCacheState state;
final AppThemeExtension ext;
final TSettingsCache ct;
@override
Widget build(BuildContext context) {
final hasCache = state.cacheItems.isNotEmpty;
if (!hasCache) {
return GlassContainer(
depth: GlassDepth.elevated,
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
children: [
_SectionHeader(
icon: CupertinoIcons.photo_fill,
title: ct.storageOverview,
ext: ext,
),
const SizedBox(height: AppSpacing.lg),
Icon(
CupertinoIcons.tray,
size: 48,
color: ext.textHint.withValues(alpha: 0.4),
),
const SizedBox(height: AppSpacing.sm),
Text(
ct.totalCache.isEmpty ? '暂无缓存' : ct.totalCache,
style: AppTypography.subhead.copyWith(color: ext.textHint),
),
const SizedBox(height: AppSpacing.xs),
Text(
'浏览图片后缓存将自动出现在这里',
style: AppTypography.caption1.copyWith(
color: ext.textHint.withValues(alpha: 0.6),
),
),
],
),
).animate().fadeIn(duration: 300.ms);
}
final limitBytes = state.cacheSizeLimit * 1024 * 1024;
final progress = state.totalSize > 0
? (state.totalSize / limitBytes).clamp(0.0, 1.0)
: 0.0;
return GlassContainer(
depth: GlassDepth.elevated,
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader(
icon: CupertinoIcons.photo_fill,
title: ct.storageOverview,
ext: ext,
),
const SizedBox(height: AppSpacing.md),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_StatItem(
ext: ext,
icon: CupertinoIcons.folder_fill,
label: ct.totalCache,
value: CacheFormatter.formatSize(state.totalSize),
color: CupertinoColors.systemBlue,
),
_StatItem(
ext: ext,
icon: CupertinoIcons.photo_fill,
label: ct.fileCount,
value: '${state.cacheItems.length}',
color: CupertinoColors.systemGreen,
),
_StatItem(
ext: ext,
icon: CupertinoIcons.doc_text_fill,
label: ct.feedCache,
value: '${state.imageCacheCount} ${ct.filesUnit}',
color: CupertinoColors.systemOrange,
),
],
),
const SizedBox(height: AppSpacing.md),
ClipRRect(
borderRadius: AppRadius.smBorder,
child: LinearProgressIndicator(
value: progress,
backgroundColor: ext.textHint.withValues(alpha: 0.1),
valueColor: AlwaysStoppedAnimation<Color>(ext.accent),
minHeight: 6,
),
),
const SizedBox(height: AppSpacing.xs),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${ct.usedSpace} ${CacheFormatter.formatSize(state.totalSize)}',
style: AppTypography.caption1.copyWith(color: ext.textHint),
),
Text(
'${ct.refLimit} ${state.cacheSizeLimit} MB',
style: AppTypography.caption1.copyWith(color: ext.textHint),
),
],
),
],
),
).animate().fadeIn(duration: 300.ms);
}
}
// ============================================================
// 分类筛选 Chips
// ============================================================
/// 分类筛选横条
class CategoryChipsSection extends StatelessWidget {
CategoryChipsSection({
required this.state,
required this.ext,
required this.ct,
required this.onCategorySelected,
super.key,
});
final ImageCacheState state;
final AppThemeExtension ext;
final TSettingsCache ct;
final ValueChanged<String?> onCategorySelected;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_CategoryChip(
ext: ext,
label: '📋 ${ct.all}',
category: null,
selectedCategory: state.selectedCategory,
onTap: onCategorySelected,
),
const SizedBox(width: AppSpacing.sm),
...CacheCategory.all.map((cat) {
final count = state.categoryCounts[cat] ?? 0;
return Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: _CategoryChip(
ext: ext,
label: '${CacheCategory.label(cat)} ($count)',
category: cat,
selectedCategory: state.selectedCategory,
onTap: onCategorySelected,
),
);
}),
],
),
);
}
}
// ============================================================
// 缓存占比区(饼图)
// ============================================================
/// 缓存占比区:饼图 + 近期/过期 + 分类详情行
class CacheBreakdownSection extends StatelessWidget {
CacheBreakdownSection({
required this.state,
required this.ext,
required this.ct,
super.key,
});
final ImageCacheState state;
final AppThemeExtension ext;
final TSettingsCache ct;
@override
Widget build(BuildContext context) {
final recentSize = state.totalSize - state.expiredSize;
final recentCount = state.cacheItems.length - state.expiredCount;
final expiredDays = state.expiredDays;
final segments = <PieSegment>[
PieSegment(
label: '${ct.recentCache}${expiredDays}${ct.withinDays}',
size: recentSize,
color: CupertinoColors.systemBlue,
),
PieSegment(
label: '${ct.expiredCache}${expiredDays}${ct.beforeDays}',
size: state.expiredSize,
color: CupertinoColors.systemOrange,
),
...CacheCategory.all.map((cat) {
final size = state.categoryStats[cat] ?? 0;
if (size <= 0) return null;
return PieSegment(
label: CacheCategory.label(cat),
size: size,
color: CacheCategoryStyle.color(cat),
);
}).whereType<PieSegment>(),
];
return GlassContainer(
depth: GlassDepth.elevated,
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader(
icon: CupertinoIcons.chart_pie_fill,
title: ct.cacheBreakdown,
ext: ext,
),
const SizedBox(height: AppSpacing.md),
CachePieChart(
segments: segments,
totalSize: state.totalSize,
ext: ext,
ct: ct,
),
const SizedBox(height: AppSpacing.md),
_BreakdownRow(
ext: ext,
icon: CupertinoIcons.clock_fill,
label: '${ct.recentCache}${expiredDays}${ct.withinDays}',
count: recentCount,
size: recentSize,
ratio: CacheFormatter.cacheRatio(recentSize, state.totalSize),
color: CupertinoColors.systemBlue,
ct: ct,
),
const SizedBox(height: AppSpacing.sm),
_BreakdownRow(
ext: ext,
icon: CupertinoIcons.clock_fill,
label: '${ct.expiredCache}${expiredDays}${ct.beforeDays}',
count: state.expiredCount,
size: state.expiredSize,
ratio: CacheFormatter.cacheRatio(
state.expiredSize,
state.totalSize,
),
color: CupertinoColors.systemOrange,
ct: ct,
),
if (state.categoryStats.isNotEmpty) ...[
const SizedBox(height: AppSpacing.md),
Divider(color: ext.textHint.withValues(alpha: 0.1)),
const SizedBox(height: AppSpacing.sm),
...CacheCategory.all.map((cat) {
final size = state.categoryStats[cat] ?? 0;
final count = state.categoryCounts[cat] ?? 0;
if (count == 0) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: _BreakdownRow(
ext: ext,
icon: CacheCategoryStyle.icon(cat),
label: CacheCategory.label(cat),
count: count,
size: size,
ratio: CacheFormatter.cacheRatio(size, state.totalSize),
color: CacheCategoryStyle.color(cat),
ct: ct,
),
);
}),
],
],
),
).animate().fadeIn(duration: 300.ms, delay: 100.ms);
}
}
// ============================================================
// 操作区
// ============================================================
/// 缓存操作区:自动清理策略 + 缓存上限 + 清理日志 + 清除过期 + 清除全部
class CacheActionsSection extends StatelessWidget {
CacheActionsSection({
required this.state,
required this.ext,
required this.ct,
required this.onClearExpired,
required this.onClearAll,
required this.onShowAutoCleanPolicy,
required this.onShowCacheSizeLimit,
required this.onShowCleanLog,
super.key,
});
final ImageCacheState state;
final AppThemeExtension ext;
final TSettingsCache ct;
final VoidCallback onClearExpired;
final VoidCallback onClearAll;
final VoidCallback onShowAutoCleanPolicy;
final VoidCallback onShowCacheSizeLimit;
final VoidCallback onShowCleanLog;
@override
Widget build(BuildContext context) {
final expiredDays = state.expiredDays;
return GlassContainer(
depth: GlassDepth.elevated,
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionHeader(
icon: CupertinoIcons.trash_fill,
title: ct.cacheActions,
ext: ext,
),
const SizedBox(height: AppSpacing.md),
_ActionRow(
ext: ext,
icon: CupertinoIcons.arrow_counterclockwise,
iconColor: ext.accent,
title: ct.autoCleanPolicy,
subtitle:
'${ct.currentLabel}: ${AutoCleanPolicy.label(state.autoCleanPolicy)}${ct.autoCleanSuffix}',
onTap: onShowAutoCleanPolicy,
),
const SizedBox(height: AppSpacing.sm),
_ActionRow(
ext: ext,
icon: CupertinoIcons.gauge,
iconColor: ext.accent,
title: ct.cacheSizeLimit,
subtitle: '${ct.currentLimitLabel}: ${state.cacheSizeLimit} MB',
onTap: onShowCacheSizeLimit,
),
const SizedBox(height: AppSpacing.sm),
_ActionRow(
ext: ext,
icon: CupertinoIcons.doc_text_search,
iconColor: ext.accent,
title: ct.cleanLog,
subtitle: ct.cleanLogDesc,
onTap: onShowCleanLog,
),
const SizedBox(height: AppSpacing.md),
SizedBox(
width: double.infinity,
child: CupertinoButton(
color: CupertinoColors.systemOrange.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
onPressed: state.expiredCount > 0 ? onClearExpired : null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
CupertinoIcons.clock_fill,
size: 18,
color: CupertinoColors.systemOrange,
),
const SizedBox(width: AppSpacing.sm),
Text(
'${ct.clearExpiredCache}${expiredDays}${ct.beforeDays} · ${state.expiredCount}${ct.filesUnit} / ${CacheFormatter.formatSize(state.expiredSize)}',
style: AppTypography.subhead.copyWith(
color: state.expiredCount > 0
? CupertinoColors.systemOrange
: CupertinoColors.systemGrey,
),
),
],
),
),
),
const SizedBox(height: AppSpacing.sm),
SizedBox(
width: double.infinity,
child: CupertinoButton(
color: CupertinoColors.systemRed.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
onPressed: state.cacheItems.isNotEmpty ? onClearAll : null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
CupertinoIcons.trash_fill,
size: 18,
color: CupertinoColors.systemRed,
),
const SizedBox(width: AppSpacing.sm),
Text(
'${ct.clearAllCache}${state.cacheItems.length}${ct.filesUnit} / ${CacheFormatter.formatSize(state.totalSize)}',
style: AppTypography.subhead.copyWith(
color: state.cacheItems.isNotEmpty
? CupertinoColors.systemRed
: CupertinoColors.systemGrey,
),
),
],
),
),
),
],
),
).animate().fadeIn(duration: 300.ms, delay: 300.ms);
}
}
// ============================================================
// 批量操作底部栏
// ============================================================
/// 批量操作底部栏:选中数量 + 全选/取消全选/删除选中/取消
class BatchActionBar extends StatelessWidget {
BatchActionBar({
required this.state,
required this.ext,
required this.ct,
required this.onSelectAll,
required this.onDeselectAll,
required this.onDeleteSelected,
required this.onCancel,
super.key,
});
final ImageCacheState state;
final AppThemeExtension ext;
final TSettingsCache ct;
final VoidCallback onSelectAll;
final VoidCallback onDeselectAll;
final VoidCallback onDeleteSelected;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
final selectedCount = state.selectedPaths.length;
final totalCount = state.cacheItems.length;
final allSelected = selectedCount == totalCount && totalCount > 0;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: ext.bgCard.withValues(alpha: 0.95),
border: Border(
top: BorderSide(
color: ext.textHint.withValues(alpha: 0.15),
width: 0.5,
),
),
),
child: SafeArea(
top: false,
child: Row(
children: [
Text(
'${ct.selectedCount} $selectedCount',
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
_BatchButton(
ext: ext,
label: allSelected ? ct.deselectAll : ct.selectAll,
icon: allSelected
? CupertinoIcons.checkmark_rectangle
: CupertinoIcons.square_grid_2x2,
color: ext.accent,
onTap: allSelected ? onDeselectAll : onSelectAll,
),
const SizedBox(width: AppSpacing.sm),
_BatchButton(
ext: ext,
label: ct.delete,
icon: CupertinoIcons.trash_fill,
color: CupertinoColors.systemRed,
onTap: selectedCount > 0 ? onDeleteSelected : null,
),
const SizedBox(width: AppSpacing.sm),
_BatchButton(
ext: ext,
label: ct.cancel,
icon: CupertinoIcons.xmark,
color: ext.textSecondary,
onTap: onCancel,
),
],
),
),
).animate().slideY(begin: 1, end: 0, duration: 250.ms);
}
}
// ============================================================
// 私有组件
// ============================================================
/// 区块标题行
class _SectionHeader extends StatelessWidget {
const _SectionHeader({
required this.icon,
required this.title,
required this.ext,
});
final IconData icon;
final String title;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 18, color: ext.accent),
const SizedBox(width: AppSpacing.sm),
Text(
title,
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
],
);
}
}
/// 统计项小组件
class _StatItem extends StatelessWidget {
const _StatItem({
required this.ext,
required this.icon,
required this.label,
required this.value,
required this.color,
});
final AppThemeExtension ext;
final IconData icon;
final String label;
final String value;
final Color color;
@override
Widget build(BuildContext context) {
return Column(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(height: 4),
Text(
value,
style: AppTypography.subhead.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
Text(
label,
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
);
}
}
/// 单个分类筛选 Chip
class _CategoryChip extends StatelessWidget {
const _CategoryChip({
required this.ext,
required this.label,
required this.category,
required this.selectedCategory,
required this.onTap,
});
final AppThemeExtension ext;
final String label;
final String? category;
final String? selectedCategory;
final ValueChanged<String?> onTap;
@override
Widget build(BuildContext context) {
final selected = selectedCategory == category;
return GestureDetector(
onTap: () => onTap(category),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: selected
? ext.accent.withValues(alpha: 0.15)
: ext.bgSecondary,
borderRadius: AppRadius.pillBorder,
border: Border.all(
color: selected
? ext.accent.withValues(alpha: 0.5)
: ext.textHint.withValues(alpha: 0.15),
),
),
child: Text(
label,
style: AppTypography.caption1.copyWith(
color: selected ? ext.accent : ext.textSecondary,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
),
);
}
}
/// 占比行组件
class _BreakdownRow extends StatelessWidget {
_BreakdownRow({
required this.ext,
required this.icon,
required this.label,
required this.count,
required this.size,
required this.ratio,
required this.color,
required this.ct,
});
final AppThemeExtension ext;
final IconData icon;
final String label;
final int count;
final int size;
final double ratio;
final Color color;
final TSettingsCache ct;
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppTypography.footnote.copyWith(color: ext.textPrimary),
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: AppRadius.xsBorder,
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: ext.textHint.withValues(alpha: 0.1),
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 4,
),
),
],
),
),
const SizedBox(width: AppSpacing.sm),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
CacheFormatter.formatSize(size),
style: AppTypography.caption1.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
Text(
'$count ${ct.filesUnit}',
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
),
],
);
}
}
/// 操作行组件(自动清理策略 / 缓存上限 / 清理日志 通用行)
class _ActionRow extends StatelessWidget {
const _ActionRow({
required this.ext,
required this.icon,
required this.iconColor,
required this.title,
required this.subtitle,
required this.onTap,
});
final AppThemeExtension ext;
final IconData icon;
final Color iconColor;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
),
child: Row(
children: [
Icon(icon, size: 18, color: iconColor),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppTypography.footnote.copyWith(
color: ext.textPrimary,
),
),
Text(
subtitle,
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
),
),
Icon(CupertinoIcons.chevron_right, size: 16, color: ext.textHint),
],
),
),
);
}
}
/// 批量操作按钮
class _BatchButton extends StatelessWidget {
const _BatchButton({
required this.ext,
required this.label,
required this.icon,
required this.color,
required this.onTap,
});
final AppThemeExtension ext;
final String label;
final IconData icon;
final Color color;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 6,
),
decoration: BoxDecoration(
color: enabled
? color.withValues(alpha: 0.12)
: ext.textHint.withValues(alpha: 0.06),
borderRadius: AppRadius.mdBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: enabled ? color : ext.textHint),
const SizedBox(width: 4),
Text(
label,
style: AppTypography.caption1.copyWith(
color: enabled ? color : ext.textHint,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}