主要变更: 1. 新增桌面端托盘图标支持深色/浅色主题切换 2. 重构应用锁、动画配置、小组件导航服务职责 3. 修复Riverpod初始化断言、防重复点击、工作台模式残留选中态问题 4. 优化诗词服务、阅读进度、搜索结果空状态体验 5. 完善macOS打包配置与错误静默处理逻辑 6. 新增快速卡片多语言适配与动画退出队列管理
1072 lines
34 KiB
Dart
1072 lines
34 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 桌面小部件管理页面
|
||
/// 创建时间: 2026-05-19
|
||
/// 更新时间: 2026-06-04
|
||
/// 作用: 管理桌面小部件的安装、数据推送、主题和平台兼容说明
|
||
/// 上次更新: 修复_WidgetDataPreview生命周期问题(framework断言错误),增加disposed保护和超时机制
|
||
/// ============================================================
|
||
|
||
import 'dart:async';
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../../core/storage/kv_storage.dart';
|
||
import '../../../core/router/app_nav_extension.dart';
|
||
import '../../../core/router/app_routes.dart';
|
||
import '../../../core/theme/app_theme.dart';
|
||
import '../../../core/theme/app_spacing.dart';
|
||
import '../../../core/theme/app_typography.dart';
|
||
import '../../../core/theme/app_radius.dart';
|
||
import '../../../core/services/data/home_widget_service.dart';
|
||
import '../../../core/utils/logger.dart';
|
||
import '../../../core/utils/platform/platform_helper.dart';
|
||
import '../../../l10n/translation_resolver.dart';
|
||
import '../../../l10n/types/t.dart';
|
||
import '../../../shared/widgets/adaptive/adaptive_back_button.dart';
|
||
import '../../../shared/widgets/beta_testing_overlay.dart';
|
||
import '../../../shared/widgets/containers/glass_container.dart';
|
||
import '../../../shared/widgets/adaptive/responsive_layout.dart';
|
||
import '../../../shared/widgets/feedback/app_toast.dart';
|
||
import '../widget_type.dart';
|
||
import '../widget_provider.dart' as wp;
|
||
|
||
class WidgetManagementPage extends ConsumerStatefulWidget {
|
||
const WidgetManagementPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<WidgetManagementPage> createState() =>
|
||
_WidgetManagementPageState();
|
||
}
|
||
|
||
class _WidgetManagementPageState extends ConsumerState<WidgetManagementPage> {
|
||
bool _isAdding = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
ref.read(wp.widgetProvider.notifier).loadInstalledWidgets();
|
||
_showDevDialogIfNeeded();
|
||
});
|
||
}
|
||
|
||
/// 开发中弹窗提示,支持"不再提醒"
|
||
void _showDevDialogIfNeeded() {
|
||
final dismissed = KvStorage.getBool('widget_dev_dismissed') ?? false;
|
||
if (dismissed) return;
|
||
final tw = ref.read(translationsProvider).widget;
|
||
showCupertinoDialog<void>(
|
||
context: context,
|
||
builder: (ctx) {
|
||
bool dontRemind = false;
|
||
return StatefulBuilder(
|
||
builder: (ctx, setDialogState) => CupertinoAlertDialog(
|
||
title: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
CupertinoIcons.hammer,
|
||
size: 20,
|
||
color: CupertinoColors.systemOrange.resolveFrom(ctx),
|
||
),
|
||
const SizedBox(width: 8),
|
||
const Text('Beta'),
|
||
],
|
||
),
|
||
content: Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(tw.devDialogContent),
|
||
const SizedBox(height: 12),
|
||
GestureDetector(
|
||
onTap: () => setDialogState(() => dontRemind = !dontRemind),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
dontRemind
|
||
? CupertinoIcons.checkmark_square_fill
|
||
: CupertinoIcons.square,
|
||
size: 20,
|
||
color: dontRemind
|
||
? CupertinoColors.systemGreen.resolveFrom(ctx)
|
||
: CupertinoColors.systemGrey.resolveFrom(ctx),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(tw.dontRemind, style: const TextStyle(fontSize: 14)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
CupertinoDialogAction(
|
||
child: Text(tw.viewExperimentalFeatures),
|
||
onPressed: () {
|
||
Navigator.pop(ctx);
|
||
// 鸿蒙端暂不支持,弹出toast提示
|
||
if (PlatformHelper.isHarmonyOS) {
|
||
AppToast.showInfo('敬请期待');
|
||
return;
|
||
}
|
||
context.appPush(AppRoutes.experimentalFeatures);
|
||
},
|
||
),
|
||
CupertinoDialogAction(
|
||
isDefaultAction: true,
|
||
onPressed: () {
|
||
if (dontRemind) {
|
||
KvStorage.setBool('widget_dev_dismissed', true);
|
||
}
|
||
Navigator.pop(ctx);
|
||
},
|
||
child: Text(tw.iKnow),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ext = AppTheme.ext(context);
|
||
final t = ref.watch(translationsProvider);
|
||
final tw = t.widget;
|
||
final wp.WidgetState widgetState = ref.watch(wp.widgetProvider);
|
||
|
||
final grouped = <int, List<WidgetType>>{};
|
||
for (final wt in WidgetTypeX.all) {
|
||
grouped.putIfAbsent(wt.priority, () => []).add(wt);
|
||
}
|
||
|
||
return Stack(
|
||
children: [
|
||
CupertinoPageScaffold(
|
||
backgroundColor: ext.bgPrimary,
|
||
child: ResponsiveMaxWidth(
|
||
maxWidth: 900,
|
||
child: SafeArea(
|
||
bottom: false,
|
||
child: CustomScrollView(
|
||
physics: const BouncingScrollPhysics(),
|
||
slivers: [
|
||
CupertinoSliverNavigationBar(
|
||
leading: const AdaptiveBackButton(),
|
||
middle: Text(
|
||
tw.title,
|
||
style: AppTypography.title3.copyWith(
|
||
color: ext.textPrimary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
largeTitle: Text(
|
||
tw.title,
|
||
style: AppTypography.title3.copyWith(
|
||
color: ext.textPrimary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
trailing: _ThemeToggle(ext: ext),
|
||
backgroundColor: ext.bgPrimary.withValues(alpha: 0.85),
|
||
border: null,
|
||
),
|
||
|
||
SliverToBoxAdapter(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.md,
|
||
vertical: AppSpacing.sm,
|
||
),
|
||
child: _PlatformCompatibilityCard(ext: ext, tw: tw),
|
||
),
|
||
),
|
||
|
||
for (final entry in grouped.entries) ...[
|
||
SliverToBoxAdapter(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
AppSpacing.md,
|
||
AppSpacing.md,
|
||
AppSpacing.md,
|
||
AppSpacing.xs,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
_PriorityDot(priority: entry.key),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
_priorityLabel(entry.key, tw),
|
||
style: AppTypography.title3.copyWith(
|
||
color: ext.textPrimary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
SliverList(
|
||
delegate: SliverChildBuilderDelegate((context, index) {
|
||
final type = entry.value[index];
|
||
final isInstalled = widgetState.installedWidgets.contains(
|
||
type,
|
||
);
|
||
return Padding(
|
||
key: ValueKey(type),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.md,
|
||
vertical: AppSpacing.xs,
|
||
),
|
||
child: _WidgetCard(
|
||
ext: ext,
|
||
tw: tw,
|
||
type: type,
|
||
isInstalled: isInstalled,
|
||
isAdding: _isAdding,
|
||
onAdd: () => _handleAddWidget(type),
|
||
onPin: () => _handlePinWidget(type),
|
||
),
|
||
);
|
||
}, childCount: entry.value.length),
|
||
),
|
||
],
|
||
|
||
if (widgetState.error != null)
|
||
SliverToBoxAdapter(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(AppSpacing.md),
|
||
child: Text(
|
||
'错误: ${widgetState.error}',
|
||
style: AppTypography.footnote.copyWith(
|
||
color: CupertinoColors.systemRed,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
const SliverToBoxAdapter(child: SizedBox(height: 120)),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Beta 测试全局透明水印蒙版
|
||
const BetaTestingOverlay(),
|
||
],
|
||
);
|
||
}
|
||
|
||
String _priorityLabel(int p, TWidget tw) => switch (p) {
|
||
0 => tw.priorityCore,
|
||
1 => tw.priorityRecommended,
|
||
2 => tw.priorityPractical,
|
||
_ => tw.priorityFun,
|
||
};
|
||
|
||
void _handleAddWidget(WidgetType type) async {
|
||
// 鸿蒙端点击添加弹出toast提示
|
||
if (PlatformHelper.isHarmonyOS) {
|
||
AppToast.showInfo(ref.read(translationsProvider).common.inDevelopment);
|
||
return;
|
||
}
|
||
if (_isAdding) return;
|
||
_isAdding = true;
|
||
|
||
try {
|
||
final notifier = ref.read(wp.widgetProvider.notifier);
|
||
final wasInstalled = ref
|
||
.read(wp.widgetProvider)
|
||
.installedWidgets
|
||
.contains(type);
|
||
|
||
final result = await notifier.requestPinWidget(type);
|
||
|
||
if (!mounted) return;
|
||
|
||
if (result == wp.PinWidgetResult.unsupported) {
|
||
_showManualAddGuide(type, isUnsupported: true);
|
||
return;
|
||
}
|
||
|
||
if (result == wp.PinWidgetResult.failed) {
|
||
_showManualAddGuide(type, isUnsupported: false);
|
||
return;
|
||
}
|
||
|
||
await Future<void>.delayed(const Duration(seconds: 2));
|
||
|
||
if (!mounted) return;
|
||
await notifier.loadInstalledWidgets();
|
||
|
||
final nowInstalled = ref
|
||
.read(wp.widgetProvider)
|
||
.installedWidgets
|
||
.contains(type);
|
||
if (nowInstalled && !wasInstalled) {
|
||
final tw = ref.read(translationsProvider).widget;
|
||
AppToast.showInfo(tw.addedToDesktop.replaceAll('{0}', type.localizedTitle(tw)));
|
||
} else if (!nowInstalled) {
|
||
_showManualAddGuide(type, isUnsupported: false);
|
||
}
|
||
} finally {
|
||
_isAdding = false;
|
||
}
|
||
}
|
||
|
||
void _handlePinWidget(WidgetType type) async {
|
||
if (_isAdding) return;
|
||
_isAdding = true;
|
||
|
||
try {
|
||
final notifier = ref.read(wp.widgetProvider.notifier);
|
||
final result = await notifier.requestPinWidget(type);
|
||
|
||
if (!mounted) return;
|
||
|
||
if (result == wp.PinWidgetResult.unsupported) {
|
||
_showManualAddGuide(type, isUnsupported: true);
|
||
return;
|
||
}
|
||
|
||
if (result == wp.PinWidgetResult.failed) {
|
||
_showManualAddGuide(type, isUnsupported: false);
|
||
return;
|
||
}
|
||
|
||
await Future<void>.delayed(const Duration(seconds: 2));
|
||
|
||
if (!mounted) return;
|
||
await notifier.loadInstalledWidgets();
|
||
|
||
final nowInstalled = ref
|
||
.read(wp.widgetProvider)
|
||
.installedWidgets
|
||
.contains(type);
|
||
if (!nowInstalled) {
|
||
_showManualAddGuide(type, isUnsupported: false);
|
||
}
|
||
} finally {
|
||
_isAdding = false;
|
||
}
|
||
}
|
||
|
||
void _showManualAddGuide(WidgetType type, {required bool isUnsupported}) {
|
||
final ext = AppTheme.ext(context);
|
||
final tw = ref.read(translationsProvider).widget;
|
||
final typeTitle = type.localizedTitle(tw);
|
||
final steps = PlatformHelper.isHarmonyOS
|
||
? [
|
||
tw.harmonyStep1,
|
||
tw.harmonyStep2,
|
||
tw.harmonyStep3,
|
||
tw.harmonyStep4.replaceAll('{0}', typeTitle),
|
||
]
|
||
: PlatformHelper.isAndroid
|
||
? [
|
||
tw.androidStep1,
|
||
tw.androidStep2,
|
||
tw.androidStep3,
|
||
tw.androidStep4.replaceAll('{0}', typeTitle),
|
||
]
|
||
: [
|
||
tw.iosStep1,
|
||
tw.iosStep2,
|
||
tw.iosStep3,
|
||
tw.iosStep4.replaceAll('{0}', typeTitle),
|
||
];
|
||
|
||
final guideReason = isUnsupported
|
||
? tw.unsupportedAddHint
|
||
: tw.failedAddHint;
|
||
|
||
showCupertinoDialog<void>(
|
||
context: context,
|
||
builder: (ctx) => CupertinoAlertDialog(
|
||
title: Padding(
|
||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
CupertinoIcons.square_grid_2x2_fill,
|
||
size: 20,
|
||
color: ext.accent,
|
||
),
|
||
const SizedBox(width: AppSpacing.xs),
|
||
Text(tw.addWidgetTitle.replaceAll('{0}', typeTitle)),
|
||
],
|
||
),
|
||
),
|
||
content: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
guideReason,
|
||
style: AppTypography.footnote.copyWith(color: ext.textSecondary),
|
||
),
|
||
const SizedBox(height: AppSpacing.md),
|
||
...steps.map(
|
||
(s) => Padding(
|
||
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
|
||
child: Text(
|
||
s,
|
||
style: AppTypography.subhead.copyWith(color: ext.textPrimary),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
CupertinoDialogAction(
|
||
child: Text(tw.pushData),
|
||
onPressed: () {
|
||
Navigator.of(ctx).pop();
|
||
ref.read(wp.widgetProvider.notifier).pushDataToWidget(type);
|
||
AppToast.showInfo(tw.pushedDataToWidget.replaceAll('{0}', typeTitle));
|
||
},
|
||
),
|
||
CupertinoDialogAction(
|
||
isDefaultAction: true,
|
||
child: Text(tw.gotIt),
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ThemeToggle extends ConsumerWidget {
|
||
const _ThemeToggle({required this.ext});
|
||
final AppThemeExtension ext;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final tw = ref.watch(translationsProvider).widget;
|
||
return GestureDetector(
|
||
onTap: () {
|
||
ref.read(wp.widgetProvider.notifier).pushThemeToAllWidgets();
|
||
AppToast.showInfo(tw.pushedThemeToWidget);
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.sm,
|
||
vertical: 4,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: ext.bgSecondary,
|
||
borderRadius: AppRadius.pillBorder,
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(CupertinoIcons.paintbrush_fill, size: 14, color: ext.accent),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
tw.syncTheme,
|
||
style: AppTypography.caption1.copyWith(
|
||
color: ext.accent,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PriorityDot extends StatelessWidget {
|
||
const _PriorityDot({required this.priority});
|
||
final int priority;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final color = switch (priority) {
|
||
0 => CupertinoColors.systemRed,
|
||
1 => CupertinoColors.systemOrange,
|
||
2 => CupertinoColors.systemBlue,
|
||
_ => CupertinoColors.systemGrey,
|
||
};
|
||
return Container(
|
||
width: 8,
|
||
height: 8,
|
||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PlatformCompatibilityCard extends StatefulWidget {
|
||
const _PlatformCompatibilityCard({required this.ext, required this.tw});
|
||
final AppThemeExtension ext;
|
||
final TWidget tw;
|
||
|
||
@override
|
||
State<_PlatformCompatibilityCard> createState() =>
|
||
_PlatformCompatibilityCardState();
|
||
}
|
||
|
||
class _PlatformCompatibilityCardState
|
||
extends State<_PlatformCompatibilityCard> {
|
||
bool _isExpanded = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ext = widget.ext;
|
||
final tw = widget.tw;
|
||
return GlassContainer(
|
||
depth: GlassDepth.elevated,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () => setState(() => _isExpanded = !_isExpanded),
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
CupertinoIcons.info_circle_fill,
|
||
size: 18,
|
||
color: ext.accent,
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: Text(
|
||
tw.platformCompatTitle,
|
||
style: AppTypography.subhead.copyWith(
|
||
color: ext.textPrimary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
AnimatedRotation(
|
||
turns: _isExpanded ? 0.0 : -0.25,
|
||
duration: const Duration(milliseconds: 250),
|
||
curve: Curves.easeInOut,
|
||
child: Icon(
|
||
CupertinoIcons.chevron_down,
|
||
size: 16,
|
||
color: ext.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
AnimatedCrossFade(
|
||
firstChild: const SizedBox.shrink(key: ValueKey('collapsed')),
|
||
secondChild: Padding(
|
||
key: const ValueKey('expanded'),
|
||
padding: const EdgeInsets.only(top: AppSpacing.sm),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
_PlatformRow(
|
||
ext: ext,
|
||
icon: '🤖',
|
||
name: 'Android',
|
||
desc: tw.androidCompatDesc,
|
||
),
|
||
const SizedBox(height: 4),
|
||
_PlatformRow(
|
||
ext: ext,
|
||
icon: '🍎',
|
||
name: 'iOS',
|
||
desc: tw.iosCompatDesc,
|
||
),
|
||
const SizedBox(height: 4),
|
||
_PlatformRow(
|
||
ext: ext,
|
||
icon: '🔴',
|
||
name: tw.badgeHarmony,
|
||
desc: tw.harmonyCompatDesc,
|
||
),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
Container(
|
||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||
decoration: BoxDecoration(
|
||
color: ext.accent.withValues(alpha: 0.06),
|
||
borderRadius: AppRadius.smBorder,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
CupertinoIcons.lightbulb_fill,
|
||
size: 14,
|
||
color: ext.accent,
|
||
),
|
||
const SizedBox(width: AppSpacing.xs),
|
||
Expanded(
|
||
child: Text(
|
||
tw.syncThemeHint,
|
||
style: AppTypography.footnote.copyWith(
|
||
color: ext.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
crossFadeState: _isExpanded
|
||
? CrossFadeState.showSecond
|
||
: CrossFadeState.showFirst,
|
||
duration: const Duration(milliseconds: 250),
|
||
sizeCurve: Curves.easeInOut,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PlatformRow extends StatelessWidget {
|
||
const _PlatformRow({
|
||
required this.ext,
|
||
required this.icon,
|
||
required this.name,
|
||
required this.desc,
|
||
});
|
||
final AppThemeExtension ext;
|
||
final String icon;
|
||
final String name;
|
||
final String desc;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(icon, style: const TextStyle(fontSize: 16)),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: RichText(
|
||
text: TextSpan(
|
||
children: [
|
||
TextSpan(
|
||
text: '$name ',
|
||
style: AppTypography.subhead.copyWith(
|
||
color: ext.textPrimary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
TextSpan(
|
||
text: desc,
|
||
style: AppTypography.footnote.copyWith(
|
||
color: ext.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _WidgetCard extends StatelessWidget {
|
||
const _WidgetCard({
|
||
required this.ext,
|
||
required this.tw,
|
||
required this.type,
|
||
required this.isInstalled,
|
||
required this.isAdding,
|
||
required this.onAdd,
|
||
required this.onPin,
|
||
});
|
||
final AppThemeExtension ext;
|
||
final TWidget tw;
|
||
final WidgetType type;
|
||
final bool isInstalled;
|
||
final bool isAdding;
|
||
final VoidCallback onAdd;
|
||
final VoidCallback onPin;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GlassContainer(
|
||
depth: GlassDepth.elevated,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 44,
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
color: ext.accent.withValues(alpha: 0.12),
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: Icon(type.icon, size: 22, color: ext.accent),
|
||
),
|
||
const SizedBox(width: AppSpacing.md),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
type.localizedTitle(tw),
|
||
style: AppTypography.subhead.copyWith(
|
||
color: ext.textPrimary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpacing.xs),
|
||
_PriorityBadge(priority: type.priority),
|
||
],
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
type.localizedSubtitle(tw),
|
||
style: AppTypography.footnote.copyWith(
|
||
color: ext.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
if (isInstalled)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.sm,
|
||
vertical: 4,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: CupertinoColors.systemGreen.withValues(alpha: 0.15),
|
||
borderRadius: AppRadius.pillBorder,
|
||
),
|
||
child: Text(
|
||
tw.installed,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: CupertinoColors.systemGreen,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
)
|
||
else
|
||
CupertinoButton(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.sm,
|
||
vertical: 4,
|
||
),
|
||
minimumSize: Size.zero,
|
||
borderRadius: AppRadius.pillBorder,
|
||
color: ext.accent,
|
||
onPressed: isAdding ? null : onAdd,
|
||
child: isAdding
|
||
? const CupertinoActivityIndicator(radius: 8)
|
||
: Text(
|
||
tw.add,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: CupertinoColors.white,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: AppSpacing.xs),
|
||
Row(
|
||
children: [
|
||
_PlatformBadges(ext: ext, tw: tw, type: type),
|
||
const Spacer(),
|
||
_DeepLinkBadge(ext: ext, route: type.deepLinkRoute),
|
||
],
|
||
),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
_WidgetDataPreview(ext: ext, tw: tw, type: type),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PriorityBadge extends StatelessWidget {
|
||
const _PriorityBadge({required this.priority});
|
||
final int priority;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final label = switch (priority) {
|
||
0 => 'P0',
|
||
1 => 'P1',
|
||
2 => 'P2',
|
||
_ => 'P3',
|
||
};
|
||
final color = switch (priority) {
|
||
0 => CupertinoColors.systemRed,
|
||
1 => CupertinoColors.systemOrange,
|
||
2 => CupertinoColors.systemBlue,
|
||
_ => CupertinoColors.systemGrey,
|
||
};
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.12),
|
||
borderRadius: AppRadius.xsBorder,
|
||
),
|
||
child: Text(
|
||
label,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: color,
|
||
fontWeight: FontWeight.w700,
|
||
fontSize: 9,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PlatformBadges extends StatelessWidget {
|
||
const _PlatformBadges({required this.ext, required this.tw, required this.type});
|
||
final AppThemeExtension ext;
|
||
final TWidget tw;
|
||
final WidgetType type;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
children: [
|
||
const _Badge(label: 'Android', supported: true),
|
||
const SizedBox(width: 4),
|
||
const _Badge(label: 'iOS', supported: true),
|
||
const SizedBox(width: 4),
|
||
_Badge(label: tw.badgeHarmony, supported: type.supportsOhos),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Badge extends StatelessWidget {
|
||
const _Badge({required this.label, required this.supported});
|
||
final String label;
|
||
final bool supported;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||
decoration: BoxDecoration(
|
||
color: supported
|
||
? CupertinoColors.systemGreen.withValues(alpha: 0.1)
|
||
: CupertinoColors.systemGrey.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(3),
|
||
),
|
||
child: Text(
|
||
supported ? label : '$label✗',
|
||
style: AppTypography.caption2.copyWith(
|
||
color: supported
|
||
? CupertinoColors.systemGreen
|
||
: CupertinoColors.systemGrey,
|
||
fontSize: 9,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DeepLinkBadge extends StatelessWidget {
|
||
const _DeepLinkBadge({required this.ext, required this.route});
|
||
final AppThemeExtension ext;
|
||
final String route;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(CupertinoIcons.link, size: 10, color: ext.textSecondary),
|
||
const SizedBox(width: 2),
|
||
Text(
|
||
route,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: ext.textSecondary,
|
||
fontSize: 9,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _WidgetDataPreview extends ConsumerStatefulWidget {
|
||
const _WidgetDataPreview({required this.ext, required this.tw, required this.type});
|
||
final AppThemeExtension ext;
|
||
final TWidget tw;
|
||
final WidgetType type;
|
||
|
||
@override
|
||
ConsumerState<_WidgetDataPreview> createState() => _WidgetDataPreviewState();
|
||
}
|
||
|
||
class _WidgetDataPreviewState extends ConsumerState<_WidgetDataPreview> {
|
||
Map<String, dynamic> _data = {};
|
||
bool _loading = true;
|
||
bool _disposed = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// 使用addPostFrameCallback确保element完全挂载后再访问ref
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted || _disposed) return;
|
||
_loadPreview();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_disposed = true;
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _loadPreview() async {
|
||
if (_disposed || !mounted) return;
|
||
try {
|
||
final service = ref.read(homeWidgetServiceProvider);
|
||
final data = await service.debugGetAllData().timeout(
|
||
const Duration(seconds: 5),
|
||
);
|
||
if (_disposed || !mounted) return;
|
||
setState(() {
|
||
_data = data;
|
||
_loading = false;
|
||
});
|
||
} on TimeoutException {
|
||
if (_disposed || !mounted) return;
|
||
setState(() => _loading = false);
|
||
Log.w('WidgetDataPreview: 加载预览数据超时');
|
||
} catch (e) {
|
||
if (_disposed || !mounted) return;
|
||
setState(() => _loading = false);
|
||
Log.e('WidgetDataPreview: 加载预览数据失败', e);
|
||
}
|
||
}
|
||
|
||
Future<void> _refreshData() async {
|
||
if (_disposed || !mounted) return;
|
||
setState(() => _loading = true);
|
||
try {
|
||
final service = ref.read(homeWidgetServiceProvider);
|
||
await service
|
||
.updateWidget(widget.type)
|
||
.timeout(const Duration(seconds: 5));
|
||
await _loadPreview();
|
||
} on TimeoutException {
|
||
if (_disposed || !mounted) return;
|
||
setState(() => _loading = false);
|
||
Log.w('WidgetDataPreview: 刷新数据超时');
|
||
} catch (e) {
|
||
if (_disposed || !mounted) return;
|
||
setState(() => _loading = false);
|
||
Log.e('WidgetDataPreview: 刷新数据失败', e);
|
||
}
|
||
}
|
||
|
||
String _getPreviewText() {
|
||
final tw = widget.tw;
|
||
return switch (widget.type) {
|
||
WidgetType.dailySentence =>
|
||
'${_data['daily_sentence'] ?? tw.previewNoData}',
|
||
WidgetType.readlater =>
|
||
tw.previewReadlaterCount.replaceAll('{0}', '${_data['readlater_count'] ?? 0}'),
|
||
WidgetType.dailyFortune =>
|
||
'${_data['fortune_text'] ?? tw.previewNoData}',
|
||
WidgetType.countdown =>
|
||
'${_data['countdown_title'] ?? tw.previewNoData}',
|
||
WidgetType.pomodoro =>
|
||
tw.previewPomodoroRemaining.replaceAll('{0}', '${_data['pomodoro_remaining'] ?? 0}'),
|
||
WidgetType.solarTerm =>
|
||
'${_data['solar_term_name'] ?? tw.previewNoData}',
|
||
WidgetType.checkin =>
|
||
tw.previewCheckinDays.replaceAll('{0}', '${_data['checkin_days'] ?? 0}'),
|
||
WidgetType.dailyWithCharacter =>
|
||
'${_data['daily_with_character_content'] ?? tw.previewNoData}',
|
||
WidgetType.dailyCard =>
|
||
tw.previewDailyCard,
|
||
};
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ext = widget.ext;
|
||
final tw = widget.tw;
|
||
return Container(
|
||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||
decoration: BoxDecoration(
|
||
color: ext.bgSecondary.withValues(alpha: 0.5),
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(CupertinoIcons.eye_fill, size: 12, color: ext.textSecondary),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
tw.dataPreview,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: ext.textSecondary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
GestureDetector(
|
||
onTap: _loading ? null : _refreshData,
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(CupertinoIcons.refresh, size: 12, color: ext.accent),
|
||
const SizedBox(width: 2),
|
||
Text(
|
||
tw.refresh,
|
||
style: AppTypography.caption2.copyWith(
|
||
color: ext.accent,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: AppSpacing.xs),
|
||
if (_loading)
|
||
const CupertinoActivityIndicator(radius: 6)
|
||
else
|
||
Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpacing.sm,
|
||
vertical: AppSpacing.xs,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: ext.bgCard.withValues(alpha: 0.6),
|
||
borderRadius: AppRadius.smBorder,
|
||
),
|
||
child: Text(
|
||
_getPreviewText(),
|
||
style: AppTypography.footnote.copyWith(color: ext.textPrimary),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|