/// ============================================================ /// 闲言APP — 桌面小部件管理页面 /// 创建时间: 2026-05-19 /// 更新时间: 2026-05-27 /// 作用: 管理桌面小部件的安装、数据推送、主题和平台兼容说明 /// 上次更新: 增加开发中弹窗提示,支持不再提醒 /// ============================================================ 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/platform/platform_helper.dart'; import '../../../shared/widgets/containers/glass_container.dart'; import '../../../shared/widgets/adaptive/responsive_layout.dart'; import '../../../shared/widgets/feedback/app_toast.dart'; import '../models/widget_type.dart'; import '../providers/widget_provider.dart' as wp; class WidgetManagementPage extends ConsumerStatefulWidget { const WidgetManagementPage({super.key}); @override ConsumerState createState() => _WidgetManagementPageState(); } class _WidgetManagementPageState extends ConsumerState { bool _isAdding = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(wp.widgetProvider.notifier).loadInstalledWidgets(); _showDevDialogIfNeeded(); }); } /// 开发中弹窗提示,支持"不再提醒" void _showDevDialogIfNeeded() { final dismissed = KvStorage.getBool('widget_dev_dismissed') ?? false; if (dismissed) return; showCupertinoDialog( 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: [ const Text( '桌面小部件功能正在积极开发中,部分功能可能尚未完善或存在不稳定情况。\n\n' '当前支持:基础小部件显示与数据推送\n' '即将支持:更多小部件样式、交互操作、跨平台同步', ), 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), const Text('不再提醒', style: TextStyle(fontSize: 14)), ], ), ), ], ), ), actions: [ CupertinoDialogAction( child: const Text('查看实验功能'), onPressed: () { Navigator.pop(ctx); context.appPush(AppRoutes.experimentalFeatures); }, ), CupertinoDialogAction( isDefaultAction: true, onPressed: () { if (dontRemind) { KvStorage.setBool('widget_dev_dismissed', true); } Navigator.pop(ctx); }, child: const Text('我知道了'), ), ], ), ); }, ); } @override Widget build(BuildContext context) { final ext = AppTheme.ext(context); final wp.WidgetState widgetState = ref.watch(wp.widgetProvider); final grouped = >{}; for (final t in WidgetTypeX.all) { grouped.putIfAbsent(t.priority, () => []).add(t); } return CupertinoPageScaffold( backgroundColor: ext.bgPrimary, child: ResponsiveMaxWidth( maxWidth: 900, child: SafeArea( bottom: false, child: CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.sm, ), child: Row( children: [ GestureDetector( onTap: () => Navigator.of(context).pop(), child: Container( width: 32, height: 32, decoration: BoxDecoration( color: ext.bgSecondary, borderRadius: AppRadius.mdBorder, ), child: Icon( CupertinoIcons.back, size: 18, color: ext.accent, ), ), ), const SizedBox(width: AppSpacing.sm), Text( '桌面小部件', style: AppTypography.title1.copyWith( color: ext.textPrimary, ), ), const Spacer(), _ThemeToggle(ext: ext), ], ), ), ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.sm, ), child: _PlatformCompatibilityCard(ext: ext), ), ), 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), 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( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.xs, ), child: _WidgetCard( ext: ext, 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)), ], ), ), ), ); } String _priorityLabel(int p) => switch (p) { 0 => '核心小部件', 1 => '推荐小部件', 2 => '实用小部件', _ => '趣味小部件', }; void _handleAddWidget(WidgetType type) async { 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.delayed(const Duration(seconds: 2)); if (!mounted) return; await notifier.loadInstalledWidgets(); final nowInstalled = ref.read(wp.widgetProvider).installedWidgets.contains(type); if (nowInstalled && !wasInstalled) { AppToast.showInfo('${type.title} 已添加到桌面'); } 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.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 steps = PlatformHelper.isHarmonyOS ? [ '1️⃣ 长按桌面空白处', '2️⃣ 选择「服务卡片」', '3️⃣ 找到「闲言」', '4️⃣ 选择「${type.title}」并添加到桌面', ] : PlatformHelper.isAndroid ? [ '1️⃣ 长按桌面空白处', '2️⃣ 选择「小部件」', '3️⃣ 找到「闲言」', '4️⃣ 选择「${type.title}」拖动到桌面', ] : [ '1️⃣ 向右滑动到今日视图', '2️⃣ 滚动到底部点击「编辑」', '3️⃣ 找到「闲言」', '4️⃣ 选择「${type.title}」并添加', ]; final guideReason = isUnsupported ? '当前设备不支持快捷添加,请按以下步骤手动添加到桌面:' : '快捷添加失败,请按以下步骤手动添加到桌面:'; showCupertinoDialog( 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('添加「${type.title}」'), ], ), ), 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: const Text('推送数据'), onPressed: () { Navigator.of(ctx).pop(); ref.read(wp.widgetProvider.notifier).pushDataToWidget(type); AppToast.showInfo('已推送数据到${type.title}小部件'); }, ), CupertinoDialogAction( isDefaultAction: true, child: const Text('知道了'), onPressed: () => Navigator.of(ctx).pop(), ), ], ), ); } } class _ThemeToggle extends StatelessWidget { const _ThemeToggle({required this.ext}); final AppThemeExtension ext; @override Widget build(BuildContext context) { return GestureDetector( onTap: () { final notifier = ProviderScope.containerOf( context, ).read(wp.widgetProvider.notifier); notifier.pushThemeToAllWidgets(); AppToast.showInfo('已推送当前主题到小部件'); }, 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( '同步主题', 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}); final AppThemeExtension ext; @override State<_PlatformCompatibilityCard> createState() => _PlatformCompatibilityCardState(); } class _PlatformCompatibilityCardState extends State<_PlatformCompatibilityCard> { bool _isExpanded = false; @override Widget build(BuildContext context) { final ext = widget.ext; 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( '平台兼容说明', 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(), secondChild: Padding( padding: const EdgeInsets.only(top: AppSpacing.sm), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _PlatformRow( ext: ext, icon: '🤖', name: 'Android', desc: '功能不完整,原生侧存在通信问题', ), const SizedBox(height: 4), _PlatformRow( ext: ext, icon: '🍎', name: 'iOS', desc: 'WidgetKit + SwiftUI,交互需 iOS 17+', ), const SizedBox(height: 4), _PlatformRow( ext: ext, icon: '🔴', name: '鸿蒙', desc: 'FormExtension + ArkUI,能力受限,系统限制刷新频率', ), 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( '点击「同步主题」可将当前深色/浅色模式推送到所有已安装小部件', 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.type, required this.isInstalled, required this.isAdding, required this.onAdd, required this.onPin, }); final AppThemeExtension ext; 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.title, 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.subtitle, 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( '已安装', 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( '添加', style: AppTypography.caption2.copyWith( color: CupertinoColors.white, fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: AppSpacing.xs), Row( children: [ _PlatformBadges(ext: ext, type: type), const Spacer(), _DeepLinkBadge(ext: ext, route: type.deepLinkRoute), ], ), const SizedBox(height: AppSpacing.sm), _WidgetDataPreview(ext: ext, 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.type}); final AppThemeExtension ext; 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: '鸿蒙', 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.type}); final AppThemeExtension ext; final WidgetType type; @override ConsumerState<_WidgetDataPreview> createState() => _WidgetDataPreviewState(); } class _WidgetDataPreviewState extends ConsumerState<_WidgetDataPreview> { Map _data = {}; bool _loading = true; @override void initState() { super.initState(); _loadPreview(); } Future _loadPreview() async { try { final data = await ref.read(homeWidgetServiceProvider).debugGetAllData(); if (mounted) { setState(() { _data = data; _loading = false; }); } } catch (_) { if (mounted) setState(() => _loading = false); } } Future _refreshData() async { setState(() => _loading = true); try { await ref.read(homeWidgetServiceProvider).updateWidget(widget.type); await _loadPreview(); } catch (_) { if (mounted) setState(() => _loading = false); } } String _getPreviewText() { return switch (widget.type) { WidgetType.dailySentence => '${_data['daily_sentence'] ?? '暂无数据'}', WidgetType.readlater => '未读 ${_data['readlater_count'] ?? 0} 条', WidgetType.dailyFortune => '${_data['fortune_text'] ?? '暂无数据'}', WidgetType.countdown => '${_data['countdown_title'] ?? '暂无数据'}', WidgetType.pomodoro => '剩余 ${_data['pomodoro_remaining'] ?? 0}s', WidgetType.solarTerm => '${_data['solar_term_name'] ?? '暂无数据'}', WidgetType.checkin => '连续 ${_data['checkin_days'] ?? 0} 天', WidgetType.dailyWithCharacter => '${_data['daily_with_character_content'] ?? '暂无数据'}', WidgetType.dailyCard => '日签卡片', }; } @override Widget build(BuildContext context) { final ext = widget.ext; 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( '数据预览', 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( '刷新', 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, ), ), ], ), ); } }