Files
nj/app/lib/features/parent/views/parent_page.dart
iven f0741450bc feat(app): 家长端数据导出 — 添加 JSON 文件下载 + 预览
- 新建 file_download.dart 跨平台下载工具(conditional import)
- download_impl_web.dart: Web 平台通过 html.AnchorElement + Blob 下载
- download_impl.dart: 非 Web 平台 stub(Phase 2 扩展 path_provider)
- _ExportDataView: 添加下载按钮 + JSON 折叠预览 + PIPL 提示
- 移除 'Phase 1 预览' 占位文案,替换为完整下载功能
2026-06-02 23:36:35 +08:00

1272 lines
40 KiB
Dart

// 家长中心页面 — 只读查看孩子日记 + 心情统计 + 数据导出/删除
//
// 通过 ParentBloc 驱动家长中心状态:
// - 进入页面 → ParentLoadChildren 加载孩子列表
// - 无孩子 → 显示绑定入口
// - 有孩子 → 显示孩子卡片 + 4 个功能按钮
// - 日记查看 → ParentViewJournals → 日记列表
// - 数据导出 → ParentExportData → 展示导出结果
// - 数据删除 → 确认对话框 → ParentDeleteData
// 保留 PIPL 合规提示。
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/utils/file_download.dart';
import '../bloc/parent_bloc.dart';
/// 家长中心页面 — 家长查看孩子日记和统计数据
class ParentPage extends StatefulWidget {
const ParentPage({super.key});
@override
State<ParentPage> createState() => _ParentPageState();
}
class _ParentPageState extends State<ParentPage> {
final _childIdController = TextEditingController();
@override
void initState() {
super.initState();
// 进入页面自动加载孩子列表
context.read<ParentBloc>().add(const ParentLoadChildren());
}
@override
void dispose() {
_childIdController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('家长中心')),
body: BlocConsumer<ParentBloc, ParentState>(
listener: (context, state) {
if (state is ParentError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.error,
),
);
}
if (state is ParentDataDeleted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('孩子数据已删除'),
backgroundColor: AppColors.success,
),
);
// 删除后重新加载列表
context.read<ParentBloc>().add(const ParentLoadChildren());
}
},
builder: (context, state) {
if (state is ParentLoading) {
return const Center(child: CircularProgressIndicator());
}
// 日记列表视图
if (state is ParentJournalsLoaded) {
return _JournalListView(
childId: state.childId,
journals: state.journals,
onBack: () =>
context.read<ParentBloc>().add(const ParentLoadChildren()),
);
}
// 导出数据视图
if (state is ParentDataExported) {
return _ExportDataView(
childId: state.childId,
data: state.data,
onBack: () =>
context.read<ParentBloc>().add(const ParentLoadChildren()),
);
}
// 孩子列表或绑定入口
final children = state is ParentChildrenLoaded ? state.children : <ChildBinding>[];
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 页面标题区
_HeaderCard(theme: theme, colorScheme: colorScheme),
const SizedBox(height: 20),
// 孩子列表或绑定入口
if (children.isEmpty) ...[
_BindChildSection(
controller: _childIdController,
onBind: () {
final childId = _childIdController.text.trim();
if (childId.isEmpty) return;
context.read<ParentBloc>().add(ParentBindChild(childId));
_childIdController.clear();
},
),
] else ...[
_ChildListSection(
children: children,
theme: theme,
colorScheme: colorScheme,
),
const SizedBox(height: 20),
_ActionGrid(
children: children,
onViewJournals: (childId) => context
.read<ParentBloc>()
.add(ParentViewJournals(childId)),
onExport: (childId) => context
.read<ParentBloc>()
.add(ParentExportData(childId)),
onDelete: (childId) => _showDeleteConfirmDialog(
context,
childId,
),
onMoodStats: (childId) {
// 复用已有心情统计页面 — 带孩子 ID 参数
context.push('/mood?child_id=$childId');
},
),
],
const SizedBox(height: 24),
// PIPL 提示
_PiplNotice(theme: theme, colorScheme: colorScheme),
],
),
);
},
),
);
}
/// 删除确认对话框
void _showDeleteConfirmDialog(BuildContext context, String childId) {
final theme = Theme.of(context);
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
title: Row(
children: [
Icon(Icons.warning_amber_rounded, color: AppColors.warning, size: 28),
const SizedBox(width: 12),
const Text('确认删除'),
],
),
content: Text(
'此操作将永久删除该孩子的所有日记数据,包括文字、图片和贴纸。'
'\n\n根据《个人信息保护法》,此操作不可撤销。',
style: theme.textTheme.bodyMedium,
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('取消'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: AppColors.error,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.smBorder,
),
),
onPressed: () {
Navigator.of(dialogContext).pop();
context.read<ParentBloc>().add(ParentDeleteData(childId));
},
child: const Text('确认删除'),
),
],
),
);
}
}
// ===== 子组件 =====
/// 标题卡片 — 孩子信息概览
class _HeaderCard extends StatelessWidget {
const _HeaderCard({
required this.theme,
required this.colorScheme,
});
final ThemeData theme;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
color: colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: AppColors.rose.withValues(alpha: 0.2),
child: const Text('👶', style: TextStyle(fontSize: 24)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'孩子的日记',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'查看和管理孩子的日记数据',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
],
),
),
);
}
}
/// 绑定孩子入口 — 输入框 + 绑定按钮
class _BindChildSection extends StatelessWidget {
const _BindChildSection({
required this.controller,
required this.onBind,
});
final TextEditingController controller;
final VoidCallback onBind;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.lgBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.link,
color: AppColors.accent,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'绑定孩子账号',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'输入孩子的账号 ID 建立绑定关系',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: '输入孩子 ID',
prefixIcon: const Icon(Icons.person_outline, size: 20),
border: OutlineInputBorder(
borderRadius: AppRadius.smBorder,
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: AppRadius.smBorder,
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: AppRadius.smBorder,
borderSide: BorderSide(color: AppColors.accent, width: 2),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
onSubmitted: (_) => onBind(),
),
),
const SizedBox(width: 12),
FilledButton(
onPressed: onBind,
style: FilledButton.styleFrom(
backgroundColor: AppColors.accent,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.smBorder,
),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 14,
),
),
child: const Text('绑定'),
),
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.tertiarySoftLight,
borderRadius: AppRadius.smBorder,
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 16,
color: AppColors.tertiary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'孩子的 ID 可在孩子的"我的"页面中查看',
style: theme.textTheme.bodySmall?.copyWith(
color: AppColors.fg2Light,
),
),
),
],
),
),
],
),
),
);
}
}
/// 孩子列表 — 显示已绑定的孩子
class _ChildListSection extends StatelessWidget {
const _ChildListSection({
required this.children,
required this.theme,
required this.colorScheme,
});
final List<ChildBinding> children;
final ThemeData theme;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'已绑定的孩子 (${children.length})',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
...children.map((child) => _ChildCard(
binding: child,
theme: theme,
colorScheme: colorScheme,
onUnbind: () {
_showUnbindConfirmDialog(context, child.childId);
},
)),
],
);
}
void _showUnbindConfirmDialog(BuildContext context, String childId) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
title: const Text('确认解绑'),
content: Text(
'解绑后将无法查看该孩子的日记数据。',
style: theme.textTheme.bodyMedium,
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () {
Navigator.of(dialogContext).pop();
context.read<ParentBloc>().add(ParentUnbindChild(childId));
},
child: const Text('确认解绑'),
),
],
),
);
}
}
/// 单个孩子卡片
class _ChildCard extends StatelessWidget {
const _ChildCard({
required this.binding,
required this.theme,
required this.colorScheme,
required this.onUnbind,
});
final ChildBinding binding;
final ThemeData theme;
final ColorScheme colorScheme;
final VoidCallback onUnbind;
@override
Widget build(BuildContext context) {
final verifiedStr = binding.verifiedAt != null
? DateFormat('yyyy-MM-dd').format(binding.verifiedAt!)
: '未验证';
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: AppColors.secondary.withValues(alpha: 0.15),
child: const Text('👧', style: TextStyle(fontSize: 20)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'孩子 ${binding.childId.substring(0, binding.childId.length > 8 ? 8 : binding.childId.length)}',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'绑定时间: $verifiedStr',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
),
IconButton(
icon: Icon(
Icons.link_off,
size: 20,
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
onPressed: onUnbind,
tooltip: '解绑',
),
],
),
),
);
}
}
/// 功能操作网格 — 4 个功能按钮
class _ActionGrid extends StatefulWidget {
const _ActionGrid({
required this.children,
required this.onViewJournals,
required this.onExport,
required this.onDelete,
required this.onMoodStats,
});
final List<ChildBinding> children;
final void Function(String childId) onViewJournals;
final void Function(String childId) onExport;
final void Function(String childId) onDelete;
final void Function(String childId) onMoodStats;
@override
State<_ActionGrid> createState() => _ActionGridState();
}
class _ActionGridState extends State<_ActionGrid> {
late String _selectedChildId;
@override
void initState() {
super.initState();
_selectedChildId = widget.children.isNotEmpty ? widget.children.first.childId : '';
}
@override
void didUpdateWidget(covariant _ActionGrid oldWidget) {
super.didUpdateWidget(oldWidget);
// 当孩子列表变化时更新选中 ID
if (oldWidget.children != widget.children) {
if (!widget.children.any((c) => c.childId == _selectedChildId)) {
_selectedChildId = widget.children.isNotEmpty ? widget.children.first.childId : '';
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 孩子选择器(多孩子时显示)
if (widget.children.length > 1) ...[
Text(
'选择孩子',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: AppRadius.mdBorder,
border: Border.all(color: colorScheme.outlineVariant),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedChildId.isEmpty ? null : _selectedChildId,
isExpanded: true,
icon: const Icon(Icons.expand_more, size: 20),
items: widget.children.map((child) {
final shortId = child.childId.length > 8
? child.childId.substring(0, 8)
: child.childId;
return DropdownMenuItem(
value: child.childId,
child: Row(
children: [
const Text('👧', style: TextStyle(fontSize: 18)),
const SizedBox(width: 8),
Text('孩子 $shortId'),
],
),
);
}).toList(),
onChanged: (v) {
if (v != null) setState(() => _selectedChildId = v);
},
),
),
),
const SizedBox(height: 16),
],
Text(
'功能',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
_ActionCard(
icon: Icons.auto_stories_outlined,
iconColor: AppColors.accent,
iconBgColor: AppColors.accent.withValues(alpha: 0.12),
title: '日记查看',
subtitle: '只读查看孩子的日记和评语',
onTap: () => widget.onViewJournals(_selectedChildId),
),
const SizedBox(height: 12),
_ActionCard(
icon: Icons.bar_chart_outlined,
iconColor: AppColors.secondary,
iconBgColor: AppColors.secondary.withValues(alpha: 0.12),
title: '心情统计',
subtitle: '查看孩子的写作频率和心情趋势',
onTap: () => widget.onMoodStats(_selectedChildId),
),
const SizedBox(height: 12),
_ActionCard(
icon: Icons.download_outlined,
iconColor: AppColors.tertiary,
iconBgColor: AppColors.tertiary.withValues(alpha: 0.12),
title: '数据导出',
subtitle: '导出孩子的所有日记数据',
onTap: () => widget.onExport(_selectedChildId),
),
const SizedBox(height: 12),
_ActionCard(
icon: Icons.delete_outline,
iconColor: AppColors.error,
iconBgColor: AppColors.error.withValues(alpha: 0.12),
title: '数据删除',
subtitle: '永久删除孩子的日记数据',
onTap: () => widget.onDelete(_selectedChildId),
),
],
);
}
}
/// 功能操作卡片
class _ActionCard extends StatelessWidget {
const _ActionCard({
required this.icon,
required this.iconColor,
required this.iconBgColor,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final Color iconColor;
final Color iconBgColor;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: onTap,
borderRadius: AppRadius.mdBorder,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconBgColor,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: iconColor, size: 22),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
),
Icon(
Icons.chevron_right,
color: colorScheme.onSurface.withValues(alpha: 0.3),
),
],
),
),
),
);
}
}
/// 日记列表视图 — 只读查看孩子日记
class _JournalListView extends StatelessWidget {
const _JournalListView({
required this.childId,
required this.journals,
required this.onBack,
});
final String childId;
final List<Map<String, dynamic>> journals;
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column(
children: [
// 返回按钮 + 标题
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
Expanded(
child: Text(
'孩子的日记',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Icon(
Icons.visibility_outlined,
size: 20,
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
const SizedBox(width: 4),
Text(
'只读',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
),
],
),
),
),
// 日记列表
Expanded(
child: journals.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('📝', style: TextStyle(fontSize: 48)),
const SizedBox(height: 16),
Text(
'暂无日记',
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: journals.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final journal = journals[index];
return _JournalCard(
journal: journal,
theme: theme,
colorScheme: colorScheme,
);
},
),
),
],
);
}
}
/// 单条日记卡片(只读)
class _JournalCard extends StatelessWidget {
const _JournalCard({
required this.journal,
required this.theme,
required this.colorScheme,
});
final Map<String, dynamic> journal;
final ThemeData theme;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
final title = journal['title'] as String? ?? '无标题';
final createdAt = journal['created_at'] as String? ?? '';
final preview = journal['preview'] as String? ??
journal['content'] as String? ??
'';
final moodEmoji = journal['mood_emoji'] as String? ?? '';
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (moodEmoji.isNotEmpty) ...[
Text(moodEmoji, style: const TextStyle(fontSize: 18)),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (createdAt.isNotEmpty)
Text(
_formatDate(createdAt),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.4),
fontSize: 12,
),
),
],
),
if (preview.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
preview,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.5,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
);
}
String _formatDate(String isoStr) {
try {
final dt = DateTime.parse(isoStr);
return DateFormat('MM-dd').format(dt);
} catch (e) {
debugPrint('ParentPage._formatDate 失败: $e');
return '';
}
}
}
/// 导出数据视图 — 展示导出结果 + 下载按钮
class _ExportDataView extends StatelessWidget {
const _ExportDataView({
required this.childId,
required this.data,
required this.onBack,
});
final String childId;
final Map<String, dynamic> data;
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final journalCount = (data['journal_count'] as num?)?.toInt() ?? 0;
final exportDate = data['export_date'] as String? ??
DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now());
return Column(
children: [
// 返回按钮 + 标题
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
Expanded(
child: Text(
'数据导出',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
// 导出结果
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 成功图标
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColors.success.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: Icon(
Icons.check_circle_outline,
color: AppColors.success,
size: 40,
),
),
const SizedBox(height: 20),
Text(
'导出成功',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
// 数据概要卡片
Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
_ExportInfoRow(
label: '导出时间',
value: exportDate,
theme: theme,
colorScheme: colorScheme,
),
const SizedBox(height: 12),
_ExportInfoRow(
label: '日记数量',
value: '$journalCount',
theme: theme,
colorScheme: colorScheme,
),
const SizedBox(height: 12),
_ExportInfoRow(
label: '数据格式',
value: 'JSON',
theme: theme,
colorScheme: colorScheme,
),
],
),
),
),
const SizedBox(height: 20),
// 下载按钮
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _handleDownload(context),
icon: const Icon(Icons.download_rounded, size: 20),
label: const Text('下载 JSON 文件'),
style: FilledButton.styleFrom(
backgroundColor: AppColors.secondary,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.smBorder,
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
const SizedBox(height: 20),
// JSON 预览(折叠面板)
_JsonPreviewCard(data: data),
const SizedBox(height: 16),
// PIPL 提示
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.tertiarySoftLight,
borderRadius: AppRadius.smBorder,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.shield_outlined,
size: 18,
color: AppColors.tertiary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'根据《个人信息保护法》,您有权导出孩子的全部个人数据。'
'导出数据仅供个人查阅,请妥善保管。',
style: theme.textTheme.bodySmall?.copyWith(
color: AppColors.fg2Light,
),
),
),
],
),
),
],
),
),
),
],
);
}
/// 触发文件下载
Future<void> _handleDownload(BuildContext context) async {
final filename = '暖记_数据导出_${DateFormat('yyyy-MM-dd').format(DateTime.now())}.json';
final success = await downloadJsonFile(data, filename);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success ? '文件已开始下载' : '下载失败,请重试'),
backgroundColor: success ? AppColors.success : AppColors.error,
),
);
}
}
}
/// JSON 预览折叠卡片
class _JsonPreviewCard extends StatefulWidget {
const _JsonPreviewCard({required this.data});
final Map<String, dynamic> data;
@override
State<_JsonPreviewCard> createState() => _JsonPreviewCardState();
}
class _JsonPreviewCardState extends State<_JsonPreviewCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Column(
children: [
InkWell(
onTap: () => setState(() => _expanded = !_expanded),
borderRadius: BorderRadius.vertical(
top: const Radius.circular(12),
bottom: _expanded ? Radius.zero : const Radius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
Icons.data_object,
size: 20,
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
const SizedBox(width: 12),
Text(
'JSON 数据预览',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const Spacer(),
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
size: 20,
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
],
),
),
),
if (_expanded) ...[
const Divider(height: 1),
Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 300),
padding: const EdgeInsets.all(16),
child: SingleChildScrollView(
child: Text(
const JsonEncoder.withIndent(' ').convert(widget.data),
style: TextStyle(
fontFamily: 'JetBrains Mono',
fontSize: 12,
height: 1.5,
color: colorScheme.onSurface.withValues(alpha: 0.7),
),
),
),
),
],
],
),
);
}
}
/// 导出信息行
class _ExportInfoRow extends StatelessWidget {
const _ExportInfoRow({
required this.label,
required this.value,
required this.theme,
required this.colorScheme,
});
final String label;
final String value;
final ThemeData theme;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
);
}
}
/// PIPL 合规提示
class _PiplNotice extends StatelessWidget {
const _PiplNotice({
required this.theme,
required this.colorScheme,
});
final ThemeData theme;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.shield_outlined,
size: 18,
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'根据《个人信息保护法》,您有权查阅、更正、删除和导出孩子的数据。',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
),
],
),
);
}
}