diff --git a/lib/data/book/book_dao.dart b/lib/data/book/book_dao.dart index 68f3be0..95403a1 100644 --- a/lib/data/book/book_dao.dart +++ b/lib/data/book/book_dao.dart @@ -45,6 +45,8 @@ class BookDao { switch (sortMode) { case 1: return 'created_at DESC'; case 2: return 'rating DESC NULLS LAST, updated_at DESC'; + case 3: return 'start_date DESC NULLS LAST, created_at DESC'; + case 4: return 'publish_date DESC NULLS LAST, created_at DESC'; default: return 'updated_at DESC'; } } diff --git a/lib/data/database_helper.dart b/lib/data/database_helper.dart index e00a8de..8d59e75 100644 --- a/lib/data/database_helper.dart +++ b/lib/data/database_helper.dart @@ -81,7 +81,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 34, + version: 36, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -336,9 +336,34 @@ class DatabaseHelper { ) '''); } - } - /// 升级books表到V26(添加阅读始末日期字段) + if (oldVersion < 35) { + // 添加观看次数/阅读次数/游玩次数字段 + final movieCols = await db.rawQuery('PRAGMA table_info(movies)'); + if (!movieCols.any((col) => col['name'] == 'watch_count')) { + await db.execute('ALTER TABLE movies ADD COLUMN watch_count INTEGER DEFAULT 0'); + } + final bookCols = await db.rawQuery('PRAGMA table_info(books)'); + if (!bookCols.any((col) => col['name'] == 'read_count')) { + await db.execute('ALTER TABLE books ADD COLUMN read_count INTEGER DEFAULT 0'); + } + final gameCols = await db.rawQuery('PRAGMA table_info(games)'); + if (!gameCols.any((col) => col['name'] == 'play_count')) { + await db.execute('ALTER TABLE games ADD COLUMN play_count INTEGER DEFAULT 0'); + } + } + + if (oldVersion < 36) { + // 添加游戏开发者和发售时间字段 + final gameCols = await db.rawQuery('PRAGMA table_info(games)'); + if (!gameCols.any((col) => col['name'] == 'developer')) { + await db.execute('ALTER TABLE games ADD COLUMN developer TEXT DEFAULT \'[]\''); + } + if (!gameCols.any((col) => col['name'] == 'release_date')) { + await db.execute('ALTER TABLE games ADD COLUMN release_date TEXT'); + } + } + } Future _upgradeBooksTableV26(Database db) async { final columns = await db.rawQuery('PRAGMA table_info(books)'); final hasStartDate = columns.any((col) => col['name'] == 'start_date'); @@ -724,6 +749,7 @@ class DatabaseHelper { status TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'movie', watch_date TEXT, + watch_count INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, is_deleted INTEGER DEFAULT 0, @@ -749,6 +775,7 @@ class DatabaseHelper { publish_date TEXT, start_date TEXT, finish_date TEXT, + read_count INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, is_deleted INTEGER DEFAULT 0, @@ -901,6 +928,9 @@ class DatabaseHelper { purchase_price TEXT, summary TEXT, cover_offset REAL DEFAULT 0, + play_count INTEGER DEFAULT 0, + developer TEXT DEFAULT '[]', + release_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, is_deleted INTEGER DEFAULT 0 diff --git a/lib/data/game/game_dao.dart b/lib/data/game/game_dao.dart index 979f0c7..392b31d 100644 --- a/lib/data/game/game_dao.dart +++ b/lib/data/game/game_dao.dart @@ -45,6 +45,7 @@ class GameDao { switch (sortMode) { case 1: return 'created_at DESC'; case 2: return 'rating DESC NULLS LAST, updated_at DESC'; + case 3: return 'release_date DESC NULLS LAST, created_at DESC'; default: return 'updated_at DESC'; } } diff --git a/lib/data/movie/movie_dao.dart b/lib/data/movie/movie_dao.dart index 4c8d578..ffd7a09 100644 --- a/lib/data/movie/movie_dao.dart +++ b/lib/data/movie/movie_dao.dart @@ -49,6 +49,8 @@ class MovieDao { switch (sortMode) { case 1: return 'created_at DESC'; case 2: return 'rating DESC NULLS LAST, updated_at DESC'; + case 3: return 'watch_date DESC NULLS LAST, created_at DESC'; + case 4: return 'release_date DESC NULLS LAST, created_at DESC'; default: return 'updated_at DESC'; } } diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index bd7a402..a40f416 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -63,6 +63,7 @@ class Movie { final String status; // watched/want_to_watch/watching final String category; // 影视分类: movie/tv/anime/variety/documentary/short/other final DateTime? watchDate; // 观看日期 + final int watchCount; // 观看次数 final DateTime createdAt; final DateTime updatedAt; final bool isDeleted; @@ -83,6 +84,7 @@ class Movie { required this.status, this.category = 'movie', this.watchDate, + this.watchCount = 0, required this.createdAt, required this.updatedAt, this.isDeleted = false, @@ -105,6 +107,7 @@ class Movie { status: json['status'] ?? 'want_to_watch', category: json['category'] ?? 'movie', watchDate: _safeParseDate(json['watch_date']), + watchCount: json['watch_count'] ?? 0, createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, @@ -128,6 +131,7 @@ class Movie { 'status': status, 'category': category, 'watch_date': watchDate?.toUtc().toIso8601String(), + 'watch_count': watchCount, 'created_at': createdAt.toUtc().toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(), 'is_deleted': isDeleted ? 1 : 0, @@ -163,6 +167,7 @@ class Movie { String? status, String? category, DateTime? watchDate, + int? watchCount, DateTime? createdAt, DateTime? updatedAt, bool? isDeleted, @@ -183,6 +188,7 @@ class Movie { status: status ?? this.status, category: category ?? this.category, watchDate: watchDate ?? this.watchDate, + watchCount: watchCount ?? this.watchCount, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, isDeleted: isDeleted ?? this.isDeleted, @@ -208,6 +214,7 @@ class Book { final DateTime? publishDate; // 出版时间 final DateTime? startDate; // 开始阅读日期 final DateTime? finishDate; // 读完日期 + final int readCount; // 阅读次数 final DateTime createdAt; final DateTime updatedAt; final bool isDeleted; @@ -229,6 +236,7 @@ class Book { this.publishDate, this.startDate, this.finishDate, + this.readCount = 0, required this.createdAt, required this.updatedAt, this.isDeleted = false, @@ -252,6 +260,7 @@ class Book { publishDate: _safeParseDate(json['publish_date']), startDate: _safeParseDate(json['start_date']), finishDate: _safeParseDate(json['finish_date']), + readCount: json['read_count'] ?? 0, createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, @@ -276,6 +285,7 @@ class Book { 'publish_date': publishDate?.toUtc().toIso8601String(), 'start_date': startDate?.toUtc().toIso8601String(), 'finish_date': finishDate?.toUtc().toIso8601String(), + 'read_count': readCount, 'created_at': createdAt.toUtc().toIso8601String(), 'updated_at': updatedAt.toUtc().toIso8601String(), 'is_deleted': isDeleted ? 1 : 0, @@ -306,6 +316,7 @@ class Book { DateTime? publishDate, DateTime? startDate, DateTime? finishDate, + int? readCount, DateTime? createdAt, DateTime? updatedAt, bool? isDeleted, @@ -327,6 +338,7 @@ class Book { publishDate: publishDate ?? this.publishDate, startDate: startDate ?? this.startDate, finishDate: finishDate ?? this.finishDate, + readCount: readCount ?? this.readCount, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, isDeleted: isDeleted ?? this.isDeleted, @@ -655,6 +667,9 @@ class Game { final List genres; // 类型 final int playTimeHours; // 游玩时长(小时) final int playTimeMinutes; // 游玩时长(分钟) + final int playCount; // 游玩次数 + final List developer; // 开发者 + final DateTime? releaseDate; // 发售时间 final List purchasePlatforms; // 购买平台 final DateTime? purchaseDate; // 购买日期 final String? purchasePrice; // 购买价格 @@ -676,6 +691,9 @@ class Game { this.genres = const [], this.playTimeHours = 0, this.playTimeMinutes = 0, + this.playCount = 0, + this.developer = const [], + this.releaseDate, this.purchasePlatforms = const [], this.purchaseDate, this.purchasePrice, @@ -699,6 +717,9 @@ class Game { genres: parseStringListGeneric(json['genres']), playTimeHours: json['play_time_hours'] ?? 0, playTimeMinutes: json['play_time_minutes'] ?? 0, + playCount: json['play_count'] ?? 0, + developer: parseStringListGeneric(json['developer']), + releaseDate: _safeParseDate(json['release_date']), purchasePlatforms: parseStringListGeneric(json['purchase_platforms']), purchaseDate: _safeParseDate(json['purchase_date']), purchasePrice: json['purchase_price'], @@ -723,6 +744,9 @@ class Game { 'genres': jsonEncode(genres), 'play_time_hours': playTimeHours, 'play_time_minutes': playTimeMinutes, + 'play_count': playCount, + 'developer': jsonEncode(developer), + 'release_date': releaseDate?.toUtc().toIso8601String(), 'purchase_platforms': jsonEncode(purchasePlatforms), 'purchase_date': purchaseDate?.toUtc().toIso8601String(), 'purchase_price': purchasePrice, @@ -753,6 +777,9 @@ class Game { List? genres, int? playTimeHours, int? playTimeMinutes, + int? playCount, + List? developer, + DateTime? releaseDate, List? purchasePlatforms, DateTime? purchaseDate, Object? purchasePrice = _copyWithNull, @@ -774,6 +801,9 @@ class Game { genres: genres ?? this.genres, playTimeHours: playTimeHours ?? this.playTimeHours, playTimeMinutes: playTimeMinutes ?? this.playTimeMinutes, + playCount: playCount ?? this.playCount, + developer: developer ?? this.developer, + releaseDate: releaseDate ?? this.releaseDate, purchasePlatforms: purchasePlatforms ?? this.purchasePlatforms, purchaseDate: purchaseDate ?? this.purchaseDate, purchasePrice: purchasePrice is _CopyWithNullSentinel ? this.purchasePrice : (purchasePrice as String?), diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index 6f3f503..ec90b1c 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -260,6 +260,13 @@ class _BookDetailPageState extends State { ])), ]), ], + if (book.readCount > 0) ...[ + const SizedBox(height: 8), + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 56, child: Text('阅读次数', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))), + Expanded(child: Text('${book.readCount} 次', style: TextStyle(fontSize: 13, color: colors.onSurface))), + ]), + ], if (book.summary != null && book.summary!.isNotEmpty) ...[ Divider(height: 32, thickness: 0.5, color: colors.outline), Row(children: [ @@ -798,7 +805,7 @@ class _BookDetailPageState extends State { if (book.isbn != null && book.isbn!.isNotEmpty) _buildIsbnSection(book), if (book.publisher != null && book.publisher!.isNotEmpty) _buildPublisherSection(book), if (book.publishDate != null) _buildPublishDateSection(book), - if (book.startDate != null || book.finishDate != null) _buildReadingDatesSection(book), + if (book.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book), Divider(height: 0.5, thickness: 0.5, color: colors.outline), if (book.summary != null && book.summary!.isNotEmpty) _buildSummarySection(book), Divider(height: 0.5, thickness: 0.5, color: colors.outline), @@ -899,7 +906,7 @@ class _BookDetailPageState extends State { if (book.isbn != null && book.isbn!.isNotEmpty) _buildIsbnSection(book), if (book.publisher != null && book.publisher!.isNotEmpty) _buildPublisherSection(book), if (book.publishDate != null) _buildPublishDateSection(book), - if (book.startDate != null || book.finishDate != null) _buildReadingDatesSection(book), + if (book.startDate != null || book.finishDate != null || book.readCount > 0) _buildReadingDatesSection(book), // 类型标签毛玻璃 if (book.genres.isNotEmpty) _buildGenresSection(book), // 简介:内部已有毛玻璃卡片 @@ -1613,35 +1620,64 @@ class _BookDetailPageState extends State { Widget _buildReadingDatesSection(Book book) { final isOverlay = _detailStyle == 1; final colors = Theme.of(context).colorScheme; - return Padding( - padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 64, - child: Text( - '阅读日期', - style: TextStyle( - fontSize: 13, - color: isOverlay ? const Color(0x66FFFFFF) : colors.onSurface.withValues(alpha: 0.4), - ), - ), - ), - Expanded( - child: Wrap( - spacing: 12, - runSpacing: 8, + return Column( + children: [ + if (book.startDate != null || book.finishDate != null) + Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (book.startDate != null) - _buildDateChip('开始', book.startDate!, isOverlay), - if (book.finishDate != null) - _buildDateChip('读完', book.finishDate!, isOverlay), + SizedBox( + width: 64, + child: Text( + '阅读日期', + style: TextStyle( + fontSize: 13, + color: isOverlay ? const Color(0x66FFFFFF) : colors.onSurface.withValues(alpha: 0.4), + ), + ), + ), + Expanded( + child: Wrap( + spacing: 12, + runSpacing: 8, + children: [ + if (book.startDate != null) + _buildDateChip('开始', book.startDate!, isOverlay), + if (book.finishDate != null) + _buildDateChip('读完', book.finishDate!, isOverlay), + ], + ), + ), ], ), ), - ], - ), + if (book.readCount > 0) + Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: isOverlay ? 5 : 4), + child: Row( + children: [ + SizedBox( + width: 64, + child: Text( + '阅读次数', + style: TextStyle( + fontSize: 13, + color: isOverlay ? const Color(0x66FFFFFF) : colors.onSurface.withValues(alpha: 0.4), + ), + ), + ), + Expanded( + child: Text( + '${book.readCount} 次', + style: TextStyle(fontSize: 13, color: isOverlay ? Colors.white70 : colors.onSurface), + ), + ), + ], + ), + ), + ], ); } diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart index d9f8dcb..8933b1e 100644 --- a/lib/pages/book/book_form_page.dart +++ b/lib/pages/book/book_form_page.dart @@ -52,6 +52,7 @@ class _BookFormPageState extends State { DateTime? _publishDate; DateTime? _startDate; DateTime? _finishDate; + int _readCount = 0; bool _isDownloading = false; @override @@ -84,6 +85,7 @@ class _BookFormPageState extends State { _publishDate = book.publishDate; _startDate = book.startDate; _finishDate = book.finishDate; + _readCount = book.readCount; } else if (widget.initialStatus != null) { _status = widget.initialStatus!; } @@ -238,6 +240,11 @@ class _BookFormPageState extends State { onTap: () => _selectFinishDate(), ), + // 阅读次数 + _halfCard('阅读次数', _readCount > 0 ? '$_readCount 次' : '', Icons.repeat_outlined, + onTap: () => _editReadCount(), + ), + // 书籍简介 SizedBox( width: double.infinity, @@ -585,6 +592,30 @@ class _BookFormPageState extends State { if (picked != null) setState(() => _finishDate = picked); } + Future _editReadCount() async { + final controller = TextEditingController(text: _readCount > 0 ? '$_readCount' : ''); + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('阅读次数'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration(hintText: '输入次数'), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(ctx, controller.text), child: const Text('确定')), + ], + ), + ); + if (result != null) { + final val = int.tryParse(result) ?? 0; + setState(() => _readCount = val < 0 ? 0 : val); + } + } + Future _editSummary() async { final result = await Navigator.push(context, MaterialPageRoute(builder: (_) => _SummaryEditorPage(initialText: _summaryController.text))); if (!mounted) return; @@ -646,7 +677,7 @@ class _BookFormPageState extends State { authors: _authors, translators: _translators, alternateTitles: _alternateTitles, publisher: _publisherController.text.trim(), genres: _genres, summary: _summaryController.text.trim(), rating: rating, status: _status, isbn: _isbnController.text.trim().isNotEmpty ? _isbnController.text.trim() : null, - publishDate: _publishDate, startDate: _startDate, finishDate: _finishDate, createdAt: now, updatedAt: now, + publishDate: _publishDate, startDate: _startDate, finishDate: _finishDate, readCount: _readCount, createdAt: now, updatedAt: now, ); await context.read().addBook(newBook); await context.read().loadBooks(); @@ -656,7 +687,7 @@ class _BookFormPageState extends State { authors: _authors, translators: _translators, alternateTitles: _alternateTitles, publisher: _publisherController.text.trim(), genres: _genres, summary: _summaryController.text.trim(), rating: rating, status: _status, isbn: _isbnController.text.trim().isNotEmpty ? _isbnController.text.trim() : null, - publishDate: _publishDate, startDate: _startDate, finishDate: _finishDate, updatedAt: now, + publishDate: _publishDate, startDate: _startDate, finishDate: _finishDate, readCount: _readCount, updatedAt: now, ); await context.read().updateBook(updatedBook); } diff --git a/lib/pages/book/book_tab_page.dart b/lib/pages/book/book_tab_page.dart index f7b90c2..2aadfa4 100644 --- a/lib/pages/book/book_tab_page.dart +++ b/lib/pages/book/book_tab_page.dart @@ -35,6 +35,7 @@ class _BookTabPageState extends State { int _lastScrollSignal = 0; int _lastEditRefreshCounter = 0; int _prevBookCount = -1; + int _prevSortMode = -1; double _dragDelta = 0.0; // 当前拖动偏移量 void _onBookTap(Book book) { @@ -82,6 +83,7 @@ class _BookTabPageState extends State { // 仅在数据实际变化时刷新列表,避免底部导航栏显隐等UI变化误触发重载 final statusChanged = provider.bookStatusIndex != _lastStatusIndex; + final sortModeChanged = UserPrefs().bookSortMode != _prevSortMode; final countChanged = provider.books.length != _prevBookCount; final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter; if (editRefreshed && provider.lastEditedItemId != null) { @@ -98,7 +100,8 @@ class _BookTabPageState extends State { } return; } - if (statusChanged || countChanged || editRefreshed) { + if (statusChanged || sortModeChanged || countChanged || editRefreshed) { + _prevSortMode = UserPrefs().bookSortMode; _prevBookCount = provider.books.length; _loadFirst(); } diff --git a/lib/pages/explore/media_calendar_page.dart b/lib/pages/explore/media_calendar_page.dart index 578d2c2..ad96f7a 100644 --- a/lib/pages/explore/media_calendar_page.dart +++ b/lib/pages/explore/media_calendar_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; +import '../../utils/user_prefs.dart'; import '../movies/movie_detail_page.dart'; import '../movies/movie_form_page.dart'; import '../book/book_detail_page.dart'; @@ -19,6 +20,7 @@ class MediaCalendarPage extends StatefulWidget { class _MediaCalendarPageState extends State { late DateTime _currentMonth; DateTime? _selectedDay; + int _dateMode = 0; // 0: 创建日期, 1: 观看/开始阅读日期 // {DateTime(dayOnly): [{path, title, type, data}]} late Map> _dayItems; @@ -26,6 +28,7 @@ class _MediaCalendarPageState extends State { @override void initState() { super.initState(); + _dateMode = UserPrefs().calendarDateMode; final now = DateTime.now(); _currentMonth = DateTime(now.year, now.month); _selectedDay = DateTime(now.year, now.month, now.day); @@ -38,7 +41,9 @@ class _MediaCalendarPageState extends State { for (final m in provider.movies.where((m) => !m.isDeleted)) { if (m.posterPath == null || m.posterPath!.isEmpty) continue; - final day = DateTime(m.createdAt.year, m.createdAt.month, m.createdAt.day); + final date = _dateMode == 1 ? m.watchDate : m.createdAt; + if (date == null) continue; + final day = DateTime(date.year, date.month, date.day); map.putIfAbsent(day, () => []); map[day]!.add(_CalendarItem( path: m.posterPath!, @@ -50,7 +55,9 @@ class _MediaCalendarPageState extends State { for (final b in provider.books.where((b) => !b.isDeleted)) { if (b.coverPath == null || b.coverPath!.isEmpty) continue; - final day = DateTime(b.createdAt.year, b.createdAt.month, b.createdAt.day); + final date = _dateMode == 1 ? b.startDate : b.createdAt; + if (date == null) continue; + final day = DateTime(date.year, date.month, date.day); map.putIfAbsent(day, () => []); map[day]!.add(_CalendarItem( path: b.coverPath!, @@ -63,6 +70,28 @@ class _MediaCalendarPageState extends State { _dayItems = map; } + Widget _buildDateModeToggle(ColorScheme colors) { + return Padding( + padding: const EdgeInsets.only(right: 4), + child: TextButton.icon( + onPressed: () { + setState(() { + _dateMode = _dateMode == 0 ? 1 : 0; + UserPrefs().setCalendarDateMode(_dateMode); + _buildDayMap(); + }); + }, + icon: Icon(_dateMode == 0 ? Icons.calendar_today_outlined : Icons.visibility_outlined, size: 16, color: colors.primary), + label: Text(_dateMode == 0 ? '创建日期' : '观看/阅读', style: TextStyle(fontSize: 12, color: colors.primary)), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ); + } + void _prevMonth() { setState(() { _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1); @@ -85,7 +114,12 @@ class _MediaCalendarPageState extends State { return Scaffold( backgroundColor: colors.surface, - appBar: AppBar(title: const Text('书影日历')), + appBar: AppBar( + title: const Text('书影日历'), + actions: [ + _buildDateModeToggle(colors), + ], + ), body: Column( children: [ _buildMonthHeader(colors), diff --git a/lib/pages/explore/reviewed_page.dart b/lib/pages/explore/reviewed_page.dart new file mode 100644 index 0000000..bdf3b73 --- /dev/null +++ b/lib/pages/explore/reviewed_page.dart @@ -0,0 +1,477 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/app_provider.dart'; +import '../../models/data_models.dart'; +import '../../widgets/fade_in_local_image.dart'; +import '../../widgets/animated_star_rating.dart'; +import '../movies/movie_detail_page.dart'; +import '../book/book_detail_page.dart'; +import '../game/game_detail_page.dart'; + +enum _ItemType { movie, book, game } + +class _ReviewedItem { + final String id; + final String title; + final String subtitle; + final String? coverPath; + final double? rating; + final DateTime createdAt; + final int? year; // 上映/出版/发售年份 + final List genres; + final _ItemType type; + final dynamic original; + + _ReviewedItem({ + required this.id, + required this.title, + required this.subtitle, + this.coverPath, + this.rating, + required this.createdAt, + this.year, + required this.genres, + required this.type, + required this.original, + }); +} + +class ReviewedPage extends StatefulWidget { + const ReviewedPage({super.key}); + + @override + State createState() => _ReviewedPageState(); +} + +class _ReviewedPageState extends State { + int? _selectedYear; // null = 全部 + _ItemType? _selectedType; // null = 全部 + String? _selectedGenre; // null = 全部 + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + final allItems = _buildAllItems(provider); + final years = _buildYearList(allItems); + final filtered = _filterItems(allItems); + + return Scaffold( + appBar: AppBar(title: const Text('已阅')), + body: CustomScrollView( + slivers: [ + // 统计概览 + SliverToBoxAdapter(child: _buildStatsHeader(allItems, filtered, context)), + // 筛选栏 + if (allItems.isNotEmpty) + SliverToBoxAdapter(child: _buildFilterBar(years, allItems, context)), + // 列表 + if (filtered.isEmpty) + SliverFillRemaining( + child: Center( + child: Text('暂无已阅记录', + style: TextStyle( + fontSize: 14, + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.3))), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.only(top: 4, bottom: 16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _buildItem(context, filtered[index]), + childCount: filtered.length, + ), + ), + ), + ], + ), + ); + }, + ); + } + + // ─── 统计概览 ───────────────────────────────────────────────────── + + Widget _buildStatsHeader(List<_ReviewedItem> all, List<_ReviewedItem> filtered, BuildContext context) { + final colors = Theme.of(context).colorScheme; + final movieCount = all.where((i) => i.type == _ItemType.movie).length; + final bookCount = all.where((i) => i.type == _ItemType.book).length; + final gameCount = all.where((i) => i.type == _ItemType.game).length; + final avgRating = filtered.isNotEmpty + ? filtered.where((i) => i.rating != null).map((i) => i.rating!).toList() + : []; + final avg = avgRating.isNotEmpty + ? (avgRating.reduce((a, b) => a + b) / avgRating.length).toStringAsFixed(1) + : '--'; + + return Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + // 总数 + 平均评分 + Row( + children: [ + _buildStatCard('已阅总数', '${filtered.length}', Icons.done_all, colors.primary, colors), + const SizedBox(width: 12), + _buildStatCard('平均评分', avg, Icons.star_outline, Colors.amber, colors), + ], + ), + const SizedBox(height: 12), + // 分类统计 + Row( + children: [ + Expanded( + child: _buildCategoryChip('影视', movieCount, Colors.blue, _ItemType.movie), + ), + const SizedBox(width: 8), + Expanded( + child: _buildCategoryChip('书籍', bookCount, Colors.teal, _ItemType.book), + ), + const SizedBox(width: 8), + Expanded( + child: _buildCategoryChip('游戏', gameCount, Colors.orange, _ItemType.game), + ), + ], + ), + ], + ), + ); + } + + Widget _buildStatCard(String label, String value, IconData icon, Color iconColor, ColorScheme colors) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(icon, size: 20, color: iconColor), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: colors.onSurface)), + const SizedBox(height: 2), + Text(label, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ], + ), + ), + ); + } + + Widget _buildCategoryChip(String label, int count, Color color, _ItemType type) { + final colors = Theme.of(context).colorScheme; + final selected = _selectedType == type; + return GestureDetector( + onTap: () => setState(() => _selectedType = selected ? null : type), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: selected ? color.withValues(alpha: 0.12) : colors.surface, + borderRadius: BorderRadius.circular(10), + border: selected ? Border.all(color: color.withValues(alpha: 0.3), width: 1) : null, + ), + child: Column( + children: [ + Text('$count', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: selected ? color : colors.onSurface)), + const SizedBox(height: 2), + Text(label, style: TextStyle(fontSize: 10, color: selected ? color : colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ), + ); + } + + // ─── 筛选栏 ─────────────────────────────────────────────────────── + + Widget _buildFilterBar(List years, List<_ReviewedItem> allItems, BuildContext context) { + final colors = Theme.of(context).colorScheme; + final genres = _buildGenreList(allItems); + + return Column( + children: [ + // 类型筛选 + if (genres.isNotEmpty) + Container( + height: 32, + margin: const EdgeInsets.only(top: 8), + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + _buildFilterChip(null, '全部', colors, isSelected: _selectedGenre == null, onTap: () => setState(() => _selectedGenre = null)), + const SizedBox(width: 6), + for (final genre in genres) ...[ + _buildFilterChip(genre, genre, colors, isSelected: _selectedGenre == genre, onTap: () => setState(() => _selectedGenre = _selectedGenre == genre ? null : genre)), + const SizedBox(width: 6), + ], + ], + ), + ), + // 年份筛选 + if (years.isNotEmpty) + Container( + height: 32, + margin: const EdgeInsets.only(top: 6), + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + _buildFilterChip(null, '全部', colors, isSelected: _selectedYear == null, onTap: () => setState(() => _selectedYear = null)), + const SizedBox(width: 6), + for (final year in years) ...[ + _buildFilterChip(year, '$year', colors, isSelected: _selectedYear == year, onTap: () => setState(() => _selectedYear = _selectedYear == year ? null : year)), + const SizedBox(width: 6), + ], + ], + ), + ), + ], + ); + } + + Widget _buildFilterChip(dynamic key, String label, ColorScheme colors, {required bool isSelected, required VoidCallback onTap}) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: isSelected ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Text(label, + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.55))), + ), + ), + ); + } + + List _buildGenreList(List<_ReviewedItem> items) { + final genres = {}; + for (final i in items) { + genres.addAll(i.genres); + } + return genres.toList()..sort(); + } + + // ─── 数据构建 ───────────────────────────────────────────────────── + + List<_ReviewedItem> _buildAllItems(AppProvider provider) { + final items = <_ReviewedItem>[]; + + for (final m in provider.movies) { + if (!m.isDeleted && m.status == 'watched') { + items.add(_ReviewedItem( + id: m.id, + title: m.title, + subtitle: m.directors.isNotEmpty ? m.directors.join(' / ') : '', + coverPath: m.posterPath, + rating: m.rating, + createdAt: m.createdAt, + year: m.releaseDate?.year, + genres: m.genres, + type: _ItemType.movie, + original: m, + )); + } + } + + for (final b in provider.books) { + if (!b.isDeleted && b.status == 'read') { + items.add(_ReviewedItem( + id: b.id, + title: b.title, + subtitle: b.authors.isNotEmpty ? b.authors.join(' / ') : '', + coverPath: b.coverPath, + rating: b.rating, + createdAt: b.createdAt, + year: b.publishDate?.year, + genres: b.genres, + type: _ItemType.book, + original: b, + )); + } + } + + for (final g in provider.games) { + if (!g.isDeleted && g.status == 'completed') { + items.add(_ReviewedItem( + id: g.id, + title: g.title, + subtitle: g.developer.isNotEmpty ? g.developer.join(' / ') : (g.platforms.isNotEmpty ? g.platforms.join(' / ') : ''), + coverPath: g.coverPath, + rating: g.rating, + createdAt: g.createdAt, + year: g.releaseDate?.year, + genres: g.genres, + type: _ItemType.game, + original: g, + )); + } + } + + items.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return items; + } + + List _buildYearList(List<_ReviewedItem> items) { + final years = items.map((i) => i.year).whereType().toSet().toList()..sort((a, b) => b.compareTo(a)); + return years; + } + + List<_ReviewedItem> _filterItems(List<_ReviewedItem> items) { + return items.where((i) { + if (_selectedYear != null && i.year != _selectedYear) return false; + if (_selectedType != null && i.type != _selectedType) return false; + if (_selectedGenre != null && !i.genres.contains(_selectedGenre)) return false; + return true; + }).toList(); + } + + // ─── 列表项 ─────────────────────────────────────────────────────── + + Widget _buildItem(BuildContext context, _ReviewedItem item) { + final colors = Theme.of(context).colorScheme; + final typeColor = switch (item.type) { + _ItemType.movie => Colors.blue, + _ItemType.book => Colors.teal, + _ItemType.game => Colors.orange, + }; + final typeLabel = switch (item.type) { + _ItemType.movie => '影视', + _ItemType.book => '书籍', + _ItemType.game => '游戏', + }; + + return InkWell( + onTap: () { + final page = switch (item.type) { + _ItemType.movie => MovieDetailPage(movie: item.original as Movie), + _ItemType.book => BookDetailPage(book: item.original as Book), + _ItemType.game => GameDetailPage(game: item.original as Game), + }; + Navigator.push(context, MaterialPageRoute(builder: (_) => page)); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 5), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 封面 + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 60, + height: 80, + child: item.coverPath != null && item.coverPath!.isNotEmpty + ? FadeInLocalImage( + path: item.coverPath, + fit: BoxFit.cover, + placeholder: _buildPlaceholder(item.type, colors), + errorWidget: _buildPlaceholder(item.type, colors), + ) + : _buildPlaceholder(item.type, colors), + ), + ), + const SizedBox(width: 12), + // 信息 + Expanded( + child: SizedBox( + height: 80, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + Text(item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: colors.onSurface)), + const SizedBox(height: 4), + // 副标题 + if (item.subtitle.isNotEmpty) + Text(item.subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + color: colors.onSurface.withValues(alpha: 0.45))), + const Spacer(), + // 底部:评分 + 类型标签 + 年份 + Row( + children: [ + if (item.rating != null) + AnimatedStarRating(rating: item.rating!, starSize: 11, showNumber: true) + else + Text('未评分', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.25))), + const Spacer(), + if (item.year != null) + Text('${item.year}', + style: TextStyle( + fontSize: 11, + color: colors.onSurface.withValues(alpha: 0.3))), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: typeColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text(typeLabel, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: typeColor)), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPlaceholder(_ItemType type, ColorScheme colors) { + final icon = switch (type) { + _ItemType.movie => Icons.movie_outlined, + _ItemType.book => Icons.menu_book_outlined, + _ItemType.game => Icons.sports_esports_outlined, + }; + return Container( + color: colors.surfaceContainerHighest, + child: Center( + child: Icon(icon, + size: 22, color: colors.onSurface.withValues(alpha: 0.25))), + ); + } +} diff --git a/lib/pages/explore/statistics_page.dart b/lib/pages/explore/statistics_page.dart index 26b2865..5d5b127 100644 --- a/lib/pages/explore/statistics_page.dart +++ b/lib/pages/explore/statistics_page.dart @@ -16,12 +16,17 @@ class StatisticsPage extends StatefulWidget { } class _StatisticsPageState extends State { - int _cloudTabIndex = 0; + int _statusTabIndex = 0; // 0: 影视, 1: 书籍 + int _top5TabIndex = 0; // 0: 导演, 1: 作者 + int _topRatedTabIndex = 0; // 0: 影视, 1: 书籍, 2: 游戏 + int _cloudTabIndex = 0; // 标签词云 tab + int _timeRange = 2; // 0: 周, 1: 月, 2: 年 + bool get _showMovies => UserPrefs().showMovieTab; bool get _showBooks => UserPrefs().showBookTab; bool get _showNotes => UserPrefs().showNoteTab; - // 缓存过滤后的列表,避免每次 build 都重新过滤 + // 缓存过滤后的列表 List? _cachedMovies; List? _cachedBooks; List? _cachedNotes; @@ -44,70 +49,145 @@ class _StatisticsPageState extends State { return (_filteredMovies!, _filteredBooks!, _filteredNotes!); } + /// 影视用观看日期优先,无则创建日期 + DateTime _movieDate(dynamic m) => (m as Movie).watchDate ?? m.createdAt; + /// 书籍用开始阅读日期优先,无则创建日期 + DateTime _bookDate(dynamic b) => (b as Book).startDate ?? b.createdAt; + /// 笔记用创建日期 + DateTime _noteDate(dynamic n) => (n as Note).createdAt; + + /// 按时间范围过滤 items + List _filterItemsByRange(List items, DateTime Function(T) getDate) { + final now = DateTime.now(); + switch (_timeRange) { + case 0: + final start = DateTime(now.year, now.month, now.day - 6); + return items.where((i) => !getDate(i).isBefore(start)).toList(); + case 1: + final start = DateTime(now.year, now.month, now.day - 29); + return items.where((i) => !getDate(i).isBefore(start)).toList(); + default: + return items; + } + } + + @override + void initState() { + super.initState(); + _timeRange = UserPrefs().statsTimeRange; + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final movies = context.select>((p) => p.movies); final books = context.select>((p) => p.books); final notes = context.select>((p) => p.notes); + final games = context.select>((p) => p.games); final (fm, fb, fn) = _getFilteredLists(movies, books, notes); + // 按时间范围过滤 + final rfm = _filterItemsByRange(fm, _movieDate); + final rfb = _filterItemsByRange(fb, _bookDate); + final rfn = _filterItemsByRange(fn, _noteDate); + final rfg = _filterItemsByRange(games.where((g) => !g.isDeleted).toList(), (g) => g.createdAt); + return Scaffold( backgroundColor: colors.surface, - appBar: AppBar(title: const Text('数据统计')), + appBar: AppBar( + title: const Text('数据统计'), + actions: [ + _buildTimeRangeSelector(colors), + ], + ), body: ListView( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), children: [ // 1. 总览 - _buildOverview(fm, fb, fn), - const SizedBox(height: 28), - // 2. 状态分布 - if (_showMovies) ...[ - _buildStatusSection('影视状态分布', fm, (m) => m.status, {'已看': 'watched', '在看': 'watching', '想看': 'want_to_watch'}), - const SizedBox(height: 28), - ], - if (_showBooks) ...[ - _buildStatusSection('阅读状态分布', fb, (b) => b.status, {'已读': 'read', '在读': 'reading', '想读': 'want_to_read'}), - const SizedBox(height: 28), - ], - // 3. 习惯洞察 - _buildHabitsInsight(fm, fb, fn), - const SizedBox(height: 28), - // 4. 类型偏好雷达图 - _buildGenreRadar(fm, fb), - const SizedBox(height: 28), - // 5. 导演/作者 TOP 5 - _buildDirectorTop5(fm), - const SizedBox(height: 28), - _buildAuthorTop5(fb), - const SizedBox(height: 28), - // 6. 高分之最 - _buildTopRated(fm, fb), - const SizedBox(height: 28), - // 7. 评分分布 - _buildRatingDistribution(fm, fb), - const SizedBox(height: 28), - // 8. 年度趋势 - _buildYearlyTrend(fm, fb, fn), - const SizedBox(height: 28), - // 9. 星期分布 - _buildWeekdayDistribution(fm, fb, fn), - const SizedBox(height: 28), - // 10. 累计增长 - _buildCumulativeGrowth(fm, fb, fn), - const SizedBox(height: 28), - // 11. 标签词云 - _buildTagCloud(fm, fb, fn), - const SizedBox(height: 28), - // 12+13. 马拉松 + 标签之最 - _buildFunStats(fm, fb, fn), - const SizedBox(height: 80), - ], - ), - ); + _buildOverview(rfm, rfb, rfn), + const SizedBox(height: 16), + // 2. 状态分布 + if (_showMovies || _showBooks) ...[ + _buildStatusSection(rfm, rfb), + const SizedBox(height: 16), + ], + // 3. 习惯洞察 + _buildHabitsInsight(rfm, rfb, rfn), + const SizedBox(height: 16), + // 4. 类型偏好雷达图 + _buildGenreRadar(rfm, rfb), + const SizedBox(height: 16), + // 5. 导演/作者 TOP 5 + if (_showMovies || _showBooks) ...[ + _buildTop5Section(rfm, rfb), + const SizedBox(height: 16), + ], + // 6. 高分之最 + _buildTopRated(rfm, rfb, rfg), + const SizedBox(height: 16), + // 7. 评分分布 + _buildRatingDistribution(rfm, rfb), + const SizedBox(height: 16), + // 8. 趋势图 + _buildTrendChart(rfm, rfb, rfn), + const SizedBox(height: 16), + // 9. 星期分布 + _buildWeekdayDistribution(rfm, rfb, rfn), + const SizedBox(height: 16), + // 10. 累计增长 + _buildCumulativeGrowth(rfm, rfb, rfn), + const SizedBox(height: 16), + // 11. 标签词云 + _buildTagCloud(rfm, rfb, rfn, rfg), + const SizedBox(height: 16), + // 12. 趣味统计 + _buildFunStats(rfm, rfb, rfn), + const SizedBox(height: 80), + ], + ), + ); } - // ─── 1. 总览区域 ────────────────────────────────────────────────────── + // ─── 时间范围选择器 ────────────────────────────────────────────── + + Widget _buildTimeRangeSelector(ColorScheme colors) { + const labels = ['周', '月', '年']; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Container( + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(3, (i) { + final selected = _timeRange == i; + return GestureDetector( + onTap: () { + setState(() => _timeRange = i); + UserPrefs().setStatsTimeRange(i); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: selected ? colors.primary : null, + borderRadius: BorderRadius.circular(6), + ), + child: Text(labels[i], style: TextStyle( + fontSize: 12, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5), + )), + ), + ); + }), + ), + ), + ); + } + + // ─── 1. 总览 ────────────────────────────────────────────────────── Widget _buildOverview(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; @@ -116,75 +196,139 @@ class _StatisticsPageState extends State { 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 thisPeriod = movies.length + books.length + notes.length; + + // 上期数据 + int lastPeriod; + if (_timeRange == 0) { + // 上周 + final start = DateTime(now.year, now.month, now.day - 13); + final end = DateTime(now.year, now.month, now.day - 7); + lastPeriod = _countInDateRange(movies, books, notes, start, end); + } else if (_timeRange == 1) { + // 上月 + final start = DateTime(now.year, now.month, now.day - 59); + final end = DateTime(now.year, now.month, now.day - 30); + lastPeriod = _countInDateRange(movies, books, notes, start, end); + } else { + // 去年同期 + final start = DateTime(now.year - 1, now.month, now.day); + final end = now; + lastPeriod = _countInDateRangeFull(movies, books, notes, start, end); + } + final diff = thisPeriod - lastPeriod; // 平均评分 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 allDates = [ + ...movies.map(_movieDate), + ...books.map(_bookDate), + ...notes.map(_noteDate), + ]; final daysTracked = allDates.isNotEmpty ? now.difference(allDates.reduce((a, b) => a.isBefore(b) ? a : b)).inDays + 1 : 0; - return Row( + final periodLabel = _timeRange == 0 ? '本周' : _timeRange == 1 ? '本月' : '本年'; + + return Column( 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), + Row( + children: [ + Expanded(child: _buildOverviewCard('完成率', completionRate == 0 ? '-' : '${(completionRate * 100).toStringAsFixed(0)}%', Icons.check_circle_outline, colors.primary, subtitle: completionRate > 0 ? '已看+已读' : null)), + const SizedBox(width: 10), + Expanded(child: _buildOverviewCard('$periodLabel新增', '$thisPeriod', Icons.trending_up, const Color(0xFF66BB6A), subtitle: diff >= 0 ? '↑$diff' : '↓${diff.abs()}')), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded(child: _buildOverviewCard('平均评分', avgRating > 0 ? avgRating.toStringAsFixed(1) : '-', Icons.star_outline, const Color(0xFFFFB800), subtitle: avgRating > 0 ? '/ 10' : null)), + const SizedBox(width: 10), + Expanded(child: _buildOverviewCard('记录天数', daysTracked > 0 ? '$daysTracked' : '-', Icons.calendar_today_outlined, const Color(0xFF7E57C2), subtitle: daysTracked > 0 ? '天' : null)), + ], + ), ], ); } + int _countInDateRange(List movies, List books, List notes, DateTime start, DateTime end) { + return movies.where((m) { final d = _movieDate(m); return !d.isBefore(start) && d.isBefore(end); }).length + + books.where((b) { final d = _bookDate(b); return !d.isBefore(start) && d.isBefore(end); }).length + + notes.where((n) { final d = _noteDate(n); return !d.isBefore(start) && d.isBefore(end); }).length; + } + + int _countInDateRangeFull(List movies, List books, List notes, DateTime start, DateTime end) { + return movies.where((m) { final d = _movieDate(m); return !d.isBefore(start) && d.isBefore(end); }).length + + books.where((b) { final d = _bookDate(b); return !d.isBefore(start) && d.isBefore(end); }).length + + notes.where((n) { final d = _noteDate(n); return !d.isBefore(start) && d.isBefore(end); }).length; + } + 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))), - ], - ), + return Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 14), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 18, color: color), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(height: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: color)), + if (subtitle != null) ...[ + const SizedBox(width: 3), + Text(subtitle, style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ], + ), + ], + ), + ), + ], ), ); } - // ─── 2. 状态分布 ──────────────────────────────────────────────────────── + // ─── 2. 状态分布 ────────────────────────────────────────────────── - Widget _buildStatusSection(String title, List items, String Function(dynamic) getStatus, Map labels) { + Widget _buildStatusSection(List movies, List books) { final colors = Theme.of(context).colorScheme; - final total = items.length; + final tabs = <(String, List, String Function(dynamic), Map)>[]; + if (_showMovies) tabs.add(('影视', movies, (m) => m.status, {'已看': 'watched', '在看': 'watching', '想看': 'want_to_watch'})); + if (_showBooks) tabs.add(('书籍', books, (b) => b.status, {'已读': 'read', '在读': 'reading', '想读': 'want_to_read'})); + if (tabs.isEmpty) return const SizedBox.shrink(); + if (_statusTabIndex >= tabs.length) _statusTabIndex = 0; + + final tab = tabs[_statusTabIndex]; + final total = tab.$2.length; return _buildCard( - title: title, + title: '状态分布', + action: tabs.length > 1 ? _buildTabChips(tabs.map((e) => e.$1).toList(), _statusTabIndex, (i) => setState(() => _statusTabIndex = i), colors) : null, child: Column( - children: labels.entries.map((e) { - final count = items.where((i) => getStatus(i) == e.value).length; + children: tab.$4.entries.map((e) { + final count = tab.$2.where((i) => tab.$3(i) == e.value).length; final pct = total > 0 ? count / total : 0.0; return Padding( padding: const EdgeInsets.only(bottom: 14), @@ -213,14 +357,14 @@ class _StatisticsPageState extends State { ); } - // ─── 3. 习惯洞察 ──────────────────────────────────────────────────────── + // ─── 3. 习惯洞察 ────────────────────────────────────────────────── Widget _buildHabitsInsight(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), + ...movies.map(_movieDate), + ...books.map(_bookDate), + ...notes.map(_noteDate), ]..sort(); if (allDates.isEmpty) return const SizedBox.shrink(); @@ -237,9 +381,9 @@ class _StatisticsPageState extends State { 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 watchedDates = movies.where((m) => m.status == 'watched').map(_movieDate).toList()..sort(); + final readDates = books.where((b) => b.status == 'read').map(_bookDate).toList()..sort(); final watchedAvgGap = _calcAvgGap(watchedDates); final readAvgGap = _calcAvgGap(readDates); @@ -250,13 +394,14 @@ class _StatisticsPageState extends State { _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) + if (watchedAvgGap > 0) ...[ + Divider(height: 1, color: colors.outlineVariant), + _buildInsightRow(Icons.movie_outlined, '观影节奏', '平均 ${watchedAvgGap.toStringAsFixed(0)} 天一部'), + ], + if (readAvgGap > 0) ...[ Divider(height: 1, color: colors.outlineVariant), - if (readAvgGap > 0) _buildInsightRow(Icons.menu_book_outlined, '阅读节奏', '平均 ${readAvgGap.toStringAsFixed(0)} 天一本'), + ], ], ), ); @@ -288,7 +433,7 @@ class _StatisticsPageState extends State { ); } - // ─── 4. 类型偏好雷达图 ────────────────────────────────────────────────── + // ─── 4. 类型偏好雷达图 ────────────────────────────────────────── Widget _buildGenreRadar(List movies, List books) { final colors = Theme.of(context).colorScheme; @@ -347,16 +492,10 @@ class _StatisticsPageState extends State { 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))), + _buildLegend(const Color(0xFF4A90D9), '影视'), 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))), - ], + if (bookGenres.isNotEmpty) _buildLegend(const Color(0xFF7E57C2), '书籍'), ], ), ], @@ -364,36 +503,53 @@ class _StatisticsPageState extends State { ); } - // ─── 5. 导演/作者 TOP 5 ──────────────────────────────────────────────── + // ─── 5. 导演/作者 TOP 5 ────────────────────────────────────────── - Widget _buildDirectorTop5(List movies) { + Widget _buildTop5Section(List movies, List books) { 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(); + final tabs = <(String, List<(String, int, Color)>)>[]; + + if (_showMovies) { + 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)); + tabs.add(('导演', sorted.take(5).map((e) => (e.key, e.value, const Color(0xFF4A90D9))).toList())); + } + if (_showBooks) { + 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)); + tabs.add(('作者', sorted.take(5).map((e) => (e.key, e.value, const Color(0xFF7E57C2))).toList())); + } + if (tabs.isEmpty) return const SizedBox.shrink(); + if (_top5TabIndex >= tabs.length) _top5TabIndex = 0; + + final tab = tabs[_top5TabIndex]; + final items = tab.$2; + if (items.isEmpty) return const SizedBox.shrink(); + final maxVal = items.first.$2.toDouble(); + final unit = tab.$1 == '导演' ? '部' : '本'; return _buildCard( - title: '导演 TOP 5', + title: '${tab.$1} TOP 5', + action: tabs.length > 1 ? _buildTabChips(tabs.map((e) => e.$1).toList(), _top5TabIndex, (i) => setState(() => _top5TabIndex = i), colors) : null, child: Column( - children: top5.map((e) { - final ratio = e.value / maxVal; + children: items.map((e) { + final ratio = e.$2 / 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.7)), overflow: TextOverflow.ellipsis)), + SizedBox(width: 60, child: Text(e.$1, 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: ratio, backgroundColor: colors.outlineVariant, color: const Color(0xFF4A90D9), minHeight: 4), + child: LinearProgressIndicator(value: ratio, backgroundColor: colors.outlineVariant, color: e.$3, minHeight: 4), ), ), const SizedBox(width: 8), - Text('${e.value}部', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)), + Text('${e.$2}$unit', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)), ], ), ); @@ -402,70 +558,38 @@ class _StatisticsPageState extends State { ); } - Widget _buildAuthorTop5(List books) { + // ─── 6. 高分之最 ────────────────────────────────────────────────── + + Widget _buildTopRated(List movies, List books, List games) { final colors = Theme.of(context).colorScheme; - 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(); + final tabs = <(String, List<(String, double, String?)>)>[]; - 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.7)), overflow: TextOverflow.ellipsis)), - const SizedBox(width: 10), - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(2), - 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)), - ], - ), - ); - }).toList(), - ), - ); - } - - // ─── 6. 高分之最 TOP 5 ───────────────────────────────────────────────── - - 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!)); + if (_showMovies) tabs.add(('影视', ratedMovies.take(5).map((m) => (m.title, m.rating!, m.posterPath)).toList())); + final ratedBooks = books.where((b) => b.rating != null && b.rating! > 0).toList() ..sort((a, b) => b.rating!.compareTo(a.rating!)); + if (_showBooks) tabs.add(('书籍', ratedBooks.take(5).map((b) => (b.title, b.rating!, b.coverPath)).toList())); - if (ratedMovies.isEmpty && ratedBooks.isEmpty) return const SizedBox.shrink(); + final ratedGames = games.where((g) => g.rating != null && g.rating! > 0).toList() + ..sort((a, b) => b.rating!.compareTo(a.rating!)); + if (UserPrefs().showGameTab) tabs.add(('游戏', ratedGames.take(5).map((g) => (g.title, g.rating!, g.coverPath)).toList())); + + if (tabs.isEmpty) return const SizedBox.shrink(); + if (_topRatedTabIndex >= tabs.length) _topRatedTabIndex = 0; 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)), - ], - ], - ), + action: _buildTabChips(tabs.map((e) => e.$1).toList(), _topRatedTabIndex, (i) => setState(() => _topRatedTabIndex = i), colors), + child: tabs[_topRatedTabIndex].$2.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Center(child: Text('暂无评分记录', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)))), + ) + : Column( + children: tabs[_topRatedTabIndex].$2.map((item) => _buildTopRatedItem(item.$1, item.$2, item.$3, colors)).toList(), + ), ); } @@ -492,7 +616,7 @@ class _StatisticsPageState extends State { ); } - // ─── 7. 评分分布 ──────────────────────────────────────────────────────── + // ─── 7. 评分分布 ────────────────────────────────────────────────── Widget _buildRatingDistribution(List movies, List books) { final colors = Theme.of(context).colorScheme; @@ -576,32 +700,87 @@ class _StatisticsPageState extends State { ); } - // ─── 8. 年度趋势折线图 ────────────────────────────────────────────────── + // ─── 8. 趋势图(周/月/年自适应)────────────────────────────────── - Widget _buildYearlyTrend(List movies, List books, List notes) { + Widget _buildTrendChart(List movies, List books, List notes) { final colors = Theme.of(context).colorScheme; final now = DateTime.now(); - final months = List.generate(12, (i) { - final d = DateTime(now.year, now.month - (11 - i), 1); - return '${d.month}月'; - }); - List countByMonth(List items) { - return List.generate(12, (i) { + // 根据时间范围生成不同的数据点和标签 + late List xLabels; + late int pointCount; + late List movieData, bookData, noteData; + + if (_timeRange == 0) { + // 周:7天 + pointCount = 7; + xLabels = List.generate(7, (i) { + final d = DateTime(now.year, now.month, now.day - (6 - i)); + return '${d.month}/${d.day}'; + }); + movieData = List.generate(7, (i) { + final d = DateTime(now.year, now.month, now.day - (6 - i)); + return movies.where((m) { final dt = _movieDate(m); return dt.year == d.year && dt.month == d.month && dt.day == d.day; }).length; + }); + bookData = List.generate(7, (i) { + final d = DateTime(now.year, now.month, now.day - (6 - i)); + return books.where((b) { final dt = _bookDate(b); return dt.year == d.year && dt.month == d.month && dt.day == d.day; }).length; + }); + noteData = List.generate(7, (i) { + final d = DateTime(now.year, now.month, now.day - (6 - i)); + return notes.where((n) { final dt = _noteDate(n); return dt.year == d.year && dt.month == d.month && dt.day == d.day; }).length; + }); + } else if (_timeRange == 1) { + // 月:最近30天,按5天一组 + pointCount = 6; + xLabels = List.generate(6, (i) { + final d = DateTime(now.year, now.month, now.day - (25 - i * 5)); + return '${d.month}/${d.day}'; + }); + movieData = List.generate(6, (i) { + final start = DateTime(now.year, now.month, now.day - (29 - i * 5)); + final end = DateTime(now.year, now.month, now.day - (29 - (i + 1) * 5)); + return movies.where((m) { final dt = _movieDate(m); return !dt.isBefore(start) && dt.isBefore(end); }).length; + }); + bookData = List.generate(6, (i) { + final start = DateTime(now.year, now.month, now.day - (29 - i * 5)); + final end = DateTime(now.year, now.month, now.day - (29 - (i + 1) * 5)); + return books.where((b) { final dt = _bookDate(b); return !dt.isBefore(start) && dt.isBefore(end); }).length; + }); + noteData = List.generate(6, (i) { + final start = DateTime(now.year, now.month, now.day - (29 - i * 5)); + final end = DateTime(now.year, now.month, now.day - (29 - (i + 1) * 5)); + return notes.where((n) { final dt = _noteDate(n); return !dt.isBefore(start) && dt.isBefore(end); }).length; + }); + } else { + // 年:12个月 + pointCount = 12; + xLabels = 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; + return '${d.month}月'; + }); + movieData = List.generate(12, (i) { + final d = DateTime(now.year, now.month - (11 - i), 1); + return movies.where((m) { final dt = _movieDate(m); return dt.year == d.year && dt.month == d.month; }).length; + }); + bookData = List.generate(12, (i) { + final d = DateTime(now.year, now.month - (11 - i), 1); + return books.where((b) { final dt = _bookDate(b); return dt.year == d.year && dt.month == d.month; }).length; + }); + noteData = List.generate(12, (i) { + final d = DateTime(now.year, now.month - (11 - i), 1); + return notes.where((n) { final dt = _noteDate(n); return dt.year == d.year && dt.month == d.month; }).length; }); } - 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 title = _timeRange == 0 ? '周趋势' : _timeRange == 1 ? '月趋势' : '年度趋势'; + return _buildCard( - title: '年度趋势', + title: title, child: Column( children: [ SizedBox( @@ -611,20 +790,20 @@ class _StatisticsPageState extends State { minY: 0, maxY: (safeMax * 1.3).toDouble(), lineBarsData: [ - _buildLineData(movieData, const Color(0xFF4A90D9)), - _buildLineData(bookData, const Color(0xFF7E57C2)), - _buildLineData(noteData, const Color(0xFF66BB6A)), + if (_showMovies) _buildLineData(movieData, const Color(0xFF4A90D9)), + if (_showBooks) _buildLineData(bookData, const Color(0xFF7E57C2)), + if (_showNotes) _buildLineData(noteData, const Color(0xFF66BB6A)), ], titlesData: FlTitlesData( bottomTitles: AxisTitles(sideTitles: SideTitles( showTitles: true, - interval: 2, + interval: pointCount > 7 ? 2 : 1, getTitlesWidget: (value, meta) { final idx = value.toInt(); - if (idx < 0 || idx >= months.length) return const SizedBox.shrink(); + if (idx < 0 || idx >= xLabels.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))), + child: Text(xLabels[idx], style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), ); }, reservedSize: 24, @@ -648,9 +827,13 @@ class _StatisticsPageState extends State { touchTooltipData: LineTouchTooltipData( getTooltipColor: (_) => colors.inverseSurface, getTooltipItems: (spots) => spots.map((s) { - final labels = ['影视', '书籍', '笔记']; + final labels = []; + if (_showMovies) labels.add('影视'); + if (_showBooks) labels.add('书籍'); + if (_showNotes) labels.add('笔记'); + if (s.barIndex >= labels.length) return null; return LineTooltipItem('${labels[s.barIndex]} ${s.y.toInt()}', TextStyle(color: colors.onInverseSurface, fontSize: 12, fontWeight: FontWeight.w600)); - }).toList(), + }).whereType().toList(), ), ), ), @@ -660,11 +843,15 @@ class _StatisticsPageState extends State { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildLegend(const Color(0xFF4A90D9), '影视'), - const SizedBox(width: 16), - _buildLegend(const Color(0xFF7E57C2), '书籍'), - const SizedBox(width: 16), - _buildLegend(const Color(0xFF66BB6A), '笔记'), + if (_showMovies) ...[ + _buildLegend(const Color(0xFF4A90D9), '影视'), + const SizedBox(width: 16), + ], + if (_showBooks) ...[ + _buildLegend(const Color(0xFF7E57C2), '书籍'), + const SizedBox(width: 16), + ], + if (_showNotes) _buildLegend(const Color(0xFF66BB6A), '笔记'), ], ), ], @@ -674,7 +861,7 @@ class _StatisticsPageState extends State { LineChartBarData _buildLineData(List data, Color color) { return LineChartBarData( - spots: List.generate(12, (i) => FlSpot(i.toDouble(), data[i].toDouble())), + spots: List.generate(data.length, (i) => FlSpot(i.toDouble(), data[i].toDouble())), isCurved: true, color: color, barWidth: 2, @@ -695,14 +882,14 @@ class _StatisticsPageState extends State { ); } - // ─── 9. 星期分布 ──────────────────────────────────────────────────────── + // ─── 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), + ...movies.map(_movieDate), + ...books.map(_bookDate), + ...notes.map(_noteDate), ]; if (allDates.isEmpty) return const SizedBox.shrink(); @@ -750,26 +937,68 @@ class _StatisticsPageState extends State { ); } - // ─── 10. 累计增长曲线 ────────────────────────────────────────────────── + // ─── 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)]; + final allItems = [ + ...movies.map(_movieDate), + ...books.map(_bookDate), + ...notes.map(_noteDate), + ]; 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; + late int pointCount; + late List xLabels; + late List cumulativeData; + + if (_timeRange == 0) { + // 周:7天累计 + pointCount = 7; + xLabels = List.generate(7, (i) { + final d = DateTime(now.year, now.month, now.day - (6 - i)); + return '${d.month}/${d.day}'; + }); + int cumulative = 0; + cumulativeData = List.generate(7, (i) { + final d = DateTime(now.year, now.month, now.day - (6 - i)); + final nextD = DateTime(d.year, d.month, d.day + 1); + cumulative += allItems.where((date) => !date.isBefore(d) && date.isBefore(nextD)).length; + return cumulative; + }); + } else if (_timeRange == 1) { + // 月:30天,按5天一组 + pointCount = 6; + xLabels = List.generate(6, (i) { + final d = DateTime(now.year, now.month, now.day - (25 - i * 5)); + return '${d.month}/${d.day}'; + }); + int cumulative = 0; + cumulativeData = List.generate(6, (i) { + final start = DateTime(now.year, now.month, now.day - (29 - i * 5)); + final end = DateTime(now.year, now.month, now.day - (29 - (i + 1) * 5)); + cumulative += allItems.where((date) => !date.isBefore(start) && date.isBefore(end)).length; + return cumulative; + }); + } else { + // 年:12个月累计 + pointCount = 12; + xLabels = List.generate(12, (i) { + final d = DateTime(now.year, now.month - (11 - i), 1); + return '${d.month}月'; + }); + int cumulative = 0; + cumulativeData = List.generate(12, (i) { + final d = DateTime(now.year, now.month - (11 - i), 1); + final nextMonth = DateTime(d.year, d.month + 1, 1); + cumulative += allItems.where((date) => !date.isBefore(d) && date.isBefore(nextMonth)).length; + return cumulative; + }); } - final maxVal = cumulative.toDouble(); + + final maxVal = cumulativeData.last.toDouble(); if (maxVal == 0) return const SizedBox.shrink(); return _buildCard( @@ -782,7 +1011,7 @@ class _StatisticsPageState extends State { maxY: maxVal * 1.2, lineBarsData: [ LineChartBarData( - spots: List.generate(12, (i) => FlSpot(i.toDouble(), (monthlyCumulative[i] ?? 0).toDouble())), + spots: List.generate(pointCount, (i) => FlSpot(i.toDouble(), cumulativeData[i].toDouble())), isCurved: true, color: colors.primary, barWidth: 2.5, @@ -793,12 +1022,13 @@ class _StatisticsPageState extends State { titlesData: FlTitlesData( bottomTitles: AxisTitles(sideTitles: SideTitles( showTitles: true, - interval: 2, + interval: pointCount > 7 ? 2 : 1, getTitlesWidget: (value, meta) { - final d = DateTime(now.year, now.month - (11 - value.toInt()), 1); + final idx = value.toInt(); + if (idx < 0 || idx >= xLabels.length) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(top: 6), - child: Text('${d.month}月', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), + child: Text(xLabels[idx], style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), ); }, reservedSize: 24, @@ -824,14 +1054,15 @@ class _StatisticsPageState extends State { ); } - // ─── 11. 标签词云 ────────────────────────────────────────────────────── + // ─── 11. 标签词云 ────────────────────────────────────────────────── - Widget _buildTagCloud(List movies, List books, List notes) { + Widget _buildTagCloud(List movies, List books, List notes, List games) { final colors = Theme.of(context).colorScheme; final tabs = []; if (_showMovies) tabs.add('影视'); if (_showBooks) tabs.add('书籍'); if (_showNotes) tabs.add('笔记'); + if (UserPrefs().showGameTab) tabs.add('游戏'); if (tabs.isEmpty) return const SizedBox.shrink(); if (_cloudTabIndex >= tabs.length) _cloudTabIndex = 0; @@ -839,20 +1070,15 @@ class _StatisticsPageState extends State { switch (tabs[_cloudTabIndex]) { case '影视': for (final m in movies) { for (final g in m.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } } - break; case '书籍': for (final b in books) { for (final g in b.genres) { tagCounts[g] = (tagCounts[g] ?? 0) + 1; } } - break; case '笔记': for (final n in notes) { for (final t in n.tags) { tagCounts[t] = (tagCounts[t] ?? 0) + 1; } } - break; + case '游戏': + for (final g in games) { for (final genre in g.genres) { tagCounts[genre] = (tagCounts[genre] ?? 0) + 1; } } } 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); const cloudColors = [ Color(0xFFE53935), Color(0xFF4A90D9), Color(0xFF7E57C2), @@ -862,54 +1088,44 @@ class _StatisticsPageState extends State { return _buildCard( title: '标签词云', - child: Column(children: [ - Row( - children: tabs.map((t) { - final i = tabs.indexOf(t); - final selected = _cloudTabIndex == i; - return Expanded( - child: GestureDetector( - onTap: () => setState(() => _cloudTabIndex = i), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8), - margin: EdgeInsets.only(right: i < tabs.length - 1 ? 6 : 0), - decoration: BoxDecoration( - color: selected ? colors.primary : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Text(t, textAlign: TextAlign.center, style: TextStyle(fontSize: 13, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), - ), - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - Wrap( - spacing: 6, - runSpacing: 6, - children: sorted.map((e) { - final ratio = (e.value - minCount) / range; - final fontSize = (12.0 + ratio * 18.0).roundToDouble(); - final c = cloudColors[((ratio * (cloudColors.length - 1)).round()).clamp(0, cloudColors.length - 1)]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1), - child: Text(e.key, style: TextStyle(fontSize: fontSize, fontWeight: fontSize > 18 ? FontWeight.w700 : FontWeight.w500, color: c, height: 1.3)), - ); - }).toList(), - ), - ]), + action: _buildTabChips(tabs, _cloudTabIndex, (i) => setState(() => _cloudTabIndex = i), colors), + child: sorted.isEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Center(child: Text('暂无标签', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.3)))), + ) + : Wrap( + spacing: 6, + runSpacing: 6, + children: sorted.map((e) { + final maxCount = sorted.first.value; + final minCount = sorted.last.value; + final range = math.max(maxCount - minCount, 1); + final ratio = (e.value - minCount) / range; + final fontSize = (12.0 + ratio * 18.0).roundToDouble(); + final c = cloudColors[((ratio * (cloudColors.length - 1)).round()).clamp(0, cloudColors.length - 1)]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1), + child: Text(e.key, style: TextStyle(fontSize: fontSize, fontWeight: fontSize > 18 ? FontWeight.w700 : FontWeight.w500, color: c, height: 1.3)), + ); + }).toList(), + ), ); } - // ─── 12+13. 趣味统计 ────────────────────────────────────────────────── + // ─── 12. 趣味统计 ────────────────────────────────────────────────── 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 allDates = [ + ...movies.map(_movieDate), + ...books.map(_bookDate), + ...notes.map(_noteDate), + ]; + if (allDates.isEmpty) return const SizedBox.shrink(); - // 观影马拉松 - final sortedDates = allItems.map((d) => DateTime(d.year, d.month, d.day)).toSet().toList()..sort(); + // 连续记录 + final sortedDates = allDates.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) { @@ -920,7 +1136,7 @@ class _StatisticsPageState extends State { } } - // 标签之最 + // 最常用标签 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; } } @@ -964,9 +1180,9 @@ class _StatisticsPageState extends State { ); } - // ─── 通用卡片 ──────────────────────────────────────────────────────── + // ─── 通用组件 ────────────────────────────────────────────────────── - Widget _buildCard({required String title, required Widget child}) { + Widget _buildCard({required String title, required Widget child, Widget? action}) { final colors = Theme.of(context).colorScheme; return Container( padding: const EdgeInsets.all(18), @@ -982,6 +1198,8 @@ class _StatisticsPageState extends State { Container(width: 3, height: 14, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(2))), const SizedBox(width: 8), Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)), + const Spacer(), + if (action != null) action, ], ), const SizedBox(height: 16), @@ -990,4 +1208,26 @@ class _StatisticsPageState extends State { ), ); } + + /// 通用 tab 切换 chips + Widget _buildTabChips(List labels, int selectedIndex, ValueChanged onTap, ColorScheme colors) { + return Row( + mainAxisSize: MainAxisSize.min, + children: labels.asMap().entries.map((e) { + final selected = selectedIndex == e.key; + return GestureDetector( + onTap: () => onTap(e.key), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + margin: EdgeInsets.only(left: e.key > 0 ? 6 : 0), + decoration: BoxDecoration( + color: selected ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Text(e.value, style: TextStyle(fontSize: 12, fontWeight: selected ? FontWeight.w600 : FontWeight.w500, color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), + ), + ); + }).toList(), + ); + } } diff --git a/lib/pages/game/game_detail_page.dart b/lib/pages/game/game_detail_page.dart index e132df4..4f751f0 100644 --- a/lib/pages/game/game_detail_page.dart +++ b/lib/pages/game/game_detail_page.dart @@ -53,11 +53,13 @@ class _GameDetailPageState extends State { List _editPlatforms = []; List _editVersions = []; List _editGenres = []; + List _editDeveloper = []; List _editPurchasePlatforms = []; String? _editCoverPath; String _editStatus = 'want_to_play'; String _editCategory = 'digital'; DateTime? _editPurchaseDate; + DateTime? _editReleaseDate; bool _editIsDownloading = false; final ImagePicker _picker = ImagePicker(); @@ -81,11 +83,13 @@ class _GameDetailPageState extends State { _editPlatforms = List.from(g.platforms); _editVersions = List.from(g.versions); _editGenres = List.from(g.genres); + _editDeveloper = List.from(g.developer); _editPurchasePlatforms = List.from(g.purchasePlatforms); _editCoverPath = g.coverPath; _editStatus = g.status; _editCategory = g.category; _editPurchaseDate = g.purchaseDate; + _editReleaseDate = g.releaseDate; } void _enterEditMode() { @@ -101,11 +105,13 @@ class _GameDetailPageState extends State { _editPlatforms = List.from(latest.platforms); _editVersions = List.from(latest.versions); _editGenres = List.from(latest.genres); + _editDeveloper = List.from(latest.developer); _editPurchasePlatforms = List.from(latest.purchasePlatforms); _editCoverPath = latest.coverPath; _editStatus = latest.status; _editCategory = latest.category; _editPurchaseDate = latest.purchaseDate; + _editReleaseDate = latest.releaseDate; setState(() => _isEditing = true); } @@ -252,8 +258,14 @@ class _GameDetailPageState extends State { )), ]), ], + if (game.developer.isNotEmpty) + _buildDesktopInfoRow('开发者', game.developer.join('、'), colors), + if (game.releaseDate != null) + _buildDesktopInfoRow('发售时间', _formatDate(game.releaseDate!), colors), if (game.playTimeHours > 0 || game.playTimeMinutes > 0) _buildDesktopInfoRow('游玩时长', '${game.playTimeHours}小时${game.playTimeMinutes}分钟', colors), + if (game.playCount > 0) + _buildDesktopInfoRow('游玩次数', '${game.playCount} 次', colors), if (game.purchasePlatforms.isNotEmpty) _buildDesktopInfoRow('购买平台', game.purchasePlatforms.join('、'), colors), if (game.purchaseDate != null) @@ -556,6 +568,21 @@ class _GameDetailPageState extends State { if (result != null) setState(() => _editGenres = result); }), const SizedBox(height: 16), + // 开发者 + _buildEditChipField('开发者', _editDeveloper, onTap: () async { + final provider = context.read(); + final data = provider.games.map((g) => g.developer).toList(); + final result = await GenreSelectorPage.show( + context: context, title: '选择开发者', + existingTagsFuture: compute(_collectUnique, data), + initialSelected: _editDeveloper, hint: '如:任天堂、FromSoftware', + ); + if (result != null) setState(() => _editDeveloper = result); + }), + const SizedBox(height: 16), + // 发售时间 + _buildEditDateField('发售时间', _editReleaseDate, (d) => setState(() => _editReleaseDate = d), clearable: true), + const SizedBox(height: 16), // 购买平台 _buildEditChipField('购买平台', _editPurchasePlatforms, onTap: () async { final provider = context.read(); @@ -889,6 +916,7 @@ class _GameDetailPageState extends State { platforms: _editPlatforms, versions: _editVersions, genres: _editGenres, + developer: _editDeveloper, purchasePlatforms: _editPurchasePlatforms, purchasePrice: _purchasePriceCtrl.text.trim().isEmpty ? null : _purchasePriceCtrl.text.trim(), playTimeHours: int.tryParse(_playTimeHoursCtrl.text) ?? 0, @@ -898,6 +926,7 @@ class _GameDetailPageState extends State { status: _editStatus, category: _editCategory, purchaseDate: _editPurchaseDate, + releaseDate: _editReleaseDate, updatedAt: DateTime.now(), ); await context.read().updateGame(updated); @@ -954,8 +983,14 @@ class _GameDetailPageState extends State { _buildInfoSection('版本', game.versions.join('、')), if (game.genres.isNotEmpty) _buildGenresSection(game), + if (game.developer.isNotEmpty) + _buildInfoSection('开发者', game.developer.join('、')), + if (game.releaseDate != null) + _buildInfoSection('发售时间', _formatDate(game.releaseDate!)), if (game.playTimeHours > 0 || game.playTimeMinutes > 0) _buildInfoSection('游玩时长', '${game.playTimeHours}小时${game.playTimeMinutes}分钟'), + if (game.playCount > 0) + _buildInfoSection('游玩次数', '${game.playCount} 次'), if (game.purchasePlatforms.isNotEmpty) _buildInfoSection('购买平台', game.purchasePlatforms.join('、')), if (game.purchaseDate != null) @@ -1097,8 +1132,14 @@ class _GameDetailPageState extends State { const SizedBox(height: 12), _buildOverlayGenres(game), ], + if (game.developer.isNotEmpty) + _buildOverlayInfoRow('开发者', game.developer.join('、')), + if (game.releaseDate != null) + _buildOverlayInfoRow('发售时间', _formatDate(game.releaseDate!)), if (game.playTimeHours > 0 || game.playTimeMinutes > 0) _buildOverlayInfoRow('游玩时长', '${game.playTimeHours}小时${game.playTimeMinutes}分钟'), + if (game.playCount > 0) + _buildOverlayInfoRow('游玩次数', '${game.playCount} 次'), if (game.purchasePlatforms.isNotEmpty) _buildOverlayInfoRow('购买平台', game.purchasePlatforms.join('、')), if (game.purchaseDate != null) diff --git a/lib/pages/game/game_form_page.dart b/lib/pages/game/game_form_page.dart index ed1397c..eace13a 100644 --- a/lib/pages/game/game_form_page.dart +++ b/lib/pages/game/game_form_page.dart @@ -41,17 +41,20 @@ class _GameFormPageState extends State { late TextEditingController _ratingController; late TextEditingController _playTimeHoursController; late TextEditingController _playTimeMinutesController; + int _playCount = 0; late TextEditingController _purchasePriceController; late TextEditingController _summaryController; List _platforms = []; List _versions = []; List _genres = []; + List _developer = []; List _purchasePlatforms = []; String? _coverPath; String _status = 'want_to_play'; String _category = 'digital'; DateTime? _purchaseDate; + DateTime? _releaseDate; bool _isDownloading = false; @override @@ -83,11 +86,14 @@ class _GameFormPageState extends State { _platforms = List.from(game.platforms); _versions = List.from(game.versions); _genres = List.from(game.genres); + _developer = List.from(game.developer); _purchasePlatforms = List.from(game.purchasePlatforms); _coverPath = game.coverPath; _status = game.status; _category = game.category; _purchaseDate = game.purchaseDate; + _releaseDate = game.releaseDate; + _playCount = game.playCount; } else if (widget.initialStatus != null) { _status = widget.initialStatus!; } @@ -278,6 +284,51 @@ class _GameFormPageState extends State { }, ), ), + // 开发者 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '开发者', + value: _developer.isEmpty + ? '' + : '${_developer.length}个:${_developer.join('、')}', + icon: Icons.code_outlined, + scrollHorizontal: true, + onTap: () async { + final provider = context.read(); + final data = provider.games.map((g) => g.developer).toList(); + final result = await GenreSelectorPage.show( + context: context, + title: '选择开发者', + existingTagsFuture: compute(_collectUnique, data), + initialSelected: _developer, + hint: '如:任天堂、FromSoftware', + ); + if (!mounted) return; + if (result != null) setState(() => _developer = result); + }, + ), + ), + // 发售时间 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '发售时间', + value: _releaseDate != null + ? '${_releaseDate!.year}.${_releaseDate!.month.toString().padLeft(2, '0')}.${_releaseDate!.day.toString().padLeft(2, '0')}' + : '', + icon: Icons.event_outlined, + trailing: _releaseDate != null + ? GestureDetector( + onTap: () => setState(() => _releaseDate = null), + child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), + ) + : null, + onTap: () => _selectReleaseDate(), + ), + ), // 游玩时长 SizedBox( width: (MediaQuery.of(context).size.width - 52) / 2, @@ -289,6 +340,19 @@ class _GameFormPageState extends State { onTap: () => _showPlayTimePicker(), ), ), + + // 游玩次数 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '游玩次数', + value: _playCount > 0 ? '$_playCount 次' : '', + icon: Icons.repeat_outlined, + onTap: () => _editPlayCount(), + ), + ), + // 购买平台 SizedBox( width: (MediaQuery.of(context).size.width - 52) / 2, @@ -943,6 +1007,30 @@ class _GameFormPageState extends State { } } + Future _editPlayCount() async { + final controller = TextEditingController(text: _playCount > 0 ? '$_playCount' : ''); + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('游玩次数'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration(hintText: '输入次数'), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(ctx, controller.text), child: const Text('确定')), + ], + ), + ); + if (result != null) { + final val = int.tryParse(result) ?? 0; + setState(() => _playCount = val < 0 ? 0 : val); + } + } + void _showPlayTimePicker() { final colors = Theme.of(context).colorScheme; final hoursController = TextEditingController(text: _playTimeHoursController.text); @@ -1025,14 +1113,28 @@ class _GameFormPageState extends State { } } + Future _selectReleaseDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _releaseDate ?? DateTime.now(), + firstDate: DateTime(1970), + lastDate: DateTime.now().add(const Duration(days: 365 * 5)), + builder: (context, child) => child!, + ); + if (!mounted) return; + if (picked != null) { + setState(() => _releaseDate = picked); + } + } + bool _hasContent() { if (widget.game != null) return true; if (_titleController.text.trim().isNotEmpty) return true; if (_ratingController.text.trim().isNotEmpty) return true; if (_coverPath != null) return true; - if (_platforms.isNotEmpty || _versions.isNotEmpty || _genres.isNotEmpty) return true; + if (_platforms.isNotEmpty || _versions.isNotEmpty || _genres.isNotEmpty || _developer.isNotEmpty) return true; if (_purchasePlatforms.isNotEmpty) return true; - if (_purchaseDate != null) return true; + if (_purchaseDate != null || _releaseDate != null) return true; if (_purchasePriceController.text.trim().isNotEmpty) return true; if (_summaryController.text.trim().isNotEmpty) return true; if (int.tryParse(_playTimeHoursController.text) != null && int.parse(_playTimeHoursController.text) > 0) return true; @@ -1100,10 +1202,13 @@ class _GameFormPageState extends State { platforms: _platforms, versions: _versions, genres: _genres, + developer: _developer, playTimeHours: playTimeHours, playTimeMinutes: playTimeMinutes, + playCount: _playCount, purchasePlatforms: _purchasePlatforms, purchaseDate: _purchaseDate, + releaseDate: _releaseDate, purchasePrice: _purchasePriceController.text.trim().isNotEmpty ? _purchasePriceController.text.trim() : null, @@ -1126,10 +1231,13 @@ class _GameFormPageState extends State { platforms: _platforms, versions: _versions, genres: _genres, + developer: _developer, playTimeHours: playTimeHours, playTimeMinutes: playTimeMinutes, + playCount: _playCount, purchasePlatforms: _purchasePlatforms, purchaseDate: _purchaseDate, + releaseDate: _releaseDate, purchasePrice: _purchasePriceController.text.trim().isNotEmpty ? _purchasePriceController.text.trim() : null, diff --git a/lib/pages/game/game_tab_page.dart b/lib/pages/game/game_tab_page.dart index b7a8256..5eb6c17 100644 --- a/lib/pages/game/game_tab_page.dart +++ b/lib/pages/game/game_tab_page.dart @@ -35,6 +35,7 @@ class _GameTabPageState extends State { int _lastEditRefreshCounter = 0; int _prevGameCount = -1; int _prevLayoutStyle = -1; + int _prevSortMode = -1; double _swipeOffset = 0.0; static const _statusMap = {0: 'completed', 1: 'playing', 2: 'want_to_play', 3: 'abandoned'}; @@ -73,6 +74,7 @@ class _GameTabPageState extends State { final statusChanged = provider.gameStatusIndex != _lastStatusIndex; final layoutChanged = provider.gameLayoutStyle != _prevLayoutStyle; final countChanged = provider.games.length != _prevGameCount; + final sortModeChanged = UserPrefs().gameSortMode != _prevSortMode; final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter; if (editRefreshed && provider.lastEditedItemId != null) { _lastEditRefreshCounter = provider.editRefreshCounter; @@ -97,8 +99,9 @@ class _GameTabPageState extends State { } return; } - if (statusChanged || layoutChanged || countChanged || editRefreshed) { + if (statusChanged || layoutChanged || sortModeChanged || countChanged || editRefreshed) { _prevLayoutStyle = provider.gameLayoutStyle; + _prevSortMode = UserPrefs().gameSortMode; _prevGameCount = provider.games.length; _loadFirst(); } diff --git a/lib/pages/home/home_page.dart b/lib/pages/home/home_page.dart index 45db128..7dd7604 100644 --- a/lib/pages/home/home_page.dart +++ b/lib/pages/home/home_page.dart @@ -3813,14 +3813,14 @@ class _DesktopListPanelState extends State<_DesktopListPanel> { case 0: return ( UserPrefs().movieSortMode, - [(0, '按更新时间', Icons.update), (1, '按创建时间', Icons.calendar_today_outlined), (2, '按评分', Icons.star_outline)], + [(0, '按更新时间', Icons.update), (1, '按创建时间', Icons.calendar_today_outlined), (2, '按评分', Icons.star_outline), (3, '按观看日期', Icons.visibility_outlined), (4, '按上映时间', Icons.movie_creation_outlined)], '影视排序', (v) { UserPrefs().setMovieSortMode(v); provider.loadMovies(); }, ); case 1: return ( UserPrefs().bookSortMode, - [(0, '按更新时间', Icons.update), (1, '按创建时间', Icons.calendar_today_outlined), (2, '按评分', Icons.star_outline)], + [(0, '按更新时间', Icons.update), (1, '按创建时间', Icons.calendar_today_outlined), (2, '按评分', Icons.star_outline), (3, '按开始阅读时间', Icons.auto_stories_outlined), (4, '按出版时间', Icons.auto_stories_outlined)], '书籍排序', (v) { UserPrefs().setBookSortMode(v); provider.loadBooks(); }, ); @@ -3834,7 +3834,7 @@ class _DesktopListPanelState extends State<_DesktopListPanel> { case 3: return ( UserPrefs().gameSortMode, - [(0, '按更新时间', Icons.update), (1, '按创建时间', Icons.calendar_today_outlined), (2, '按评分', Icons.star_outline)], + [(0, '按更新时间', Icons.update), (1, '按创建时间', Icons.calendar_today_outlined), (2, '按评分', Icons.star_outline), (3, '按发售时间', Icons.event_outlined)], '游戏排序', (v) { UserPrefs().setGameSortMode(v); provider.loadGames(); }, ); diff --git a/lib/pages/home/main_content_page.dart b/lib/pages/home/main_content_page.dart index 7742f3b..66cbd7e 100644 --- a/lib/pages/home/main_content_page.dart +++ b/lib/pages/home/main_content_page.dart @@ -214,7 +214,9 @@ class _MainContentPageState extends State { _showSortMenu(context, isWallMode ? '影视墙排序' : '影视排序', UserPrefs().movieSortMode, [ (0, '按更新时间排序', Icons.update), (1, '按创建时间排序', Icons.calendar_today_outlined), - (2, '按评分排序', Icons.star_outline), + (2, '按影视评分排序', Icons.star_outline), + (3, '按观看日期排序', Icons.visibility_outlined), + (4, '按上映时间排序', Icons.movie_creation_outlined), ], (v) { UserPrefs().setMovieSortMode(v); context.read().loadMovies(); }); } : tab.label == '阅读' @@ -223,7 +225,9 @@ class _MainContentPageState extends State { _showSortMenu(context, isWallMode ? '书架排序' : '书籍排序', UserPrefs().bookSortMode, [ (0, '按更新时间排序', Icons.update), (1, '按创建时间排序', Icons.calendar_today_outlined), - (2, '按评分排序', Icons.star_outline), + (2, '按书籍评分排序', Icons.star_outline), + (3, '按开始阅读时间排序', Icons.auto_stories_outlined), + (4, '按出版时间排序', Icons.auto_stories_outlined), ], (v) { UserPrefs().setBookSortMode(v); context.read().loadBooks(); }); } : tab.label == '笔记' @@ -237,7 +241,8 @@ class _MainContentPageState extends State { _showSortMenu(context, isWallMode ? '游戏墙排序' : '游戏排序', UserPrefs().gameSortMode, [ (0, '按更新时间排序', Icons.update), (1, '按创建时间排序', Icons.calendar_today_outlined), - (2, '按评分排序', Icons.star_outline), + (2, '按游戏评分排序', Icons.star_outline), + (3, '按发售时间排序', Icons.event_outlined), ], (v) { UserPrefs().setGameSortMode(v); context.read().loadGames(); }); } : null, diff --git a/lib/pages/movies/movie_detail_page.dart b/lib/pages/movies/movie_detail_page.dart index 524ffcd..00749fd 100644 --- a/lib/pages/movies/movie_detail_page.dart +++ b/lib/pages/movies/movie_detail_page.dart @@ -244,6 +244,11 @@ class _MovieDetailPageState extends State { Text('观看于 ${_formatDate(movie.watchDate!)}', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), ], + if (movie.watchCount > 0) ...[ + const SizedBox(height: 4), + Text('已观看 ${movie.watchCount} 次', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), + ], Divider(height: 32, thickness: 0.5, color: colors.outline), // 详细信息 if (movie.directors.isNotEmpty) _buildDesktopInfoRow('导演', movie.directors.join(','), colors), @@ -1464,6 +1469,14 @@ class _MovieDetailPageState extends State { color: colors.onSurface.withValues(alpha: 0.4), ), ), + if (movie.watchCount > 0) + Text( + '已观看 ${movie.watchCount} 次', + style: TextStyle( + fontSize: 14, + color: colors.onSurface.withValues(alpha: 0.4), + ), + ), ], ), ); diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index a6809bf..6151bba 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -53,6 +53,7 @@ class _MovieFormPageState extends State { String _category = 'movie'; DateTime? _releaseDate; DateTime? _watchDate; + int _watchCount = 0; bool _isDownloading = false; @override @@ -89,6 +90,7 @@ class _MovieFormPageState extends State { _category = movie.category; _releaseDate = movie.releaseDate; _watchDate = movie.watchDate; + _watchCount = movie.watchCount; } else if (widget.initialStatus != null) { // 添加模式:使用传入的默认状态 _status = widget.initialStatus!; @@ -601,6 +603,18 @@ class _MovieFormPageState extends State { ), ), + // 观看次数 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '观看次数', + value: _watchCount > 0 ? '$_watchCount 次' : '', + icon: Icons.repeat_outlined, + onTap: () => _editWatchCount(), + ), + ), + // 第五行:剧情简介(独占一行) SizedBox( width: double.infinity, @@ -734,6 +748,31 @@ class _MovieFormPageState extends State { ); } + /// 编辑观看次数 + Future _editWatchCount() async { + final controller = TextEditingController(text: _watchCount > 0 ? '$_watchCount' : ''); + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('观看次数'), + content: TextField( + controller: controller, + keyboardType: TextInputType.number, + autofocus: true, + decoration: const InputDecoration(hintText: '输入次数'), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(ctx, controller.text), child: const Text('确定')), + ], + ), + ); + if (result != null) { + final val = int.tryParse(result) ?? 0; + setState(() => _watchCount = val < 0 ? 0 : val); + } + } + /// 全屏编辑剧情简介 Future _editSummary() async { final result = await Navigator.push( @@ -1357,6 +1396,7 @@ class _MovieFormPageState extends State { status: _status, category: _category, watchDate: _watchDate, + watchCount: _watchCount, createdAt: now, updatedAt: now, ); @@ -1378,6 +1418,7 @@ class _MovieFormPageState extends State { status: _status, category: _category, watchDate: _watchDate, + watchCount: _watchCount, updatedAt: now, ); diff --git a/lib/pages/movies/movie_tab_page.dart b/lib/pages/movies/movie_tab_page.dart index 0b88571..74bcb3a 100644 --- a/lib/pages/movies/movie_tab_page.dart +++ b/lib/pages/movies/movie_tab_page.dart @@ -38,6 +38,7 @@ class _MovieTabPageState extends State { int _prevLayoutStyle = -1; int _prevCategoryIndex = -1; int _prevDisplayMode = -1; + int _prevSortMode = -1; double _swipeOffset = 0.0; // 当前拖动偏移量(用于左右滑动切换状态) static const _statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'}; @@ -79,6 +80,7 @@ class _MovieTabPageState extends State { final layoutChanged = provider.movieLayoutStyle != _prevLayoutStyle; final categoryChanged = provider.movieCategoryIndex != _prevCategoryIndex; final displayModeChanged = provider.movieDisplayMode != _prevDisplayMode; + final sortModeChanged = UserPrefs().movieSortMode != _prevSortMode; final countChanged = provider.movies.length != _prevMovieCount; final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter; if (editRefreshed && provider.lastEditedItemId != null) { @@ -95,10 +97,11 @@ class _MovieTabPageState extends State { } return; } - if (statusChanged || layoutChanged || categoryChanged || displayModeChanged || countChanged || editRefreshed) { + if (statusChanged || layoutChanged || categoryChanged || displayModeChanged || sortModeChanged || countChanged || editRefreshed) { _prevLayoutStyle = provider.movieLayoutStyle; _prevCategoryIndex = provider.movieCategoryIndex; _prevDisplayMode = provider.movieDisplayMode; + _prevSortMode = UserPrefs().movieSortMode; _prevMovieCount = provider.movies.length; _loadFirst(); } diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index e6d8316..a07775e 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -31,6 +31,7 @@ class _NoteTabPageState extends State { bool _initialized = false; int _lastScrollSignal = 0; int _prevNoteCount = -1; + int _prevSortMode = -1; int _lastEditRefreshCounter = 0; @override @@ -67,6 +68,7 @@ class _NoteTabPageState extends State { // 仅在数据实际变化时刷新列表,避免底部导航栏显隐等UI变化误触发重载 final countChanged = provider.notes.length != _prevNoteCount; + final sortModeChanged = UserPrefs().noteSortMode != _prevSortMode; final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter; if (editRefreshed && provider.lastEditedItemId != null) { // 就地更新被编辑的条目,不重置分页 @@ -82,7 +84,8 @@ class _NoteTabPageState extends State { } return; } - if (countChanged || editRefreshed) { + if (countChanged || sortModeChanged || editRefreshed) { + _prevSortMode = UserPrefs().noteSortMode; _prevNoteCount = provider.notes.length; _loadFirst(); } diff --git a/lib/pages/profile/feature_settings_page.dart b/lib/pages/profile/feature_settings_page.dart index b88107c..6dd1615 100644 --- a/lib/pages/profile/feature_settings_page.dart +++ b/lib/pages/profile/feature_settings_page.dart @@ -26,6 +26,7 @@ class _FeatureSettingsPageState extends State { bool _showRecent = true; bool _showEncounter = true; bool _showStroll = true; + bool _showReviewed = true; bool _showCalendar = true; bool _showPerson = true; bool _showTags = true; @@ -51,6 +52,7 @@ class _FeatureSettingsPageState extends State { _showRecent = _userPrefs.showSidebarRecent; _showEncounter = _userPrefs.showSidebarEncounter; _showStroll = _userPrefs.showSidebarStroll; + _showReviewed = _userPrefs.showSidebarReviewed; _showCalendar = _userPrefs.showSidebarCalendar; _showPerson = _userPrefs.showSidebarPerson; _showTags = _userPrefs.showSidebarTags; @@ -251,6 +253,16 @@ class _FeatureSettingsPageState extends State { await _userPrefs.setShowSidebarStroll(v); setState(() => _showStroll = v); }), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + _buildSwitchItem(Icons.done_all, '已阅', '查看已看/已读/已通关记录', _showReviewed, + (v) async { + await _userPrefs.setShowSidebarReviewed(v); + setState(() => _showReviewed = v); + }), Divider( height: 0.5, indent: 24, diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 4f54bf6..2761545 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -130,6 +130,9 @@ class UserPrefs { bool get showSidebarStroll => prefs.getBool('showSidebarStroll') ?? true; Future setShowSidebarStroll(bool value) => prefs.setBool('showSidebarStroll', value); + bool get showSidebarReviewed => prefs.getBool('showSidebarReviewed') ?? true; + Future setShowSidebarReviewed(bool value) => prefs.setBool('showSidebarReviewed', value); + bool get showSidebarCalendar => prefs.getBool('showSidebarCalendar') ?? true; Future setShowSidebarCalendar(bool value) => prefs.setBool('showSidebarCalendar', value); @@ -156,11 +159,19 @@ class UserPrefs { int get noteSortMode => prefs.getInt('noteSortMode') ?? 0; Future setNoteSortMode(int value) => prefs.setInt('noteSortMode', value); - /// 影视排序方式 (0: 更新时间, 1: 创建时间, 2: 评分) + /// 书影日历日期模式 (0: 创建日期, 1: 观看/开始阅读日期) + int get calendarDateMode => prefs.getInt('calendarDateMode') ?? 0; + Future setCalendarDateMode(int value) => prefs.setInt('calendarDateMode', value); + + /// 数据统计时间范围 (0: 周, 1: 月, 2: 年) + int get statsTimeRange => prefs.getInt('statsTimeRange') ?? 2; + Future setStatsTimeRange(int value) => prefs.setInt('statsTimeRange', value); + + /// 影视排序方式 (0: 更新时间, 1: 创建时间, 2: 评分, 3: 观看日期, 4: 上映时间) int get movieSortMode => prefs.getInt('movieSortMode') ?? 0; Future setMovieSortMode(int value) => prefs.setInt('movieSortMode', value); - /// 书籍排序方式 (0: 更新时间, 1: 创建时间, 2: 评分) + /// 书籍排序方式 (0: 更新时间, 1: 创建时间, 2: 评分, 3: 开始阅读时间, 4: 出版时间) int get bookSortMode => prefs.getInt('bookSortMode') ?? 0; Future setBookSortMode(int value) => prefs.setInt('bookSortMode', value); diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index acf56ea..9664a61 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -5,6 +5,7 @@ import '../providers/app_provider.dart'; import '../utils/user_prefs.dart'; import '../pages/explore/encounter_page.dart'; import '../pages/explore/stroll_page.dart'; +import '../pages/explore/reviewed_page.dart'; import '../pages/explore/media_calendar_page.dart'; import '../pages/explore/person_list_page.dart'; import '../pages/markdown_reader/md_reader_tab_page.dart'; @@ -88,6 +89,7 @@ class _CustomDrawerState extends State { final showQuickActions = userPrefs.showSidebarQuickActions; final showTools = userPrefs.showSidebarEncounter || userPrefs.showSidebarStroll || + userPrefs.showSidebarReviewed || userPrefs.showSidebarCalendar || userPrefs.showSidebarPerson || userPrefs.showSidebarTags || @@ -319,6 +321,7 @@ class _CustomDrawerState extends State { final exploreItems = <(IconData, String, Widget)>[]; if (userPrefs.showSidebarEncounter) exploreItems.add((Icons.favorite_border, '统计', const EncounterPage())); if (userPrefs.showSidebarStroll) exploreItems.add((Icons.explore_outlined, '漫步', const StrollPage())); + if (userPrefs.showSidebarReviewed) exploreItems.add((Icons.done_all, '已阅', const ReviewedPage())); if (userPrefs.showSidebarCalendar) exploreItems.add((Icons.calendar_month_outlined, '书影日历', const MediaCalendarPage())); final toolItems = <(IconData, String, Widget)>[];