diff --git a/lib/pages/profile/profile_page.dart b/lib/pages/profile/profile_page.dart index b5ab107..3a7c20d 100644 --- a/lib/pages/profile/profile_page.dart +++ b/lib/pages/profile/profile_page.dart @@ -11,6 +11,7 @@ import '../../utils/user_prefs.dart'; import '../../utils/responsive.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; +import '../../utils/excel_exporter.dart'; import '../settings/recycle_bin_page.dart'; import '../sync/backup_page.dart'; import '../../widgets/fade_in_local_image.dart'; @@ -19,6 +20,7 @@ import '../settings/tag_management_page.dart'; import '../explore/stroll_page.dart'; import '../sync/cloud_sync_page.dart'; import 'settings_page.dart'; +import 'watchlist_page.dart'; /// 个人中心页面 class ProfilePage extends StatefulWidget { @@ -36,7 +38,7 @@ class _ProfilePageState extends State with RouteAware { String _motto = '好运不会眷顾一无所有之人。'; String? _avatarPath; - // 我的模块切换索引 (0=影视, 1=阅读, 2=笔记, 3=游戏) + // 我的模块切换索引 (0=影视, 1=阅读, 2=游戏, 3=笔记) int _myModuleIndex = 0; @override @@ -107,6 +109,8 @@ class _ProfilePageState extends State with RouteAware { const SizedBox(height: 20), _buildMyModule(movies, books, notes, games), const SizedBox(height: 20), + _buildWatchlist(movies, books, games), + const SizedBox(height: 20), _buildTagsSection(movies, books, notes), const SizedBox(height: 20), _buildToolsGrid(context), @@ -294,8 +298,8 @@ class _ProfilePageState extends State with RouteAware { static const _moduleDefs = <(String, IconData)>[ ('影视', Icons.movie_outlined), ('阅读', Icons.menu_book_outlined), - ('笔记', Icons.sticky_note_2_outlined), ('游戏', Icons.sports_esports_outlined), + ('笔记', Icons.sticky_note_2_outlined), ]; List<(String, IconData)> get _visibleModules { @@ -660,6 +664,174 @@ class _ProfilePageState extends State with RouteAware { ); } + // ─── 想看清单 ────────────────────────────────────────────────────── + + Widget _buildWatchlist(List movies, List books, List games) { + final colors = Theme.of(context).colorScheme; + + final items = <_WatchlistItem>[]; + if (_userPrefs.showMovieTab) { + for (final m in movies.where((m) => m.status == 'want_to_watch')) { + items.add(_WatchlistItem( + title: m.title, + imagePath: m.posterPath, + type: 'movie', + createdAt: m.createdAt, + onTap: () => Navigator.pushNamed(context, '/movie-detail', arguments: m), + )); + } + } + if (_userPrefs.showBookTab) { + for (final b in books.where((b) => b.status == 'want_to_read')) { + items.add(_WatchlistItem( + title: b.title, + imagePath: b.coverPath, + type: 'book', + createdAt: b.createdAt, + onTap: () => Navigator.pushNamed(context, '/book-detail', arguments: b), + )); + } + } + if (_userPrefs.showGameTab) { + for (final g in games.where((g) => g.status == 'want_to_play')) { + items.add(_WatchlistItem( + title: g.title, + imagePath: g.coverPath, + type: 'game', + createdAt: g.createdAt, + onTap: () => Navigator.pushNamed(context, '/game-detail', arguments: g), + )); + } + } + items.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + final typeIcon = { + 'movie': Icons.movie_outlined, + 'book': Icons.menu_book_outlined, + 'game': Icons.sports_esports_outlined, + }; + final typeColor = { + 'movie': const Color(0xFF2563EB), + 'book': const Color(0xFF16A34A), + 'game': const Color(0xFFEA580C), + }; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Text('想看清单', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colors.onSurface)), + const Spacer(), + GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const WatchlistPage())), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('全部', + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.4))), + Icon(Icons.chevron_right, + size: 16, + color: colors.onSurface.withValues(alpha: 0.3)), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 12), + if (items.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Center( + child: Text('暂无想看记录', + style: TextStyle( + fontSize: 13, + color: colors.onSurface.withValues(alpha: 0.3)))), + ) + else + SizedBox( + height: 120, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: items.take(20).length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, i) { + final item = items[i]; + return GestureDetector( + onTap: item.onTap, + child: SizedBox( + width: 78, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + children: [ + Container( + width: 78, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + clipBehavior: Clip.antiAlias, + child: item.imagePath != null && item.imagePath!.isNotEmpty + ? FadeInLocalImage( + path: item.imagePath, + fit: BoxFit.cover, + errorWidget: Icon(Icons.image_outlined, + size: 20, + color: colors.onSurface.withValues(alpha: 0.2))) + : Icon(Icons.image_outlined, + size: 20, + color: colors.onSurface.withValues(alpha: 0.2)), + ), + Positioned( + top: 4, + left: 4, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: typeColor[item.type]!, + borderRadius: BorderRadius.circular(4), + ), + child: Icon(typeIcon[item.type], + size: 10, color: Colors.white), + ), + ), + ], + ), + ), + const SizedBox(height: 4), + Text(item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + color: colors.onSurface.withValues(alpha: 0.6))), + ], + ), + ), + ); + }, + ), + ), + ], + ); + } + Widget _buildEmptyHint(String text) { final colors = Theme.of(context).colorScheme; return Padding( @@ -777,18 +949,19 @@ class _ProfilePageState extends State with RouteAware { context, MaterialPageRoute(builder: (_) => const StatisticsPage())) ), (Icons.backup_outlined, '备份', () => _showBackupOptions(context)), - ( - Icons.delete_outline, - '回收', - () => Navigator.push( - context, MaterialPageRoute(builder: (_) => const RecycleBinPage())) - ), + (Icons.ios_share_outlined, '导出', () => _showExportOptions(context)), ( Icons.settings_outlined, '设置', () => Navigator.push( context, MaterialPageRoute(builder: (_) => const SettingsPage())) ), + ( + Icons.delete_outline, + '回收', + () => Navigator.push( + context, MaterialPageRoute(builder: (_) => const RecycleBinPage())) + ), (Icons.feedback_outlined, '反馈', () => _showFeedbackDialog(context)), ]; @@ -1212,4 +1385,136 @@ class _ProfilePageState extends State with RouteAware { ), ); } + + void _showExportOptions(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final provider = context.read(); + final userPrefs = UserPrefs(); + + final options = <(String, IconData, int)>[]; + if (userPrefs.showMovieTab) { + options.add(('影视', Icons.movie_outlined, provider.movies.where((m) => !m.isDeleted).length)); + } + if (userPrefs.showBookTab) { + options.add(('阅读', Icons.menu_book_outlined, provider.books.where((b) => !b.isDeleted).length)); + } + if (userPrefs.showGameTab) { + options.add(('游戏', Icons.sports_esports_outlined, provider.games.where((g) => !g.isDeleted).length)); + } + options.add(('笔记', Icons.sticky_note_2_outlined, provider.notes.where((n) => !n.isDeleted).length)); + + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerLeft, + child: Text('选择导出类型', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: colors.onSurface))), + const SizedBox(height: 4), + Align( + alignment: Alignment.centerLeft, + child: Text('导出为 Excel (.xlsx) 格式', + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4)))), + const SizedBox(height: 12), + for (final (label, icon, count) in options) ...[ + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10)), + child: Icon(icon, + size: 18, + color: colors.onSurface.withValues(alpha: 0.6))), + title: Text(label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + subtitle: Text('$count 条记录', + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4))), + trailing: Icon(Icons.chevron_right, + color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () { + Navigator.pop(ctx); + _doExport(context, label); + }, + ), + if (label != options.last.$1) + Divider(height: 0.5, color: colors.outlineVariant), + ], + const SizedBox(height: 20), + ], + ), + ), + ); + } + + Future _doExport(BuildContext context, String type) async { + final provider = context.read(); + try { + File file; + switch (type) { + case '影视': + file = await ExcelExporter.exportMovies( + provider.movies.where((m) => !m.isDeleted).toList()); + break; + case '阅读': + file = await ExcelExporter.exportBooks( + provider.books.where((b) => !b.isDeleted).toList()); + break; + case '游戏': + file = await ExcelExporter.exportGames( + provider.games.where((g) => !g.isDeleted).toList()); + break; + default: + file = await ExcelExporter.exportNotes( + provider.notes.where((n) => !n.isDeleted).toList()); + } + if (!context.mounted) return; + ToastUtil.show(context, '已导出到 ${file.path}'); + } catch (e) { + if (!context.mounted) return; + ToastUtil.show(context, '导出失败:$e'); + } + } +} + +class _WatchlistItem { + final String title; + final String? imagePath; + final String type; // movie / book / game + final DateTime createdAt; + final VoidCallback onTap; + + const _WatchlistItem({ + required this.title, + this.imagePath, + required this.type, + required this.createdAt, + required this.onTap, + }); } diff --git a/lib/pages/profile/watchlist_page.dart b/lib/pages/profile/watchlist_page.dart new file mode 100644 index 0000000..7deb19d --- /dev/null +++ b/lib/pages/profile/watchlist_page.dart @@ -0,0 +1,424 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/app_provider.dart'; +import '../../utils/user_prefs.dart'; +import '../../widgets/fade_in_local_image.dart'; + +/// 想看清单总览页面 - 汇总影视/书籍/游戏的想看记录 +class WatchlistPage extends StatefulWidget { + const WatchlistPage({super.key}); + + @override + State createState() => _WatchlistPageState(); +} + +class _WatchlistPageState extends State { + // 筛选索引:0=全部, 1=影视, 2=阅读, 3=游戏 + int _filterIndex = 0; + + static const _filterDefs = <(String, String)>[ + ('全部', ''), + ('影视', 'movie'), + ('阅读', 'book'), + ('游戏', 'game'), + ]; + + static const _typeLabel = { + 'movie': '影视', + 'book': '阅读', + 'game': '游戏', + }; + static const _typeIcon = { + 'movie': Icons.movie_outlined, + 'book': Icons.menu_book_outlined, + 'game': Icons.sports_esports_outlined, + }; + static const _typeColor = { + 'movie': Color(0xFF2563EB), + 'book': Color(0xFF16A34A), + 'game': Color(0xFFEA580C), + }; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final provider = context.watch(); + final userPrefs = UserPrefs(); + + final movies = provider.movies.where((m) => !m.isDeleted).toList(); + final books = provider.books.where((b) => !b.isDeleted).toList(); + final games = provider.games.where((g) => !g.isDeleted).toList(); + + final items = <_WatchlistEntry>[]; + if (userPrefs.showMovieTab) { + for (final m in movies.where((m) => m.status == 'want_to_watch')) { + items.add(_WatchlistEntry( + title: m.title, + imagePath: m.posterPath, + type: 'movie', + createdAt: m.createdAt, + onTap: () => Navigator.pushNamed(context, '/movie-detail', arguments: m), + )); + } + } + if (userPrefs.showBookTab) { + for (final b in books.where((b) => b.status == 'want_to_read')) { + items.add(_WatchlistEntry( + title: b.title, + imagePath: b.coverPath, + type: 'book', + createdAt: b.createdAt, + onTap: () => Navigator.pushNamed(context, '/book-detail', arguments: b), + )); + } + } + if (userPrefs.showGameTab) { + for (final g in games.where((g) => g.status == 'want_to_play')) { + items.add(_WatchlistEntry( + title: g.title, + imagePath: g.coverPath, + type: 'game', + createdAt: g.createdAt, + onTap: () => Navigator.pushNamed(context, '/game-detail', arguments: g), + )); + } + } + items.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + // 可见筛选标签 + final visibleFilters = <(String, String)>[]; + visibleFilters.add(_filterDefs[0]); + if (userPrefs.showMovieTab) visibleFilters.add(_filterDefs[1]); + if (userPrefs.showBookTab) visibleFilters.add(_filterDefs[2]); + if (userPrefs.showGameTab) visibleFilters.add(_filterDefs[3]); + + final (_, filterType) = visibleFilters[_filterIndex.clamp(0, visibleFilters.length - 1)]; + final filtered = filterType.isEmpty + ? items + : items.where((i) => i.type == filterType).toList(); + + // 按类型分组(仅"全部"视图分组,单类型筛选不分组) + final useGroups = filterType.isEmpty; + final groups = <_WatchlistGroup>[]; + if (useGroups) { + for (final type in const ['movie', 'book', 'game']) { + final groupItems = filtered.where((i) => i.type == type).toList(); + if (groupItems.isNotEmpty) { + groups.add(_WatchlistGroup(type: type, items: groupItems)); + } + } + } + + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar(title: const Text('想看清单')), + body: CustomScrollView( + slivers: _buildSlivers( + colors: colors, + visibleFilters: visibleFilters, + filtered: filtered, + useGroups: useGroups, + groups: groups, + ), + ), + ); + } + + List _buildSlivers({ + required ColorScheme colors, + required List<(String, String)> visibleFilters, + required List<_WatchlistEntry> filtered, + required bool useGroups, + required List<_WatchlistGroup> groups, + }) { + final slivers = []; + if (visibleFilters.length > 1) { + slivers.add(SliverPersistentHeader( + pinned: true, + delegate: _StickyFilterDelegate( + visibleFilters: visibleFilters, + filterIndex: _filterIndex, + count: filtered.length, + onTap: (i) => setState(() => _filterIndex = i), + ), + )); + } + if (filtered.isEmpty) { + slivers.add(SliverFillRemaining( + hasScrollBody: false, + child: _buildEmpty(colors), + )); + } else if (useGroups) { + for (final g in groups) { + slivers.addAll(_buildGroupSlivers(g, colors)); + } + } else { + slivers.add(SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 120), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (ctx, i) => _buildItemCard(colors, filtered[i]), + childCount: filtered.length, + ), + ), + )); + } + return slivers; + } + + // 分组 slivers:小标题 + 卡片列表 + List _buildGroupSlivers(_WatchlistGroup group, ColorScheme colors) { + return [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Row( + children: [ + Container( + width: 3, + height: 12, + decoration: BoxDecoration( + color: _typeColor[group.type], + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), + Text(_typeLabel[group.type]!, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colors.onSurface.withValues(alpha: 0.7))), + const SizedBox(width: 6), + Text('${group.items.length}', + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (ctx, i) => _buildItemCard(colors, group.items[i]), + childCount: group.items.length, + ), + ), + ), + ]; + } + + // 单条卡片 + Widget _buildItemCard(ColorScheme colors, _WatchlistEntry item) { + return GestureDetector( + onTap: item.onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 封面 + Container( + width: 52, + height: 74, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: item.imagePath != null && item.imagePath!.isNotEmpty + ? FadeInLocalImage( + path: item.imagePath, + fit: BoxFit.cover, + errorWidget: Icon(Icons.image_outlined, + size: 18, color: colors.onSurface.withValues(alpha: 0.2))) + : Icon(Icons.image_outlined, + size: 18, color: colors.onSurface.withValues(alpha: 0.2)), + ), + const SizedBox(width: 10), + // 左侧竖色条 + Container( + width: 3, + height: 50, + decoration: BoxDecoration( + color: _typeColor[item.type]!.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 10), + // 标题 + 类型 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 2), + Text(item.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colors.onSurface, + height: 1.3)), + const SizedBox(height: 6), + Row( + children: [ + Icon(_typeIcon[item.type], + size: 11, color: _typeColor[item.type]), + const SizedBox(width: 3), + Text(_typeLabel[item.type]!, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: _typeColor[item.type])), + ], + ), + ], + ), + ), + // 右侧加入时间 + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(_formatRelative(item.createdAt), + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ], + ), + ), + ); + } + + Widget _buildEmpty(ColorScheme colors) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bookmark_border, + size: 48, color: colors.onSurface.withValues(alpha: 0.15)), + const SizedBox(height: 12), + Text('暂无想看记录', + style: TextStyle( + fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3))), + ], + ), + ); + } + + String _formatRelative(DateTime d) { + final now = DateTime.now(); + final diff = now.difference(d); + if (diff.inMinutes < 1) return '刚刚'; + if (diff.inHours < 1) return '${diff.inMinutes}分钟前'; + if (diff.inDays < 1) return '${diff.inHours}小时前'; + if (diff.inDays < 30) return '${diff.inDays}天前'; + if (diff.inDays < 365) return '${(diff.inDays / 30).floor()}个月前'; + return '${(diff.inDays / 365).floor()}年前'; + } +} + +// 吸顶筛选栏 +class _StickyFilterDelegate extends SliverPersistentHeaderDelegate { + final List<(String, String)> visibleFilters; + final int filterIndex; + final int count; + final ValueChanged onTap; + + _StickyFilterDelegate({ + required this.visibleFilters, + required this.filterIndex, + required this.count, + required this.onTap, + }); + + @override + double get minExtent => 52; + @override + double get maxExtent => 52; + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + final colors = Theme.of(context).colorScheme; + return Container( + color: colors.surface, + height: 52, + padding: const EdgeInsets.fromLTRB(20, 10, 20, 8), + alignment: Alignment.centerLeft, + child: Row( + children: [ + for (int i = 0; i < visibleFilters.length; i++) ...[ + if (i != 0) const SizedBox(width: 8), + GestureDetector( + onTap: () => onTap(i), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 5), + decoration: BoxDecoration( + color: i == filterIndex + ? colors.primary + : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Text(visibleFilters[i].$1, + style: TextStyle( + fontSize: 12, + fontWeight: + i == filterIndex ? FontWeight.w600 : FontWeight.w400, + color: i == filterIndex + ? colors.onPrimary + : colors.onSurface.withValues(alpha: 0.6))), + ), + ), + ], + const Spacer(), + Text('$count项', + style: TextStyle( + fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ); + } + + @override + bool shouldRebuild(covariant _StickyFilterDelegate oldDelegate) => + filterIndex != oldDelegate.filterIndex || count != oldDelegate.count; +} + +class _WatchlistEntry { + final String title; + final String? imagePath; + final String type; // movie / book / game + final DateTime createdAt; + final VoidCallback onTap; + + const _WatchlistEntry({ + required this.title, + this.imagePath, + required this.type, + required this.createdAt, + required this.onTap, + }); +} + +class _WatchlistGroup { + final String type; + final List<_WatchlistEntry> items; + const _WatchlistGroup({required this.type, required this.items}); +} diff --git a/lib/utils/excel_exporter.dart b/lib/utils/excel_exporter.dart new file mode 100644 index 0000000..be34ff8 --- /dev/null +++ b/lib/utils/excel_exporter.dart @@ -0,0 +1,195 @@ +import 'dart:io'; +import 'package:excel/excel.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import '../models/data_models.dart'; + +/// 数据导出为 Excel 文件 +class ExcelExporter { + /// 导出影视数据,返回生成的文件 + static Future exportMovies(List movies) async { + final excel = Excel.createExcel(); + final sheet = excel['影视']; + + sheet.appendRow(_row([ + '名称', '类别', '状态', '评分', '导演', '编剧', '主演', + '类型', '别名', '上映时间', '观看日期', '观看次数', '时长(分钟)', + '简介', '创建时间', '更新时间', + ])); + + final statusMap = {'watched': '已看', 'watching': '在看', 'want_to_watch': '想看'}; + final categoryMap = { + 'movie': '电影', 'tv': '电视剧', 'anime': '动漫', + 'variety': '综艺', 'documentary': '纪录片', 'short': '短片', 'other': '其他', + }; + + for (final m in movies) { + sheet.appendRow(_row([ + m.title, + categoryMap[m.category] ?? m.category, + statusMap[m.status] ?? m.status, + m.rating?.toString() ?? '', + m.directors.join('、'), + m.writers.join('、'), + m.actors.join('、'), + m.genres.join('、'), + m.alternateTitles.join('、'), + _fmtDate(m.releaseDate), + _fmtDate(m.watchDate), + m.watchCount.toString(), + m.duration.toString(), + m.summary ?? '', + _fmtDateTime(m.createdAt), + _fmtDateTime(m.updatedAt), + ])); + } + + return _save(excel, '影视'); + } + + /// 导出阅读数据 + static Future exportBooks(List books) async { + final excel = Excel.createExcel(); + final sheet = excel['阅读']; + + sheet.appendRow(_row([ + '名称', '状态', '评分', '作者', '译者', '别名', '出版社', + '类型', 'ISBN', '出版时间', '开始阅读', '读完时间', '阅读次数', + '简介', '创建时间', '更新时间', + ])); + + final statusMap = {'read': '已读', 'reading': '在读', 'want_to_read': '想读'}; + + for (final b in books) { + sheet.appendRow(_row([ + b.title, + statusMap[b.status] ?? b.status, + b.rating?.toString() ?? '', + b.authors.join('、'), + b.translators.join('、'), + b.alternateTitles.join('、'), + b.publisher ?? '', + b.genres.join('、'), + b.isbn ?? '', + _fmtDate(b.publishDate), + _fmtDate(b.startDate), + _fmtDate(b.finishDate), + b.readCount.toString(), + b.summary ?? '', + _fmtDateTime(b.createdAt), + _fmtDateTime(b.updatedAt), + ])); + } + + return _save(excel, '阅读'); + } + + /// 导出游戏数据 + static Future exportGames(List games) async { + final excel = Excel.createExcel(); + final sheet = excel['游戏']; + + sheet.appendRow(_row([ + '名称', '状态', '评分', '分类', '平台', '版本', '类型', + '游玩时长(小时)', '游玩时长(分钟)', '游玩次数', '开发者', + '发售时间', '购买平台', '购买日期', '购买价格', '简介', + '创建时间', '更新时间', + ])); + + final statusMap = { + 'completed': '已通关', 'playing': '在玩', + 'want_to_play': '想玩', 'abandoned': '弃游', + }; + final categoryMap = {'digital': '数字版', 'cartridge': '卡带', 'disc': '光盘'}; + + for (final g in games) { + sheet.appendRow(_row([ + g.title, + statusMap[g.status] ?? g.status, + g.rating?.toString() ?? '', + categoryMap[g.category] ?? g.category, + g.platforms.join('、'), + g.versions.join('、'), + g.genres.join('、'), + g.playTimeHours.toString(), + g.playTimeMinutes.toString(), + g.playCount.toString(), + g.developer.join('、'), + _fmtDate(g.releaseDate), + g.purchasePlatforms.join('、'), + _fmtDate(g.purchaseDate), + g.purchasePrice ?? '', + g.summary ?? '', + _fmtDateTime(g.createdAt), + _fmtDateTime(g.updatedAt), + ])); + } + + return _save(excel, '游戏'); + } + + /// 导出笔记数据 + static Future exportNotes(List notes) async { + final excel = Excel.createExcel(); + final sheet = excel['笔记']; + + sheet.appendRow(_row([ + '标题', '内容', '内容类型', '标签', '图片数', '是否置顶', + '创建时间', '更新时间', + ])); + + for (final n in notes) { + sheet.appendRow(_row([ + n.title, + n.content, + n.contentType, + n.tags.join('、'), + n.images.length.toString(), + n.isPinned ? '是' : '否', + _fmtDateTime(n.createdAt), + _fmtDateTime(n.updatedAt), + ])); + } + + return _save(excel, '笔记'); + } + + static List _row(List values) { + return values.map((v) => TextCellValue(v)).toList(); + } + + static Future _save(Excel excel, String name) async { + if (excel.sheets.containsKey('Sheet1')) { + excel.delete('Sheet1'); + } + final fileName = '${name}_导出_${_timestamp()}.xlsx'; + String filePath; + if (Platform.isAndroid) { + final exportDir = Directory('/sdcard/Download/mooknote/export'); + if (!await exportDir.exists()) { + await exportDir.create(recursive: true); + } + filePath = p.join(exportDir.path, fileName); + } else { + final tempDir = await getTemporaryDirectory(); + filePath = p.join(tempDir.path, fileName); + } + final file = File(filePath); + await file.writeAsBytes(excel.encode()!); + return file; + } + + static String _fmtDate(DateTime? d) { + if (d == null) return ''; + return '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + } + + static String _fmtDateTime(DateTime d) { + return '${_fmtDate(d)} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}'; + } + + static String _timestamp() { + final d = DateTime.now(); + return '${d.year}${d.month.toString().padLeft(2, '0')}${d.day.toString().padLeft(2, '0')}_${d.hour.toString().padLeft(2, '0')}${d.minute.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index f83404c..1e9d9ed 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -20,8 +20,8 @@ import '../pages/movies/movie_form_page.dart'; import '../pages/book/book_detail_page.dart'; import '../pages/book/book_form_page.dart'; import '../pages/note/note_detail_page.dart'; -import '../pages/note/note_form_page.dart'; import '../pages/game/game_detail_page.dart'; +import '../pages/game/game_form_page.dart'; import '../pages/profile/settings_page.dart'; import '../models/data_models.dart'; import 'fade_in_local_image.dart'; @@ -270,9 +270,9 @@ class _CustomDrawerState extends State { Icons.menu_book_outlined, '阅读', const Color(0xFF16A34A), () { if (!widget.embedded) { Navigator.pop(context); } Navigator.push(context, MaterialPageRoute(builder: (_) => const BookFormPage())); }, )); - if (userPrefs.showNoteTab) actions.add(( - Icons.edit_note_outlined, '笔记', const Color(0xFF9333EA), - () { if (!widget.embedded) { Navigator.pop(context); } Navigator.push(context, MaterialPageRoute(builder: (_) => const NoteFormPage())); }, + if (userPrefs.showGameTab) actions.add(( + Icons.sports_esports_outlined, '游戏', const Color(0xFFEA580C), + () { if (!widget.embedded) { Navigator.pop(context); } Navigator.push(context, MaterialPageRoute(builder: (_) => const GameFormPage())); }, )); if (actions.isEmpty) return const SizedBox.shrink(); diff --git a/pubspec.lock b/pubspec.lock index 1089439..53c29bb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -129,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.8" + excel: + dependency: "direct main" + description: + name: excel + sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780" + url: "https://pub.dev" + source: hosted + version: "4.0.6" expandable: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index dcf477a..d358431 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: share_plus: ^10.0.0 cross_file: ^0.3.4 archive: ^3.6.0 + excel: ^4.0.6 xml: ^6.6.1 permission_handler: ^11.3.0 flutter_staggered_grid_view: ^0.7.0