diff --git a/lib/main.dart b/lib/main.dart index 0301ee2..f740c43 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -225,7 +225,7 @@ class _MyAppState extends State with WidgetsBindingObserver { return MaterialApp( title: 'MookNote', debugShowCheckedModeBanner: false, - theme: AppTheme.lightTheme, + theme: AppTheme.getLightTheme(provider.colorSchemeIndex), darkTheme: AppTheme.darkTheme, themeMode: provider.themeMode, localizationsDelegates: const [ diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index 294c5db..f8d439f 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -141,7 +141,7 @@ class Movie { List? genres, List? alternateTitles, Object? summary = _copyWithNull, - double? rating, + Object? rating = _copyWithNull, String? status, DateTime? watchDate, DateTime? createdAt, @@ -160,7 +160,7 @@ class Movie { genres: genres ?? this.genres, alternateTitles: alternateTitles ?? this.alternateTitles, summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?), - rating: rating ?? this.rating, + rating: rating is _CopyWithNullSentinel ? this.rating : (rating as double?), status: status ?? this.status, watchDate: watchDate ?? this.watchDate, createdAt: createdAt ?? this.createdAt, @@ -273,7 +273,7 @@ class Book { String? publisher, List? genres, Object? summary = _copyWithNull, - double? rating, + Object? rating = _copyWithNull, String? status, Object? isbn = _copyWithNull, DateTime? publishDate, @@ -291,7 +291,7 @@ class Book { publisher: publisher ?? this.publisher, genres: genres ?? this.genres, summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?), - rating: rating ?? this.rating, + rating: rating is _CopyWithNullSentinel ? this.rating : (rating as double?), status: status ?? this.status, isbn: isbn is _CopyWithNullSentinel ? this.isbn : (isbn as String?), publishDate: publishDate ?? this.publishDate, diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart index 82dd12d..1d7a22e 100644 --- a/lib/pages/book/book_form_page.dart +++ b/lib/pages/book/book_form_page.dart @@ -10,6 +10,7 @@ import '../../widgets/fade_in_local_image.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; +import '../../widgets/genre_selector_page.dart'; /// 添加/编辑书籍页面 - 紧凑双行布局设计 class BookFormPage extends StatefulWidget { @@ -266,13 +267,18 @@ class _BookFormPageState extends State { final tags = await provider.getTags('book_genre', excludeHidden: true); final existingNames = tags.map((t) => t['name'] as String).toList(); if (mounted) { - _showMultiValueDialog( - title: '添加类型', - initialValues: _genres, - hint: '如:小说、历史、传记', - existingTags: existingNames, - onConfirm: (values) => setState(() => _genres = values), + final result = await Navigator.push>( + context, + MaterialPageRoute( + builder: (_) => GenreSelectorPage( + title: '选择类型', + existingTags: existingNames, + initialSelected: _genres, + hint: '如:小说、历史、传记', + ), + ), ); + if (result != null) setState(() => _genres = result); } }, ), @@ -314,15 +320,9 @@ class _BookFormPageState extends State { label: '书籍简介', value: _summaryController.text, icon: Icons.description_outlined, - height: 120, + height: 160, scrollable: true, - onTap: () => _showTextInputDialog( - title: '书籍简介', - initialValue: _summaryController.text, - hint: '写下书籍简介...', - maxLines: 8, - onConfirm: (value) => setState(() => _summaryController.text = value), - ), + onTap: () => _editSummary(), ), ), ], @@ -715,6 +715,7 @@ class _BookFormPageState extends State { /// 构建星星评分(支持手动输入) Widget _buildStarRating() { final colors = Theme.of(context).colorScheme; + final hasRating = _ratingController.text.isNotEmpty; return Row( children: [ Text( @@ -727,6 +728,14 @@ class _BookFormPageState extends State { const SizedBox(width: 12), // 手动输入框 _buildRatingInputField(), + // 清除按钮 + if (hasRating) ...[ + const SizedBox(width: 8), + GestureDetector( + onTap: () => setState(() => _ratingController.clear()), + child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), + ), + ], ], ); } @@ -1429,6 +1438,19 @@ class _BookFormPageState extends State { return null; } + /// 全屏编辑书籍简介 + Future _editSummary() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => _SummaryEditorPage(initialText: _summaryController.text), + ), + ); + if (result != null) { + setState(() => _summaryController.text = result); + } + } + /// 显示文本输入对话框 Future _showTextInputDialog({ required String title, @@ -1522,7 +1544,6 @@ class _TextInputDialogState extends State<_TextInputDialog> { content: TextField( controller: controller, maxLines: widget.maxLines, - autofocus: true, style: TextStyle(fontSize: 15, color: colors.onSurface), decoration: InputDecoration( hintText: widget.hint, @@ -1664,6 +1685,7 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { spacing: 8, runSpacing: 8, children: values.map((v) { + final display = v.length > 8 ? '${v.substring(0, 8)}...' : v; return Container( padding: const EdgeInsets.only(left: 12, right: 6, top: 7, bottom: 7), decoration: BoxDecoration( @@ -1674,7 +1696,7 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { mainAxisSize: MainAxisSize.min, children: [ Text( - v, + display, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, @@ -1718,6 +1740,7 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { spacing: 8, runSpacing: 8, children: availableTags.map((tag) { + final display = tag.length > 8 ? '${tag.substring(0, 8)}...' : tag; return GestureDetector( onTap: () { setState(() => values.add(tag)); @@ -1738,7 +1761,7 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { Icon(Icons.add, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), const SizedBox(width: 4), Text( - tag, + display, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, @@ -1827,3 +1850,61 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { ); } } + +/// 书籍简介全屏编辑页 +class _SummaryEditorPage extends StatefulWidget { + final String initialText; + const _SummaryEditorPage({required this.initialText}); + + @override + State<_SummaryEditorPage> createState() => _SummaryEditorPageState(); +} + +class _SummaryEditorPageState extends State<_SummaryEditorPage> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialText); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: const Text('书籍简介'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, _controller.text.trim()), + child: Text('完成', style: TextStyle( + fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary, + )), + ), + const SizedBox(width: 8), + ], + ), + body: TextField( + controller: _controller, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.6), + decoration: InputDecoration( + hintText: '写下书籍简介...', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)), + contentPadding: const EdgeInsets.all(20), + border: InputBorder.none, + ), + ), + ); + } +} diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index c1b097f..f7dbb3c 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -10,6 +10,7 @@ import '../../widgets/fade_in_local_image.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; +import '../../widgets/genre_selector_page.dart'; /// 添加/编辑影视页面 - 紧凑双行布局设计 class MovieFormPage extends StatefulWidget { @@ -542,13 +543,18 @@ class _MovieFormPageState extends State { final tags = await provider.getTags('movie_genre', excludeHidden: true); final existingNames = tags.map((t) => t['name'] as String).toList(); if (mounted) { - _showMultiValueDialog( - title: '添加类型', - initialValues: _genres, - hint: '如:剧情、科幻、悬疑', - existingTags: existingNames, - onConfirm: (values) => setState(() => _genres = values), + final result = await Navigator.push>( + context, + MaterialPageRoute( + builder: (_) => GenreSelectorPage( + title: '选择类型', + existingTags: existingNames, + initialSelected: _genres, + hint: '如:剧情、科幻、悬疑', + ), + ), ); + if (result != null) setState(() => _genres = result); } }, ), @@ -606,15 +612,9 @@ class _MovieFormPageState extends State { label: '剧情简介', value: _summaryController.text, icon: Icons.description_outlined, - height: 120, + height: 160, scrollable: true, - onTap: () => _showTextInputDialog( - title: '剧情简介', - initialValue: _summaryController.text, - hint: '写下剧情简介...', - maxLines: 8, - onConfirm: (value) => setState(() => _summaryController.text = value), - ), + onTap: () => _editSummary(), ), ), ], @@ -733,6 +733,19 @@ class _MovieFormPageState extends State { ); } + /// 全屏编辑剧情简介 + Future _editSummary() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => _SummaryEditorPage(initialText: _summaryController.text), + ), + ); + if (result != null) { + setState(() => _summaryController.text = result); + } + } + /// 显示文本输入对话框 Future _showTextInputDialog({ required String title, @@ -1252,6 +1265,7 @@ class _MovieFormPageState extends State { /// 构建星星评分(支持手动输入) Widget _buildStarRating() { final colors = Theme.of(context).colorScheme; + final hasRating = _ratingController.text.isNotEmpty; return Row( children: [ Text( @@ -1264,6 +1278,14 @@ class _MovieFormPageState extends State { const SizedBox(width: 12), // 手动输入框 _buildRatingInputField(), + // 清除按钮 + if (hasRating) ...[ + const SizedBox(width: 8), + GestureDetector( + onTap: () => setState(() => _ratingController.clear()), + child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), + ), + ], ], ); } @@ -2096,6 +2118,7 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { spacing: 8, runSpacing: 8, children: values.map((v) { + final display = v.length > 8 ? '${v.substring(0, 8)}...' : v; return Container( padding: const EdgeInsets.only(left: 12, right: 6, top: 7, bottom: 7), decoration: BoxDecoration( @@ -2106,7 +2129,7 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { mainAxisSize: MainAxisSize.min, children: [ Text( - v, + display, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, @@ -2150,6 +2173,7 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { spacing: 8, runSpacing: 8, children: availableTags.map((tag) { + final display = tag.length > 8 ? '${tag.substring(0, 8)}...' : tag; return GestureDetector( onTap: () { setState(() => values.add(tag)); @@ -2170,7 +2194,7 @@ class _MultiValueDialogState extends State<_MultiValueDialog> { Icon(Icons.add, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), const SizedBox(width: 4), Text( - tag, + display, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, @@ -2306,7 +2330,6 @@ class _TextInputDialogState extends State<_TextInputDialog> { content: TextField( controller: controller, maxLines: widget.maxLines, - autofocus: true, style: TextStyle(fontSize: 15, color: colors.onSurface), decoration: InputDecoration( hintText: widget.hint, @@ -2344,3 +2367,61 @@ class _TextInputDialogState extends State<_TextInputDialog> { ); } } + +/// 剧情简介全屏编辑页 +class _SummaryEditorPage extends StatefulWidget { + final String initialText; + const _SummaryEditorPage({required this.initialText}); + + @override + State<_SummaryEditorPage> createState() => _SummaryEditorPageState(); +} + +class _SummaryEditorPageState extends State<_SummaryEditorPage> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialText); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: const Text('剧情简介'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, _controller.text.trim()), + child: Text('完成', style: TextStyle( + fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary, + )), + ), + const SizedBox(width: 8), + ], + ), + body: TextField( + controller: _controller, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.6), + decoration: InputDecoration( + hintText: '写下剧情简介...', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)), + contentPadding: const EdgeInsets.all(20), + border: InputBorder.none, + ), + ), + ); + } +} diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index d1364a1..a25b10d 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -232,9 +232,9 @@ class _MovieTabPageState extends State { Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(movie.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), - if (movie.alternateTitles.isNotEmpty) ...[ + ...[ const SizedBox(height: 3), - Text(movie.alternateTitles.take(2).join('、'), maxLines: 1, overflow: TextOverflow.ellipsis, + Text(_buildSubtitle(movie), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), ], const SizedBox(height: 6), @@ -248,6 +248,14 @@ class _MovieTabPageState extends State { ); } + String _buildSubtitle(Movie movie) { + final parts = []; + if (movie.directors.isNotEmpty) parts.add(movie.directors.first); + if (movie.actors.isNotEmpty) parts.add(movie.actors.take(2).join('、')); + if (movie.genres.isNotEmpty) parts.add(movie.genres.take(2).join('、')); + return parts.join(' · '); + } + void _showDeleteDialog(BuildContext context, Movie movie) { final colors = Theme.of(context).colorScheme; showDialog( diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 3dad448..22d59b3 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -8,6 +8,7 @@ import 'package:webview_flutter/webview_flutter.dart'; import '../models/data_models.dart'; import '../providers/app_provider.dart'; import '../utils/user_prefs.dart'; +import '../utils/theme/app_theme.dart'; import '../utils/toast_util.dart'; import 'recycle_bin_page.dart'; import 'sync/backup_page.dart'; @@ -704,6 +705,8 @@ class _SettingsPageState extends State { Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), _buildThemeModeSelector(), Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + _buildColorSchemeSelector(), + Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), _buildSectionHeader('其他设置'), _buildSwitchItem( icon: Icons.swipe_vertical_outlined, @@ -781,22 +784,32 @@ class _SettingsPageState extends State { backgroundColor: Colors.transparent, builder: (ctx) => Container( decoration: BoxDecoration(color: colors.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(16))), - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.only(bottom: 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: Padding(padding: const EdgeInsets.symmetric(horizontal: 24), child: Text('主题模式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)))), - const SizedBox(height: 16), - for (int i = 0; i < _themeModeLabels.length; i++) - ListTile( - leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), child: Icon(_themeModeIcons[i], color: colors.onSurface.withValues(alpha: 0.6))), - title: Text(_themeModeLabels[i], style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), - trailing: _themeMode == i ? Icon(Icons.check, color: colors.onSurface, size: 20) : null, - onTap: () async { await _setThemeMode(i); Navigator.pop(ctx); }, - ), + Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 12), + decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Align(alignment: Alignment.centerLeft, child: Text('主题模式', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface))), + ), const SizedBox(height: 8), + for (int i = 0; i < _themeModeLabels.length; i++) + InkWell( + onTap: () async { await _setThemeMode(i); Navigator.pop(ctx); }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), + child: Row( + children: [ + Icon(_themeModeIcons[i], size: 18, color: colors.onSurface.withValues(alpha: 0.6)), + const SizedBox(width: 12), + Expanded(child: Text(_themeModeLabels[i], style: TextStyle(fontSize: 13, color: colors.onSurface))), + if (_themeMode == i) Icon(Icons.check, color: colors.onSurface, size: 18), + ], + ), + ), + ), ], ), ), @@ -816,6 +829,88 @@ class _SettingsPageState extends State { } } + Widget _buildColorSchemeSelector() { + final colors = Theme.of(context).colorScheme; + final provider = context.watch(); + final currentIndex = provider.colorSchemeIndex; + return InkWell( + onTap: () => _showColorSchemePicker(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(Icons.palette_outlined, color: colors.onSurface.withValues(alpha: 0.6), size: 18)), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('配色方案', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface)), + const SizedBox(height: 2), + Text(AppTheme.colorSchemeNames[currentIndex], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + ]), + ), + Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25), size: 20), + ], + ), + ), + ); + } + + void _showColorSchemePicker() { + final colors = Theme.of(context).colorScheme; + final provider = context.read(); + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (ctx) => Container( + decoration: BoxDecoration(color: colors.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(16))), + padding: const EdgeInsets.only(bottom: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 36, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 16), + decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Align(alignment: Alignment.centerLeft, child: Text('配色方案', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface))), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, mainAxisSpacing: 10, crossAxisSpacing: 10, childAspectRatio: 2.2), + itemCount: AppTheme.seedColors.length, + itemBuilder: (_, i) { + final selected = provider.colorSchemeIndex == i; + return GestureDetector( + onTap: () { provider.setColorScheme(i); Navigator.pop(ctx); }, + child: Container( + decoration: BoxDecoration( + color: selected ? AppTheme.seedColors[i].withValues(alpha: 0.12) : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: selected ? AppTheme.seedColors[i] : Colors.transparent, width: 1.5), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container(width: 14, height: 14, decoration: BoxDecoration(color: AppTheme.seedColors[i], shape: BoxShape.circle)), + const SizedBox(width: 8), + Text(AppTheme.colorSchemeNames[i], style: TextStyle(fontSize: 13, fontWeight: selected ? FontWeight.w600 : FontWeight.w400, color: colors.onSurface)), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + Widget _buildSwitchItem({required IconData icon, required String title, required String subtitle, required bool value, required ValueChanged onChanged}) { final colors = Theme.of(context).colorScheme; return InkWell( diff --git a/lib/pages/statistics_page.dart b/lib/pages/statistics_page.dart index a53fef5..b81e0cc 100644 --- a/lib/pages/statistics_page.dart +++ b/lib/pages/statistics_page.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; import '../models/data_models.dart'; import '../utils/user_prefs.dart'; +import '../widgets/fade_in_local_image.dart'; /// 数据统计页面 - 多维度数据分析 class StatisticsPage extends StatefulWidget { @@ -15,7 +16,7 @@ class StatisticsPage extends StatefulWidget { } class _StatisticsPageState extends State { - int _cloudTabIndex = 0; // 0=影视, 1=书籍, 2=笔记 + int _cloudTabIndex = 0; bool get _showMovies => UserPrefs().showMovieTab; bool get _showBooks => UserPrefs().showBookTab; bool get _showNotes => UserPrefs().showNoteTab; @@ -29,35 +30,55 @@ class _StatisticsPageState extends State { body: Consumer( builder: (context, provider, child) { final movies = provider.movies.where((m) => !m.isDeleted).toList(); - final books = provider.books; - final notes = provider.notes; + final books = provider.books.where((b) => !b.isDeleted).toList(); + final notes = provider.notes.where((n) => !n.isDeleted).toList(); return ListView( padding: const EdgeInsets.all(20), children: [ - _buildTotalCards(movies, books, notes), + // 1. 总览 + _buildOverview(movies, books, notes), const SizedBox(height: 28), + // 2. 状态分布 if (_showMovies) ...[ _buildStatusSection('影视状态分布', movies, (m) => m.status, {'已看': 'watched', '在看': 'watching', '想看': 'want_to_watch'}), const SizedBox(height: 28), - _buildGenreDistribution('影视类型分布', movies), - const SizedBox(height: 28), ], if (_showBooks) ...[ _buildStatusSection('阅读状态分布', books, (b) => b.status, {'已读': 'read', '在读': 'reading', '想读': 'want_to_read'}), const SizedBox(height: 28), - _buildGenreDistribution('书籍类型分布', books), - const SizedBox(height: 28), - ], - if (_showNotes) ...[ - _buildNoteTagDistribution('笔记标签分布', notes), - const SizedBox(height: 28), ], + // 3. 习惯洞察 + _buildHabitsInsight(movies, books, notes), + const SizedBox(height: 28), + // 4. 类型偏好雷达图 + _buildGenreRadar(movies, books), + const SizedBox(height: 28), + // 5. 导演/作者 TOP 5 + _buildDirectorTop5(movies), + const SizedBox(height: 28), + _buildAuthorTop5(books), + const SizedBox(height: 28), + // 6. 高分之最 + _buildTopRated(movies, books), + const SizedBox(height: 28), + // 7. 评分分布 + _buildRatingDistribution(movies, books), + const SizedBox(height: 28), + // 8. 年度趋势 + _buildYearlyTrend(movies, books, notes), + const SizedBox(height: 28), + // 9. 星期分布 + _buildWeekdayDistribution(movies, books, notes), + const SizedBox(height: 28), + // 10. 累计增长 + _buildCumulativeGrowth(movies, books, notes), + const SizedBox(height: 28), + // 11. 标签词云 _buildTagCloud(movies, books, notes), const SizedBox(height: 28), - _buildRatingDistribution('评分分布', movies, books), - const SizedBox(height: 28), - _buildMonthlyTrend(movies, books, notes), + // 12+13. 马拉松 + 标签之最 + _buildFunStats(movies, books, notes), const SizedBox(height: 80), ], ); @@ -66,50 +87,84 @@ class _StatisticsPageState extends State { ); } - // ─── 总览卡片 ──────────────────────────────────────────────────────── + // ─── 1. 总览区域 ────────────────────────────────────────────────────── - Widget _buildTotalCards(List movies, List books, List notes) { + Widget _buildOverview(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; - final items = <_CardData>[]; - if (_showMovies) items.add(_CardData('影视', movies.length, Icons.movie_outlined, const Color(0xFF4A90D9))); - if (_showBooks) items.add(_CardData('书籍', books.length, Icons.menu_book_outlined, const Color(0xFF7E57C2))); - if (_showNotes) items.add(_CardData('笔记', notes.length, Icons.note_outlined, const Color(0xFF66BB6A))); + final completed = movies.where((m) => m.status == 'watched').length + + books.where((b) => b.status == 'read').length; + final totalWithStatus = movies.length + books.length; + final completionRate = totalWithStatus > 0 ? completed / totalWithStatus : 0.0; + + // 本月新增 + final now = DateTime.now(); + final thisMonth = movies.where((m) => m.createdAt.year == now.year && m.createdAt.month == now.month).length + + books.where((b) => b.createdAt.year == now.year && b.createdAt.month == now.month).length + + notes.where((n) => n.createdAt.year == now.year && n.createdAt.month == now.month).length; + final lastMonthDate = DateTime(now.year, now.month - 1, 1); + final lastMonth = movies.where((m) => m.createdAt.year == lastMonthDate.year && m.createdAt.month == lastMonthDate.month).length + + books.where((b) => b.createdAt.year == lastMonthDate.year && b.createdAt.month == lastMonthDate.month).length + + notes.where((n) => n.createdAt.year == lastMonthDate.year && n.createdAt.month == lastMonthDate.month).length; + final monthDiff = thisMonth - lastMonth; + + // 平均评分 + final allRatings = [...movies, ...books].map((e) => (e as dynamic).rating as double?).where((r) => r != null && r > 0).toList(); + final avgRating = allRatings.isNotEmpty ? allRatings.reduce((a, b) => a! + b!)! / allRatings.length : 0.0; + + // 记录天数 + final allDates = [...movies.map((m) => m.createdAt), ...books.map((b) => b.createdAt), ...notes.map((n) => n.createdAt)]; + final daysTracked = allDates.isNotEmpty ? now.difference(allDates.reduce((a, b) => a.isBefore(b) ? a : b)).inDays + 1 : 0; return Row( - children: items.map((d) => Expanded( - child: Container( - margin: EdgeInsets.only(right: d == items.last ? 0 : 10), - padding: const EdgeInsets.symmetric(vertical: 20), - decoration: BoxDecoration( - color: d.color.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(14), - ), - child: Column( - children: [ - Icon(d.icon, size: 26, color: d.color), - const SizedBox(height: 10), - Text('${d.count}', style: TextStyle(fontSize: 26, fontWeight: FontWeight.w700, color: d.color)), - const SizedBox(height: 2), - Text(d.label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))), - ], - ), - ), - )).toList(), + children: [ + _buildOverviewCard('完成率', completionRate == 0 ? '-' : '${(completionRate * 100).toStringAsFixed(0)}%', Icons.check_circle_outline, colors.primary, subtitle: completionRate > 0 ? '已看+已读' : null), + const SizedBox(width: 10), + _buildOverviewCard('本月新增', '$thisMonth', Icons.trending_up, const Color(0xFF66BB6A), subtitle: monthDiff >= 0 ? '↑$monthDiff' : '↓${monthDiff.abs()}'), + const SizedBox(width: 10), + _buildOverviewCard('平均评分', avgRating > 0 ? avgRating.toStringAsFixed(1) : '-', Icons.star_outline, const Color(0xFFFFB800), subtitle: avgRating > 0 ? '/ 10' : null), + const SizedBox(width: 10), + _buildOverviewCard('记录天数', daysTracked > 0 ? '$daysTracked' : '-', Icons.calendar_today_outlined, const Color(0xFF7E57C2), subtitle: daysTracked > 0 ? '天' : null), + ], ); } - // ─── 状态分布 ──────────────────────────────────────────────────────── + Widget _buildOverviewCard(String label, String value, IconData icon, Color color, {String? subtitle}) { + final colors = Theme.of(context).colorScheme; + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(height: 8), + Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: color)), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text(subtitle, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), + ], + const SizedBox(height: 4), + Text(label, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), + ], + ), + ), + ); + } + + // ─── 2. 状态分布 ──────────────────────────────────────────────────────── Widget _buildStatusSection(String title, List items, String Function(dynamic) getStatus, Map labels) { final colors = Theme.of(context).colorScheme; - final active = items.where((i) => !(i is Movie) || !i.isDeleted).toList(); - final total = active.length; + final total = items.length; return _buildCard( title: title, child: Column( children: labels.entries.map((e) { - final count = active.where((i) => getStatus(i) == e.value).length; + final count = items.where((i) => getStatus(i) == e.value).length; final pct = total > 0 ? count / total : 0.0; return Padding( padding: const EdgeInsets.only(bottom: 14), @@ -128,12 +183,7 @@ class _StatisticsPageState extends State { const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(3), - child: LinearProgressIndicator( - value: pct, - backgroundColor: colors.outlineVariant, - color: colors.primary, - minHeight: 6, - ), + child: LinearProgressIndicator(value: pct, backgroundColor: colors.outlineVariant, color: colors.primary, minHeight: 6), ), ], ), @@ -143,44 +193,187 @@ class _StatisticsPageState extends State { ); } - // ─── 类型/标签分布 ─────────────────────────────────────────────────── + // ─── 3. 习惯洞察 ──────────────────────────────────────────────────────── - Widget _buildGenreDistribution(String title, List items) { + Widget _buildHabitsInsight(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; - final genreCounts = {}; - for (final item in items) { - for (final genre in (item.genres as List)) { - genreCounts[genre] = (genreCounts[genre] ?? 0) + 1; - } + final allDates = [ + ...movies.map((m) => m.createdAt), + ...books.map((b) => b.createdAt), + ...notes.map((n) => n.createdAt), + ]..sort(); + if (allDates.isEmpty) return const SizedBox.shrink(); + + // 最活跃月份 + final monthCounts = {}; + for (final d in allDates) { + monthCounts[d.month] = (monthCounts[d.month] ?? 0) + 1; } - final sorted = genreCounts.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); - final top = sorted.take(8).toList(); - if (top.isEmpty) return const SizedBox.shrink(); - final maxCount = top.first.value; + final busiestMonth = monthCounts.entries.isEmpty ? 1 : monthCounts.entries.reduce((a, b) => a.value >= b.value ? a : b).key; + const monthNames = ['', '一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']; + + // 记录频率 + final firstDate = allDates.first; + final totalMonths = math.max(1, (DateTime.now().year - firstDate.year) * 12 + DateTime.now().month - firstDate.month + 1); + final avgPerMonth = (allDates.length / totalMonths).toStringAsFixed(1); + + // 观影/阅读节奏(已看完的平均间隔天数) + final watchedDates = movies.where((m) => m.status == 'watched').map((m) => m.createdAt).toList()..sort(); + final readDates = books.where((b) => b.status == 'read').map((b) => b.createdAt).toList()..sort(); + final watchedAvgGap = _calcAvgGap(watchedDates); + final readAvgGap = _calcAvgGap(readDates); return _buildCard( - title: title, + title: '习惯洞察', child: Column( - children: top.map((e) { - final pct = maxCount > 0 ? e.value / maxCount : 0.0; + children: [ + _buildInsightRow(Icons.calendar_month_outlined, '最活跃月份', monthNames[busiestMonth]), + Divider(height: 1, color: colors.outlineVariant), + _buildInsightRow(Icons.speed_outlined, '记录频率', '平均每月 $avgPerMonth 条'), + Divider(height: 1, color: colors.outlineVariant), + if (watchedAvgGap > 0) + _buildInsightRow(Icons.movie_outlined, '观影节奏', '平均 ${watchedAvgGap.toStringAsFixed(0)} 天一部'), + if (watchedAvgGap > 0 && readAvgGap > 0) + Divider(height: 1, color: colors.outlineVariant), + if (readAvgGap > 0) + _buildInsightRow(Icons.menu_book_outlined, '阅读节奏', '平均 ${readAvgGap.toStringAsFixed(0)} 天一本'), + ], + ), + ); + } + + double _calcAvgGap(List dates) { + if (dates.length < 2) return 0; + final sorted = dates.toList()..sort(); + double totalGap = 0; + for (int i = 1; i < sorted.length; i++) { + totalGap += sorted[i].difference(sorted[i - 1]).inDays.toDouble(); + } + return totalGap / (sorted.length - 1); + } + + Widget _buildInsightRow(IconData icon, String label, String value) { + final colors = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + Icon(icon, size: 18, color: colors.onSurface.withValues(alpha: 0.5)), + const SizedBox(width: 12), + Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), + const Spacer(), + Text(value, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), + ], + ), + ); + } + + // ─── 4. 类型偏好雷达图 ────────────────────────────────────────────────── + + Widget _buildGenreRadar(List movies, List books) { + final colors = Theme.of(context).colorScheme; + final movieGenres = {}; + final bookGenres = {}; + for (final m in movies) { for (final g in m.genres) { movieGenres[g] = (movieGenres[g] ?? 0) + 1; } } + for (final b in books) { for (final g in b.genres) { bookGenres[g] = (bookGenres[g] ?? 0) + 1; } } + + final allGenres = {}; + allGenres.addAll(movieGenres); + for (final e in bookGenres.entries) { allGenres[e.key] = (allGenres[e.key] ?? 0) + e.value; } + final sorted = allGenres.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + final top6 = sorted.take(6).toList(); + if (top6.length < 3) return const SizedBox.shrink(); + + final maxVal = top6.first.value.toDouble(); + + return _buildCard( + title: '类型偏好', + child: Column( + children: [ + SizedBox( + height: 220, + child: RadarChart( + RadarChartData( + radarShape: RadarShape.polygon, + dataSets: [ + if (movieGenres.isNotEmpty) + RadarDataSet( + dataEntries: top6.map((e) => RadarEntry(value: (movieGenres[e.key] ?? 0) / math.max(1, maxVal))).toList(), + borderColor: const Color(0xFF4A90D9), + fillColor: const Color(0xFF4A90D9).withValues(alpha: 0.15), + borderWidth: 2, + ), + if (bookGenres.isNotEmpty) + RadarDataSet( + dataEntries: top6.map((e) => RadarEntry(value: (bookGenres[e.key] ?? 0) / math.max(1, maxVal))).toList(), + borderColor: const Color(0xFF7E57C2), + fillColor: const Color(0xFF7E57C2).withValues(alpha: 0.15), + borderWidth: 2, + ), + ], + radarBorderData: BorderSide(color: colors.outlineVariant, width: 0.5), + gridBorderData: BorderSide(color: colors.outlineVariant, width: 0.5), + tickBorderData: BorderSide(color: colors.outlineVariant.withValues(alpha: 0.3), width: 0.5), + ticksTextStyle: TextStyle(fontSize: 9, color: colors.onSurface.withValues(alpha: 0.3)), + titleTextStyle: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.6)), + titlePositionPercentageOffset: 0.15, + getTitle: (index, angle) => RadarChartTitle(text: top6[index].key), + tickCount: 3, + ), + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (movieGenres.isNotEmpty) ...[ + Container(width: 10, height: 3, decoration: BoxDecoration(color: const Color(0xFF4A90D9), borderRadius: BorderRadius.circular(1.5))), + const SizedBox(width: 4), + Text('影视', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(width: 16), + ], + if (bookGenres.isNotEmpty) ...[ + Container(width: 10, height: 3, decoration: BoxDecoration(color: const Color(0xFF7E57C2), borderRadius: BorderRadius.circular(1.5))), + const SizedBox(width: 4), + Text('书籍', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), + ], + ], + ), + ], + ), + ); + } + + // ─── 5. 导演/作者 TOP 5 ──────────────────────────────────────────────── + + Widget _buildDirectorTop5(List movies) { + final colors = Theme.of(context).colorScheme; + final counts = {}; + for (final m in movies) { for (final d in m.directors) { counts[d] = (counts[d] ?? 0) + 1; } } + final sorted = counts.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + final top5 = sorted.take(5).toList(); + if (top5.isEmpty) return const SizedBox.shrink(); + final maxVal = top5.first.value.toDouble(); + + return _buildCard( + title: '导演 TOP 5', + child: Column( + children: top5.map((e) { + final ratio = e.value / maxVal; return Padding( padding: const EdgeInsets.only(bottom: 10), child: Row( children: [ - SizedBox( - width: 60, - child: Text(e.key, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.6)), overflow: TextOverflow.ellipsis), - ), + SizedBox(width: 60, child: Text(e.key, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.7)), overflow: TextOverflow.ellipsis)), const SizedBox(width: 10), Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator(value: pct, backgroundColor: colors.outlineVariant, color: colors.primary, minHeight: 4), + child: LinearProgressIndicator(value: ratio, backgroundColor: colors.outlineVariant, color: const Color(0xFF4A90D9), minHeight: 4), ), ), const SizedBox(width: 8), - Text('${e.value}', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)), + Text('${e.value}部', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)), ], ), ); @@ -189,42 +382,34 @@ class _StatisticsPageState extends State { ); } - Widget _buildNoteTagDistribution(String title, List notes) { + Widget _buildAuthorTop5(List books) { final colors = Theme.of(context).colorScheme; - final tagCounts = {}; - for (final note in notes) { - for (final tag in note.tags) { - tagCounts[tag] = (tagCounts[tag] ?? 0) + 1; - } - } - final sorted = tagCounts.entries.toList() - ..sort((a, b) => b.value.compareTo(a.value)); - final top = sorted.take(8).toList(); - if (top.isEmpty) return const SizedBox.shrink(); - final maxCount = top.first.value; + final counts = {}; + for (final b in books) { for (final a in b.authors) { counts[a] = (counts[a] ?? 0) + 1; } } + final sorted = counts.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); + final top5 = sorted.take(5).toList(); + if (top5.isEmpty) return const SizedBox.shrink(); + final maxVal = top5.first.value.toDouble(); return _buildCard( - title: title, + title: '作者 TOP 5', child: Column( - children: top.map((e) { - final pct = maxCount > 0 ? e.value / maxCount : 0.0; + children: top5.map((e) { + final ratio = e.value / maxVal; return Padding( padding: const EdgeInsets.only(bottom: 10), child: Row( children: [ - SizedBox( - width: 60, - child: Text(e.key, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.6)), overflow: TextOverflow.ellipsis), - ), + SizedBox(width: 60, child: Text(e.key, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.7)), overflow: TextOverflow.ellipsis)), const SizedBox(width: 10), Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator(value: pct, backgroundColor: colors.outlineVariant, color: colors.primary, minHeight: 4), + child: LinearProgressIndicator(value: ratio, backgroundColor: colors.outlineVariant, color: const Color(0xFF7E57C2), minHeight: 4), ), ), const SizedBox(width: 8), - Text('${e.value}', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)), + Text('${e.value}本', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)), ], ), ); @@ -233,44 +418,85 @@ class _StatisticsPageState extends State { ); } - // ─── 评分分布 ──────────────────────────────────────────────────────── + // ─── 6. 高分之最 TOP 5 ───────────────────────────────────────────────── - Widget _buildRatingDistribution(String title, List movies, List books) { + Widget _buildTopRated(List movies, List books) { + final colors = Theme.of(context).colorScheme; + final ratedMovies = movies.where((m) => m.rating != null && m.rating! > 0).toList() + ..sort((a, b) => b.rating!.compareTo(a.rating!)); + final ratedBooks = books.where((b) => b.rating != null && b.rating! > 0).toList() + ..sort((a, b) => b.rating!.compareTo(a.rating!)); + + if (ratedMovies.isEmpty && ratedBooks.isEmpty) return const SizedBox.shrink(); + + return _buildCard( + title: '高分之最', + child: Column( + children: [ + if (ratedMovies.isNotEmpty) ...[ + Text('影视 TOP 5', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(height: 8), + ...ratedMovies.take(5).map((m) => _buildTopRatedItem(m.title, m.rating!, m.posterPath, colors)), + if (ratedBooks.isNotEmpty) const SizedBox(height: 16), + ], + if (ratedBooks.isNotEmpty) ...[ + Text('书籍 TOP 5', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(height: 8), + ...ratedBooks.take(5).map((b) => _buildTopRatedItem(b.title, b.rating!, b.coverPath, colors)), + ], + ], + ), + ); + } + + Widget _buildTopRatedItem(String title, double rating, String? imagePath, ColorScheme colors) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 32, height: 44, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(4)), + clipBehavior: Clip.antiAlias, + child: imagePath != null && imagePath.isNotEmpty + ? FadeInLocalImage(path: imagePath, fit: BoxFit.cover, errorWidget: Icon(Icons.image_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2))) + : Icon(Icons.image_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.2)), + ), + const SizedBox(width: 10), + Expanded(child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 13, color: colors.onSurface))), + Icon(Icons.star, size: 16, color: const Color(0xFFFFB800)), + const SizedBox(width: 4), + Text(rating.toStringAsFixed(1), style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: colors.onSurface)), + ], + ), + ); + } + + // ─── 7. 评分分布 ──────────────────────────────────────────────────────── + + Widget _buildRatingDistribution(List movies, List books) { final colors = Theme.of(context).colorScheme; final allRatings = []; - for (final m in movies) { - if (m.rating != null) allRatings.add(m.rating!); - } - for (final b in books) { - if (b.rating != null) allRatings.add(b.rating!); - } + for (final m in movies) { if (m.rating != null) allRatings.add(m.rating!); } + for (final b in books) { if (b.rating != null) allRatings.add(b.rating!); } if (allRatings.isEmpty) return const SizedBox.shrink(); final avg = allRatings.reduce((a, b) => a + b) / allRatings.length; - - // 按 1-10 分统计 final counts = List.filled(10, 0); - for (final r in allRatings) { - final idx = (r.round()).clamp(1, 10) - 1; - counts[idx]++; - } + for (final r in allRatings) { counts[(r.round()).clamp(1, 10) - 1]++; } final maxCount = counts.reduce((a, b) => a > b ? a : b).toDouble(); if (maxCount == 0) return const SizedBox.shrink(); - // 渐变色:低分灰色 → 高分金色 const barColors = [ - Color(0xFFBDBDBD), Color(0xFFBDBDBD), // 1-2 灰 - Color(0xFFFFCC80), Color(0xFFFFCC80), // 3-4 橙黄 - Color(0xFFFFB74D), Color(0xFFFFB74D), // 5-6 橙 - Color(0xFFFFA726), Color(0xFFFFA726), // 7-8 深橙 - Color(0xFFFFB800), Color(0xFFFFB800), // 9-10 金 + Color(0xFFBDBDBD), Color(0xFFBDBDBD), Color(0xFFFFCC80), Color(0xFFFFCC80), + Color(0xFFFFB74D), Color(0xFFFFB74D), Color(0xFFFFA726), Color(0xFFFFA726), + Color(0xFFFFB800), Color(0xFFFFB800), ]; return _buildCard( - title: title, + title: '评分分布', child: Column( children: [ - // 平均评分 Row( children: [ Text('平均评分', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), @@ -280,7 +506,6 @@ class _StatisticsPageState extends State { ], ), const SizedBox(height: 20), - // 柱状图 SizedBox( height: 160, child: BarChart( @@ -292,164 +517,294 @@ class _StatisticsPageState extends State { touchTooltipData: BarTouchTooltipData( getTooltipColor: (_) => colors.inverseSurface, getTooltipItem: (group, groupIndex, rod, rodIndex) { - return BarTooltipItem( - '${group.x + 1}星 ${rod.toY.toInt()}部', - TextStyle(color: colors.onInverseSurface, fontSize: 12, fontWeight: FontWeight.w600), - ); + return BarTooltipItem('${group.x + 1}星 ${rod.toY.toInt()}部', TextStyle(color: colors.onInverseSurface, fontSize: 12, fontWeight: FontWeight.w600)); }, ), ), titlesData: FlTitlesData( show: true, - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (value, meta) { - return Padding( - padding: const EdgeInsets.only(top: 6), - child: Text('${value.toInt() + 1}', style: TextStyle( - fontSize: 10, color: colors.onSurface.withValues(alpha: 0.5))), - ); - }, - reservedSize: 24, + bottomTitles: AxisTitles(sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) => Padding( + padding: const EdgeInsets.only(top: 6), + child: Text('${value.toInt() + 1}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.5))), ), - ), + reservedSize: 24, + )), leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), ), borderData: FlBorderData(show: false), gridData: const FlGridData(show: false), - barGroups: List.generate(10, (i) { - return BarChartGroupData( - x: i, - barRods: [ - BarChartRodData( - toY: counts[i].toDouble(), - color: barColors[i], - width: 20, - borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), - backDrawRodData: BackgroundBarChartRodData( - show: true, - toY: maxCount * 1.2, - color: colors.outlineVariant.withValues(alpha: 0.3), - ), - ), - ], - ); - }), + barGroups: List.generate(10, (i) => BarChartGroupData(x: i, barRods: [ + BarChartRodData( + toY: counts[i].toDouble(), + color: barColors[i], + width: 20, + borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), + backDrawRodData: BackgroundBarChartRodData(show: true, toY: maxCount * 1.2, color: colors.outlineVariant.withValues(alpha: 0.3)), + ), + ])), ), ), ), const SizedBox(height: 8), - // 底部标签 Text('星级评分', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), ], ), ); } - // ─── 月度趋势 ──────────────────────────────────────────────────────── + // ─── 8. 年度趋势折线图 ────────────────────────────────────────────────── - Widget _buildMonthlyTrend(List movies, List books, List notes) { + Widget _buildYearlyTrend(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; final now = DateTime.now(); - final months = List.generate(6, (i) { - final d = DateTime(now.year, now.month - (5 - i), 1); + final months = List.generate(12, (i) { + final d = DateTime(now.year, now.month - (11 - i), 1); return '${d.month}月'; }); - final counts = >{}; - if (_showMovies) counts['影视'] = List.filled(6, 0); - if (_showBooks) counts['书籍'] = List.filled(6, 0); - if (_showNotes) counts['笔记'] = List.filled(6, 0); - - for (final m in movies) { - final idx = _monthIndex(m.createdAt, now); - if (idx >= 0 && idx < 6) counts['影视']?[idx] = (counts['影视']?[idx] ?? 0) + 1; - } - for (final b in books) { - final idx = _monthIndex(b.createdAt, now); - if (idx >= 0 && idx < 6) counts['书籍']?[idx] = (counts['书籍']?[idx] ?? 0) + 1; - } - for (final n in notes) { - final idx = _monthIndex(n.createdAt, now); - if (idx >= 0 && idx < 6) counts['笔记']?[idx] = (counts['笔记']?[idx] ?? 0) + 1; + List countByMonth(List items) { + return List.generate(12, (i) { + final d = DateTime(now.year, now.month - (11 - i), 1); + return items.where((item) => item.createdAt.year == d.year && item.createdAt.month == d.month).length; + }); } - final allValues = counts.values.expand((l) => l); + final movieData = countByMonth(movies); + final bookData = countByMonth(books); + final noteData = countByMonth(notes); + final allValues = [...movieData, ...bookData, ...noteData]; final maxVal = allValues.isEmpty ? 1 : allValues.reduce((a, b) => a > b ? a : b); final safeMax = maxVal == 0 ? 1 : maxVal; - final brandColors = { '影视': const Color(0xFF4A90D9), '书籍': const Color(0xFF7E57C2), '笔记': const Color(0xFF66BB6A) }; - return _buildCard( - title: '近6月趋势', + title: '年度趋势', child: Column( children: [ SizedBox( - height: 120, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: List.generate(6, (i) => Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ...counts.entries.map((e) { - final h = (e.value[i] / safeMax * 80).clamp(0, 80).toDouble(); - return Container( - height: h < 2 && e.value[i] > 0 ? 2 : h, - margin: const EdgeInsets.only(top: 1), - decoration: BoxDecoration( - color: brandColors[e.key]!.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(2), - ), - ); - }).toList(), - ], + height: 180, + child: LineChart( + LineChartData( + minY: 0, + maxY: (safeMax * 1.3).toDouble(), + lineBarsData: [ + _buildLineData(movieData, const Color(0xFF4A90D9)), + _buildLineData(bookData, const Color(0xFF7E57C2)), + _buildLineData(noteData, const Color(0xFF66BB6A)), + ], + titlesData: FlTitlesData( + bottomTitles: AxisTitles(sideTitles: SideTitles( + showTitles: true, + interval: 2, + getTitlesWidget: (value, meta) { + final idx = value.toInt(); + if (idx < 0 || idx >= months.length) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(months[idx], style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), + ); + }, + reservedSize: 24, + )), + leftTitles: AxisTitles(sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) => Text('${value.toInt()}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3))), + reservedSize: 28, + )), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: math.max(1, safeMax / 3).toDouble(), + getDrawingHorizontalLine: (value) => FlLine(color: colors.outlineVariant, strokeWidth: 0.5), + ), + borderData: FlBorderData(show: false), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => colors.inverseSurface, + getTooltipItems: (spots) => spots.map((s) { + final labels = ['影视', '书籍', '笔记']; + return LineTooltipItem('${labels[s.barIndex]} ${s.y.toInt()}', TextStyle(color: colors.onInverseSurface, fontSize: 12, fontWeight: FontWeight.w600)); + }).toList(), ), ), - )), + ), ), ), - const SizedBox(height: 8), - Divider(height: 1, color: colors.outlineVariant), - const SizedBox(height: 8), - // 月份标签 - Row( - children: months.map((m) => Expanded( - child: Text(m, textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), - )).toList(), - ), const SizedBox(height: 12), - // 图例 Row( mainAxisAlignment: MainAxisAlignment.center, - children: counts.keys.map((k) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container(width: 8, height: 8, decoration: BoxDecoration(color: brandColors[k], borderRadius: BorderRadius.circular(2))), - const SizedBox(width: 4), - Text(k, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))), - ], - ), - )).toList(), + children: [ + _buildLegend(const Color(0xFF4A90D9), '影视'), + const SizedBox(width: 16), + _buildLegend(const Color(0xFF7E57C2), '书籍'), + const SizedBox(width: 16), + _buildLegend(const Color(0xFF66BB6A), '笔记'), + ], ), ], ), ); } - int _monthIndex(DateTime date, DateTime now) { - final diff = (now.year - date.year) * 12 + (now.month - date.month); - return 5 - diff; // index 0..5 where 0=5 months ago, 5=current month + LineChartBarData _buildLineData(List data, Color color) { + return LineChartBarData( + spots: List.generate(12, (i) => FlSpot(i.toDouble(), data[i].toDouble())), + isCurved: true, + color: color, + barWidth: 2, + dotData: FlDotData(show: true, getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter(radius: 3, color: color, strokeWidth: 0)), + belowBarData: BarAreaData(show: true, color: color.withValues(alpha: 0.08)), + ); } - // ─── 标签词云 ────────────────────────────────────────────────────────── + Widget _buildLegend(Color color, String label) { + final colors = Theme.of(context).colorScheme; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 10, height: 3, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(1.5))), + const SizedBox(width: 4), + Text(label, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), + ], + ); + } + + // ─── 9. 星期分布 ──────────────────────────────────────────────────────── + + Widget _buildWeekdayDistribution(List movies, List books, List notes) { + final colors = Theme.of(context).colorScheme; + final allDates = [ + ...movies.map((m) => m.createdAt), + ...books.map((b) => b.createdAt), + ...notes.map((n) => n.createdAt), + ]; + if (allDates.isEmpty) return const SizedBox.shrink(); + + final weekdayCounts = List.filled(7, 0); + for (final d in allDates) { + weekdayCounts[d.weekday - 1]++; + } + final maxCount = weekdayCounts.reduce((a, b) => a > b ? a : b).toDouble(); + if (maxCount == 0) return const SizedBox.shrink(); + final dayLabels = ['一', '二', '三', '四', '五', '六', '日']; + + return _buildCard( + title: '星期分布', + child: SizedBox( + height: 140, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate(7, (i) { + final ratio = weekdayCounts[i] / maxCount; + final barHeight = (ratio * 100).clamp(4.0, 100.0); + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text('${weekdayCounts[i]}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 4), + Container( + height: barHeight, + decoration: BoxDecoration( + color: colors.primary.withValues(alpha: 0.6 + ratio * 0.4), + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 6), + Text(dayLabels[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), + ], + ), + ), + ); + }), + ), + ), + ); + } + + // ─── 10. 累计增长曲线 ────────────────────────────────────────────────── + + Widget _buildCumulativeGrowth(List movies, List books, List notes) { + final colors = Theme.of(context).colorScheme; + final allItems = [...movies.map((m) => m.createdAt), ...books.map((b) => b.createdAt), ...notes.map((n) => n.createdAt)]; + if (allItems.isEmpty) return const SizedBox.shrink(); + allItems.sort(); + + // 按月累计 + final monthlyCumulative = {}; + int cumulative = 0; + final now = DateTime.now(); + for (int i = 11; i >= 0; i--) { + final d = DateTime(now.year, now.month - i, 1); + final nextMonth = DateTime(d.year, d.month + 1, 1); + final count = allItems.where((date) => !date.isBefore(d) && date.isBefore(nextMonth)).length; + cumulative += count; + monthlyCumulative[11 - i] = cumulative; + } + final maxVal = cumulative.toDouble(); + if (maxVal == 0) return const SizedBox.shrink(); + + return _buildCard( + title: '累计增长', + child: SizedBox( + height: 160, + child: LineChart( + LineChartData( + minY: 0, + maxY: maxVal * 1.2, + lineBarsData: [ + LineChartBarData( + spots: List.generate(12, (i) => FlSpot(i.toDouble(), (monthlyCumulative[i] ?? 0).toDouble())), + isCurved: true, + color: colors.primary, + barWidth: 2.5, + dotData: FlDotData(show: true, getDotPainter: (spot, percent, bar, index) => FlDotCirclePainter(radius: 3, color: colors.primary, strokeWidth: 0)), + belowBarData: BarAreaData(show: true, color: colors.primary.withValues(alpha: 0.08)), + ), + ], + titlesData: FlTitlesData( + bottomTitles: AxisTitles(sideTitles: SideTitles( + showTitles: true, + interval: 2, + getTitlesWidget: (value, meta) { + final d = DateTime(now.year, now.month - (11 - value.toInt()), 1); + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text('${d.month}月', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), + ); + }, + reservedSize: 24, + )), + leftTitles: AxisTitles(sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) => Text('${value.toInt()}', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3))), + reservedSize: 28, + )), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: math.max(1, maxVal / 3).toDouble(), + getDrawingHorizontalLine: (value) => FlLine(color: colors.outlineVariant, strokeWidth: 0.5), + ), + borderData: FlBorderData(show: false), + ), + ), + ), + ); + } + + // ─── 11. 标签词云 ────────────────────────────────────────────────────── Widget _buildTagCloud(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; @@ -458,8 +813,6 @@ class _StatisticsPageState extends State { if (_showBooks) tabs.add('书籍'); if (_showNotes) tabs.add('笔记'); if (tabs.isEmpty) return const SizedBox.shrink(); - - // 确保 cloudTabIndex 在有效范围内 if (_cloudTabIndex >= tabs.length) _cloudTabIndex = 0; final tagCounts = {}; @@ -477,7 +830,6 @@ class _StatisticsPageState extends State { final sorted = tagCounts.entries.toList()..sort((a, b) => b.value.compareTo(a.value)); if (sorted.isEmpty) return const SizedBox.shrink(); - final maxCount = sorted.first.value; final minCount = sorted.last.value; final range = math.max(maxCount - minCount, 1); @@ -491,7 +843,6 @@ class _StatisticsPageState extends State { return _buildCard( title: '标签词云', child: Column(children: [ - // Tab 切换 Row( children: tabs.map((t) { final i = tabs.indexOf(t); @@ -513,7 +864,6 @@ class _StatisticsPageState extends State { }).toList(), ), const SizedBox(height: 16), - // 词云 Wrap( spacing: 6, runSpacing: 6, @@ -531,6 +881,69 @@ class _StatisticsPageState extends State { ); } + // ─── 12+13. 趣味统计 ────────────────────────────────────────────────── + + Widget _buildFunStats(List movies, List books, List notes) { + final colors = Theme.of(context).colorScheme; + final allItems = [...movies.map((m) => m.createdAt), ...books.map((b) => b.createdAt), ...notes.map((n) => n.createdAt)]; + if (allItems.isEmpty) return const SizedBox.shrink(); + + // 观影马拉松 + final sortedDates = allItems.map((d) => DateTime(d.year, d.month, d.day)).toSet().toList()..sort(); + int maxStreak = 1, currentStreak = 1; + for (int i = 1; i < sortedDates.length; i++) { + if (sortedDates[i].difference(sortedDates[i - 1]).inDays == 1) { + currentStreak++; + maxStreak = math.max(maxStreak, currentStreak); + } else { + currentStreak = 1; + } + } + + // 标签之最 + final tagCounts = {}; + for (final m in movies) { for (final g in m.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } } + for (final b in books) { for (final g in b.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } } + for (final n in notes) { for (final t in n.tags) { tagCounts[t] = (tagCounts[t] ?? 0) + 1; } } + final topTag = tagCounts.entries.isEmpty ? null : tagCounts.entries.reduce((a, b) => a.value >= b.value ? a : b); + + return Row( + children: [ + Expanded( + child: _buildFunCard(Icons.local_fire_department_outlined, '连续记录', maxStreak > 1 ? '$maxStreak 天' : '-', '最长连续记录天数', const Color(0xFFFF8F00), colors), + ), + const SizedBox(width: 12), + Expanded( + child: _buildFunCard(Icons.label_outlined, '最常用标签', topTag != null ? topTag.key : '-', topTag != null ? '使用 ${topTag.value} 次' : '', const Color(0xFF26A69A), colors), + ), + ], + ); + } + + Widget _buildFunCard(IconData icon, String label, String value, String sub, Color color, ColorScheme colors) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 22, color: color), + const SizedBox(height: 10), + Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: color)), + const SizedBox(height: 4), + Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))), + if (sub.isNotEmpty) ...[ + const SizedBox(height: 2), + Text(sub, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.3))), + ], + ], + ), + ); + } + // ─── 通用卡片 ──────────────────────────────────────────────────────── Widget _buildCard({required String title, required Widget child}) { @@ -558,11 +971,3 @@ class _StatisticsPageState extends State { ); } } - -class _CardData { - final String label; - final int count; - final IconData icon; - final Color color; - _CardData(this.label, this.count, this.icon, this.color); -} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 8261f76..e5f772c 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -47,6 +47,9 @@ class AppProvider extends ChangeNotifier { // 主题模式 ThemeMode _themeMode = ThemeMode.system; + // 配色方案索引 + int _colorSchemeIndex = 0; + /// 是否使用远程服务端(同步开关 + 已激活) bool get _useRemote { final prefs = UserPrefs(); @@ -206,6 +209,7 @@ class AppProvider extends ChangeNotifier { bool get drawerOpen => _drawerOpen; bool get bottomNavVisible => _bottomNavVisible; ThemeMode get themeMode => _themeMode; + int get colorSchemeIndex => _colorSchemeIndex; List get movies => _movies; List get books => _books; List get notes => _notes; @@ -263,9 +267,18 @@ class AppProvider extends ChangeNotifier { default: _themeMode = ThemeMode.system; } + _colorSchemeIndex = prefs.colorSchemeIndex; notifyListeners(); } + void setColorScheme(int index) { + if (_colorSchemeIndex != index) { + _colorSchemeIndex = index; + UserPrefs().setColorSchemeIndex(index); + notifyListeners(); + } + } + void setMovieStatusIndex(int index) { _movieStatusIndex = index; notifyListeners(); diff --git a/lib/utils/theme/app_theme.dart b/lib/utils/theme/app_theme.dart index 4fb9351..a760a28 100644 --- a/lib/utils/theme/app_theme.dart +++ b/lib/utils/theme/app_theme.dart @@ -34,6 +34,92 @@ class AppTheme { static const FontWeight _medium = FontWeight.w500; static const FontWeight _semibold = FontWeight.w600; + // 配色方案种子色 + static const List seedColors = [ + Color(0xFF333333), // 经典 + Color(0xFF3F51B5), // 靛蓝 + Color(0xFF009688), // 薄荷 + Color(0xFFFF8F00), // 琥珀 + Color(0xFFE91E63), // 玫瑰 + Color(0xFF673AB7), // 紫罗兰 + ]; + + static const List colorSchemeNames = ['经典', '靛蓝', '薄荷', '琥珀', '玫瑰', '紫罗兰']; + + /// 根据配色索引获取浅色主题 + static ThemeData getLightTheme(int index) { + if (index <= 0) return lightTheme; + return _buildColoredLightTheme(seedColors[index]); + } + + /// 带配色的浅色主题 + static ThemeData _buildColoredLightTheme(Color seed) { + final scheme = ColorScheme.fromSeed(seedColor: seed, brightness: Brightness.light); + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + scaffoldBackgroundColor: scheme.surface, + colorScheme: scheme, + appBarTheme: AppBarTheme( + backgroundColor: scheme.surface, + foregroundColor: scheme.onSurface, + elevation: 0, + centerTitle: false, + titleSpacing: 24, + titleTextStyle: TextStyle( + fontFamily: _fontFamily, fontSize: 18, fontWeight: _semibold, + color: scheme.onSurface, letterSpacing: 0, + ), + ), + cardTheme: CardThemeData( + color: scheme.surface, elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + side: BorderSide(color: scheme.outlineVariant, width: 0.5), + ), + margin: EdgeInsets.zero, + ), + listTileTheme: const ListTileThemeData( + contentPadding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + minLeadingWidth: 0, dense: true, + ), + dividerTheme: DividerThemeData(color: scheme.outlineVariant, thickness: 0.5, space: 0), + inputDecorationTheme: InputDecorationTheme( + filled: false, + border: UnderlineInputBorder(borderSide: BorderSide(color: scheme.outlineVariant, width: 0.5)), + enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: scheme.outlineVariant, width: 0.5)), + focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: scheme.primary, width: 1)), + errorBorder: UnderlineInputBorder(borderSide: BorderSide(color: scheme.error, width: 0.5)), + contentPadding: const EdgeInsets.symmetric(vertical: 12), + hintStyle: TextStyle(fontFamily: _fontFamily, fontSize: 15, fontWeight: _regular, color: scheme.onSurfaceVariant), + labelStyle: TextStyle(fontFamily: _fontFamily, fontSize: 13, fontWeight: _medium, color: scheme.onSurfaceVariant), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: scheme.primary, foregroundColor: scheme.onPrimary, + elevation: 0, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + textStyle: TextStyle(fontFamily: _fontFamily, fontSize: 14, fontWeight: _medium, letterSpacing: 0.3), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: scheme.primary, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + textStyle: TextStyle(fontFamily: _fontFamily, fontSize: 14, fontWeight: _medium), + ), + ), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + backgroundColor: scheme.surface, + selectedItemColor: scheme.primary, + unselectedItemColor: scheme.onSurfaceVariant, + elevation: 0, type: BottomNavigationBarType.fixed, + selectedLabelStyle: const TextStyle(fontFamily: _fontFamily, fontSize: 11, fontWeight: _medium), + unselectedLabelStyle: TextStyle(fontFamily: _fontFamily, fontSize: 11, fontWeight: _regular, color: scheme.onSurfaceVariant), + ), + ); + } + // 亮色主题 - 极简主义 static ThemeData get lightTheme { return ThemeData( diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 7b5409f..6984046 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -47,6 +47,10 @@ class UserPrefs { int get themeMode => prefs.getInt('themeMode') ?? 0; Future setThemeMode(int value) => prefs.setInt('themeMode', value); + /// 配色方案: 0=经典, 1=靛蓝, 2=薄荷, 3=琥珀, 4=玫瑰, 5=紫罗兰 + int get colorSchemeIndex => prefs.getInt('colorSchemeIndex') ?? 0; + Future setColorSchemeIndex(int value) => prefs.setInt('colorSchemeIndex', value); + /// 上映日期:显示到日(true)/ 显示到月(false) bool get showExactReleaseDate => prefs.getBool('showExactReleaseDate') ?? true; Future setShowExactReleaseDate(bool value) => prefs.setBool('showExactReleaseDate', value); diff --git a/lib/widgets/genre_selector_page.dart b/lib/widgets/genre_selector_page.dart new file mode 100644 index 0000000..c02fa24 --- /dev/null +++ b/lib/widgets/genre_selector_page.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; + +/// 类型/标签选择全屏页(影视类型、书籍类型通用) +class GenreSelectorPage extends StatefulWidget { + final String title; + final List existingTags; + final List initialSelected; + final String hint; + const GenreSelectorPage({ + super.key, + required this.title, + required this.existingTags, + required this.initialSelected, + this.hint = '', + }); + + @override + State createState() => _GenreSelectorPageState(); +} + +class _GenreSelectorPageState extends State { + late List _selected; + final _controller = TextEditingController(); + String _newTag = ''; + + @override + void initState() { + super.initState(); + _selected = List.from(widget.initialSelected); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _toggle(String tag) { + setState(() { + if (_selected.contains(tag)) { + _selected.remove(tag); + } else { + _selected.add(tag); + } + }); + } + + void _addCustom() { + final tag = _newTag.trim(); + if (tag.isNotEmpty && !_selected.contains(tag)) { + setState(() { + _selected.add(tag); + _newTag = ''; + _controller.clear(); + }); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final available = widget.existingTags.where((t) => !_selected.contains(t)).toList(); + + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: Text(widget.title), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, _selected), + child: Text('完成', style: TextStyle( + fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary, + )), + ), + const SizedBox(width: 8), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 已选择 + if (_selected.isNotEmpty) ...[ + Text('已选择', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(height: 8), + Wrap( + spacing: 8, runSpacing: 8, + children: _selected.map((tag) { + final displayTag = tag.length > 8 ? '${tag.substring(0, 8)}...' : tag; + return GestureDetector( + onTap: () => _toggle(tag), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: colors.primary, borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(displayTag, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onPrimary)), + const SizedBox(width: 6), + Icon(Icons.close, size: 14, color: colors.onPrimary.withValues(alpha: 0.7)), + ], + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 24), + ], + // 自定义输入 + Text('自定义添加', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + style: TextStyle(fontSize: 14, color: colors.onSurface), + decoration: InputDecoration( + hintText: widget.hint.isNotEmpty ? widget.hint : '输入自定义类型', + hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)), + filled: true, fillColor: colors.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + ), + onChanged: (v) => setState(() => _newTag = v), + onSubmitted: (_) => _addCustom(), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: _addCustom, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: _newTag.trim().isNotEmpty ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(Icons.add, size: 20, + color: _newTag.trim().isNotEmpty ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.3)), + ), + ), + ], + ), + // 已有类型 + if (available.isNotEmpty) ...[ + const SizedBox(height: 24), + Text('已有类型', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(height: 8), + Wrap( + spacing: 8, runSpacing: 8, + children: available.map((tag) { + final displayTag = tag.length > 8 ? '${tag.substring(0, 8)}...' : tag; + return GestureDetector( + onTap: () => _toggle(tag), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.add, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), + const SizedBox(width: 4), + Text(displayTag, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.7))), + ], + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ); + } +}