1. 新增工作台三栏布局模式,适配宽屏设备 2. 添加跨平台系统托盘支持,新增托盘图标资源 3. 修复工作台模式下导航返回异常问题 4. 统一JSON类型安全解析,替换硬类型转换 5. 增加macOS深度链接支持,统一渠道分发信息 6. 优化部分页面生命周期和状态加载逻辑 7. 移除废弃的nearby_connections依赖
290 lines
9.2 KiB
Dart
290 lines
9.2 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 壁纸源切换横滑组件
|
||
/// 创建时间: 2026-05-04
|
||
/// 更新时间: 2026-06-19
|
||
/// 作用: 横向滑动切换壁纸来源,显示源名称+速度标签+健康状态
|
||
/// + 收藏/全部/已缓存(离线模式) 三个快捷入口
|
||
/// 上次更新: 新增"已缓存"离线模式切换chip,支持离线浏览已缓存壁纸
|
||
/// ============================================================
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter_svg/flutter_svg.dart';
|
||
|
||
import '../../../core/theme/app_theme.dart';
|
||
import '../../../core/theme/app_spacing.dart';
|
||
import '../../../core/theme/app_radius.dart';
|
||
import '../../../editor/services/core/editor_icons.dart';
|
||
import '../models/template_models.dart';
|
||
import '../services/wallpaper_health_service.dart';
|
||
|
||
class WallpaperSourceBar extends StatelessWidget {
|
||
const WallpaperSourceBar({
|
||
super.key,
|
||
required this.currentSource,
|
||
required this.onSourceChanged,
|
||
this.showSpeed = true,
|
||
this.isFavoriteMode = false,
|
||
this.onFavoriteTap,
|
||
this.isOfflineMode = false,
|
||
this.onOfflineTap,
|
||
this.cachedCount = 0,
|
||
});
|
||
|
||
final WallpaperSource? currentSource;
|
||
final ValueChanged<WallpaperSource?> onSourceChanged;
|
||
final bool showSpeed;
|
||
final bool isFavoriteMode;
|
||
final VoidCallback? onFavoriteTap;
|
||
|
||
/// 是否处于离线缓存模式
|
||
final bool isOfflineMode;
|
||
|
||
/// 离线模式切换回调
|
||
final VoidCallback? onOfflineTap;
|
||
|
||
/// 已缓存壁纸数量(用于在chip上显示)
|
||
final int cachedCount;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ext = AppTheme.ext(context);
|
||
final isAll = currentSource == null &&
|
||
!isFavoriteMode &&
|
||
!isOfflineMode;
|
||
final healthMap = WallpaperHealthService.healthMap;
|
||
|
||
return SizedBox(
|
||
height: 40,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||
itemCount: WallpaperSource.values.length + 3,
|
||
separatorBuilder: (_, __) => const SizedBox(width: 6),
|
||
itemBuilder: (_, index) {
|
||
if (index == 0) {
|
||
return _SourceChip(
|
||
label: '⭐ 收藏',
|
||
isActive: isFavoriteMode,
|
||
ext: ext,
|
||
showSpeed: false,
|
||
speedText: '',
|
||
healthStatus: _HealthStatus.normal,
|
||
onTap: () => onFavoriteTap?.call(),
|
||
);
|
||
}
|
||
if (index == 1) {
|
||
return _SourceChip(
|
||
label: '🌐 全部',
|
||
isActive: isAll,
|
||
ext: ext,
|
||
showSpeed: false,
|
||
speedText: '',
|
||
healthStatus: _HealthStatus.normal,
|
||
onTap: () => onSourceChanged(null),
|
||
);
|
||
}
|
||
if (index == 2) {
|
||
return _SourceChip(
|
||
label: cachedCount > 0 ? '💾 已缓存 $cachedCount' : '💾 已缓存',
|
||
isActive: isOfflineMode,
|
||
ext: ext,
|
||
showSpeed: false,
|
||
speedText: '',
|
||
healthStatus: _HealthStatus.normal,
|
||
onTap: () => onOfflineTap?.call(),
|
||
);
|
||
}
|
||
final source = WallpaperSource.values[index - 3];
|
||
final isActive = source == currentSource &&
|
||
!isFavoriteMode &&
|
||
!isOfflineMode;
|
||
final health = healthMap[source];
|
||
|
||
final healthStatus = _resolveHealthStatus(source, health);
|
||
final speedText = _resolveSpeedText(source, health);
|
||
|
||
return _SourceChip(
|
||
source: source,
|
||
isActive: isActive,
|
||
ext: ext,
|
||
showSpeed: showSpeed,
|
||
speedText: speedText,
|
||
healthStatus: healthStatus,
|
||
onTap: () => onSourceChanged(source),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
_HealthStatus _resolveHealthStatus(
|
||
WallpaperSource source,
|
||
SourceHealth? health,
|
||
) {
|
||
if (health == null) return _HealthStatus.normal;
|
||
if (!health.isAvailable) return _HealthStatus.unavailable;
|
||
if (health.avgMs > 2000) return _HealthStatus.slow;
|
||
return _HealthStatus.normal;
|
||
}
|
||
|
||
String _resolveSpeedText(WallpaperSource source, SourceHealth? health) {
|
||
if (health != null && health.isAvailable) {
|
||
return health.avgMs < 1000
|
||
? '${health.avgMs}ms'
|
||
: '${(health.avgMs / 1000).toStringAsFixed(1)}s';
|
||
}
|
||
return source.avgMs < 1000
|
||
? '${source.avgMs}ms'
|
||
: '${(source.avgMs / 1000).toStringAsFixed(1)}s';
|
||
}
|
||
}
|
||
|
||
enum _HealthStatus { normal, slow, unavailable }
|
||
|
||
class _SourceChip extends StatelessWidget {
|
||
const _SourceChip({
|
||
this.source,
|
||
this.label,
|
||
required this.isActive,
|
||
required this.ext,
|
||
required this.showSpeed,
|
||
required this.speedText,
|
||
required this.healthStatus,
|
||
required this.onTap,
|
||
});
|
||
|
||
final WallpaperSource? source;
|
||
final String? label;
|
||
final bool isActive;
|
||
final AppThemeExtension ext;
|
||
final bool showSpeed;
|
||
final String speedText;
|
||
final _HealthStatus healthStatus;
|
||
final VoidCallback onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isUnavailable = healthStatus == _HealthStatus.unavailable;
|
||
final isSlow = healthStatus == _HealthStatus.slow;
|
||
|
||
final chipColor = isUnavailable
|
||
? CupertinoColors.systemGrey
|
||
: isActive
|
||
? ext.accent
|
||
: ext.textSecondary;
|
||
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: isActive
|
||
? (isUnavailable
|
||
? CupertinoColors.systemGrey.withValues(alpha: 0.15)
|
||
: ext.accent.withValues(alpha: 0.15))
|
||
: ext.bgCard.withValues(alpha: 0.5),
|
||
borderRadius: AppRadius.pillBorder,
|
||
border: Border.all(
|
||
color: isActive
|
||
? (isUnavailable
|
||
? CupertinoColors.systemGrey.withValues(alpha: 0.4)
|
||
: ext.accent.withValues(alpha: 0.4))
|
||
: ext.bgCard.withValues(alpha: 0.3),
|
||
),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (source != null)
|
||
_buildSourceIcon(source!, chipColor, 14),
|
||
if (label != null)
|
||
Text(
|
||
label!,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
color: isActive ? ext.accent : ext.textSecondary,
|
||
),
|
||
),
|
||
if (source != null) ...[
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
source!.label,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
color: chipColor,
|
||
),
|
||
),
|
||
],
|
||
if (isUnavailable) ...[
|
||
const SizedBox(width: 3),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||
decoration: BoxDecoration(
|
||
color: CupertinoColors.systemGrey.withValues(alpha: 0.2),
|
||
borderRadius: BorderRadius.circular(3),
|
||
),
|
||
child: const Text(
|
||
'不可用',
|
||
style: TextStyle(
|
||
fontSize: 8,
|
||
fontWeight: FontWeight.w600,
|
||
color: CupertinoColors.systemGrey,
|
||
),
|
||
),
|
||
),
|
||
] else if (isSlow) ...[
|
||
const SizedBox(width: 3),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||
decoration: BoxDecoration(
|
||
color: CupertinoColors.systemOrange.withValues(alpha: 0.2),
|
||
borderRadius: BorderRadius.circular(3),
|
||
),
|
||
child: const Text(
|
||
'慢',
|
||
style: TextStyle(
|
||
fontSize: 8,
|
||
fontWeight: FontWeight.w600,
|
||
color: CupertinoColors.systemOrange,
|
||
),
|
||
),
|
||
),
|
||
] else if (showSpeed && speedText.isNotEmpty) ...[
|
||
const SizedBox(width: 3),
|
||
Text(
|
||
speedText,
|
||
style: TextStyle(
|
||
fontSize: 9,
|
||
color: isActive
|
||
? ext.accent.withValues(alpha: 0.6)
|
||
: ext.textHint,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildSourceIcon(WallpaperSource source, Color color, double size) {
|
||
final iconData = EditorIconData.cupertino(source.iconName);
|
||
if (iconData != null) {
|
||
return Icon(iconData, size: size, color: color);
|
||
}
|
||
final svgPath = EditorIconData.svg(source.iconName);
|
||
if (svgPath != null) {
|
||
return SvgPicture.asset(
|
||
svgPath,
|
||
width: size,
|
||
height: size,
|
||
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
|
||
);
|
||
}
|
||
return Text(source.emoji, style: TextStyle(fontSize: size));
|
||
}
|
||
}
|