1. 新增TDashboard翻译类型与多语言文案 2. 完善macOS权限管理与Impeller渲染适配 3. 更新服务器部署配置与协议文件上传脚本 4. 修复翻译导入服务与根类型编译问题
588 lines
22 KiB
Dart
588 lines
22 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 概览仪表盘
|
||
/// 创建时间: 2026-05-29
|
||
/// 更新时间: 2026-06-26
|
||
/// 作用: 宽屏分屏右侧面板的空状态页面,显示概览信息
|
||
/// 上次更新: 全面国际化+动态主题/样式+触觉反馈+骨架屏+无障碍降级+时段自动刷新+空指针防护
|
||
/// ============================================================
|
||
|
||
import 'dart:async';
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:shimmer/shimmer.dart';
|
||
|
||
import '../../core/theme/app_theme.dart';
|
||
import '../../core/theme/app_spacing.dart';
|
||
import '../../features/auth/providers/auth_provider.dart';
|
||
import '../../features/home/providers/favorite_provider.dart';
|
||
import '../../features/home/providers/home_provider.dart';
|
||
import '../../features/home/providers/likes_provider.dart';
|
||
import '../../features/home/providers/tool_center_recent_provider.dart';
|
||
import '../../features/home/presentation/home_tool_center.dart';
|
||
import '../../features/settings/providers/general_settings_provider.dart';
|
||
import '../../features/signin/providers/signin_provider.dart';
|
||
import '../../core/router/app_nav_extension.dart';
|
||
import '../../core/router/app_routes.dart';
|
||
import '../../core/services/recent_route_service.dart';
|
||
import '../../l10n/translation_resolver.dart';
|
||
import '../../l10n/types/t.dart';
|
||
import '../../shared/widgets/containers/glass_container.dart';
|
||
import '../../shared/widgets/display/app_icon.dart';
|
||
|
||
/// 工作台概览仪表盘 — 右栏默认面板
|
||
///
|
||
/// 特性:
|
||
/// - 全文案多语言([translationsProvider] → [TDashboard])
|
||
/// - 动态主题/样式([AppThemeExtension] 字号缩放/字重/玻璃/圆角)
|
||
/// - 触觉反馈([HapticFeedback])
|
||
/// - 骨架屏加载([Shimmer])
|
||
/// - 无障碍动画降级(响应 reduceAnimations 设置)
|
||
/// - 时段问候语自动刷新(每分钟校验小时变更)
|
||
/// - 空指针防护(provider 异常时回退 0)
|
||
class OverviewDashboard extends ConsumerStatefulWidget {
|
||
const OverviewDashboard({super.key});
|
||
|
||
@override
|
||
ConsumerState<OverviewDashboard> createState() => _OverviewDashboardState();
|
||
}
|
||
|
||
class _OverviewDashboardState extends ConsumerState<OverviewDashboard> {
|
||
/// 当前小时缓存 — 用于检测时段切换触发刷新
|
||
int _currentHour = DateTime.now().hour;
|
||
|
||
/// 时段轮询定时器 — 每分钟校验一次,跨午时/傍晚时自动刷新问候语
|
||
late final Timer _hourTimer;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_hourTimer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||
final h = DateTime.now().hour;
|
||
if (h != _currentHour && mounted) {
|
||
setState(() => _currentHour = h);
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_hourTimer.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = ref.watch(translationsProvider);
|
||
final ext = AppTheme.ext(context);
|
||
final reduceAnim = ref.watch(generalSettingsProvider).reduceAnimations;
|
||
final db = t.dashboard;
|
||
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.all(AppSpacing.md),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildGreeting(context, db, ext, reduceAnim),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
_buildTodayRecommend(context, ref, db, ext),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
_buildQuickActions(context, db, ext, reduceAnim),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
_buildRecentHistory(context, ref, db, ext, reduceAnim),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
_buildStats(context, ref, db, ext, reduceAnim),
|
||
const SizedBox(height: AppSpacing.xxl),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 问候区
|
||
// ============================================================
|
||
|
||
Widget _buildGreeting(BuildContext context, TDashboard db, AppThemeExtension ext, bool reduceAnim) {
|
||
// 时段问候语 + 图标(基于 _currentHour,跨时段自动刷新)
|
||
final greeting = switch (_currentHour) {
|
||
>= 6 && < 12 => db.goodMorning,
|
||
>= 12 && < 14 => db.goodNoon,
|
||
>= 14 && < 18 => db.goodAfternoon,
|
||
>= 18 && < 22 => db.goodEvening,
|
||
_ => db.goodNight,
|
||
};
|
||
final greetingIcon = switch (_currentHour) {
|
||
>= 6 && < 12 => CupertinoIcons.sun_max,
|
||
>= 12 && < 14 => CupertinoIcons.cloud_sun,
|
||
>= 14 && < 18 => CupertinoIcons.sunset,
|
||
>= 18 && < 22 => CupertinoIcons.moon,
|
||
_ => CupertinoIcons.moon_stars,
|
||
};
|
||
|
||
return GlassContainer(
|
||
depth: GlassDepth.elevated,
|
||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||
child: Row(
|
||
children: [
|
||
AppIcon(
|
||
cupertinoIcon: greetingIcon,
|
||
size: 28,
|
||
animate: !reduceAnim,
|
||
semanticLabel: greeting,
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
greeting,
|
||
style: TextStyle(
|
||
fontSize: 22 * ext.fontScale,
|
||
fontWeight: FontWeight.bold,
|
||
color: ext.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpacing.xs),
|
||
Text(
|
||
db.greetingHint,
|
||
style: TextStyle(
|
||
fontSize: 14 * ext.fontScale,
|
||
fontWeight: ext.fontWeight,
|
||
color: ext.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 今日推荐
|
||
// ============================================================
|
||
|
||
Widget _buildTodayRecommend(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
TDashboard db,
|
||
AppThemeExtension ext,
|
||
) {
|
||
final homeState = ref.watch(homeProvider);
|
||
final recommends = homeState.dailySentences;
|
||
final isLoading = homeState.isLoading;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildSectionHeader(context, CupertinoIcons.sparkles, db.sectionRecommend, ext),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
SizedBox(
|
||
height: 120,
|
||
// 加载中且无数据 → 骨架屏;无数据 → 空状态;有数据 → 列表
|
||
child: isLoading && recommends.isEmpty
|
||
? _buildRecommendSkeleton(ext)
|
||
: recommends.isEmpty
|
||
? _buildEmptyState(context, CupertinoIcons.tray, db.emptyRecommend, ext)
|
||
: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: recommends.length,
|
||
separatorBuilder: (_, __) => const SizedBox(width: AppSpacing.sm),
|
||
itemBuilder: (context, index) {
|
||
final sentence = recommends[index];
|
||
final author = sentence.author != null && sentence.author!.isNotEmpty
|
||
? '$db.authorPrefix${sentence.author}'
|
||
: '$db.authorPrefix${db.anonymousAuthor}';
|
||
return GlassContainer(
|
||
width: 180,
|
||
padding: const EdgeInsets.all(AppSpacing.md),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
sentence.text,
|
||
style: TextStyle(
|
||
fontSize: 13 * ext.fontScale,
|
||
fontWeight: ext.fontWeight,
|
||
color: ext.textPrimary,
|
||
),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
author,
|
||
style: TextStyle(
|
||
fontSize: 11 * ext.fontScale,
|
||
color: ext.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 今日推荐骨架屏 — 加载态占位
|
||
Widget _buildRecommendSkeleton(AppThemeExtension ext) {
|
||
return Shimmer.fromColors(
|
||
baseColor: ext.isDark ? ext.bgCard : ext.bgSecondary,
|
||
highlightColor: ext.isDark ? ext.bgElevated : ext.bgPrimary,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
itemCount: 3,
|
||
separatorBuilder: (_, __) => const SizedBox(width: AppSpacing.sm),
|
||
itemBuilder: (_, __) => GlassContainer(
|
||
width: 180,
|
||
padding: const EdgeInsets.all(AppSpacing.md),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_skeletonBlock(ext, double.infinity, 12),
|
||
const SizedBox(height: AppSpacing.xs),
|
||
_skeletonBlock(ext, 120, 12),
|
||
const Spacer(),
|
||
_skeletonBlock(ext, 80, 10),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 快捷操作
|
||
// ============================================================
|
||
|
||
Widget _buildQuickActions(
|
||
BuildContext context,
|
||
TDashboard db,
|
||
AppThemeExtension ext,
|
||
bool reduceAnim,
|
||
) {
|
||
final actions = <({IconData icon, String label, String route})>[
|
||
(icon: CupertinoIcons.search, label: db.actionSearch, route: AppRoutes.search),
|
||
(icon: CupertinoIcons.star, label: db.actionFavorites, route: AppRoutes.favorites),
|
||
(icon: CupertinoIcons.book, label: db.actionReadLater, route: AppRoutes.readLater),
|
||
(icon: CupertinoIcons.clock, label: db.actionHistory, route: AppRoutes.history),
|
||
(icon: CupertinoIcons.checkmark_seal, label: db.actionSignin, route: AppRoutes.signin),
|
||
(icon: CupertinoIcons.chart_bar, label: db.actionReadingReport, route: AppRoutes.readingReport),
|
||
(icon: CupertinoIcons.cloud_sun, label: db.actionDailyCard, route: AppRoutes.dailyCard),
|
||
(icon: CupertinoIcons.settings, label: db.actionSettings, route: AppRoutes.generalSettings),
|
||
];
|
||
final animDuration = reduceAnim ? Duration.zero : const Duration(milliseconds: 375);
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildSectionHeader(context, CupertinoIcons.bolt, db.sectionQuickActions, ext),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
AnimationLimiter(
|
||
child: Wrap(
|
||
spacing: AppSpacing.sm,
|
||
runSpacing: AppSpacing.sm,
|
||
children: actions.asMap().entries.map((entry) {
|
||
return AnimationConfiguration.staggeredList(
|
||
position: entry.key,
|
||
duration: animDuration,
|
||
child: SlideAnimation(
|
||
verticalOffset: reduceAnim ? 0 : 50.0,
|
||
child: FadeInAnimation(
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
HapticFeedback.selectionClick();
|
||
context.appPush(entry.value.route);
|
||
},
|
||
child: GlassContainer(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.md,
|
||
vertical: AppSpacing.sm,
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
AppIcon(
|
||
cupertinoIcon: entry.value.icon,
|
||
size: 16,
|
||
animate: !reduceAnim,
|
||
animationDelay: entry.key * 50,
|
||
semanticLabel: entry.value.label,
|
||
),
|
||
const SizedBox(width: AppSpacing.xs),
|
||
Text(
|
||
entry.value.label,
|
||
style: TextStyle(
|
||
fontSize: 13 * ext.fontScale,
|
||
fontWeight: ext.fontWeight,
|
||
color: ext.textPrimary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 最近浏览
|
||
// ============================================================
|
||
|
||
Widget _buildRecentHistory(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
TDashboard db,
|
||
AppThemeExtension ext,
|
||
bool reduceAnim,
|
||
) {
|
||
ref.watch(toolCenterRecentProvider);
|
||
final recentRoutes = RecentRouteService.getRecentRoutes();
|
||
final animDuration = reduceAnim ? Duration.zero : const Duration(milliseconds: 375);
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildSectionHeader(context, CupertinoIcons.clock, db.sectionRecent, ext),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
recentRoutes.isEmpty
|
||
? _buildEmptyState(context, CupertinoIcons.tray, db.emptyRecent, ext)
|
||
: AnimationLimiter(
|
||
child: Column(
|
||
children: recentRoutes.take(6).toList().asMap().entries.map((entry) {
|
||
final String route = entry.value;
|
||
final String iconText = ToolCenterIconMap.getIconText(route);
|
||
final String name = ToolCenterIconMap.getName(route);
|
||
return AnimationConfiguration.staggeredList(
|
||
position: entry.key,
|
||
duration: animDuration,
|
||
child: SlideAnimation(
|
||
verticalOffset: reduceAnim ? 0 : 30.0,
|
||
child: FadeInAnimation(
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
HapticFeedback.selectionClick();
|
||
context.appPush(route);
|
||
},
|
||
child: GlassContainer(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.md,
|
||
vertical: AppSpacing.sm,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Text(iconText, style: const TextStyle(fontSize: 18)),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: Text(
|
||
name,
|
||
style: TextStyle(
|
||
fontSize: 14 * ext.fontScale,
|
||
fontWeight: ext.fontWeight,
|
||
color: ext.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
Icon(
|
||
CupertinoIcons.chevron_right,
|
||
size: 14,
|
||
color: ext.textHint,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 数据统计
|
||
// ============================================================
|
||
|
||
Widget _buildStats(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
TDashboard db,
|
||
AppThemeExtension ext,
|
||
bool reduceAnim,
|
||
) {
|
||
final authState = ref.watch(authProvider);
|
||
final favoriteState = ref.watch(favoriteProvider);
|
||
final likesState = ref.watch(likesProvider);
|
||
final signinState = ref.watch(signinProvider);
|
||
|
||
// 空指针防护 — provider 异常或字段缺失时回退 0
|
||
final readCount = authState.user?.signinDays ?? 0;
|
||
final favCount = favoriteState.total;
|
||
final likeCount = likesState.total;
|
||
final streakDays = signinState.continuous;
|
||
final streakText = '$streakDays${db.streakDayUnit}';
|
||
|
||
final stats = <(IconData, String, String)>[
|
||
(CupertinoIcons.book, db.statRead, '$readCount'),
|
||
(CupertinoIcons.star, db.statFavorites, '$favCount'),
|
||
(CupertinoIcons.hand_thumbsup, db.statLikes, '$likeCount'),
|
||
(CupertinoIcons.flame, db.statStreak, streakText),
|
||
];
|
||
final animDuration = reduceAnim ? Duration.zero : const Duration(milliseconds: 375);
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_buildSectionHeader(context, CupertinoIcons.chart_bar, db.sectionStats, ext),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
AnimationLimiter(
|
||
child: Row(
|
||
children: stats.asMap().entries.map((entry) {
|
||
final stat = entry.value;
|
||
// Expanded 必须是 Row 的直接子节点,因此放在动画包裹链的最外层
|
||
return Expanded(
|
||
child: AnimationConfiguration.staggeredList(
|
||
position: entry.key,
|
||
duration: animDuration,
|
||
child: SlideAnimation(
|
||
verticalOffset: reduceAnim ? 0 : 50.0,
|
||
child: FadeInAnimation(
|
||
child: GlassContainer(
|
||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||
margin: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.xs / 2,
|
||
),
|
||
child: Column(
|
||
children: [
|
||
AppIcon(
|
||
cupertinoIcon: stat.$1,
|
||
style: AppIconStyle.accent,
|
||
animate: !reduceAnim,
|
||
animationType: AppIconAnimation.bounceIn,
|
||
animationDelay: entry.key * 80,
|
||
semanticLabel: stat.$2,
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
stat.$3,
|
||
style: TextStyle(
|
||
fontSize: 16 * ext.fontScale,
|
||
fontWeight: FontWeight.bold,
|
||
color: ext.textPrimary,
|
||
),
|
||
),
|
||
Text(
|
||
stat.$2,
|
||
style: TextStyle(
|
||
fontSize: 11 * ext.fontScale,
|
||
color: ext.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 通用子组件
|
||
// ============================================================
|
||
|
||
/// 区块标题 — 图标 + 标题文字,统一强调色图标
|
||
Widget _buildSectionHeader(
|
||
BuildContext context,
|
||
IconData icon,
|
||
String title,
|
||
AppThemeExtension ext,
|
||
) {
|
||
return Row(
|
||
children: [
|
||
AppIcon(
|
||
cupertinoIcon: icon,
|
||
size: 16,
|
||
style: AppIconStyle.accent,
|
||
semanticLabel: title,
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
title,
|
||
style: TextStyle(
|
||
fontSize: 16 * ext.fontScale,
|
||
fontWeight: FontWeight.w600,
|
||
color: ext.textPrimary,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 空状态占位 — 图标 + 提示文案
|
||
Widget _buildEmptyState(
|
||
BuildContext context,
|
||
IconData icon,
|
||
String message,
|
||
AppThemeExtension ext,
|
||
) {
|
||
return GlassContainer(
|
||
padding: const EdgeInsets.all(AppSpacing.md),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(icon, size: 28, color: ext.textHint),
|
||
const SizedBox(height: AppSpacing.xs),
|
||
Text(
|
||
message,
|
||
style: TextStyle(
|
||
fontSize: 13 * ext.fontScale,
|
||
color: ext.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 骨架屏色块 — 圆角灰色占位
|
||
Widget _skeletonBlock(AppThemeExtension ext, double width, double height) {
|
||
return Container(
|
||
width: width,
|
||
height: height,
|
||
decoration: BoxDecoration(
|
||
color: ext.textPrimary,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
);
|
||
}
|
||
}
|