From 4a7296f00ff10940792a8a72f201f9c038095093 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Mon, 6 Jul 2026 01:33:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B8=B8=E6=88=8F=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E8=AE=B0=E5=BD=95=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/database_helper.dart | 116 +- lib/data/game/game_dao.dart | 153 +++ lib/data/game/game_review_dao.dart | 131 ++ lib/data/game/game_screenshot_dao.dart | 68 + lib/models/data_models.dart | 266 ++++ lib/pages/game/game_detail_page.dart | 986 ++++++++++++++ lib/pages/game/game_form_page.dart | 1237 ++++++++++++++++++ lib/pages/game/game_review_detail_page.dart | 179 +++ lib/pages/game/game_review_form_page.dart | 266 ++++ lib/pages/game/game_reviews_page.dart | 262 ++++ lib/pages/game/game_screenshots_page.dart | 366 ++++++ lib/pages/game/game_share_page.dart | 262 ++++ lib/pages/game/game_tab_page.dart | 503 +++++++ lib/pages/game/screenshot_gallery_page.dart | 102 ++ lib/pages/home/main_content_page.dart | 19 +- lib/pages/online_search/search_page.dart | 73 +- lib/pages/profile/feature_settings_page.dart | 64 +- lib/pages/profile/settings_page.dart | 8 + lib/pages/settings/recycle_bin_page.dart | 34 +- lib/pages/settings/tag_management_page.dart | 19 +- lib/pages/sync/backup_page.dart | 1 + lib/pages/sync/webdav_sync_page.dart | 1 + lib/providers/app_provider.dart | 196 ++- lib/services/sync/backup_service.dart | 60 + lib/utils/app_router.dart | 18 + lib/utils/image_path_helper.dart | 39 + lib/utils/user_prefs.dart | 16 + lib/widgets/add_sheet.dart | 25 + lib/widgets/custom_drawer.dart | 29 +- lib/widgets/game_list_item.dart | 129 ++ lib/widgets/game_status_bar.dart | 104 ++ lib/widgets/shimmer_skeleton.dart | 40 + 32 files changed, 5735 insertions(+), 37 deletions(-) create mode 100644 lib/data/game/game_dao.dart create mode 100644 lib/data/game/game_review_dao.dart create mode 100644 lib/data/game/game_screenshot_dao.dart create mode 100644 lib/pages/game/game_detail_page.dart create mode 100644 lib/pages/game/game_form_page.dart create mode 100644 lib/pages/game/game_review_detail_page.dart create mode 100644 lib/pages/game/game_review_form_page.dart create mode 100644 lib/pages/game/game_reviews_page.dart create mode 100644 lib/pages/game/game_screenshots_page.dart create mode 100644 lib/pages/game/game_share_page.dart create mode 100644 lib/pages/game/game_tab_page.dart create mode 100644 lib/pages/game/screenshot_gallery_page.dart create mode 100644 lib/widgets/game_list_item.dart create mode 100644 lib/widgets/game_status_bar.dart diff --git a/lib/data/database_helper.dart b/lib/data/database_helper.dart index da6543a..454c0fd 100644 --- a/lib/data/database_helper.dart +++ b/lib/data/database_helper.dart @@ -72,7 +72,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 30, + version: 34, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -278,6 +278,67 @@ class DatabaseHelper { await db.execute("ALTER TABLE reader_books ADD COLUMN authors TEXT DEFAULT ''"); } } + if (oldVersion < 31) { + // 创建游戏表 + await db.execute(''' + CREATE TABLE IF NOT EXISTS games ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + cover_path TEXT, + rating REAL, + status TEXT NOT NULL DEFAULT 'want_to_play', + category TEXT NOT NULL DEFAULT 'digital', + platforms TEXT DEFAULT '[]', + versions TEXT DEFAULT '[]', + genres TEXT DEFAULT '[]', + play_time_hours INTEGER DEFAULT 0, + play_time_minutes INTEGER DEFAULT 0, + purchase_platforms TEXT DEFAULT '[]', + purchase_date TEXT, + purchase_price TEXT, + cover_offset REAL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + is_deleted INTEGER DEFAULT 0 + ) + '''); + } + if (oldVersion < 32) { + // games表添加summary字段 + await db.execute('ALTER TABLE games ADD COLUMN summary TEXT'); + } + if (oldVersion < 34) { + // 确保games表summary字段存在 + final columns = await db.rawQuery('PRAGMA table_info(games)'); + if (!columns.any((col) => col['name'] == 'summary')) { + await db.execute('ALTER TABLE games ADD COLUMN summary TEXT'); + } + // 确保游戏评价表和游戏截图表存在 + await db.execute(''' + CREATE TABLE IF NOT EXISTS game_reviews ( + id TEXT PRIMARY KEY, + game_id TEXT NOT NULL, + content TEXT NOT NULL, + reviewer TEXT, + source TEXT, + review_type INTEGER DEFAULT 1, + is_deleted INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (game_id) REFERENCES games (id) + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS game_screenshots ( + id TEXT PRIMARY KEY, + game_id TEXT NOT NULL, + screenshot_path TEXT NOT NULL, + is_deleted INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY (game_id) REFERENCES games (id) + ) + '''); + } } /// 升级books表到V27(添加阅读始末日期字段) @@ -831,6 +892,59 @@ class DatabaseHelper { updated_at TEXT NOT NULL ) '''); + + // 游戏表 + await db.execute(''' + CREATE TABLE games ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + cover_path TEXT, + rating REAL, + status TEXT NOT NULL DEFAULT 'want_to_play', + category TEXT NOT NULL DEFAULT 'digital', + platforms TEXT DEFAULT '[]', + versions TEXT DEFAULT '[]', + genres TEXT DEFAULT '[]', + play_time_hours INTEGER DEFAULT 0, + play_time_minutes INTEGER DEFAULT 0, + purchase_platforms TEXT DEFAULT '[]', + purchase_date TEXT, + purchase_price TEXT, + summary TEXT, + cover_offset REAL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + is_deleted INTEGER DEFAULT 0 + ) + '''); + + // 游戏评价表 + await db.execute(''' + CREATE TABLE game_reviews ( + id TEXT PRIMARY KEY, + game_id TEXT NOT NULL, + content TEXT NOT NULL, + reviewer TEXT, + source TEXT, + review_type INTEGER DEFAULT 1, + is_deleted INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (game_id) REFERENCES games (id) + ) + '''); + + // 游戏截图表 + await db.execute(''' + CREATE TABLE game_screenshots ( + id TEXT PRIMARY KEY, + game_id TEXT NOT NULL, + screenshot_path TEXT NOT NULL, + is_deleted INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY (game_id) REFERENCES games (id) + ) + '''); } // 关闭数据库 diff --git a/lib/data/game/game_dao.dart b/lib/data/game/game_dao.dart new file mode 100644 index 0000000..3a3fe9c --- /dev/null +++ b/lib/data/game/game_dao.dart @@ -0,0 +1,153 @@ +import 'package:flutter/foundation.dart'; +import '../../models/data_models.dart'; +import '../database_helper.dart'; + +/// 游戏数据访问对象 +class GameDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + Future _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[GameDao] $op error: $e'); + rethrow; + } + } + + // 获取所有游戏记录(未删除的) + Future> getAllGames() => _wrap('getAllGames', () async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'games', + where: 'is_deleted = ?', + whereArgs: [0], + orderBy: 'created_at DESC', + ); + return List.generate(maps.length, (i) => Game.fromJson(maps[i])); + }); + + // 分页查询游戏记录 + Future> getGamesPaged({String? status, int limit = 20, int offset = 0, int sortMode = 0}) => _wrap('getGamesPaged', () async { + final db = await _dbHelper.database; + String where = 'is_deleted = 0'; + List whereArgs = []; + if (status != null && status.isNotEmpty) { + where += ' AND status = ?'; + whereArgs.add(status); + } + final maps = await db.query('games', where: where, whereArgs: whereArgs, + orderBy: _buildGameOrderBy(sortMode), limit: limit, offset: offset); + return List.generate(maps.length, (i) => Game.fromJson(maps[i])); + }); + + static String _buildGameOrderBy(int sortMode) { + switch (sortMode) { + case 1: return 'created_at DESC'; + case 2: return 'rating DESC NULLS LAST, updated_at DESC'; + default: return 'updated_at DESC'; + } + } + + // 搜索游戏(标题) + Future> searchGames(String keyword) => _wrap('searchGames', () async { + final db = await _dbHelper.database; + final likeKeyword = '%$keyword%'; + final List> maps = await db.query( + 'games', + where: 'title LIKE ? AND is_deleted = ?', + whereArgs: [likeKeyword, 0], + orderBy: 'created_at DESC', + ); + return maps.map((m) => Game.fromJson(m)).toList(); + }); + + // 添加游戏记录 + Future insertGame(Game game) => _wrap('insertGame', () async { + final db = await _dbHelper.database; + return await db.insert('games', game.toJson()); + }); + + // 更新游戏记录 + Future updateGame(Game game) => _wrap('updateGame', () async { + final db = await _dbHelper.database; + return await db.update( + 'games', + game.toJson(), + where: 'id = ?', + whereArgs: [game.id], + ); + }); + + // 仅更新封面偏移量 + Future updateCoverOffset(String gameId, double offset) => _wrap('updateCoverOffset', () async { + final db = await _dbHelper.database; + await db.update('games', {'cover_offset': offset}, where: 'id = ?', whereArgs: [gameId]); + }); + + // 删除游戏记录(软删除) + Future deleteGame(String id) => _wrap('deleteGame', () async { + final db = await _dbHelper.database; + return await db.update( + 'games', + {'is_deleted': 1, 'updated_at': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + }); + + // 获取所有类型(去重) + Future> getAllGenres() => _wrap('getAllGenres', () async { + final games = await getAllGames(); + final genres = {}; + for (final game in games) { + genres.addAll(game.genres); + } + return genres.toList()..sort(); + }); + + // 获取所有平台(去重) + Future> getAllPlatforms() => _wrap('getAllPlatforms', () async { + final games = await getAllGames(); + final platforms = {}; + for (final game in games) { + platforms.addAll(game.platforms); + } + return platforms.toList()..sort(); + }); + + // ========== 回收站相关方法 ========== + + // 获取已删除的游戏 + Future> getDeletedGames() => _wrap('getDeletedGames', () async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'games', + where: 'is_deleted = ?', + whereArgs: [1], + orderBy: 'created_at DESC', + ); + return List.generate(maps.length, (i) => Game.fromJson(maps[i])); + }); + + // 恢复已删除的游戏 + Future restoreGame(String id) => _wrap('restoreGame', () async { + final db = await _dbHelper.database; + return await db.update( + 'games', + {'is_deleted': 0, 'updated_at': DateTime.now().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + }); + + // 彻底删除游戏 + Future permanentDeleteGame(String id) => _wrap('permanentDeleteGame', () async { + final db = await _dbHelper.database; + return await db.delete( + 'games', + where: 'id = ?', + whereArgs: [id], + ); + }); +} diff --git a/lib/data/game/game_review_dao.dart b/lib/data/game/game_review_dao.dart new file mode 100644 index 0000000..19a8752 --- /dev/null +++ b/lib/data/game/game_review_dao.dart @@ -0,0 +1,131 @@ +import 'package:flutter/foundation.dart'; +import '../database_helper.dart'; +import '../../models/data_models.dart'; + +/// 游戏评价数据访问对象 +class GameReviewDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + Future _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[GameReviewDao] $op error: $e'); + rethrow; + } + } + + /// 获取游戏的所有评价 + Future> getReviewsByGameId(String gameId) => _wrap('getReviewsByGameId', () async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'game_reviews', + where: 'game_id = ? AND is_deleted = 0', + whereArgs: [gameId], + orderBy: 'created_at DESC', + ); + return List.generate(maps.length, (i) => GameReview.fromJson(maps[i])); + }); + + /// 根据ID获取评价 + Future getReviewById(String id) => _wrap('getReviewById', () async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'game_reviews', + where: 'id = ? AND is_deleted = 0', + whereArgs: [id], + ); + if (maps.isEmpty) return null; + return GameReview.fromJson(maps.first); + }); + + /// 添加评价 + Future insertReview(GameReview review) => _wrap('insertReview', () async { + final db = await _dbHelper.database; + return await db.insert('game_reviews', review.toJson()); + }); + + /// 更新评价 + Future updateReview(GameReview review) => _wrap('updateReview', () async { + final db = await _dbHelper.database; + return await db.update( + 'game_reviews', + review.toJson(), + where: 'id = ?', + whereArgs: [review.id], + ); + }); + + /// 软删除评价 + Future deleteReview(String id) => _wrap('deleteReview', () async { + final db = await _dbHelper.database; + return await db.update( + 'game_reviews', + {'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + }); + + /// 获取游戏评价数量 + Future getReviewCount(String gameId) => _wrap('getReviewCount', () async { + final db = await _dbHelper.database; + final result = await db.rawQuery( + 'SELECT COUNT(*) as count FROM game_reviews WHERE game_id = ? AND is_deleted = 0', + [gameId], + ); + return result.first['count'] as int? ?? 0; + }); + + /// 获取短评列表 + Future> getShortReviews(String gameId) => _wrap('getShortReviews', () async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'game_reviews', + where: 'game_id = ? AND review_type = 1 AND is_deleted = 0', + whereArgs: [gameId], + orderBy: 'created_at DESC', + ); + return List.generate(maps.length, (i) => GameReview.fromJson(maps[i])); + }); + + /// 获取长评列表 + Future> getLongReviews(String gameId) => _wrap('getLongReviews', () async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'game_reviews', + where: 'game_id = ? AND review_type = 2 AND is_deleted = 0', + whereArgs: [gameId], + orderBy: 'created_at DESC', + ); + return List.generate(maps.length, (i) => GameReview.fromJson(maps[i])); + }); + + /// 获取已删除的评价 + Future> getDeletedReviews() => _wrap('getDeletedReviews', () async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'game_reviews', + where: 'is_deleted = 1', + orderBy: 'updated_at DESC', + ); + return List.generate(maps.length, (i) => GameReview.fromJson(maps[i])); + }); + + /// 恢复已删除的评价 + Future restoreReview(String id) => _wrap('restoreReview', () async { + final db = await _dbHelper.database; + return await db.update( + 'game_reviews', + {'is_deleted': 0, 'updated_at': DateTime.now().toUtc().toIso8601String()}, + where: 'id = ?', + whereArgs: [id], + ); + }); + + /// 永久删除评价 + Future permanentDeleteReview(String id) => _wrap('permanentDeleteReview', () async { + final db = await _dbHelper.database; + return await db.delete('game_reviews', where: 'id = ?', whereArgs: [id]); + }); +} diff --git a/lib/data/game/game_screenshot_dao.dart b/lib/data/game/game_screenshot_dao.dart new file mode 100644 index 0000000..76306c0 --- /dev/null +++ b/lib/data/game/game_screenshot_dao.dart @@ -0,0 +1,68 @@ +import 'package:flutter/foundation.dart'; +import '../database_helper.dart'; +import '../../models/data_models.dart'; + +/// 游戏截图数据访问对象 +class GameScreenshotDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + Future _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[GameScreenshotDao] $op error: $e'); + rethrow; + } + } + + /// 获取游戏的所有截图 + Future> getScreenshotsByGameId(String gameId) => _wrap('getScreenshotsByGameId', () async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'game_screenshots', + where: 'game_id = ? AND is_deleted = 0', + whereArgs: [gameId], + orderBy: 'created_at DESC', + ); + return List.generate(maps.length, (i) => GameScreenshot.fromJson(maps[i])); + }); + + /// 根据ID获取截图 + Future getScreenshotById(String id) => _wrap('getScreenshotById', () async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'game_screenshots', + where: 'id = ? AND is_deleted = 0', + whereArgs: [id], + ); + if (maps.isEmpty) return null; + return GameScreenshot.fromJson(maps.first); + }); + + /// 添加截图 + Future insertScreenshot(GameScreenshot screenshot) => _wrap('insertScreenshot', () async { + final db = await _dbHelper.database; + return await db.insert('game_screenshots', screenshot.toJson()); + }); + + /// 软删除截图 + Future deleteScreenshot(String id) => _wrap('deleteScreenshot', () async { + final db = await _dbHelper.database; + return await db.update( + 'game_screenshots', + {'is_deleted': 1}, + where: 'id = ?', + whereArgs: [id], + ); + }); + + /// 获取游戏截图数量 + Future getScreenshotCount(String gameId) => _wrap('getScreenshotCount', () async { + final db = await _dbHelper.database; + final result = await db.rawQuery( + 'SELECT COUNT(*) as count FROM game_screenshots WHERE game_id = ? AND is_deleted = 0', + [gameId], + ); + return result.first['count'] as int? ?? 0; + }); +} diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index 0c2f288..d1786e0 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -637,6 +637,272 @@ class BookReview { String get typeText => reviewType == 1 ? '短评' : '长评'; } +/// 游戏条目模型 +class Game { + final String id; + final String title; // 游戏名称 + final String? coverPath; // 本地封面路径 + final double? rating; // 评分 1-10 + final String status; // completed/playing/want_to_play/abandoned + final String category; // 游戏分类: digital/cartridge/disc + final List platforms; // 平台列表 + final List versions; // 版本列表 + final List genres; // 类型 + final int playTimeHours; // 游玩时长(小时) + final int playTimeMinutes; // 游玩时长(分钟) + final List purchasePlatforms; // 购买平台 + final DateTime? purchaseDate; // 购买日期 + final String? purchasePrice; // 购买价格 + final String? summary; // 游戏简介 + final double coverOffset; // 封面偏移量 + final DateTime createdAt; + final DateTime updatedAt; + final bool isDeleted; + + Game({ + required this.id, + required this.title, + this.coverPath, + this.rating, + required this.status, + this.category = 'digital', + this.platforms = const [], + this.versions = const [], + this.genres = const [], + this.playTimeHours = 0, + this.playTimeMinutes = 0, + this.purchasePlatforms = const [], + this.purchaseDate, + this.purchasePrice, + this.summary, + this.coverOffset = 0.0, + required this.createdAt, + required this.updatedAt, + this.isDeleted = false, + }); + + factory Game.fromJson(Map json) { + return Game( + id: json['id'] ?? '', + title: json['title'] ?? '', + coverPath: json['cover_path'], + rating: _safeParseDouble(json['rating']), + status: json['status'] ?? 'want_to_play', + category: json['category'] ?? 'digital', + platforms: parseStringListGeneric(json['platforms']), + versions: parseStringListGeneric(json['versions']), + genres: parseStringListGeneric(json['genres']), + playTimeHours: json['play_time_hours'] ?? 0, + playTimeMinutes: json['play_time_minutes'] ?? 0, + purchasePlatforms: parseStringListGeneric(json['purchase_platforms']), + purchaseDate: _safeParseDate(json['purchase_date']), + purchasePrice: json['purchase_price'], + summary: json['summary'], + coverOffset: _safeParseDouble(json['cover_offset'], fallback: 0.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, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'cover_path': coverPath, + 'rating': rating, + 'status': status, + 'category': category, + 'platforms': jsonEncode(platforms), + 'versions': jsonEncode(versions), + 'genres': jsonEncode(genres), + 'play_time_hours': playTimeHours, + 'play_time_minutes': playTimeMinutes, + 'purchase_platforms': jsonEncode(purchasePlatforms), + 'purchase_date': purchaseDate?.toUtc().toIso8601String(), + 'purchase_price': purchasePrice, + 'summary': summary, + 'cover_offset': coverOffset, + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), + 'is_deleted': isDeleted ? 1 : 0, + }; + } + + /// 获取封面文件 + File? get coverFile { + if (coverPath == null || coverPath!.isEmpty) return null; + return File(coverPath!); + } + + /// 复制并修改 + Game copyWith({ + String? id, + String? title, + Object? coverPath = _copyWithNull, + Object? rating = _copyWithNull, + String? status, + String? category, + List? platforms, + List? versions, + List? genres, + int? playTimeHours, + int? playTimeMinutes, + List? purchasePlatforms, + DateTime? purchaseDate, + Object? purchasePrice = _copyWithNull, + Object? summary = _copyWithNull, + double? coverOffset, + DateTime? createdAt, + DateTime? updatedAt, + bool? isDeleted, + }) { + return Game( + id: id ?? this.id, + title: title ?? this.title, + coverPath: coverPath is _CopyWithNullSentinel ? this.coverPath : (coverPath as String?), + rating: rating is _CopyWithNullSentinel ? this.rating : (rating as double?), + status: status ?? this.status, + category: category ?? this.category, + platforms: platforms ?? this.platforms, + versions: versions ?? this.versions, + genres: genres ?? this.genres, + playTimeHours: playTimeHours ?? this.playTimeHours, + playTimeMinutes: playTimeMinutes ?? this.playTimeMinutes, + purchasePlatforms: purchasePlatforms ?? this.purchasePlatforms, + purchaseDate: purchaseDate ?? this.purchaseDate, + purchasePrice: purchasePrice is _CopyWithNullSentinel ? this.purchasePrice : (purchasePrice as String?), + summary: summary is _CopyWithNullSentinel ? this.summary : (summary as String?), + coverOffset: coverOffset ?? this.coverOffset, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + isDeleted: isDeleted ?? this.isDeleted, + ); + } +} + +/// 游戏评价模型 +class GameReview { + final String id; + final String gameId; + final String content; + final String reviewer; + final String source; + final int reviewType; // 1: 短评, 2: 长评 + final bool isDeleted; + final DateTime createdAt; + final DateTime updatedAt; + + GameReview({ + required this.id, + required this.gameId, + required this.content, + this.reviewer = '', + this.source = '', + this.reviewType = 1, + this.isDeleted = false, + required this.createdAt, + required this.updatedAt, + }); + + factory GameReview.fromJson(Map json) { + return GameReview( + id: json['id']?.toString() ?? '', + gameId: json['game_id']?.toString() ?? '', + content: json['content'] ?? '', + reviewer: json['reviewer'] ?? '', + source: json['source'] ?? '', + reviewType: json['review_type'] ?? 1, + isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, + updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!, + ); + } + + Map toJson() { + return { + 'id': id, + 'game_id': gameId, + 'content': content, + 'reviewer': reviewer, + 'source': source, + 'review_type': reviewType, + 'is_deleted': isDeleted ? 1 : 0, + 'created_at': createdAt.toUtc().toIso8601String(), + 'updated_at': updatedAt.toUtc().toIso8601String(), + }; + } + + GameReview copyWith({ + String? id, + String? gameId, + String? content, + String? reviewer, + String? source, + int? reviewType, + bool? isDeleted, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return GameReview( + id: id ?? this.id, + gameId: gameId ?? this.gameId, + content: content ?? this.content, + reviewer: reviewer ?? this.reviewer, + source: source ?? this.source, + reviewType: reviewType ?? this.reviewType, + isDeleted: isDeleted ?? this.isDeleted, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + String get summary => content.length <= 50 ? content : '${content.substring(0, 50)}...'; + String get typeText => reviewType == 1 ? '短评' : '长评'; +} + +/// 游戏截图模型 +class GameScreenshot { + final String id; + final String gameId; + final String screenshotPath; + final bool isDeleted; + final DateTime createdAt; + + GameScreenshot({ + required this.id, + required this.gameId, + required this.screenshotPath, + this.isDeleted = false, + required this.createdAt, + }); + + factory GameScreenshot.fromJson(Map json) { + return GameScreenshot( + id: json['id']?.toString() ?? '', + gameId: json['game_id']?.toString() ?? '', + screenshotPath: json['screenshot_path'] ?? '', + isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true, + createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!, + ); + } + + Map toJson() { + return { + 'id': id, + 'game_id': gameId, + 'screenshot_path': screenshotPath, + 'is_deleted': isDeleted ? 1 : 0, + 'created_at': createdAt.toUtc().toIso8601String(), + }; + } + + File? get screenshotFile { + if (screenshotPath.isEmpty) return null; + return File(screenshotPath); + } +} + /// 书籍摘抄模型 class BookExcerpt { final String id; diff --git a/lib/pages/game/game_detail_page.dart b/lib/pages/game/game_detail_page.dart new file mode 100644 index 0000000..8c385e5 --- /dev/null +++ b/lib/pages/game/game_detail_page.dart @@ -0,0 +1,986 @@ +import 'dart:io'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import '../../widgets/fade_in_local_image.dart'; +import '../../providers/app_provider.dart'; +import '../../models/data_models.dart'; +import '../../utils/user_prefs.dart'; +import '../../utils/toast_util.dart'; +import 'game_reviews_page.dart'; +import 'game_screenshots_page.dart'; +import 'game_share_page.dart'; + +/// 游戏详情页 - 极简主义设计 +class GameDetailPage extends StatefulWidget { + final Game game; + final bool embedded; + + const GameDetailPage({super.key, required this.game, this.embedded = false}); + + @override + State createState() => _GameDetailPageState(); +} + +class _GameDetailPageState extends State { + final ValueNotifier _coverOffset = ValueNotifier(0.0); + double _coverDragStartOffset = 0.0; + final ValueNotifier _draggingCover = ValueNotifier(false); + final GlobalKey _coverImageKey = GlobalKey(); + double _coverImageHeight = 0.0; + bool _isLandscapeCover = false; + late int _detailStyle; + final ValueNotifier _showTitle = ValueNotifier(false); + ScrollController? _overlayScrollController; + + @override + void initState() { + super.initState(); + _detailStyle = UserPrefs().detailPageStyle; + _coverOffset.value = UserPrefs().getCoverOffset(widget.game.id); + _detectCoverAspect(); + } + + Future _detectCoverAspect() async { + final path = widget.game.coverPath; + if (path == null || path.isEmpty || path.startsWith('http')) return; + final file = File(path); + if (!file.existsSync()) return; + final bytes = await file.readAsBytes(); + final codec = await instantiateImageCodec(bytes); + final frame = await codec.getNextFrame(); + final w = frame.image.width; + final h = frame.image.height; + frame.image.dispose(); + codec.dispose(); + if (w > h && mounted) { + setState(() => _isLandscapeCover = true); + } + } + + @override + void dispose() { + _coverOffset.dispose(); + _draggingCover.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final game = context.watch().games + .where((g) => g.id == widget.game.id) + .firstOrNull ?? widget.game; + + return _detailStyle == 1 + ? _buildOverlayStyle(game, colors) + : _buildStandardStyle(game, colors); + } + + Widget _buildStandardStyle(Game game, ColorScheme colors) { + final topSafe = MediaQuery.of(context).padding.top; + return Scaffold( + backgroundColor: colors.surface, + body: Stack( + children: [ + Padding( + padding: EdgeInsets.only(top: topSafe + 48), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 320, + width: double.infinity, + child: _buildCoverSection(game), + ), + _buildBasicInfo(game), + Divider(height: 0.5, thickness: 0.5, color: colors.outline), + if (game.platforms.isNotEmpty) + _buildInfoSection('平台', game.platforms.join('、')), + if (game.versions.isNotEmpty) + _buildInfoSection('版本', game.versions.join('、')), + if (game.genres.isNotEmpty) + _buildGenresSection(game), + if (game.playTimeHours > 0 || game.playTimeMinutes > 0) + _buildInfoSection('游玩时长', '${game.playTimeHours}小时${game.playTimeMinutes}分钟'), + if (game.purchasePlatforms.isNotEmpty) + _buildInfoSection('购买平台', game.purchasePlatforms.join('、')), + if (game.purchaseDate != null) + _buildInfoSection('购买时间', _formatDate(game.purchaseDate!)), + if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty) + _buildInfoSection('购买价格', game.purchasePrice!), + if (game.summary != null && game.summary!.isNotEmpty) + _buildInfoSection('游戏简介', game.summary!), + Divider(height: 0.5, thickness: 0.5, color: colors.outline), + _buildExtraSections(game), + const SizedBox(height: 120), + ], + ), + ), + ), + Positioned( + top: 0, left: 0, right: 0, + child: Container( + padding: EdgeInsets.only(top: topSafe), + color: colors.surface, + child: SizedBox( + height: 48, + child: Row(children: [ + const SizedBox(width: 4), + IconButton( + icon: widget.embedded + ? Icon(Icons.close, color: colors.onSurface, size: 18) + : Icon(Icons.arrow_back_ios_new, color: colors.onSurface, size: 18), + onPressed: widget.embedded + ? () => context.read().selectGame(null) + : () => Navigator.pop(context), + ), + const SizedBox(width: 4), + Expanded( + child: Text(game.title, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface), + maxLines: 1, overflow: TextOverflow.ellipsis), + ), + IconButton( + icon: Icon(Icons.tune, color: colors.onSurface, size: 20), + tooltip: '切换样式', + onPressed: _showStylePicker, + ), + ]), + ), + ), + ), + Positioned( + right: 16, + bottom: 24, + child: _buildFloatingActionButtons(game), + ), + ], + ), + ); + } + + Widget _buildOverlayStyle(Game game, ColorScheme colors) { + final screenH = MediaQuery.of(context).size.height; + final hasCover = game.coverPath != null && game.coverPath!.isNotEmpty; + + _overlayScrollController ??= ScrollController()..addListener(() { + final show = (_overlayScrollController?.offset ?? 0) > 10; + if (_showTitle.value != show) _showTitle.value = show; + }); + + return Scaffold( + body: Stack( + children: [ + // 封面背景 + if (hasCover) + Positioned.fill( + child: Image( + image: FileImage(File(game.coverPath!)), + fit: BoxFit.cover, width: double.infinity, height: screenH, + repeat: ImageRepeat.repeatY, + ), + ) + else + Container(color: colors.surfaceContainerHighest), + + // 毛玻璃 + ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25), + child: Container(color: Colors.black.withValues(alpha: 0.4)), + ), + ), + + // 内容 + SafeArea( + child: Column(children: [ + // 顶部栏 + SizedBox( + height: 48, + child: Row(children: [ + const SizedBox(width: 4), + IconButton( + icon: widget.embedded + ? const Icon(Icons.close, color: Colors.white, size: 18) + : const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18), + onPressed: widget.embedded + ? () => context.read().selectGame(null) + : () => Navigator.pop(context), + ), + ValueListenableBuilder( + valueListenable: _showTitle, + builder: (_, show, __) => AnimatedOpacity( + opacity: show ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.5), + child: Text(game.title, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white), + maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.tune, color: Colors.white, size: 20), + tooltip: '切换样式', + onPressed: _showStylePicker, + ), + ]), + ), + Expanded( + child: SingleChildScrollView( + controller: _overlayScrollController, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 100), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildOverlayHeader(game), + const SizedBox(height: 20), + if (game.platforms.isNotEmpty) + _buildOverlayInfoRow('平台', game.platforms.join('、')), + if (game.versions.isNotEmpty) + _buildOverlayInfoRow('版本', game.versions.join('、')), + if (game.genres.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildOverlayGenres(game), + ], + if (game.playTimeHours > 0 || game.playTimeMinutes > 0) + _buildOverlayInfoRow('游玩时长', '${game.playTimeHours}小时${game.playTimeMinutes}分钟'), + if (game.purchasePlatforms.isNotEmpty) + _buildOverlayInfoRow('购买平台', game.purchasePlatforms.join('、')), + if (game.purchaseDate != null) + _buildOverlayInfoRow('购买时间', _formatDate(game.purchaseDate!)), + if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty) + _buildOverlayInfoRow('购买价格', game.purchasePrice!), + if (game.summary != null && game.summary!.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildOverlaySummary(game), + ], + const SizedBox(height: 12), + _buildExtraSectionsOverlay(game), + ]), + ), + ), + ]), + ), + + Positioned(right: 16, bottom: 24, child: _buildFloatingActionButtons(game)), + ], + ), + ); + } + + Widget _buildOverlayHeader(Game game) { + final hasCover = game.coverPath != null && game.coverPath!.isNotEmpty; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 100, height: 140, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 12, offset: const Offset(0, 4))], + ), + clipBehavior: Clip.antiAlias, + child: hasCover + ? FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover) + : Container(color: Colors.white24, child: const Icon(Icons.sports_esports_outlined, color: Colors.white38, size: 32)), + ), + const SizedBox(width: 16), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 4), + Text(game.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white)), + if (game.genres.isNotEmpty) ...[ + const SizedBox(height: 6), + Text(game.genres.join(' / '), style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.6))), + ], + const SizedBox(height: 12), + Row(children: [ + if (game.rating != null && game.rating! > 0) ...[ + const Icon(Icons.star, size: 16, color: Color(0xFFFFB800)), + const SizedBox(width: 4), + Text(game.rating!.toStringAsFixed(1), style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFFFB800))), + const SizedBox(width: 16), + ], + _buildOverlayStatusChip(game.status), + ]), + ]), + ), + ], + ); + } + + Widget _buildOverlayStatusChip(String status) { + final (label, bg) = switch (status) { + 'completed' => ('已通关', const Color(0xFF1A1A1A)), + 'playing' => ('在玩', const Color(0xFF666666)), + 'want_to_play' => ('想玩', const Color(0xFF999999)), + 'abandoned' => ('弃游', const Color(0xFF8B4513)), + _ => ('', const Color(0xFF999999)), + }; + if (label.isEmpty) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(12)), + child: Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.white)), + ); + } + + Widget _buildOverlayInfoRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 72, child: Text(label, style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.5)))), + Expanded(child: Text(value, style: TextStyle(fontSize: 15, color: Colors.white.withValues(alpha: 0.9), height: 1.5))), + ]), + ); + } + + Widget _buildOverlayGenres(Game game) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 72, child: Text('类型', style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.5)))), + Expanded( + child: Wrap( + spacing: 8, runSpacing: 8, + children: game.genres.map((g) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(16)), + child: Text(g, style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.8))), + )).toList(), + ), + ), + ]), + ); + } + + Widget _buildOverlaySummary(Game game) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('游戏简介', style: TextStyle(fontSize: 13, color: Colors.white.withValues(alpha: 0.5))), + const SizedBox(height: 8), + Text(game.summary!, style: TextStyle(fontSize: 15, color: Colors.white.withValues(alpha: 0.9), height: 1.6)), + ]), + ); + } + + Widget _buildExtraSectionsOverlay(Game game) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + _buildFrostedExtraItem( + icon: Icons.rate_review_outlined, + title: '游戏评价', + subtitleFuture: context.read().getGameReviewCount(game.id), + emptyText: '暂无评价', + unit: '条评价', + onTap: () => _navigateToReviews(game), + ), + const SizedBox(height: 12), + _buildFrostedExtraItem( + icon: Icons.photo_library_outlined, + title: '游戏截图', + subtitleFuture: context.read().getGameScreenshotCount(game.id), + emptyText: '暂无截图', + unit: '张截图', + onTap: () => _navigateToScreenshots(game), + ), + ], + ), + ); + } + + Widget _buildFrostedExtraItem({ + required IconData icon, + required String title, + required Future subtitleFuture, + required String emptyText, + required String unit, + required VoidCallback onTap, + }) { + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row(children: [ + Icon(icon, size: 20, color: Colors.white.withValues(alpha: 0.7)), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Colors.white)), + FutureBuilder( + future: subtitleFuture, + builder: (ctx, snap) { + final count = snap.data ?? 0; + return Text(count > 0 ? '$count $unit' : emptyText, + style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5))); + }, + ), + ]), + ), + Icon(Icons.chevron_right, size: 16, color: Colors.white.withValues(alpha: 0.3)), + ]), + ), + ), + ), + ), + ), + ); + } + + Widget _buildFloatingActionButtons(Game game) { + final colors = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFloatingButton( + icon: Icons.edit_outlined, + onPressed: () => _navigateToEdit(context), + tooltip: '编辑', + backgroundColor: colors.primary, + foregroundColor: colors.onPrimary, + ), + const SizedBox(height: 12), + _buildFloatingButton( + icon: Icons.delete_outline, + onPressed: () => _showDeleteDialog(context), + tooltip: '删除', + backgroundColor: colors.error, + foregroundColor: colors.onError, + ), + const SizedBox(height: 12), + _buildFloatingButton( + icon: Icons.share_outlined, + onPressed: () => _showSharePoster(game), + tooltip: '分享海报', + backgroundColor: const Color(0xFF4CAF50), + foregroundColor: Colors.white, + ), + ], + ); + } + + Widget _buildFloatingButton({ + required IconData icon, + required VoidCallback onPressed, + required String tooltip, + required Color backgroundColor, + required Color foregroundColor, + }) { + return Tooltip( + message: tooltip, + child: GestureDetector( + onTap: onPressed, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: backgroundColor.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon(icon, size: 18, color: foregroundColor), + ), + ), + ); + } + + Widget _buildCoverSection(Game game) { + final colors = Theme.of(context).colorScheme; + final hasCover = game.coverPath != null && game.coverPath!.isNotEmpty; + + // 横图:直接居中裁剪填满,无需拖拽偏移 + if (_isLandscapeCover && hasCover) { + return Stack( + fit: StackFit.expand, + children: [ + FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover), + Positioned( + left: 0, right: 0, bottom: 0, + child: IgnorePointer( + child: Container( + height: 60, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [colors.surface.withValues(alpha: 0), colors.surface], + ), + ), + ), + ), + ), + ], + ); + } + + // 竖图:原有逻辑,支持上下拖拽调整偏移 + return LayoutBuilder( + builder: (context, constraints) { + final containerH = constraints.maxHeight; + return GestureDetector( + onLongPressStart: hasCover ? (_) { + HapticFeedback.mediumImpact(); + final ctx = _coverImageKey.currentContext; + if (ctx != null) { + final box = ctx.findRenderObject() as RenderBox?; + if (box != null) _coverImageHeight = box.size.height; + } + _draggingCover.value = true; + _coverDragStartOffset = _coverOffset.value; + } : null, + onLongPressMoveUpdate: hasCover ? (d) { + final raw = _coverDragStartOffset + d.offsetFromOrigin.dy; + final imgH = _coverImageHeight > 0 ? _coverImageHeight : containerH; + final minOffset = -(imgH - containerH).clamp(0, double.infinity); + _coverOffset.value = raw.clamp(minOffset, 0.0) as double; + } : null, + onLongPressEnd: hasCover ? (_) { + _draggingCover.value = false; + final offset = _coverOffset.value; + UserPrefs().setCoverOffset(widget.game.id, offset); + context.read().updateGameCoverOffset(widget.game.id, offset); + } : null, + child: ValueListenableBuilder( + valueListenable: _coverOffset, + builder: (context, offset, _) { + return Stack( + fit: StackFit.expand, + children: [ + if (hasCover) + ClipRect( + child: Stack( + children: [ + Positioned( + top: offset, + left: 0, right: 0, + child: FadeInLocalImage( + key: _coverImageKey, + path: game.coverPath, + fit: BoxFit.fitWidth, + width: constraints.maxWidth, + ), + ), + ], + ), + ) + else + _buildCoverPlaceholder(), + ValueListenableBuilder( + valueListenable: _draggingCover, + builder: (context, dragging, _) { + return Stack( + children: [ + if (!dragging) + Positioned( + left: 0, right: 0, bottom: 0, + child: IgnorePointer( + child: Container( + height: 60, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colors.surface.withValues(alpha: 0), + colors.surface, + ], + ), + ), + ), + ), + ), + if (dragging) ...[ + Positioned.fill( + child: Container(color: Colors.black.withValues(alpha: 0.3)), + ), + Positioned( + left: 0, right: 0, bottom: 20, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(20), + ), + child: const Text('上下滑动调整图片位置', + style: TextStyle(fontSize: 13, color: Colors.white70)), + ), + ), + ), + ], + ], + ); + }, + ), + ], + ); + }, + ), + ); + }, + ); + } + + Widget _buildCoverPlaceholder() { + final colors = Theme.of(context).colorScheme; + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.sports_esports_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.25)), + const SizedBox(height: 16), + Text('暂无封面', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ); + } + + Widget _buildBasicInfo(Game game) { + final colors = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + game.title, + style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.3), + ), + const SizedBox(height: 16), + Row( + children: [ + if (game.rating != null) ...[ + const Icon(Icons.star, size: 20, color: Colors.amber), + const SizedBox(width: 4), + Text(game.rating!.toStringAsFixed(1), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + const SizedBox(width: 16), + ], + _buildStatusTag(game), + const SizedBox(width: 6), + _buildCategoryTag(game), + ], + ), + ], + ), + ); + } + + Widget _buildStatusTag(Game game) { + final colors = Theme.of(context).colorScheme; + String label; + Color bgColor; + Color textColor; + switch (game.status) { + case 'completed': + label = '已通关'; + bgColor = colors.primary; + textColor = colors.onPrimary; + case 'playing': + label = '在玩'; + bgColor = colors.outlineVariant; + textColor = colors.onSurface.withValues(alpha: 0.6); + case 'want_to_play': + label = '想玩'; + bgColor = colors.surfaceContainerHighest; + textColor = colors.onSurface.withValues(alpha: 0.4); + case 'abandoned': + label = '弃游'; + bgColor = colors.errorContainer; + textColor = colors.onError; + default: + label = '未知'; + bgColor = colors.outlineVariant; + textColor = colors.onSurface.withValues(alpha: 0.25); + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(6)), + child: Text(label, style: TextStyle(fontSize: 12, color: textColor, fontWeight: FontWeight.w600)), + ); + } + + Widget _buildCategoryTag(Game game) { + final colors = Theme.of(context).colorScheme; + const labels = {'digital': '数字版', 'cartridge': '卡带', 'disc': '光盘'}; + final label = labels[game.category]; + if (label == null) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(6)), + child: Text(label, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5), fontWeight: FontWeight.w500)), + ); + } + + Widget _buildInfoSection(String label, String value) { + final colors = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 72, + child: Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), + ), + Expanded( + child: Text(value, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.5)), + ), + ], + ), + ); + } + + Widget _buildGenresSection(Game game) { + final colors = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 72, + child: Text('类型', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), + ), + Expanded( + child: Wrap( + spacing: 8, runSpacing: 8, + children: game.genres.map((g) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(16)), + child: Text(g, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))), + )).toList(), + ), + ), + ], + ), + ); + } + + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + Widget _buildExtraSections(Game game) { + final colors = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 4, height: 16, + decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(width: 8), + Text('更多', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + ], + ), + const SizedBox(height: 16), + _buildExtraSectionItem( + icon: Icons.rate_review_outlined, + title: '游戏评价', + subtitleFuture: context.read().getGameReviewCount(game.id), + emptyText: '暂无评价', + unit: '条评价', + onTap: () => _navigateToReviews(game), + ), + const SizedBox(height: 12), + _buildExtraSectionItem( + icon: Icons.photo_library_outlined, + title: '游戏截图', + subtitleFuture: context.read().getGameScreenshotCount(game.id), + emptyText: '暂无截图', + unit: '张截图', + onTap: () => _navigateToScreenshots(game), + ), + ], + ), + ); + } + + Widget _buildExtraSectionItem({ + required IconData icon, + required String title, + required Future subtitleFuture, + required String emptyText, + required String unit, + required VoidCallback onTap, + }) { + final colors = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.outlineVariant, width: 0.5), + ), + child: Row( + children: [ + Container( + width: 40, height: 40, + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: colors.outlineVariant, width: 0.5), + ), + child: Icon(icon, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + const SizedBox(height: 4), + FutureBuilder( + future: subtitleFuture, + builder: (context, snapshot) { + final count = snapshot.data ?? 0; + return Text( + count > 0 ? '$count $unit' : emptyText, + style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)), + ); + }, + ), + ], + ), + ), + Icon(Icons.chevron_right, size: 20, color: colors.onSurface.withValues(alpha: 0.25)), + ], + ), + ), + ); + } + + void _navigateToReviews(Game game) { + Navigator.push(context, MaterialPageRoute(builder: (_) => GameReviewsPage(game: game))); + } + + void _navigateToScreenshots(Game game) { + Navigator.push(context, MaterialPageRoute(builder: (_) => GameScreenshotsPage(game: game))); + } + + void _showStylePicker() { + final colors = Theme.of(context).colorScheme; + final currentStyle = UserPrefs().detailPageStyle; + const names = ['默认样式', '毛玻璃层叠']; + const icons = [Icons.article_outlined, Icons.blur_on_outlined]; + const subtitles = ['标准封面顶部布局', '封面背景 + 毛玻璃卡片']; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 20), + Align(alignment: Alignment.centerLeft, child: Text('详情页样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))), + const SizedBox(height: 12), + for (int i = 0; i < names.length; i++) ...[ + if (i > 0) Divider(height: 0.5, color: colors.outlineVariant), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(icons[i], size: 20, color: currentStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))), + title: Text(names[i], style: TextStyle(fontSize: 13, fontWeight: currentStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)), + subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + trailing: currentStyle == i + ? Icon(Icons.check_circle, size: 20, color: colors.primary) + : Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () { setState(() => _detailStyle = i); UserPrefs().setDetailPageStyle(i); Navigator.pop(ctx); }, + ), + ], + const SizedBox(height: 12), + ]), + ), + ); + } + + void _showSharePoster(Game game) { + Navigator.push(context, MaterialPageRoute(builder: (_) => GameSharePage(game: game))); + } + + void _navigateToEdit(BuildContext context) { + final provider = context.read(); + Navigator.pushNamed(context, '/game-form', arguments: widget.game).then((_) { + provider.setEditRefresh(widget.game.id); + provider.loadGames(); + }); + } + + void _showDeleteDialog(BuildContext context) { + final colors = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定要删除"${widget.game.title}"吗?删除后可在回收站恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom(foregroundColor: colors.onSurface.withValues(alpha: 0.6), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () async { + final provider = context.read(); + await provider.removeGame(widget.game.id); + if (!mounted) return; + if (widget.embedded) { + Navigator.of(context).pop(); + provider.selectGame(null); + } else { + final navigator = Navigator.of(context); + navigator.pop(); + navigator.pop(); + } + if (mounted) { + ToastUtil.show(context, '已删除'); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('删除'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + } +} diff --git a/lib/pages/game/game_form_page.dart b/lib/pages/game/game_form_page.dart new file mode 100644 index 0000000..0ae19e9 --- /dev/null +++ b/lib/pages/game/game_form_page.dart @@ -0,0 +1,1237 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import '../../providers/app_provider.dart'; +import '../../widgets/fade_in_local_image.dart'; +import '../../models/data_models.dart'; +import '../../utils/toast_util.dart'; +import '../../utils/image_path_helper.dart'; +import '../../widgets/genre_selector_page.dart'; +import '../../widgets/text_input_panel.dart'; + +/// 从多值字段列表中提取去重排序的唯一值(供 compute 使用) +List _collectUnique(List> lists) { + final s = {}; + for (final l in lists) { s.addAll(l); } + return s.toList()..sort(); +} + +/// 添加/编辑游戏页面 +class GameFormPage extends StatefulWidget { + final Game? game; + final String? initialStatus; + + const GameFormPage({super.key, this.game, this.initialStatus}); + + @override + State createState() => _GameFormPageState(); +} + +class _GameFormPageState extends State { + final _formKey = GlobalKey(); + final ImagePicker _picker = ImagePicker(); + + late TextEditingController _titleController; + late TextEditingController _ratingController; + late TextEditingController _playTimeHoursController; + late TextEditingController _playTimeMinutesController; + late TextEditingController _purchasePriceController; + late TextEditingController _summaryController; + + List _platforms = []; + List _versions = []; + List _genres = []; + List _purchasePlatforms = []; + String? _coverPath; + String _status = 'want_to_play'; + String _category = 'digital'; + DateTime? _purchaseDate; + bool _isDownloading = false; + + @override + void initState() { + super.initState(); + _initializeData(); + } + + void _initializeData() { + Game? game = widget.game; + if (game != null) { + final appProvider = context.read(); + final latestGame = appProvider.games + .where((g) => g.id == game!.id) + .firstOrNull; + if (latestGame != null) { + game = latestGame; + } + } + + _titleController = TextEditingController(text: game?.title ?? ''); + _ratingController = TextEditingController(text: game?.rating?.toString() ?? ''); + _playTimeHoursController = TextEditingController(text: game?.playTimeHours.toString() ?? '0'); + _playTimeMinutesController = TextEditingController(text: game?.playTimeMinutes.toString() ?? '0'); + _purchasePriceController = TextEditingController(text: game?.purchasePrice ?? ''); + _summaryController = TextEditingController(text: game?.summary ?? ''); + + if (game != null) { + _platforms = List.from(game.platforms); + _versions = List.from(game.versions); + _genres = List.from(game.genres); + _purchasePlatforms = List.from(game.purchasePlatforms); + _coverPath = game.coverPath; + _status = game.status; + _category = game.category; + _purchaseDate = game.purchaseDate; + } else if (widget.initialStatus != null) { + _status = widget.initialStatus!; + } + } + + @override + void dispose() { + _titleController.dispose(); + _ratingController.dispose(); + _playTimeHoursController.dispose(); + _playTimeMinutesController.dispose(); + _purchasePriceController.dispose(); + _summaryController.dispose(); + super.dispose(); + } + + Widget _buildActionButton({ + required IconData icon, + required VoidCallback onPressed, + required String tooltip, + Color? color, + }) { + final colors = Theme.of(context).colorScheme; + final iconColor = color ?? colors.onSurface; + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + decoration: BoxDecoration( + color: colors.surface.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(8), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(8), + child: Icon(icon, color: iconColor, size: 22), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final isEdit = widget.game != null; + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _confirmLeave(); + if (shouldPop && context.mounted) Navigator.pop(context); + }, + child: Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: Text(isEdit ? '编辑游戏' : '添加游戏'), + actions: [ + _buildActionButton( + icon: Icons.save_outlined, + onPressed: _saveGame, + tooltip: '保存', + ), + const SizedBox(width: 8), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + children: [ + Center(child: _buildCoverPicker()), + const SizedBox(height: 20), + _buildStatusRatingRow(), + const SizedBox(height: 24), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + // 名称 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '名称', + value: _titleController.text, + required: true, + icon: Icons.sports_esports_outlined, + onTap: () async { + final result = await TextInputPanel.show( + context: context, + title: '游戏名称', + initialValue: _titleController.text, + hint: '请输入游戏名称', + ); + if (result != null) setState(() => _titleController.text = result); + }, + ), + ), + // 平台 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '平台', + value: _platforms.isEmpty + ? '' + : '${_platforms.length}个:${_platforms.join('、')}', + icon: Icons.devices_outlined, + scrollHorizontal: true, + onTap: () async { + final provider = context.read(); + final data = provider.games.map((g) => g.platforms).toList(); + final result = await GenreSelectorPage.show( + context: context, + title: '选择平台', + existingTagsFuture: compute(_collectUnique, data), + initialSelected: _platforms, + hint: '如:PS5、Switch、Steam', + ); + if (result != null) setState(() => _platforms = result); + }, + ), + ), + // 版本 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '版本', + value: _versions.isEmpty + ? '' + : '${_versions.length}个:${_versions.join('、')}', + icon: Icons.library_books_outlined, + scrollHorizontal: true, + onTap: () async { + final provider = context.read(); + final data = provider.games.map((g) => g.versions).toList(); + final result = await GenreSelectorPage.show( + context: context, + title: '选择版本', + existingTagsFuture: compute(_collectUnique, data), + initialSelected: _versions, + hint: '如:标准版、豪华版', + ); + if (result != null) setState(() => _versions = result); + }, + ), + ), + // 类型 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '类型', + value: _genres.isEmpty + ? '' + : '${_genres.length}个:${_genres.join('、')}', + icon: Icons.style_outlined, + onTap: () async { + final provider = context.read(); + final tags = await provider.getTags('game_genre', excludeHidden: true); + final existingNames = tags.map((t) => t['name'] as String).toList(); + // 补充已有游戏中使用过的类型 + final gameGenres = provider.games + .expand((g) => g.genres) + .toSet() + .toList(); + for (final g in gameGenres) { + if (!existingNames.contains(g)) existingNames.add(g); + } + if (!mounted) return; + final result = await GenreSelectorPage.show( + context: context, + title: '选择类型', + existingTags: existingNames, + initialSelected: _genres, + hint: '如:RPG、动作、冒险', + ); + if (result != null) setState(() => _genres = result); + }, + ), + ), + // 游玩时长 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '游玩时长', + value: _buildPlayTimeText(), + icon: Icons.timer_outlined, + onTap: () => _showPlayTimePicker(), + ), + ), + // 购买平台 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '购买平台', + value: _purchasePlatforms.isEmpty + ? '' + : '${_purchasePlatforms.length}个:${_purchasePlatforms.join('、')}', + icon: Icons.store_outlined, + scrollHorizontal: true, + onTap: () async { + final provider = context.read(); + final data = provider.games.map((g) => g.purchasePlatforms).toList(); + final result = await GenreSelectorPage.show( + context: context, + title: '选择购买平台', + existingTagsFuture: compute(_collectUnique, data), + initialSelected: _purchasePlatforms, + hint: '如:Steam、eShop、PlayStation Store', + ); + if (result != null) setState(() => _purchasePlatforms = result); + }, + ), + ), + // 购买时间 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '购买时间', + value: _purchaseDate != null + ? '${_purchaseDate!.year}.${_purchaseDate!.month.toString().padLeft(2, '0')}.${_purchaseDate!.day.toString().padLeft(2, '0')}' + : '', + icon: Icons.calendar_today_outlined, + trailing: _purchaseDate != null + ? GestureDetector( + onTap: () => setState(() => _purchaseDate = null), + child: Icon(Icons.close, size: 16, color: colors.onSurface.withValues(alpha: 0.35)), + ) + : null, + onTap: () => _selectPurchaseDate(), + ), + ), + // 购买价格 + SizedBox( + width: (MediaQuery.of(context).size.width - 52) / 2, + height: 90, + child: _buildInfoCard( + label: '购买价格', + value: _purchasePriceController.text.isNotEmpty ? _purchasePriceController.text : '', + icon: Icons.payments_outlined, + onTap: () async { + final result = await TextInputPanel.show( + context: context, + title: '购买价格', + initialValue: _purchasePriceController.text, + hint: '如:298元、49.99美元', + keyboardType: TextInputType.text, + ); + if (result != null) setState(() => _purchasePriceController.text = result); + }, + ), + ), + ], + ), + const SizedBox(height: 12), + // 游戏简介(独占一行) + SizedBox( + width: double.infinity, + child: _buildInfoCard( + label: '游戏简介', + value: _summaryController.text, + icon: Icons.description_outlined, + height: 160, + scrollable: true, + onTap: () => _editSummary(), + ), + ), + const SizedBox(height: 48), + ], + ), + ), + ), + ); + } + + static const _categoryLabels = {'digital': '数字版', 'cartridge': '卡带', 'disc': '光盘'}; + + String _buildPlayTimeText() { + final h = int.tryParse(_playTimeHoursController.text) ?? 0; + final m = int.tryParse(_playTimeMinutesController.text) ?? 0; + if (h == 0 && m == 0) return ''; + final parts = []; + if (h > 0) parts.add('$h小时'); + if (m > 0) parts.add('$m分钟'); + return parts.join(''); + } + + Widget _buildInfoCard({ + required String label, + required String value, + required VoidCallback onTap, + bool required = false, + IconData? icon, + Widget? trailing, + double? height, + bool scrollable = false, + bool scrollHorizontal = false, + }) { + final hasValue = value.isNotEmpty; + final colors = Theme.of(context).colorScheme; + + Widget buildContent() { + if (scrollable && height != null) { + return Flexible( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Text( + hasValue ? value : '点击填写', + style: TextStyle( + fontSize: 15, + color: hasValue ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25), + fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal, + ), + ), + ), + ); + } else if (scrollHorizontal) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: Text( + hasValue ? value : '点击填写', + style: TextStyle( + fontSize: 15, + color: hasValue ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25), + fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal, + ), + ), + ); + } else { + return Text( + hasValue ? value : '点击填写', + style: TextStyle( + fontSize: 15, + color: hasValue ? colors.onSurface : colors.onSurface.withValues(alpha: 0.25), + fontWeight: hasValue ? FontWeight.w500 : FontWeight.normal, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + } + + return GestureDetector( + onTap: onTap, + child: Container( + height: height, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outline), + boxShadow: [ + BoxShadow( + color: colors.onSurface.withValues(alpha: 0.018), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: height != null ? MainAxisSize.max : MainAxisSize.min, + children: [ + Row( + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: colors.onSurface.withValues(alpha: 0.4)), + const SizedBox(width: 6), + ], + Text( + required ? '$label *' : label, + style: TextStyle( + fontSize: 12, + color: required ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4), + fontWeight: required ? FontWeight.w500 : FontWeight.normal, + ), + ), + if (trailing != null) ...[ + const Spacer(), + trailing, + ], + ], + ), + const SizedBox(height: 8), + buildContent(), + ], + ), + ), + ); + } + + /// 全屏编辑游戏简介 + Future _editSummary() async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => _SummaryEditorPage(initialText: _summaryController.text), + ), + ); + if (result != null) { + setState(() => _summaryController.text = result); + } + } + + /// 状态 + 评分 + 类别合并行 + Widget _buildStatusRatingRow() { + final colors = Theme.of(context).colorScheme; + final currentRating = double.tryParse(_ratingController.text) ?? 0; + final starRating = currentRating / 2; + final hasRating = _ratingController.text.isNotEmpty; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outlineVariant, width: 0.5), + ), + child: Column( + children: [ + // 状态 + Row( + children: [ + Text('状态', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildStatusOption('想玩', 'want_to_play'), + _buildStatusOption('在玩', 'playing'), + _buildStatusOption('已通关', 'completed'), + _buildStatusOption('弃游', 'abandoned'), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + // 评分 + Row( + children: [ + Text('评分', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(width: 12), + ...List.generate(5, (index) { + final starValue = index + 1; + final isFilled = starValue <= starRating; + final isHalf = starValue == starRating.ceil() && starRating % 1 != 0; + return GestureDetector( + onTap: () => setState(() => _ratingController.text = (starValue * 2).toString()), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Icon( + isHalf ? Icons.star_half : (isFilled ? Icons.star : Icons.star_border), + size: 22, + color: (isFilled || isHalf) ? const Color(0xFFFFB800) : colors.outline, + ), + ), + ); + }), + const SizedBox(width: 8), + Container( + width: 48, height: 28, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: TextFormField( + controller: _ratingController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + textAlign: TextAlign.center, + inputFormatters: [GameRatingInputFormatter()], + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface), + decoration: InputDecoration( + hintText: '0-10', + hintStyle: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.25)), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 6), + isDense: true, + ), + onChanged: (_) => setState(() {}), + ), + ), + if (hasRating) ...[ + const SizedBox(width: 6), + GestureDetector( + onTap: () => setState(() => _ratingController.clear()), + child: Icon(Icons.close, size: 14, color: colors.onSurface.withValues(alpha: 0.3)), + ), + ], + ], + ), + const SizedBox(height: 12), + // 类别 + Row( + children: [ + Text('类别', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(width: 12), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: _categoryLabels.entries.map((e) { + final isSelected = _category == e.key; + return GestureDetector( + onTap: () => setState(() => _category = e.key), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(right: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: isSelected ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + e.value, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, + color: isSelected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5), + ), + ), + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildStatusOption(String label, String value) { + final isSelected = _status == value; + final colors = Theme.of(context).colorScheme; + + return GestureDetector( + onTap: () => setState(() => _status = value), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isSelected ? colors.surface : Colors.transparent, + borderRadius: BorderRadius.circular(6), + boxShadow: isSelected + ? [ + BoxShadow( + color: colors.onSurface.withValues(alpha: 0.03), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w500 : FontWeight.normal, + color: isSelected ? colors.onSurface : colors.onSurface.withValues(alpha: 0.4), + ), + ), + ), + ); + } + + Widget _buildCoverPicker() { + final hasCover = _coverPath != null && _coverPath!.isNotEmpty; + final colors = Theme.of(context).colorScheme; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: _showCoverOptions, + child: Container( + width: 120, + height: 170, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + clipBehavior: Clip.antiAlias, + child: Stack( + alignment: Alignment.center, + children: [ + if (hasCover) + FadeInLocalImage(path: _coverPath, fit: BoxFit.cover) + else + _buildCoverPlaceholder(), + if (_isDownloading) + Container( + color: Colors.black.withValues(alpha: 0.4), + child: const CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ), + ], + ), + ), + ), + if (hasCover) + Padding( + padding: const EdgeInsets.only(top: 10), + child: GestureDetector( + onTap: () => setState(() => _coverPath = null), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.delete_outline, size: 14, color: colors.onSurface.withValues(alpha: 0.6)), + const SizedBox(width: 4), + Text('移除封面', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.6))), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildCoverPlaceholder() { + final colors = Theme.of(context).colorScheme; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.image_outlined, size: 32, color: colors.onSurface.withValues(alpha: 0.25)), + const SizedBox(height: 8), + Text('封面', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), + ], + ); + } + + Future _pickCover() async { + try { + final XFile? pickedFile = await _picker.pickImage( + source: ImageSource.gallery, + maxWidth: 800, + maxHeight: 1200, + imageQuality: 85, + ); + + if (pickedFile != null) { + final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final gameId = widget.game?.id ?? DateTime.now().millisecondsSinceEpoch.toString(); + final targetPath = await ImagePathHelper.instance.getGameCoverPath(gameId, fileName); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + await File(pickedFile.path).copy(targetPath); + setState(() => _coverPath = targetPath); + } + } catch (e) { + if (mounted) { + ToastUtil.show(context, '选择封面失败: $e'); + } + } + } + + /// 显示封面选择选项 + void _showCoverOptions() { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + final colors = Theme.of(context).colorScheme; + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, height: 4, + decoration: BoxDecoration(color: colors.outline, borderRadius: BorderRadius.circular(2)), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Align( + alignment: Alignment.centerLeft, + child: Text('添加封面', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + ), + ), + const SizedBox(height: 16), + _buildCoverOption( + icon: Icons.photo_library_outlined, + title: '从相册选择', + onTap: () { Navigator.pop(context); _pickCover(); }, + ), + _buildCoverOption( + icon: Icons.link_outlined, + title: '网络链接', + onTap: () { Navigator.pop(context); _pickCoverFromUrl(); }, + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildCoverOption({required IconData icon, required String title, required VoidCallback onTap}) { + final colors = Theme.of(context).colorScheme; + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(icon, size: 22, color: colors.onSurface.withValues(alpha: 0.6)), + ), + const SizedBox(width: 16), + Text(title, style: TextStyle(fontSize: 16, color: colors.onSurface)), + const Spacer(), + Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25), size: 20), + ], + ), + ), + ); + } + + /// 从网络链接选择封面 + Future _pickCoverFromUrl() async { + final urlController = TextEditingController(); + final confirmed = await showDialog( + context: context, + builder: (ctx) { + final colors = Theme.of(ctx).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('添加网络图片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('请输入图片链接地址', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(height: 12), + TextField( + controller: urlController, + keyboardType: TextInputType.url, + style: TextStyle(fontSize: 14, color: colors.onSurface), + decoration: InputDecoration( + hintText: 'https://example.com/image.jpg', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)), + filled: true, + fillColor: colors.surfaceContainerHigh, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: colors.primary, width: 1)), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('确定'), + ), + ], + ); + }, + ); + + final url = urlController.text.trim(); + WidgetsBinding.instance.addPostFrameCallback((_) { + urlController.dispose(); + }); + + if (confirmed != true || url.isEmpty) return; + await _downloadCoverFromUrl(url); + } + + /// 从URL下载封面图 + Future _downloadCoverFromUrl(String url) async { + setState(() => _isDownloading = true); + try { + final response = await http.get( + Uri.parse(url), + headers: { + 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'Referer': Uri.parse(url).replace(path: '/').toString(), + }, + ); + + if (response.statusCode != 200) throw Exception('下载失败: HTTP ${response.statusCode}'); + + final contentType = response.headers['content-type']; + if (contentType != null && !contentType.startsWith('image/')) throw Exception('链接返回的不是图片'); + if (response.bodyBytes.length > 10 * 1024 * 1024) throw Exception('图片太大'); + + final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final gameId = widget.game?.id ?? DateTime.now().millisecondsSinceEpoch.toString(); + final targetPath = await ImagePathHelper.instance.getGameCoverPath(gameId, fileName); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + await File(targetPath).writeAsBytes(response.bodyBytes); + + setState(() => _coverPath = targetPath); + } catch (e) { + debugPrint('封面下载失败: $e'); + if (mounted) ToastUtil.show(context, '下载失败: $e'); + } finally { + if (mounted) setState(() => _isDownloading = false); + } + } + + void _showPlayTimePicker() { + final colors = Theme.of(context).colorScheme; + final hoursController = TextEditingController(text: _playTimeHoursController.text); + final minutesController = TextEditingController(text: _playTimeMinutesController.text); + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('游玩时长', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Row( + children: [ + Expanded( + child: TextField( + controller: hoursController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: '小时', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: minutesController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: '分钟', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), + ), + ElevatedButton( + onPressed: () { + setState(() { + _playTimeHoursController.text = hoursController.text; + _playTimeMinutesController.text = minutesController.text; + }); + Navigator.pop(ctx); + }, + style: ElevatedButton.styleFrom( + backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('确定'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ).then((_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + hoursController.dispose(); + minutesController.dispose(); + }); + }); + } + + Future _selectPurchaseDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _purchaseDate ?? DateTime.now(), + firstDate: DateTime(1990), + lastDate: DateTime.now().add(const Duration(days: 365 * 5)), + builder: (context, child) => child!, + ); + if (picked != null) { + setState(() => _purchaseDate = 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 (_purchasePlatforms.isNotEmpty) return true; + if (_purchaseDate != 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; + if (int.tryParse(_playTimeMinutesController.text) != null && int.parse(_playTimeMinutesController.text) > 0) return true; + return false; + } + + Future _confirmLeave() async { + if (!_hasContent()) return true; + final colors = Theme.of(context).colorScheme; + final result = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('未保存', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('当前内容未保存,确定要离开吗?', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('离开'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + return result ?? false; + } + + Future _saveGame() async { + if (!_formKey.currentState!.validate()) return; + + try { + final rating = _ratingController.text.isNotEmpty + ? double.tryParse(_ratingController.text) + : null; + final playTimeHours = int.tryParse(_playTimeHoursController.text) ?? 0; + final playTimeMinutes = int.tryParse(_playTimeMinutesController.text) ?? 0; + final now = DateTime.now(); + + if (widget.game == null) { + final newGameId = now.millisecondsSinceEpoch.toString(); + String? finalCoverPath; + if (_coverPath != null && _coverPath!.isNotEmpty) { + finalCoverPath = await _moveCoverToNewId(_coverPath!, newGameId); + } + + final newGame = Game( + id: newGameId, + title: _titleController.text.trim(), + coverPath: finalCoverPath, + rating: rating, + status: _status, + category: _category, + platforms: _platforms, + versions: _versions, + genres: _genres, + playTimeHours: playTimeHours, + playTimeMinutes: playTimeMinutes, + purchasePlatforms: _purchasePlatforms, + purchaseDate: _purchaseDate, + purchasePrice: _purchasePriceController.text.trim().isNotEmpty + ? _purchasePriceController.text.trim() + : null, + summary: _summaryController.text.trim().isNotEmpty + ? _summaryController.text.trim() + : null, + createdAt: now, + updatedAt: now, + ); + + await context.read().addGame(newGame); + } else { + final updatedGame = widget.game!.copyWith( + title: _titleController.text.trim(), + coverPath: _coverPath, + rating: rating, + status: _status, + category: _category, + platforms: _platforms, + versions: _versions, + genres: _genres, + playTimeHours: playTimeHours, + playTimeMinutes: playTimeMinutes, + purchasePlatforms: _purchasePlatforms, + purchaseDate: _purchaseDate, + purchasePrice: _purchasePriceController.text.trim().isNotEmpty + ? _purchasePriceController.text.trim() + : null, + summary: _summaryController.text.trim().isNotEmpty + ? _summaryController.text.trim() + : null, + updatedAt: now, + ); + + await context.read().updateGame(updatedGame); + } + + if (!mounted) return; + ToastUtil.show(context, widget.game == null ? '添加成功' : '更新成功'); + Navigator.pop(context); + } catch (e) { + if (!mounted) return; + ToastUtil.show(context, '保存失败: $e'); + } + } + + Future _moveCoverToNewId(String currentPath, String newGameId) async { + final normalizedPath = currentPath.replaceAll('\\', '/'); + if (normalizedPath.contains('/games/$newGameId/')) { + return currentPath; + } + + final fileName = p.basename(currentPath); + final newPath = await ImagePathHelper.instance.getGameCoverPath(newGameId, fileName); + await ImagePathHelper.instance.ensureDirExists(p.dirname(newPath)); + + final currentFile = File(currentPath); + if (await currentFile.exists()) { + await currentFile.rename(newPath); + final tempDir = Directory(p.dirname(currentPath)); + if (await tempDir.exists()) { + try { + await tempDir.delete(recursive: true); + } catch (_) {} + } + return newPath; + } + + return null; + } +} + +/// 评分输入格式化器:只允许 0-10,最多1位小数 +class GameRatingInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { + final text = newValue.text; + if (text.isEmpty) return newValue; + if (!RegExp(r'^\d{0,2}\.?\d{0,1}$').hasMatch(text)) return oldValue; + final n = double.tryParse(text); + if (n != null && n > 10) return oldValue; + return newValue; + } +} + +/// 游戏简介全屏编辑页 +class _SummaryEditorPage extends StatefulWidget { + final String initialText; + const _SummaryEditorPage({required this.initialText}); + + @override + State<_SummaryEditorPage> createState() => _SummaryEditorPageState(); +} + +class _SummaryEditorPageState extends State<_SummaryEditorPage> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialText); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: const Text('游戏简介'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, _controller.text.trim()), + child: Text('完成', style: TextStyle( + fontSize: 15, fontWeight: FontWeight.w600, color: colors.primary, + )), + ), + const SizedBox(width: 8), + ], + ), + body: TextField( + controller: _controller, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.6), + decoration: InputDecoration( + hintText: '写下游戏简介...', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3)), + contentPadding: const EdgeInsets.all(20), + border: InputBorder.none, + ), + ), + ); + } +} diff --git a/lib/pages/game/game_review_detail_page.dart b/lib/pages/game/game_review_detail_page.dart new file mode 100644 index 0000000..bc84ddb --- /dev/null +++ b/lib/pages/game/game_review_detail_page.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/data_models.dart'; +import '../../providers/app_provider.dart'; +import '../../utils/toast_util.dart'; +import '../../widgets/fade_in_local_image.dart'; +import 'game_review_form_page.dart'; + +/// 游戏评价详情页 +class GameReviewDetailPage extends StatefulWidget { + final GameReview review; + final String gameId; + + const GameReviewDetailPage({super.key, required this.review, required this.gameId}); + + @override + State createState() => _GameReviewDetailPageState(); +} + +class _GameReviewDetailPageState extends State { + late GameReview _review; + + @override + void initState() { + super.initState(); + _review = widget.review; + } + + Future _refreshReviewData() async { + final provider = context.read(); + final reviews = await provider.getGameReviews(widget.gameId); + final updatedReview = reviews.where((r) => r.id == widget.review.id).firstOrNull; + if (updatedReview != null && updatedReview.id == _review.id) { + setState(() => _review = updatedReview); + } + } + + Game? _getGame() { + return context.read().games.where((g) => g.id == widget.gameId).firstOrNull; + } + + Future _deleteReview() async { + final colors = Theme.of(context).colorScheme; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定要删除这条评价吗?删除后可在回收站恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('删除'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + if (confirmed == true) { + await context.read().removeGameReview(_review.id); + if (mounted) { ToastUtil.show(context, '已删除'); Navigator.pop(context); } + } + } + + void _navigateToEdit(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => GameReviewFormPage(gameId: widget.gameId, review: _review)), + ).then((_) => _refreshReviewData()); + } + + String _formatDate(DateTime date) { + return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final game = _getGame(); + + return Scaffold( + backgroundColor: colors.surfaceContainerHigh, + appBar: AppBar( + title: const Text('评价详情'), + actions: [ + IconButton(icon: const Icon(Icons.edit_outlined, size: 20), onPressed: () => _navigateToEdit(context), tooltip: '编辑'), + IconButton(icon: Icon(Icons.delete_outline, size: 20, color: colors.error.withValues(alpha: 0.7)), onPressed: _deleteReview, tooltip: '删除'), + const SizedBox(width: 4), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (game != null) _buildGameCard(game, colors), + if (game != null) const SizedBox(height: 16), + _buildContentCard(colors), + const SizedBox(height: 16), + _buildInfoCard(colors), + ]), + ), + ); + } + + Widget _buildGameCard(Game game, ColorScheme colors) => Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(12)), + child: Row(children: [ + Container( + width: 52, height: 68, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), color: colors.surfaceContainerHighest), + clipBehavior: Clip.antiAlias, + child: game.coverPath != null + ? FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover, + errorWidget: Icon(Icons.sports_esports_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25))) + : Icon(Icons.sports_esports_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)), + ), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(game.title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface), maxLines: 2, overflow: TextOverflow.ellipsis), + if (game.rating != null) ...[const SizedBox(height: 4), Row(children: [ + Icon(Icons.star, size: 14, color: const Color(0xFFFFB800)), + const SizedBox(width: 2), + Text('${game.rating}', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))), + ])], + ])), + ]), + ); + + Widget _buildContentCard(ColorScheme colors) => Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(12)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(_review.content, style: TextStyle(fontSize: 16, color: colors.onSurface, height: 1.8)), + const SizedBox(height: 16), + Row(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration(color: colors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(10)), + child: Text(_review.typeText, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: colors.primary)), + ), + ]), + ]), + ); + + Widget _buildInfoCard(ColorScheme colors) => Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(12)), + child: Column(children: [ + _infoRow(Icons.person_outline, '评价人', _review.reviewer.isNotEmpty ? _review.reviewer : '匿名', colors), + Divider(height: 24, color: colors.outlineVariant), + if (_review.source.isNotEmpty) ...[ + _infoRow(Icons.link, '来源', _review.source, colors), + const Divider(height: 24), + ], + _infoRow(Icons.access_time, '时间', _formatDate(_review.createdAt), colors), + ]), + ); + + Widget _infoRow(IconData icon, String label, String value, ColorScheme colors) => Row(children: [ + Icon(icon, size: 18, color: colors.onSurface.withValues(alpha: 0.35)), + const SizedBox(width: 10), + Text(label, style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))), + const Spacer(), + Text(value, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface)), + ]); +} diff --git a/lib/pages/game/game_review_form_page.dart b/lib/pages/game/game_review_form_page.dart new file mode 100644 index 0000000..a51e14e --- /dev/null +++ b/lib/pages/game/game_review_form_page.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/app_provider.dart'; +import '../../widgets/fade_in_local_image.dart'; +import '../../models/data_models.dart'; +import '../../utils/toast_util.dart'; + +/// 添加/编辑游戏评价页面 +class GameReviewFormPage extends StatefulWidget { + final String gameId; + final GameReview? review; + + const GameReviewFormPage({super.key, required this.gameId, this.review}); + + @override + State createState() => _GameReviewFormPageState(); +} + +class _GameReviewFormPageState extends State { + final _formKey = GlobalKey(); + late TextEditingController _contentController; + late TextEditingController _reviewerController; + late TextEditingController _sourceController; + late int _reviewType; + + @override + void initState() { + super.initState(); + _contentController = TextEditingController(text: widget.review?.content ?? ''); + _reviewerController = TextEditingController(text: widget.review?.reviewer ?? ''); + _sourceController = TextEditingController(text: widget.review?.source ?? ''); + _reviewType = widget.review?.reviewType ?? 1; + } + + @override + void dispose() { + _contentController.dispose(); + _reviewerController.dispose(); + _sourceController.dispose(); + super.dispose(); + } + + Game? _getGame() { + return context.read().games.where((g) => g.id == widget.gameId).firstOrNull; + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final isEdit = widget.review != null; + final game = _getGame(); + + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar(title: Text(isEdit ? '编辑评价' : '写评价')), + body: Form( + key: _formKey, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (game != null) _buildGameCard(game, colors), + if (game != null) const SizedBox(height: 20), + Text('评价类型', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(height: 8), + _buildTypeSelector(colors), + const SizedBox(height: 20), + _buildMetaField(icon: Icons.person_outline, hint: '评论人(选填)', controller: _reviewerController, colors: colors), + const SizedBox(height: 12), + _buildMetaField(icon: Icons.link, hint: '来源(选填)', controller: _sourceController, colors: colors), + const SizedBox(height: 20), + Text('评论内容', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.5))), + const SizedBox(height: 8), + _buildContentField(colors), + ], + ), + ), + ), + Container( + padding: EdgeInsets.only( + left: 16, right: 16, top: 12, + bottom: MediaQuery.of(context).padding.bottom + 12, + ), + decoration: BoxDecoration( + color: colors.surface, + border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)), + ), + child: SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: _saveReview, + style: ElevatedButton.styleFrom( + backgroundColor: colors.primary, foregroundColor: colors.onPrimary, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text(isEdit ? '更新评价' : '保存评价', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildGameCard(Game game, ColorScheme colors) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(12)), + child: Row( + children: [ + Container( + width: 56, height: 72, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), color: colors.outlineVariant), + clipBehavior: Clip.antiAlias, + child: game.coverPath != null + ? FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover, + errorWidget: Icon(Icons.sports_esports_outlined, size: 24, color: colors.onSurface.withValues(alpha: 0.25))) + : Icon(Icons.sports_esports_outlined, size: 24, color: colors.onSurface.withValues(alpha: 0.25)), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(game.title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface), maxLines: 2, overflow: TextOverflow.ellipsis), + if (game.rating != null) ...[ + const SizedBox(height: 4), + Row(children: [ + Icon(Icons.star, size: 14, color: const Color(0xFFFFB800)), + const SizedBox(width: 2), + Text('${game.rating}', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: colors.onSurface.withValues(alpha: 0.6))), + ]), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _buildTypeSelector(ColorScheme colors) { + return SegmentedButton( + segments: const [ + ButtonSegment(value: 1, label: Text('短评'), icon: Icon(Icons.short_text)), + ButtonSegment(value: 2, label: Text('长评'), icon: Icon(Icons.menu_book)), + ], + selected: {_reviewType}, + onSelectionChanged: (v) => setState(() => _reviewType = v.first), + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return colors.primary; + return colors.surfaceContainerHighest; + }), + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return colors.onPrimary; + return colors.onSurface.withValues(alpha: 0.6); + }), + iconColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return colors.onPrimary; + return colors.onSurface.withValues(alpha: 0.4); + }), + ), + ); + } + + Widget _buildMetaField({required IconData icon, required String hint, required TextEditingController controller, required ColorScheme colors}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Row( + children: [ + Icon(icon, size: 18, color: colors.onSurface.withValues(alpha: 0.35)), + const SizedBox(width: 10), + Expanded( + child: TextField( + controller: controller, + style: TextStyle(fontSize: 14, color: colors.onSurface), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3)), + border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ); + } + + Widget _buildContentField(ColorScheme colors) { + return Container( + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Column( + children: [ + TextFormField( + controller: _contentController, + maxLines: 10, minLines: 6, + textAlignVertical: TextAlignVertical.top, + style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.7), + decoration: InputDecoration( + hintText: '写下你的游戏评价...', + hintStyle: TextStyle(fontSize: 15, color: colors.onSurface.withValues(alpha: 0.25)), + border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.all(14), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) return '请输入评论内容'; + return null; + }, + ), + Padding( + padding: const EdgeInsets.only(right: 14, bottom: 10), + child: Align( + alignment: Alignment.centerRight, + child: Text('${_contentController.text.length} 字', + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), + ), + ), + ], + ), + ); + } + + Future _saveReview() async { + if (!_formKey.currentState!.validate()) return; + try { + final now = DateTime.now(); + if (widget.review == null) { + final newReview = GameReview( + id: now.millisecondsSinceEpoch.toString(), + gameId: widget.gameId, + content: _contentController.text.trim(), + reviewer: _reviewerController.text.trim(), + source: _sourceController.text.trim(), + reviewType: _reviewType, + createdAt: now, + updatedAt: now, + ); + await context.read().addGameReview(newReview); + } else { + final updatedReview = widget.review!.copyWith( + content: _contentController.text.trim(), + reviewer: _reviewerController.text.trim(), + source: _sourceController.text.trim(), + reviewType: _reviewType, + updatedAt: now, + ); + await context.read().updateGameReview(updatedReview); + } + if (!mounted) return; + ToastUtil.show(context, widget.review == null ? '添加成功' : '更新成功'); + Navigator.pop(context); + } catch (e) { + if (!mounted) return; + ToastUtil.show(context, '保存失败: $e'); + } + } +} diff --git a/lib/pages/game/game_reviews_page.dart b/lib/pages/game/game_reviews_page.dart new file mode 100644 index 0000000..8aeabd7 --- /dev/null +++ b/lib/pages/game/game_reviews_page.dart @@ -0,0 +1,262 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import '../../providers/app_provider.dart'; +import '../../models/data_models.dart'; +import '../../utils/toast_util.dart'; +import 'game_review_form_page.dart'; +import 'game_review_detail_page.dart'; + +/// 游戏评价列表页面 +class GameReviewsPage extends StatefulWidget { + final Game game; + + const GameReviewsPage({super.key, required this.game}); + + @override + State createState() => _GameReviewsPageState(); +} + +class _GameReviewsPageState extends State { + List _reviews = []; + List _filteredReviews = []; + bool _isLoading = true; + bool _isSearching = false; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadReviews(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadReviews() async { + setState(() => _isLoading = true); + final reviews = await context.read().getGameReviews(widget.game.id); + setState(() { + _reviews = reviews; + _filteredReviews = reviews; + _isLoading = false; + }); + } + + void _toggleSearch() { + setState(() { + _isSearching = !_isSearching; + if (!_isSearching) { + _searchController.clear(); + _filteredReviews = _reviews; + } + }); + } + + void _onSearchChanged(String query) { + setState(() { + if (query.isEmpty) { + _filteredReviews = _reviews; + } else { + final lowerQuery = query.toLowerCase(); + _filteredReviews = _reviews.where((review) { + return review.content.toLowerCase().contains(lowerQuery) || + review.reviewer.toLowerCase().contains(lowerQuery) || + review.source.toLowerCase().contains(lowerQuery); + }).toList(); + } + }); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar( + title: _isSearching + ? TextField( + controller: _searchController, + autofocus: true, + decoration: InputDecoration( + hintText: '搜索评价内容、评论人、来源...', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.4)), + border: InputBorder.none, + ), + style: TextStyle(color: colors.onSurface), + onChanged: _onSearchChanged, + ) + : const Text('游戏评价'), + actions: [ + IconButton( + icon: Icon(_isSearching ? Icons.close : Icons.search), + onPressed: _toggleSearch, + ), + const SizedBox(width: 8), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _navigateToAddReview(), + icon: const Icon(Icons.add, size: 20), + label: const Text('添加评价'), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _filteredReviews.isEmpty + ? _buildEmptyState() + : _buildReviewList(), + ); + } + + Widget _buildEmptyState() { + final colors = Theme.of(context).colorScheme; + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, height: 80, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), + child: Icon(Icons.rate_review_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25)), + ), + const SizedBox(height: 20), + Text('暂无评价', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 24), + ], + ), + ); + } + + Widget _buildReviewList() { + return MasonryGridView.count( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + padding: const EdgeInsets.all(12), + itemCount: _filteredReviews.length, + itemBuilder: (context, index) { + final review = _filteredReviews[index]; + return _buildReviewCard(review); + }, + ); + } + + Widget _buildReviewCard(GameReview review) { + final colors = Theme.of(context).colorScheme; + return InkWell( + onTap: () => _navigateToReviewDetail(review), + onLongPress: () => _showDeleteDialog(review), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: review.reviewType == 1 ? colors.surface : colors.primary, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + review.typeText, + style: TextStyle( + fontSize: 10, + color: review.reviewType == 1 + ? colors.onSurface.withValues(alpha: 0.6) + : colors.onPrimary, + ), + ), + ), + const SizedBox(height: 8), + Text( + review.content, + maxLines: review.reviewType == 1 ? 4 : 8, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 13, color: colors.onSurface, height: 1.5), + ), + const SizedBox(height: 12), + if (review.reviewer.isNotEmpty) + Text(review.reviewer, + style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.6)), + overflow: TextOverflow.ellipsis), + const SizedBox(height: 4), + Row( + children: [ + if (review.source.isNotEmpty) + Expanded( + child: Text(review.source, + style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4)), + overflow: TextOverflow.ellipsis), + ), + Text(_formatDate(review.createdAt), + style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ], + ), + ), + ); + } + + String _formatDate(DateTime date) { + return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; + } + + void _navigateToAddReview() { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => GameReviewFormPage(gameId: widget.game.id)), + ).then((_) => _loadReviews()); + } + + void _navigateToReviewDetail(GameReview review) { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => GameReviewDetailPage(review: review, gameId: widget.game.id)), + ).then((_) => _loadReviews()); + } + + void _showDeleteDialog(GameReview review) { + showDialog( + context: context, + builder: (context) { + final colors = Theme.of(context).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定要删除这条评价吗?删除后可在回收站恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), + ), + ElevatedButton( + onPressed: () async { + await context.read().removeGameReview(review.id); + Navigator.pop(context); + _loadReviews(); + ToastUtil.show(context, '已删除'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('删除'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ); + }, + ); + } +} diff --git a/lib/pages/game/game_screenshots_page.dart b/lib/pages/game/game_screenshots_page.dart new file mode 100644 index 0000000..8244118 --- /dev/null +++ b/lib/pages/game/game_screenshots_page.dart @@ -0,0 +1,366 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:http/http.dart' as http; +import '../../providers/app_provider.dart'; +import '../../widgets/fade_in_local_image.dart'; +import '../../models/data_models.dart'; +import '../../utils/toast_util.dart'; +import '../../utils/image_path_helper.dart'; +import 'screenshot_gallery_page.dart'; + +/// 游戏截图页面 +class GameScreenshotsPage extends StatefulWidget { + final Game game; + + const GameScreenshotsPage({super.key, required this.game}); + + @override + State createState() => _GameScreenshotsPageState(); +} + +class _GameScreenshotsPageState extends State { + final ImagePicker _picker = ImagePicker(); + List _screenshots = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadScreenshots(); + } + + Future _loadScreenshots() async { + setState(() => _isLoading = true); + final screenshots = await context.read().getGameScreenshots(widget.game.id); + setState(() { + _screenshots = screenshots; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colors.surface, + appBar: AppBar(title: const Text('游戏截图')), + floatingActionButton: FloatingActionButton.extended( + onPressed: _pickScreenshot, + icon: const Icon(Icons.add_photo_alternate, size: 20), + label: const Text('添加截图'), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _screenshots.isEmpty + ? _buildEmptyState() + : _buildScreenshotGrid(), + ); + } + + Widget _buildEmptyState() { + final colors = Theme.of(context).colorScheme; + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, height: 80, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), + child: Icon(Icons.photo_library_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25)), + ), + const SizedBox(height: 20), + Text('暂无截图', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 24), + ], + ), + ); + } + + Widget _buildScreenshotGrid() { + return MasonryGridView.count( + padding: const EdgeInsets.all(16), + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + itemCount: _screenshots.length, + itemBuilder: (context, index) { + final screenshot = _screenshots[index]; + return _buildScreenshotItem(screenshot, index); + }, + ); + } + + Widget _buildScreenshotItem(GameScreenshot screenshot, int index) { + final heights = [180.0, 220.0, 160.0, 200.0, 240.0, 190.0]; + final height = heights[index % heights.length]; + + return GestureDetector( + onTap: () => _showScreenshotDetail(screenshot), + onLongPress: () => _showDeleteDialog(screenshot), + child: Container( + height: height, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Stack( + fit: StackFit.expand, + children: [ + FadeInLocalImage(path: screenshot.screenshotPath, fit: BoxFit.cover), + Positioned( + bottom: 0, left: 0, right: 0, + child: Container( + height: 40, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.3)], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _showScreenshotDetail(GameScreenshot screenshot) { + final initialIndex = _screenshots.indexWhere((s) => s.id == screenshot.id); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ScreenshotGalleryPage( + screenshots: _screenshots, + initialIndex: initialIndex >= 0 ? initialIndex : 0, + ), + ), + ); + } + + Future _pickScreenshot() async { + final result = await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (context) { + final colors = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration(color: colors.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(16))), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(width: 40, height: 4, decoration: BoxDecoration(color: colors.outline, borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row(children: [ + Text('添加截图', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + ]), + ), + const SizedBox(height: 16), + _buildAddOption(colors: colors, icon: Icons.photo_library_outlined, title: '从相册选择', subtitle: '选择本地图片', onTap: () => Navigator.pop(context, 0)), + _buildAddOption(colors: colors, icon: Icons.link_outlined, title: '网络链接', subtitle: '输入图片URL地址', onTap: () => Navigator.pop(context, 1)), + ], + ), + ), + ), + ); + }, + ); + + if (result == null) return; + if (result == 0) { + await _pickFromGallery(); + } else if (result == 1) { + await _pickFromUrl(); + } + } + + Widget _buildAddOption({required ColorScheme colors, required IconData icon, required String title, required String subtitle, required VoidCallback onTap}) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + child: Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(icon, size: 22, color: colors.onSurface.withValues(alpha: 0.6)), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: colors.onSurface)), + const SizedBox(height: 2), + Text(subtitle, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), + ], + ), + ), + Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25), size: 20), + ], + ), + ), + ); + } + + Future _pickFromGallery() async { + try { + final XFile? pickedFile = await _picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1200, maxHeight: 1800, imageQuality: 85, + ); + if (pickedFile != null) { + final fileName = 'screenshot_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final targetPath = await ImagePathHelper.instance.getGameScreenshotImgPath(widget.game.id, fileName); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + await File(pickedFile.path).copy(targetPath); + + final newScreenshot = GameScreenshot( + id: DateTime.now().millisecondsSinceEpoch.toString(), + gameId: widget.game.id, + screenshotPath: targetPath, + createdAt: DateTime.now(), + ); + await context.read().addGameScreenshot(newScreenshot); + _loadScreenshots(); + if (mounted) ToastUtil.show(context, '添加成功'); + } + } catch (e) { + if (mounted) ToastUtil.show(context, '添加截图失败: $e'); + } + } + + Future _pickFromUrl() async { + final urlController = TextEditingController(); + final confirmed = await showDialog( + context: context, + builder: (context) { + final colors = Theme.of(context).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('添加网络图片'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('请输入图片链接地址', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(height: 12), + TextField( + controller: urlController, + decoration: InputDecoration( + hintText: 'https://example.com/image.jpg', + hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.25)), + border: const UnderlineInputBorder(), + enabledBorder: UnderlineInputBorder(borderSide: BorderSide(color: colors.outline)), + focusedBorder: UnderlineInputBorder(borderSide: BorderSide(color: colors.primary)), + ), + style: const TextStyle(fontSize: 14), + keyboardType: TextInputType.url, + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + TextButton(onPressed: () => Navigator.pop(context, true), child: Text('确定', style: TextStyle(color: colors.onSurface))), + ], + ); + }, + ); + + if (confirmed != true) return; + final url = urlController.text.trim(); + WidgetsBinding.instance.addPostFrameCallback((_) { + urlController.dispose(); + }); + if (url.isEmpty) { if (mounted) ToastUtil.show(context, '请输入图片链接'); return; } + + try { + await _downloadAndSaveScreenshot(url); + } catch (e) { + if (mounted) ToastUtil.show(context, '添加失败: $e'); + } + } + + Future _downloadAndSaveScreenshot(String url) async { + try { + final response = await http.get( + Uri.parse(url), + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + 'Referer': Uri.parse(url).replace(path: '/').toString(), + }, + ); + if (response.statusCode != 200) throw Exception('下载失败: HTTP ${response.statusCode}'); + final contentType = response.headers['content-type']; + if (contentType != null && !contentType.startsWith('image/')) throw Exception('链接返回的不是图片'); + if (response.bodyBytes.length > 10 * 1024 * 1024) throw Exception('图片太大'); + + final fileName = 'screenshot_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final targetPath = await ImagePathHelper.instance.getGameScreenshotImgPath(widget.game.id, fileName); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + await File(targetPath).writeAsBytes(response.bodyBytes); + + final newScreenshot = GameScreenshot( + id: DateTime.now().millisecondsSinceEpoch.toString(), + gameId: widget.game.id, + screenshotPath: targetPath, + createdAt: DateTime.now(), + ); + await context.read().addGameScreenshot(newScreenshot); + _loadScreenshots(); + if (mounted) ToastUtil.show(context, '添加成功'); + } catch (e) { + throw Exception('下载图片失败: $e'); + } + } + + void _showDeleteDialog(GameScreenshot screenshot) { + showDialog( + context: context, + builder: (context) { + final colors = Theme.of(context).colorScheme; + return AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定要删除这张截图吗?', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton( + onPressed: () async { + await context.read().removeGameScreenshot(screenshot.id); + Navigator.pop(context); + _loadScreenshots(); + ToastUtil.show(context, '已删除'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('删除'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ); + }, + ); + } +} diff --git a/lib/pages/game/game_share_page.dart b/lib/pages/game/game_share_page.dart new file mode 100644 index 0000000..66586b1 --- /dev/null +++ b/lib/pages/game/game_share_page.dart @@ -0,0 +1,262 @@ +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import '../../models/data_models.dart'; +import '../../utils/toast_util.dart'; +import '../../widgets/fade_in_local_image.dart'; + +class GameSharePage extends StatefulWidget { + final Game game; + const GameSharePage({super.key, required this.game}); + + @override + State createState() => _GameSharePageState(); +} + +class _GameSharePageState extends State { + final GlobalKey _posterKey = GlobalKey(); + bool _isGenerating = false; + int _currentStyle = 0; + + static const _styleNames = ['海报', '游戏卡']; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colors.surfaceContainerHighest, + appBar: AppBar( + backgroundColor: colors.surface, + elevation: 0, + leading: IconButton(icon: Icon(Icons.close, color: colors.onSurface), onPressed: () => Navigator.pop(context)), + title: Text('分享海报', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + centerTitle: true, + actions: [ + IconButton(icon: Icon(Icons.palette_outlined, color: colors.onSurface, size: 22), tooltip: '选择样式', onPressed: _showStylePicker), + TextButton( + onPressed: _isGenerating ? null : _generateAndShare, + child: _isGenerating + ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : Text('分享', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface)), + ), + const SizedBox(width: 4), + ], + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: RepaintBoundary( + key: _posterKey, + child: _currentStyle == 1 ? _buildGameCard() : _buildPosterWidget(), + ), + ), + ), + ); + } + + void _showStylePicker() { + final colors = Theme.of(context).colorScheme; + const icons = [Icons.image_outlined, Icons.sports_esports_outlined]; + const subtitles = ['简约海报风格', '游戏信息卡风格']; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (ctx) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 36, height: 4, decoration: BoxDecoration(color: colors.onSurface.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(2))), + const SizedBox(height: 20), + Align(alignment: Alignment.centerLeft, child: Text('选择样式', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface))), + const SizedBox(height: 12), + for (int i = 0; i < _styleNames.length; i++) ...[ + if (i > 0) Divider(height: 0.5, color: colors.outlineVariant), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Container(width: 36, height: 36, decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(10)), + child: Icon(icons[i], size: 20, color: _currentStyle == i ? colors.primary : colors.onSurface.withValues(alpha: 0.6))), + title: Text(_styleNames[i], style: TextStyle(fontSize: 13, fontWeight: _currentStyle == i ? FontWeight.w600 : FontWeight.w500, color: colors.onSurface)), + subtitle: Text(subtitles[i], style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))), + trailing: _currentStyle == i + ? Icon(Icons.check_circle, size: 20, color: colors.primary) + : Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.25)), + onTap: () { setState(() => _currentStyle = i); Navigator.pop(ctx); }, + ), + ], + const SizedBox(height: 12), + ]), + ), + ); + } + + // ─── 样式 0:海报 ─── + + Widget _buildPosterWidget() { + final colors = Theme.of(context).colorScheme; + final game = widget.game; + final hasCover = game.coverPath != null && game.coverPath!.isNotEmpty; + + return Container( + width: 320, + decoration: BoxDecoration(color: colors.surface, borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))]), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (hasCover) + ClipRRect(borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + child: FadeInLocalImage(path: game.coverPath, width: 320, height: 200, fit: BoxFit.cover)), + Padding(padding: const EdgeInsets.all(20), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(game.title, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: colors.onSurface)), + const SizedBox(height: 16), + if (game.rating != null && game.rating! > 0) ...[ + Row(children: [ + const Icon(Icons.star, size: 18, color: Color(0xFFFFB800)), + const SizedBox(width: 4), + Text(game.rating!.toStringAsFixed(1), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFFFFB800))), + const SizedBox(width: 4), + Text('/ 10', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + ]), + const SizedBox(height: 12), + ], + if (game.platforms.isNotEmpty) _infoRow('平台', game.platforms.join(' / '), colors), + if (game.genres.isNotEmpty) _infoRow('类型', game.genres.join(' / '), colors), + if (game.playTimeHours > 0 || game.playTimeMinutes > 0) + _infoRow('时长', '${game.playTimeHours}时${game.playTimeMinutes}分', colors), + if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty) + _infoRow('价格', game.purchasePrice!, colors), + if (game.summary != null && game.summary!.isNotEmpty) ...[ + const SizedBox(height: 16), + Text('简介', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))), + const SizedBox(height: 8), + Text(game.summary!, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6), height: 1.6), maxLines: 5, overflow: TextOverflow.ellipsis), + ], + const SizedBox(height: 20), + Divider(height: 1, color: colors.outline), + const SizedBox(height: 12), + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.sports_esports_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.5)), + const SizedBox(width: 6), + Text('来自 MookNote', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5))), + ]), + ]), + ), + ]), + ); + } + + Widget _infoRow(String label, String value, ColorScheme colors) { + return Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('$label:', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))), + Expanded(child: Text(value, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.75)))), + ])); + } + + // ─── 样式 1:游戏卡 ─── + + Widget _buildGameCard() { + final game = widget.game; + const c = Color(0xFF2D2D2D); + + return Container( + width: 300, + decoration: BoxDecoration(color: const Color(0xFFFFFBF5), borderRadius: BorderRadius.circular(8), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.08), blurRadius: 16, offset: const Offset(0, 6))]), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Padding(padding: const EdgeInsets.all(16), child: Column(children: [ + if (game.coverPath != null && game.coverPath!.isNotEmpty) + ClipRRect(borderRadius: BorderRadius.circular(4), + child: FadeInLocalImage(path: game.coverPath, width: 268, height: 160, fit: BoxFit.cover)), + const SizedBox(height: 12), + Text(game.title, textAlign: TextAlign.center, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: c, letterSpacing: 1)), + ])), + _dashedLine(c.withValues(alpha: 0.15)), + Padding(padding: const EdgeInsets.fromLTRB(20, 14, 20, 16), child: Column(children: [ + _classicRow('PLATFORM', game.platforms.isNotEmpty ? game.platforms.join(', ') : '--'), + const SizedBox(height: 10), + _classicRow('GENRE', game.genres.isNotEmpty ? game.genres.join(' / ') : '--'), + const SizedBox(height: 10), + _classicRow('STATUS', _statusEN(game.status)), + if (game.playTimeHours > 0 || game.playTimeMinutes > 0) ...[ + const SizedBox(height: 10), + _classicRow('PLAY TIME', '${game.playTimeHours}h ${game.playTimeMinutes}m'), + ], + if (game.rating != null && game.rating! > 0) ...[ + const SizedBox(height: 10), + _classicRow('RATING', '${game.rating!.toStringAsFixed(1)} / 10'), + ], + const SizedBox(height: 14), + Row(children: [ + Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(border: Border.all(color: c.withValues(alpha: 0.2), width: 0.5)), + child: Text(_statusEN(game.status), style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, letterSpacing: 2, color: c.withValues(alpha: 0.5)))), + const Spacer(), + Icon(Icons.sports_esports_outlined, size: 12, color: c.withValues(alpha: 0.3)), + const SizedBox(width: 4), + Text('MookNote', style: TextStyle(fontSize: 9, letterSpacing: 1, color: c.withValues(alpha: 0.3))), + ]), + ]), + ), + ]), + ); + } + + Widget _classicRow(String label, String value) { + return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 80, child: Text(label, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, letterSpacing: 1.5, color: const Color(0xFF2D2D2D).withValues(alpha: 0.35)))), + Expanded(child: Text(value, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: Color(0xFF2D2D2D), height: 1.4))), + ]); + } + + Widget _dashedLine(Color color) { + return Padding(padding: const EdgeInsets.symmetric(horizontal: 8), + child: CustomPaint(size: const Size(double.infinity, 1), painter: _DashedLinePainter(color: color))); + } + + String _statusEN(String s) { + switch (s) { + case 'completed': return 'COMPLETED'; + case 'playing': return 'PLAYING'; + case 'want_to_play': return 'WISHLIST'; + case 'abandoned': return 'DROPPED'; + default: return s.toUpperCase(); + } + } + + Future _generateAndShare() async { + setState(() => _isGenerating = true); + try { + final boundary = _posterKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) throw Exception('无法获取海报边界'); + final image = await boundary.toImage(pixelRatio: 3.0); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) throw Exception('无法生成图片数据'); + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/game_poster_${DateTime.now().millisecondsSinceEpoch}.png'); + await file.writeAsBytes(byteData.buffer.asUint8List()); + await Share.shareXFiles([XFile(file.path)], text: '分享游戏:${widget.game.title}'); + } catch (e) { + if (mounted) ToastUtil.show(context, '生成海报失败:$e'); + } finally { + if (mounted) setState(() => _isGenerating = false); + } + } +} + +class _DashedLinePainter extends CustomPainter { + final Color color; + final double dashWidth; + final double dashSpace; + _DashedLinePainter({required this.color, this.dashWidth = 4, this.dashSpace = 4}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = color..strokeWidth = 1..style = PaintingStyle.stroke; + double x = 0; + while (x < size.width) { canvas.drawLine(Offset(x, 0), Offset(x + dashWidth, 0), paint); x += dashWidth + dashSpace; } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/pages/game/game_tab_page.dart b/lib/pages/game/game_tab_page.dart new file mode 100644 index 0000000..bfd2b26 --- /dev/null +++ b/lib/pages/game/game_tab_page.dart @@ -0,0 +1,503 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/data_models.dart'; +import '../../providers/app_provider.dart'; +import '../../utils/user_prefs.dart'; +import '../../widgets/game_status_bar.dart'; +import '../../widgets/game_list_item.dart'; +import '../../widgets/animated_star_rating.dart'; +import '../../widgets/shimmer_skeleton.dart'; +import '../../widgets/fade_in_local_image.dart'; +import '../../utils/responsive.dart'; +import '../../widgets/master_detail_scaffold.dart'; +import '../../widgets/detail_placeholder.dart'; +import 'game_detail_page.dart'; + +/// 游戏标签页(分页 + 触底加载) +class GameTabPage extends StatefulWidget { + const GameTabPage({super.key}); + + @override + State createState() => _GameTabPageState(); +} + +class _GameTabPageState extends State { + final List _items = []; + bool _hasMore = true; + bool _isLoading = false; + int _offset = 0; + bool _initialized = false; + int _lastStatusIndex = -1; + late ScrollController _scrollController; + AppProvider? _provider; + int _lastScrollSignal = 0; + int _lastEditRefreshCounter = 0; + int _prevGameCount = -1; + int _prevLayoutStyle = -1; + double _swipeOffset = 0.0; + + static const _statusMap = {0: 'completed', 1: 'playing', 2: 'want_to_play', 3: 'abandoned'}; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController()..addListener(_onScroll); + WidgetsBinding.instance.addPostFrameCallback((_) { + final provider = context.read(); + _provider = provider; + provider.addListener(_onDataChanged); + _loadFirst(); + }); + } + + @override + void dispose() { + _provider?.removeListener(_onDataChanged); + _scrollController.dispose(); + super.dispose(); + } + + void _onDataChanged() { + if (!_initialized || !mounted) return; + final provider = context.read(); + + if (provider.scrollToTopSignal != _lastScrollSignal && provider.scrollToTopSignal > 0) { + _lastScrollSignal = provider.scrollToTopSignal; + if (_scrollController.hasClients) { + _scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut); + } + } + + final statusChanged = provider.gameStatusIndex != _lastStatusIndex; + final layoutChanged = provider.gameLayoutStyle != _prevLayoutStyle; + final countChanged = provider.games.length != _prevGameCount; + final editRefreshed = provider.editRefreshCounter > _lastEditRefreshCounter; + if (editRefreshed && provider.lastEditedItemId != null) { + _lastEditRefreshCounter = provider.editRefreshCounter; + _prevGameCount = provider.games.length; + final editedId = provider.lastEditedItemId!; + final idx = _items.indexWhere((g) => g.id == editedId); + final updated = provider.games.where((g) => g.id == editedId).firstOrNull; + if (updated != null) { + final isWallMode = provider.gameWallMode; + final currentStatus = isWallMode ? null : (_statusMap[provider.gameStatusIndex] ?? 'completed'); + if (currentStatus != null && updated.status != currentStatus) { + // 状态已变更,从当前列表移除 + if (idx != -1) { + setState(() { _items.removeAt(idx); }); + } + } else if (idx != -1) { + setState(() { _items[idx] = updated; }); + } + } else if (idx != -1) { + // 游戏已被删除,从列表移除 + setState(() { _items.removeAt(idx); }); + } + return; + } + if (statusChanged || layoutChanged || countChanged || editRefreshed) { + _prevLayoutStyle = provider.gameLayoutStyle; + _prevGameCount = provider.games.length; + _loadFirst(); + } + if (editRefreshed) { + _lastEditRefreshCounter = provider.editRefreshCounter; + } + } + + void _onScroll() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + _loadMore(); + } + } + + Future _loadFirst() async { + final provider = context.read(); + final isWallMode = provider.gameWallMode; + final statusIdx = provider.gameStatusIndex; + _lastStatusIndex = statusIdx; + _initialized = true; + final status = isWallMode ? null : (_statusMap[statusIdx] ?? 'completed'); + final sortMode = UserPrefs().gameSortMode; + setState(() { _isLoading = true; _offset = 0; _hasMore = true; }); + final list = await provider.loadGamesPaged(status: status, offset: 0, sortMode: sortMode); + if (!mounted) return; + setState(() { + _items.clear(); + _items.addAll(list); + _offset = list.length; + _hasMore = list.length >= 20; + _isLoading = false; + }); + } + + Future _loadMore() async { + if (_isLoading || !_hasMore) return; + setState(() => _isLoading = true); + final provider = context.read(); + final isWallMode = provider.gameWallMode; + final status = isWallMode ? null : (_statusMap[provider.gameStatusIndex] ?? 'completed'); + final sortMode = UserPrefs().gameSortMode; + final list = await provider.loadGamesPaged(status: status, offset: _offset, sortMode: sortMode); + if (!mounted) return; + setState(() { + _items.addAll(list); + _offset += list.length; + _hasMore = list.length >= 20; + _isLoading = false; + }); + } + + Future _refresh() async { + final provider = context.read(); + await provider.loadGames(); + await _loadFirst(); + } + + void _onGameTap(Game game) { + if (Breakpoint.isWideContent(context)) { + context.read().selectGame(game); + } else { + Navigator.pushNamed(context, '/game-detail', arguments: game); + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final isWideContent = Breakpoint.isWideContent(context); + final provider = context.watch(); + final isWallMode = provider.gameWallMode; + + final masterContent = Column( + children: [ + if (!isWallMode) const GameStatusBar(), + if (!isWallMode) Divider(height: 0.5, thickness: 0.5, color: colors.outlineVariant), + Expanded(child: _buildBody(context)), + ], + ); + + if (!isWideContent) return masterContent; + + final selectedGame = provider.selectedGame; + return MasterDetailScaffold( + master: masterContent, + detail: selectedGame != null + ? GameDetailPage(game: selectedGame, embedded: true) + : const DetailPlaceholder(icon: Icons.sports_esports_outlined, message: '选择一款游戏查看详情'), + ); + } + + Widget _buildBody(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Consumer( + builder: (context, provider, _) { + if (_initialized && provider.gameStatusIndex != _lastStatusIndex) { + _lastStatusIndex = provider.gameStatusIndex; + WidgetsBinding.instance.addPostFrameCallback((_) => _loadFirst()); + } + + final content = () { + if (_items.isEmpty && _isLoading) return _buildSkeleton(); + if (_items.isEmpty) { + return RefreshIndicator( + onRefresh: _refresh, + color: colors.primary, + backgroundColor: colors.surface, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [_buildEmptyState(context, provider.gameStatusIndex)], + ), + ); + } + return RefreshIndicator( + onRefresh: _refresh, + color: colors.primary, + backgroundColor: colors.surface, + child: provider.gameLayoutStyle == 1 ? _buildListView() : provider.gameLayoutStyle == 2 ? _buildCoverCardView() : _buildGridView(), + ); + }(); + + return GestureDetector( + onHorizontalDragStart: (_) => _swipeOffset = 0.0, + onHorizontalDragUpdate: (details) => setState(() => _swipeOffset += details.primaryDelta ?? 0), + onHorizontalDragEnd: (details) { + final velocity = details.primaryVelocity; + if ((velocity ?? 0).abs() < 80) { + setState(() => _swipeOffset = 0.0); + return; + } + final direction = (velocity ?? 0) > 0 ? -1 : 1; + final currentIndex = provider.gameStatusIndex; + final newIndex = (currentIndex + direction + 4) % 4; + setState(() => _swipeOffset = 0.0); + provider.setGameStatusIndex(newIndex); + }, + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: _swipeOffset.clamp(-100.0, 100.0)), + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + builder: (context, value, child) { + return Transform.translate(offset: Offset(value, 0), child: child); + }, + child: content, + ), + ); + }, + ); + } + + Widget _buildGridView() { + return LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 110); + final isWideContent = Breakpoint.isWideContent(context); + final provider = context.read(); + return GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, childAspectRatio: 0.55, crossAxisSpacing: 12, mainAxisSpacing: 16, + ), + itemCount: _items.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) return _buildLoadMoreIndicator(); + final item = _items[index]; + return GameListItem( + game: item, + selected: isWideContent && provider.selectedGame?.id == item.id, + onTap: () => _onGameTap(item), + ); + }, + ); + }, + ); + } + + Widget _buildListView() { + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), + itemCount: _items.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) return _buildLoadMoreIndicator(); + return _buildListCard(_items[index]); + }, + ); + } + + Widget _buildLoadMoreIndicator() { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Center( + child: _isLoading + ? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Theme.of(context).colorScheme.primary)) + : Text('没有更多了', style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3))), + ), + ); + } + + Widget _buildListCard(Game game) { + final colors = Theme.of(context).colorScheme; + return GestureDetector( + onTap: () => _onGameTap(game), + onLongPress: () => _showDeleteDialog(context, game), + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)), + child: Row(children: [ + Container( + width: 48, height: 64, + decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(6)), + clipBehavior: Clip.antiAlias, + child: game.coverPath != null && game.coverPath!.isNotEmpty + ? FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover, + errorWidget: Icon(Icons.sports_esports_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25))) + : Icon(Icons.sports_esports_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)), + ), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(game.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + const SizedBox(height: 3), + Text(_buildSubtitle(game), maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))), + const SizedBox(height: 6), + if (game.rating != null) AnimatedStarRating(rating: game.rating!, starSize: 12, showNumber: true) + else const SizedBox(height: 14), + ])), + const SizedBox(width: 8), + Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.2), size: 20), + ]), + ), + ); + } + + String _buildSubtitle(Game game) { + final parts = []; + if (game.platforms.isNotEmpty) parts.add(game.platforms.take(2).join('、')); + if (game.genres.isNotEmpty) parts.add(game.genres.take(2).join('、')); + if (game.playTimeHours > 0 || game.playTimeMinutes > 0) { + parts.add('${game.playTimeHours}时${game.playTimeMinutes}分'); + } + return parts.join(' · '); + } + + Widget _buildCoverCardView() { + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), + itemCount: _items.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) return _buildLoadMoreIndicator(); + return _buildCoverCard(_items[index]); + }, + ); + } + + Widget _buildCoverCard(Game game) { + final colors = Theme.of(context).colorScheme; + return GestureDetector( + onTap: () => _onGameTap(game), + onLongPress: () => _showDeleteDialog(context, game), + child: Container( + height: 200, + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: colors.surfaceContainerHigh, + ), + clipBehavior: Clip.antiAlias, + child: Stack(fit: StackFit.expand, children: [ + if (game.coverPath != null && game.coverPath!.isNotEmpty) + FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover, + errorWidget: Container(color: colors.surfaceContainerHighest)) + else + Container(color: colors.surfaceContainerHighest, + child: Icon(Icons.sports_esports_outlined, size: 48, color: colors.onSurface.withValues(alpha: 0.15))), + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.75)], + stops: const [0.4, 1.0], + ), + ), + ), + ), + Positioned( + left: 14, right: 14, bottom: 14, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text(game.title, maxLines: 1, overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Colors.white)), + const SizedBox(height: 4), + Row(children: [ + Expanded( + child: Text(_buildSubtitle(game), maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))), + ), + if (game.rating != null) ...[ + const SizedBox(width: 8), + Icon(Icons.star_rounded, size: 16, color: Colors.amber.shade400), + const SizedBox(width: 2), + Text(game.rating!.toStringAsFixed(1), + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)), + ], + ]), + ]), + ), + ]), + ), + ); + } + + Widget _buildCoverCardSkeleton() { + final colors = Theme.of(context).colorScheme; + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 100), + itemCount: 4, + itemBuilder: (_, __) => Container( + height: 200, + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(14), + ), + ), + ); + } + + void _showDeleteDialog(BuildContext context, Game game) { + final colors = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: colors.surface, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), + content: Text('确定要删除《${game.title}》吗?删除后可在回收站恢复。', + style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), + child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))), + ElevatedButton( + onPressed: () async { + await context.read().removeGame(game.id); + Navigator.pop(ctx); + _loadFirst(); + }, + style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + child: const Text('删除'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + } + + Widget _buildSkeleton() { + final layoutStyle = context.read().gameLayoutStyle; + if (layoutStyle == 1) return _buildListSkeleton(); + if (layoutStyle == 2) return _buildCoverCardSkeleton(); + return const GameSkeletonGrid(); + } + + Widget _buildListSkeleton() { + final colors = Theme.of(context).colorScheme; + return ListView.builder( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), itemCount: 6, + itemBuilder: (_, __) => Container( + margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)), + child: const Row(children: [ + ShimmerSkeleton(width: 48, height: 64, borderRadius: 6), SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + ShimmerSkeleton(width: 160, height: 16), SizedBox(height: 6), + ShimmerSkeleton(width: 100, height: 12), SizedBox(height: 6), + ShimmerSkeleton(width: 70, height: 12), + ])), + SizedBox(width: 8), ShimmerSkeleton(width: 20, height: 20, borderRadius: 10), + ]), + ), + ); + } + + Widget _buildEmptyState(BuildContext context, int statusIndex) { + final colors = Theme.of(context).colorScheme; + final provider = context.read(); + final isWallMode = provider.gameWallMode; + final statusText = isWallMode ? '' : ['已通关', '在玩', '想玩', '弃游'][statusIndex]; + return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + Container(width: 80, height: 80, + decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), + child: Icon(Icons.sports_esports_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25))), + const SizedBox(height: 20), + Text(isWallMode ? '暂无游戏' : '暂无$statusText的游戏', style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))), + ])); + } +} diff --git a/lib/pages/game/screenshot_gallery_page.dart b/lib/pages/game/screenshot_gallery_page.dart new file mode 100644 index 0000000..5ee047b --- /dev/null +++ b/lib/pages/game/screenshot_gallery_page.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import '../../models/data_models.dart'; +import '../../widgets/fade_in_local_image.dart'; + +/// 游戏截图画廊页面 - 支持左右滑动浏览 +class ScreenshotGalleryPage extends StatefulWidget { + final List screenshots; + final int initialIndex; + + const ScreenshotGalleryPage({super.key, required this.screenshots, required this.initialIndex}); + + @override + State createState() => _ScreenshotGalleryPageState(); +} + +class _ScreenshotGalleryPageState extends State { + late PageController _pageController; + late int _currentIndex; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + PageView.builder( + controller: _pageController, + itemCount: widget.screenshots.length, + onPageChanged: (index) => setState(() => _currentIndex = index), + itemBuilder: (context, index) { + final screenshot = widget.screenshots[index]; + return InteractiveViewer( + minScale: 0.5, + maxScale: 3.0, + child: Center( + child: FadeInLocalImage(path: screenshot.screenshotPath, fit: BoxFit.contain), + ), + ); + }, + ), + Positioned( + top: 0, left: 0, right: 0, + child: SafeArea( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, end: Alignment.bottomCenter, + colors: [Colors.black.withValues(alpha: 0.7), Colors.transparent], + ), + ), + child: Row( + children: [ + IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.arrow_back, color: Colors.white)), + const Spacer(), + Text('${_currentIndex + 1} / ${widget.screenshots.length}', + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500)), + const Spacer(), + const SizedBox(width: 48), + ], + ), + ), + ), + ), + if (widget.screenshots.length > 1) + Positioned( + bottom: 20, left: 0, right: 0, + child: SafeArea( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + widget.screenshots.length, + (index) => Container( + width: 8, height: 8, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: index == _currentIndex ? Colors.white : Colors.white.withValues(alpha: 0.4), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/home/main_content_page.dart b/lib/pages/home/main_content_page.dart index e106acf..46ff3d4 100644 --- a/lib/pages/home/main_content_page.dart +++ b/lib/pages/home/main_content_page.dart @@ -6,6 +6,7 @@ import '../../services/sync/webdav_service.dart'; import '../movies/movie_tab_page.dart'; import '../book/book_tab_page.dart'; import '../note/note_tab_page.dart'; +import '../game/game_tab_page.dart'; import '../online_search/search_page.dart'; import '../online_search/online_search_page.dart'; import '../sync/webdav_sync_page.dart'; @@ -24,6 +25,7 @@ class _MainContentPageState extends State { bool _showMovieTab = true; bool _showBookTab = true; bool _showNoteTab = true; + bool _showGameTab = true; late PageController _pageController; bool _isTabTap = false; @@ -47,6 +49,7 @@ class _MainContentPageState extends State { _showMovieTab = _userPrefs.showMovieTab; _showBookTab = _userPrefs.showBookTab; _showNoteTab = _userPrefs.showNoteTab; + _showGameTab = _userPrefs.showGameTab; }); } @@ -61,6 +64,7 @@ class _MainContentPageState extends State { if (_showMovieTab) tabs.add(_TabItem('影视', 0)); if (_showBookTab) tabs.add(_TabItem('阅读', 1)); if (_showNoteTab) tabs.add(_TabItem('笔记', 2)); + if (_showGameTab) tabs.add(_TabItem('游戏', 3)); return tabs; } @@ -134,6 +138,7 @@ class _MainContentPageState extends State { case 0: return '影视'; case 1: return '阅读'; case 2: return '笔记'; + case 3: return '游戏'; default: return 'MookNote'; } } @@ -222,6 +227,7 @@ class _MainContentPageState extends State { await provider.loadMovies(); await provider.loadBooks(); await provider.loadNotes(); + await provider.loadGames(); } if (context.mounted) { _showResultDialog(context, title: result.success ? '同步成功' : '同步失败', message: result.message.isNotEmpty ? result.message : (result.success ? '同步成功' : '同步失败'), isSuccess: result.success, details: {'uploaded': result.uploadedFiles + result.uploadedImages, 'downloaded': result.downloadedFiles + result.downloadedImages}); @@ -313,7 +319,16 @@ class _MainContentPageState extends State { (0, '按更新时间排序', Icons.update), (1, '按创建时间排序', Icons.calendar_today_outlined), ], (v) { UserPrefs().setNoteSortMode(v); context.read().loadNotes(); }) - : null, + : tab.label == '游戏' + ? () { + final isWallMode = UserPrefs().gameWallMode; + _showSortMenu(context, isWallMode ? '游戏墙排序' : '游戏排序', UserPrefs().gameSortMode, [ + (0, '按更新时间排序', Icons.update), + (1, '按创建时间排序', Icons.calendar_today_outlined), + (2, '按评分排序', Icons.star_outline), + ], (v) { UserPrefs().setGameSortMode(v); context.read().loadGames(); }); + } + : null, child: Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row(mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ @@ -434,6 +449,7 @@ class _MainContentPageState extends State { if (_showMovieTab) const MovieTabPage(), if (_showBookTab) const BookTabPage(), if (_showNoteTab) const NoteTabPage(), + if (_showGameTab) const GameTabPage(), ], ); }, @@ -445,6 +461,7 @@ class _MainContentPageState extends State { case '影视': return Icons.movie_outlined; case '阅读': return Icons.menu_book_outlined; case '笔记': return Icons.note_outlined; + case '游戏': return Icons.sports_esports_outlined; default: return Icons.circle; } } diff --git a/lib/pages/online_search/search_page.dart b/lib/pages/online_search/search_page.dart index 9eb633d..52b99f6 100644 --- a/lib/pages/online_search/search_page.dart +++ b/lib/pages/online_search/search_page.dart @@ -6,6 +6,7 @@ import '../../models/data_models.dart'; import '../movies/movie_detail_page.dart'; import '../book/book_detail_page.dart'; import '../note/note_detail_page.dart'; +import '../game/game_detail_page.dart'; import '../../widgets/fade_in_local_image.dart'; /// 搜索页面 @@ -23,6 +24,7 @@ class _SearchPageState extends State { bool _showMovies = true; bool _showBooks = true; bool _showNotes = true; + bool _showGames = true; List<_SearchResult> _results = []; bool _hasSearched = false; @@ -92,6 +94,16 @@ class _SearchPageState extends State { } } } + if (_showGames) { + for (final game in provider.games.where((g) => !g.isDeleted)) { + if (game.title.toLowerCase().contains(lowerKeyword) || + game.genres.any((g) => g.toLowerCase().contains(lowerKeyword)) || + game.platforms.any((p) => p.toLowerCase().contains(lowerKeyword)) || + game.versions.any((v) => v.toLowerCase().contains(lowerKeyword))) { + results.add(_SearchResult(type: 'game', data: game)); + } + } + } setState(() { _results = results; _hasSearched = true; @@ -120,6 +132,11 @@ class _SearchPageState extends State { if (t.toLowerCase().contains(lowerKeyword)) tagSet.add(t); } } + for (final g in provider.games.where((g) => !g.isDeleted)) { + for (final genre in g.genres) { + if (genre.toLowerCase().contains(lowerKeyword)) tagSet.add(genre); + } + } return tagSet.toList()..sort(); } @@ -138,6 +155,9 @@ class _SearchPageState extends State { for (final n in provider.notes.where((n) => !n.isDeleted)) { if (n.tags.contains(tag)) results.add(_SearchResult(type: 'note', data: n)); } + for (final g in provider.games.where((g) => !g.isDeleted)) { + if (g.genres.contains(tag)) results.add(_SearchResult(type: 'game', data: g)); + } _results = results; }); } @@ -203,12 +223,13 @@ class _SearchPageState extends State { Widget _buildFilterRow() { final keyword = _searchController.text.trim(); final provider = context.read(); - int movieCount = 0, bookCount = 0, noteCount = 0; + int movieCount = 0, bookCount = 0, noteCount = 0, gameCount = 0; if (keyword.isNotEmpty) { final kw = keyword.toLowerCase(); movieCount = provider.movies.where((m) => !m.isDeleted && (m.title.toLowerCase().contains(kw) || m.alternateTitles.any((t) => t.toLowerCase().contains(kw)) || (m.summary?.toLowerCase().contains(kw) ?? false) || m.genres.any((g) => g.toLowerCase().contains(kw)) || m.directors.any((d) => d.toLowerCase().contains(kw)) || m.writers.any((w) => w.toLowerCase().contains(kw)) || m.actors.any((a) => a.toLowerCase().contains(kw)))).length; bookCount = provider.books.where((b) => !b.isDeleted && (b.title.toLowerCase().contains(kw) || b.alternateTitles.any((t) => t.toLowerCase().contains(kw)) || (b.summary?.toLowerCase().contains(kw) ?? false) || b.authors.any((a) => a.toLowerCase().contains(kw)))).length; noteCount = provider.notes.where((n) => !n.isDeleted && (n.title.toLowerCase().contains(kw) || n.content.toLowerCase().contains(kw) || n.tags.any((t) => t.toLowerCase().contains(kw)))).length; + gameCount = provider.games.where((g) => !g.isDeleted && (g.title.toLowerCase().contains(kw) || g.genres.any((e) => e.toLowerCase().contains(kw)) || g.platforms.any((p) => p.toLowerCase().contains(kw)) || g.versions.any((v) => v.toLowerCase().contains(kw)))).length; } return Padding( @@ -219,6 +240,8 @@ class _SearchPageState extends State { _filterChip('书籍', Icons.menu_book_outlined, _showBooks, bookCount, () { setState(() { _showBooks = !_showBooks; _performSearch(); }); }), const SizedBox(width: 8), _filterChip('笔记', Icons.note_outlined, _showNotes, noteCount, () { setState(() { _showNotes = !_showNotes; _performSearch(); }); }), + const SizedBox(width: 8), + _filterChip('游戏', Icons.sports_esports_outlined, _showGames, gameCount, () { setState(() { _showGames = !_showGames; _performSearch(); }); }), ]), ); } @@ -304,6 +327,7 @@ class _SearchPageState extends State { case 'movie': return _buildMovieItem(item.data as Movie); case 'book': return _buildBookItem(item.data as Book); case 'note': return _buildNoteItem(item.data as Note); + case 'game': return _buildGameItem(item.data as Game); default: return const SizedBox.shrink(); } }, @@ -479,6 +503,46 @@ class _SearchPageState extends State { ); } + Widget _buildGameItem(Game game) { + final colors = Theme.of(context).colorScheme; + return GestureDetector( + onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => GameDetailPage(game: game))), + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)), + child: Row(children: [ + _posterThumb(game.coverPath, Icons.sports_esports_outlined), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + _typeBadge('游戏'), + const Spacer(), + _statusBadge(game.status, colors), + ]), + const SizedBox(height: 6), + Text(game.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)), + const SizedBox(height: 4), + Row(children: [ + if (game.rating != null) ...[ + Icon(Icons.star, size: 13, color: const Color(0xFFFFB800)), + const SizedBox(width: 2), + Text('${game.rating}', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: const Color(0xFFFFB800))), + const SizedBox(width: 8), + ], + if (game.platforms.isNotEmpty) + Expanded(child: Text(game.platforms.take(2).join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)))), + ]), + ]), + ), + const SizedBox(width: 4), + Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.15), size: 18), + ]), + ), + ); + } + Widget _posterThumb(String? path, IconData fallback) { final colors = Theme.of(context).colorScheme; return Container( @@ -503,9 +567,10 @@ class _SearchPageState extends State { Widget _statusBadge(String status, ColorScheme colors) { final (label, bg, fg) = switch (status) { - 'watched' || 'read' => ('已看' , colors.primary, colors.onPrimary), - 'watching' || 'reading' => ('在看', colors.outlineVariant, colors.onSurface.withValues(alpha: 0.6)), - 'want_to_watch' || 'want_to_read' => ('想看', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.4)), + 'watched' || 'read' || 'completed' => ('已看' , colors.primary, colors.onPrimary), + 'watching' || 'reading' || 'playing' => ('在看', colors.outlineVariant, colors.onSurface.withValues(alpha: 0.6)), + 'want_to_watch' || 'want_to_read' || 'want_to_play' => ('想看', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.4)), + 'abandoned' => ('弃游', colors.errorContainer, colors.onError), _ => ('', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.3)), }; if (label.isEmpty) return const SizedBox.shrink(); diff --git a/lib/pages/profile/feature_settings_page.dart b/lib/pages/profile/feature_settings_page.dart index 2c7bf44..21de73d 100644 --- a/lib/pages/profile/feature_settings_page.dart +++ b/lib/pages/profile/feature_settings_page.dart @@ -16,6 +16,7 @@ class _FeatureSettingsPageState extends State { bool _showMovieTab = true; bool _showBookTab = true; bool _showNoteTab = true; + bool _showGameTab = true; int _defaultTabIndex = 0; // 侧边栏 @@ -40,6 +41,7 @@ class _FeatureSettingsPageState extends State { _showMovieTab = _userPrefs.showMovieTab; _showBookTab = _userPrefs.showBookTab; _showNoteTab = _userPrefs.showNoteTab; + _showGameTab = _userPrefs.showGameTab; _defaultTabIndex = _userPrefs.defaultMainTabIndex; _showHeatmap = _userPrefs.showSidebarHeatmap; _showRecent = _userPrefs.showSidebarRecent; @@ -58,6 +60,7 @@ class _FeatureSettingsPageState extends State { if (_showMovieTab) count++; if (_showBookTab) count++; if (_showNoteTab) count++; + if (_showGameTab) count++; return count; } @@ -66,12 +69,14 @@ class _FeatureSettingsPageState extends State { (0, '影视', Icons.movie_outlined), (1, '阅读', Icons.menu_book_outlined), (2, '笔记', Icons.note_outlined), + (3, '游戏', Icons.sports_esports_outlined), ]; return all.where((t) { return switch (t.$1) { 0 => _showMovieTab, 1 => _showBookTab, 2 => _showNoteTab, + 3 => _showGameTab, _ => false, }; }).toList(); @@ -121,6 +126,18 @@ class _FeatureSettingsPageState extends State { }); } + Future _toggleGameTab(bool value) async { + if (!value && _enabledTabCount <= 1) { + ToastUtil.show(context, '至少保留一个标签页'); + return; + } + await _userPrefs.setShowGameTab(value); + setState(() { + _showGameTab = value; + _fixDefaultTabIndex(); + }); + } + @override Widget build(BuildContext context) { @@ -162,6 +179,13 @@ class _FeatureSettingsPageState extends State { indent: 24, endIndent: 24, color: colors.outlineVariant), + _buildSwitchItem(Icons.sports_esports_outlined, '游戏', '记录和管理游戏记录', _showGameTab, + _toggleGameTab), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), // ── 侧边栏:信息模块 ── _buildSectionHeader('侧边栏 · 信息模块'), _buildSwitchItem( @@ -383,28 +407,34 @@ class _FeatureSettingsPageState extends State { color: colors.onSurface)))), const SizedBox(height: 16), for (final t in enabled) - ListTile( - leading: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10)), - child: Icon(t.$3, - color: colors.onSurface.withValues(alpha: 0.6))), - title: Text(t.$2, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: colors.onSurface)), - trailing: _defaultTabIndex == t.$1 - ? Icon(Icons.check, color: colors.onSurface, size: 20) - : null, + InkWell( onTap: () async { await _userPrefs.setDefaultMainTabIndex(t.$1); setState(() => _defaultTabIndex = t.$1); Navigator.pop(ctx); }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Row(children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8)), + child: Icon(t.$3, size: 16, + color: colors.onSurface.withValues(alpha: 0.6))), + const SizedBox(width: 12), + Text(t.$2, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: colors.onSurface)), + const Spacer(), + if (_defaultTabIndex == t.$1) + Icon(Icons.check_circle, size: 20, color: colors.primary), + ]), + ), ), const SizedBox(height: 8), ], diff --git a/lib/pages/profile/settings_page.dart b/lib/pages/profile/settings_page.dart index f672bc3..2a6f4a1 100644 --- a/lib/pages/profile/settings_page.dart +++ b/lib/pages/profile/settings_page.dart @@ -1085,6 +1085,14 @@ class _SettingsPageState extends State { if (poster.posterPath.isNotEmpty) paths.add(poster.posterPath); } } + for (final game in provider.games) { + if (game.coverPath?.isNotEmpty == true) paths.add(game.coverPath!); + } + for (final gameId in provider.games.map((g) => g.id)) { + for (final screenshot in await provider.getGameScreenshots(gameId)) { + if (screenshot.screenshotPath.isNotEmpty) paths.add(screenshot.screenshotPath); + } + } return paths; } diff --git a/lib/pages/settings/recycle_bin_page.dart b/lib/pages/settings/recycle_bin_page.dart index 1949c4e..8124c1e 100644 --- a/lib/pages/settings/recycle_bin_page.dart +++ b/lib/pages/settings/recycle_bin_page.dart @@ -12,7 +12,7 @@ class RecycleBinPage extends StatefulWidget { State createState() => _RecycleBinPageState(); } -enum _ItemType { movie, book, note, movieReview, bookReview, bookExcerpt } +enum _ItemType { movie, book, note, game, movieReview, bookReview, bookExcerpt, gameReview } class _DeletedItem { final _ItemType type; @@ -70,6 +70,22 @@ class _DeletedItem { icon = Icons.format_quote_outlined, typeLabel = '书摘'; + _DeletedItem.game(Game g) + : type = _ItemType.game, + id = g.id, + title = g.title, + subtitle = '删除于 ${g.updatedAt.year}.${g.updatedAt.month.toString().padLeft(2, '0')}.${g.updatedAt.day.toString().padLeft(2, '0')}', + icon = Icons.sports_esports_outlined, + typeLabel = '游戏'; + + _DeletedItem.gameReview(GameReview r) + : type = _ItemType.gameReview, + id = r.id, + title = r.content.isNotEmpty ? r.content : '游戏评价', + subtitle = '删除于 ${r.updatedAt.year}.${r.updatedAt.month.toString().padLeft(2, '0')}.${r.updatedAt.day.toString().padLeft(2, '0')}', + icon = Icons.rate_review_outlined, + typeLabel = '游戏评价'; + } class _RecycleBinPageState extends State { @@ -92,18 +108,22 @@ class _RecycleBinPageState extends State { final movies = await provider.getDeletedMovies(); final books = await provider.getDeletedBooks(); final notes = await provider.getDeletedNotes(); + final games = await provider.getDeletedGames(); final movieReviews = await provider.getDeletedMovieReviews(); final bookReviews = await provider.getDeletedBookReviews(); final bookExcerpts = await provider.getDeletedBookExcerpts(); + final gameReviews = await provider.getDeletedGameReviews(); if (!mounted) return; setState(() { _allItems = [ for (final m in movies) _DeletedItem.movie(m), for (final b in books) _DeletedItem.book(b), for (final n in notes) _DeletedItem.note(n), + for (final g in games) _DeletedItem.game(g), for (final r in movieReviews) _DeletedItem.movieReview(r), for (final r in bookReviews) _DeletedItem.bookReview(r), for (final e in bookExcerpts) _DeletedItem.bookExcerpt(e), + for (final r in gameReviews) _DeletedItem.gameReview(r), ]; _isLoading = false; }); @@ -175,9 +195,11 @@ class _RecycleBinPageState extends State { _filterChip('影视', _ItemType.movie), _filterChip('书籍', _ItemType.book), _filterChip('笔记', _ItemType.note), + _filterChip('游戏', _ItemType.game), _filterChip('影评', _ItemType.movieReview), _filterChip('书评', _ItemType.bookReview), _filterChip('书摘', _ItemType.bookExcerpt), + _filterChip('游戏评价', _ItemType.gameReview), ], ), ); @@ -391,6 +413,9 @@ class _RecycleBinPageState extends State { case _ItemType.note: await provider.restoreNote(item.id); if (mounted) ToastUtil.show(context, '笔记已恢复'); + case _ItemType.game: + await provider.restoreGame(item.id); + if (mounted) ToastUtil.show(context, '游戏已恢复'); case _ItemType.movieReview: await provider.restoreMovieReview(item.id); if (mounted) ToastUtil.show(context, '影评已恢复'); @@ -400,6 +425,9 @@ class _RecycleBinPageState extends State { case _ItemType.bookExcerpt: await provider.restoreBookExcerpt(item.id); if (mounted) ToastUtil.show(context, '书摘已恢复'); + case _ItemType.gameReview: + await provider.restoreGameReview(item.id); + if (mounted) ToastUtil.show(context, '游戏评价已恢复'); } _loadDeletedItems(); } @@ -415,12 +443,16 @@ class _RecycleBinPageState extends State { await provider.permanentDeleteBook(item.id); case _ItemType.note: await provider.permanentDeleteNote(item.id); + case _ItemType.game: + await provider.permanentDeleteGame(item.id); case _ItemType.movieReview: await provider.permanentDeleteMovieReview(item.id); case _ItemType.bookReview: await provider.permanentDeleteBookReview(item.id); case _ItemType.bookExcerpt: await provider.permanentDeleteBookExcerpt(item.id); + case _ItemType.gameReview: + await provider.permanentDeleteGameReview(item.id); } _loadDeletedItems(); if (mounted) ToastUtil.show(context, '已彻底删除'); diff --git a/lib/pages/settings/tag_management_page.dart b/lib/pages/settings/tag_management_page.dart index 31d18e9..90c43ee 100644 --- a/lib/pages/settings/tag_management_page.dart +++ b/lib/pages/settings/tag_management_page.dart @@ -15,9 +15,9 @@ class _TagManagementPageState extends State { int _currentIndex = 0; bool _isSyncing = false; - static const _tabTypes = ['movie_genre', 'book_genre', 'note_tag']; - static const _typeLabels = ['影视类型', '书籍类型', '笔记标签']; - static const _typeIcons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined]; + static const _tabTypes = ['movie_genre', 'book_genre', 'note_tag', 'game_genre']; + static const _typeLabels = ['影视类型', '书籍类型', '笔记标签', '游戏类型']; + static const _typeIcons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined, Icons.sports_esports_outlined]; final Map>> _tagCache = {}; Map _usageCounts = {}; @@ -63,6 +63,11 @@ class _TagManagementPageState extends State { counts[t] = (counts[t] ?? 0) + 1; } } + for (final g in provider.games.where((g) => !g.isDeleted)) { + for (final genre in g.genres) { + counts[genre] = (counts[genre] ?? 0) + 1; + } + } _usageCounts = counts; } @@ -121,7 +126,7 @@ class _TagManagementPageState extends State { children: [ // 弹出的类别胶囊按钮 if (_showTypePicker) ...[ - ...[0, 1, 2].where((i) => i != _currentIndex).map((i) => Padding( + ...[0, 1, 2, 3].where((i) => i != _currentIndex).map((i) => Padding( padding: const EdgeInsets.only(bottom: 8), child: GestureDetector( onTap: () { @@ -420,7 +425,7 @@ class _TagManagementPageState extends State { final idx = _tabTypes.indexOf(type); final icon = _typeIcons[idx]; final label = _typeLabels[idx]; - final hints = ['同步或手动添加影视类型', '同步或手动添加书籍类型', '同步或手动添加笔记标签']; + final hints = ['同步或手动添加影视类型', '同步或手动添加书籍类型', '同步或手动添加笔记标签', '同步或手动添加游戏类型']; return Center( key: ValueKey('empty_$type'), @@ -544,6 +549,10 @@ class _TagManagementPageState extends State { for (final b in provider.books.where((b) => !b.isDeleted && b.genres.contains(tagName))) { items.add((title: b.title, subtitle: b.authors.take(2).join(' / '), type: '书籍')); } + } else if (_currentType == 'game_genre') { + for (final g in provider.games.where((g) => !g.isDeleted && g.genres.contains(tagName))) { + items.add((title: g.title, subtitle: g.platforms.take(2).join(' / '), type: '游戏')); + } } else { for (final n in provider.notes.where((n) => !n.isDeleted && n.tags.contains(tagName))) { items.add((title: n.title.isNotEmpty ? n.title : '随手记', subtitle: null, type: '笔记')); diff --git a/lib/pages/sync/backup_page.dart b/lib/pages/sync/backup_page.dart index cdf3981..9685cfc 100644 --- a/lib/pages/sync/backup_page.dart +++ b/lib/pages/sync/backup_page.dart @@ -471,6 +471,7 @@ class _BackupPageState extends State { await context.read().loadMovies(); await context.read().loadBooks(); await context.read().loadNotes(); + await context.read().loadGames(); if (!mounted) return; diff --git a/lib/pages/sync/webdav_sync_page.dart b/lib/pages/sync/webdav_sync_page.dart index 88e82b1..b2d3b48 100644 --- a/lib/pages/sync/webdav_sync_page.dart +++ b/lib/pages/sync/webdav_sync_page.dart @@ -144,6 +144,7 @@ class _WebDAVSyncPageState extends State { await provider.loadMovies(); await provider.loadBooks(); await provider.loadNotes(); + await provider.loadGames(); if (mounted) _showResultDialog('同步成功', details); } else { _showResultDialog('同步成功', details); diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index f53748c..bdb2b52 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -8,6 +8,9 @@ import '../data/movie/movie_review_dao.dart'; import '../data/movie/movie_poster_dao.dart'; import '../data/book/book_review_dao.dart'; import '../data/book/book_excerpt_dao.dart'; +import '../data/game/game_dao.dart'; +import '../data/game/game_review_dao.dart'; +import '../data/game/game_screenshot_dao.dart'; import '../data/tag/tag_dao.dart'; import '../data/database_helper.dart'; import '../utils/image_path_helper.dart'; @@ -25,11 +28,15 @@ class AppProvider extends ChangeNotifier { final MoviePosterDao _posterDao = MoviePosterDao(); final BookReviewDao _bookReviewDao = BookReviewDao(); final BookExcerptDao _bookExcerptDao = BookExcerptDao(); + final GameDao _gameDao = GameDao(); + final GameReviewDao _gameReviewDao = GameReviewDao(); + final GameScreenshotDao _gameScreenshotDao = GameScreenshotDao(); final TagDao _tagDao = TagDao(); // 数据列表 List _movies = []; List _books = []; List _notes = []; + List _games = []; // 当前主界面选中的标签 (0: 观影,1: 阅读,2: 笔记) int _mainTabIndex = 0; @@ -44,6 +51,7 @@ class AppProvider extends ChangeNotifier { Movie? _selectedMovie; Book? _selectedBook; Note? _selectedNote; + Game? _selectedGame; // 主题模式 ThemeMode _themeMode = ThemeMode.system; @@ -68,6 +76,15 @@ class AppProvider extends ChangeNotifier { // 书架模式(不显示分类,按创建时间排序) bool _bookshelfMode = false; + + // 游戏选中的状态 (0: 已通关,1: 在玩,2: 想玩,3: 弃游) + int _gameStatusIndex = 0; + + // 游戏列表布局样式 (0: 网格, 1: 列表, 2: 大图卡片) + int _gameLayoutStyle = 0; + + // 游戏墙模式 + bool _gameWallMode = false; // 侧边菜单是否打开 bool _drawerOpen = false; @@ -91,11 +108,13 @@ class AppProvider extends ChangeNotifier { _movieDao.getAllMovies(), _bookDao.getAllBooks(), _noteDao.getAllNotes(), + _gameDao.getAllGames(), ]); _movies = results[0] as List; _books = results[1] as List; _notes = results[2] as List; - debugPrint('[AppProvider] 本地数据: movies=${_movies.length}, books=${_books.length}, notes=${_notes.length}'); + _games = results[3] as List; + debugPrint('[AppProvider] 本地数据: movies=${_movies.length}, books=${_books.length}, notes=${_notes.length}, games=${_games.length}'); notifyListeners(); } @@ -105,12 +124,15 @@ class AppProvider extends ChangeNotifier { _movieLayoutStyle = userPrefs.movieLayoutStyle; _movieWallMode = userPrefs.movieWallMode; _bookshelfMode = userPrefs.bookshelfMode; + _gameLayoutStyle = userPrefs.gameLayoutStyle; + _gameWallMode = userPrefs.gameWallMode; final defaultIndex = userPrefs.defaultMainTabIndex; // 确保选中的标签是启用的 final showMovie = userPrefs.showMovieTab; final showBook = userPrefs.showBookTab; final showNote = userPrefs.showNoteTab; - final enabled = [showMovie, showBook, showNote]; + final showGame = userPrefs.showGameTab; + final enabled = [showMovie, showBook, showNote, showGame]; if (defaultIndex >= 0 && defaultIndex < enabled.length && enabled[defaultIndex]) { _mainTabIndex = defaultIndex; } else { @@ -119,8 +141,10 @@ class AppProvider extends ChangeNotifier { _mainTabIndex = 0; } else if (showBook) { _mainTabIndex = 1; - } else { + } else if (showNote) { _mainTabIndex = 2; + } else if (showGame) { + _mainTabIndex = 3; } } notifyListeners(); @@ -144,6 +168,12 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + // 加载游戏数据 + Future loadGames() async { + _games = await _gameDao.getAllGames(); + notifyListeners(); + } + /// 编辑返回后触发列表页重载 /// [itemId] 被编辑条目的 ID,用于就地更新而非重置分页 void setEditRefresh([String? itemId]) { @@ -168,17 +198,25 @@ class AppProvider extends ChangeNotifier { return _noteDao.getNotesPaged(limit: _pageSize, offset: offset, sortMode: sortMode); } + Future> loadGamesPaged({String? status, required int offset, int sortMode = 0}) async { + return _gameDao.getGamesPaged(status: status, limit: _pageSize, offset: offset, sortMode: sortMode); + } + // Getters int get mainTabIndex => _mainTabIndex; int get bottomNavIndex => _bottomNavIndex; Movie? get selectedMovie => _selectedMovie; Book? get selectedBook => _selectedBook; Note? get selectedNote => _selectedNote; + Game? get selectedGame => _selectedGame; int get movieStatusIndex => _movieStatusIndex; int get movieLayoutStyle => _movieLayoutStyle; bool get movieWallMode => _movieWallMode; int get bookStatusIndex => _bookStatusIndex; bool get bookshelfMode => _bookshelfMode; + int get gameStatusIndex => _gameStatusIndex; + int get gameLayoutStyle => _gameLayoutStyle; + bool get gameWallMode => _gameWallMode; bool get drawerOpen => _drawerOpen; bool get bottomNavVisible => _bottomNavVisible; ThemeMode get themeMode => _themeMode; @@ -187,6 +225,7 @@ class AppProvider extends ChangeNotifier { List get movies => UnmodifiableListView(_movies); List get books => UnmodifiableListView(_books); List get notes => UnmodifiableListView(_notes); + List get games => UnmodifiableListView(_games); // 根据状态获取影视列表 List getMoviesByStatus(String status) { @@ -231,6 +270,11 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + void selectGame(Game? game) { + _selectedGame = game; + notifyListeners(); + } + void setBottomNavVisible(bool visible) { if (_bottomNavVisible != visible) { _bottomNavVisible = visible; @@ -311,6 +355,23 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + void setGameStatusIndex(int index) { + _gameStatusIndex = index; + notifyListeners(); + } + + void setGameLayoutStyle(int style) { + _gameLayoutStyle = style; + UserPrefs().setGameLayoutStyle(style); + notifyListeners(); + } + + void setGameWallMode(bool enabled) { + _gameWallMode = enabled; + UserPrefs().setGameWallMode(enabled); + notifyListeners(); + } + void toggleDrawer() { _drawerOpen = !_drawerOpen; notifyListeners(); @@ -389,6 +450,31 @@ class AppProvider extends ChangeNotifier { await loadNotes(); } + Future addGame(Game game) async { + await _gameDao.insertGame(game); + await loadGames(); + } + + Future updateGame(Game game) async { + await _gameDao.updateGame(game); + await loadGames(); + } + + Future removeGame(String id) async { + await _gameDao.deleteGame(id); + await loadGames(); + } + + /// 仅更新游戏封面偏移量(不触发全量刷新) + Future updateGameCoverOffset(String gameId, double offset) async { + await _gameDao.updateCoverOffset(gameId, offset); + final idx = _games.indexWhere((g) => g.id == gameId); + if (idx != -1) { + _games[idx] = _games[idx].copyWith(coverOffset: offset); + notifyListeners(); + } + } + Future toggleNotePin(String id, bool isPinned) async { await _noteDao.togglePin(id, isPinned); await loadNotes(); @@ -448,6 +534,59 @@ class AppProvider extends ChangeNotifier { return await _posterDao.getPosterCount(movieId); } + // ========== 游戏评价相关方法 ========== + + /// 获取游戏的所有评价 + Future> getGameReviews(String gameId) async { + return await _gameReviewDao.getReviewsByGameId(gameId); + } + + /// 添加游戏评价 + Future addGameReview(GameReview review) async { + await _gameReviewDao.insertReview(review); + } + + /// 更新游戏评价 + Future updateGameReview(GameReview review) async { + await _gameReviewDao.updateReview(review); + } + + /// 删除游戏评价 + Future removeGameReview(String id) async { + await _gameReviewDao.deleteReview(id); + } + + /// 获取游戏的评价数量 + Future getGameReviewCount(String gameId) async { + return await _gameReviewDao.getReviewCount(gameId); + } + + // ========== 游戏截图相关方法 ========== + + /// 获取游戏的所有截图 + Future> getGameScreenshots(String gameId) async { + return await _gameScreenshotDao.getScreenshotsByGameId(gameId); + } + + /// 添加游戏截图 + Future addGameScreenshot(GameScreenshot screenshot) async { + await _gameScreenshotDao.insertScreenshot(screenshot); + } + + /// 删除游戏截图 + Future removeGameScreenshot(String id) async { + final screenshot = await _gameScreenshotDao.getScreenshotById(id); + if (screenshot != null) { + await ImagePathHelper.instance.deleteFile(screenshot.screenshotPath); + } + await _gameScreenshotDao.deleteScreenshot(id); + } + + /// 获取游戏的截图数量 + Future getGameScreenshotCount(String gameId) async { + return await _gameScreenshotDao.getScreenshotCount(gameId); + } + // ========== 书评相关方法 ========== /// 获取书籍的所有书评 @@ -554,15 +693,34 @@ class AppProvider extends ChangeNotifier { await ImagePathHelper.instance.deleteNoteImages(id); await _noteDao.permanentDeleteNote(id); } + + /// 获取已删除的游戏 + Future> getDeletedGames() async { + return await _gameDao.getDeletedGames(); + } + + /// 恢复游戏 + Future restoreGame(String id) async { + await _gameDao.restoreGame(id); + await loadGames(); + } + + /// 彻底删除游戏 + Future permanentDeleteGame(String id) async { + await ImagePathHelper.instance.deleteGameImages(id); + await _gameDao.permanentDeleteGame(id); + } /// 清空回收站 Future clearRecycleBin() async { final deletedMovies = await getDeletedMovies(); final deletedBooks = await getDeletedBooks(); final deletedNotes = await getDeletedNotes(); + final deletedGames = await getDeletedGames(); final deletedMovieReviews = await getDeletedMovieReviews(); final deletedBookReviews = await getDeletedBookReviews(); final deletedBookExcerpts = await getDeletedBookExcerpts(); + final deletedGameReviews = await getDeletedGameReviews(); for (final movie in deletedMovies) { await permanentDeleteMovie(movie.id); @@ -573,6 +731,9 @@ class AppProvider extends ChangeNotifier { for (final note in deletedNotes) { await permanentDeleteNote(note.id); } + for (final game in deletedGames) { + await permanentDeleteGame(game.id); + } for (final review in deletedMovieReviews) { await _reviewDao.permanentDeleteReview(review.id); } @@ -582,10 +743,14 @@ class AppProvider extends ChangeNotifier { for (final excerpt in deletedBookExcerpts) { await _bookExcerptDao.permanentDeleteExcerpt(excerpt.id); } + for (final review in deletedGameReviews) { + await _gameReviewDao.permanentDeleteReview(review.id); + } await loadMovies(); await loadBooks(); await loadNotes(); + await loadGames(); } // ========== 影评书评回收站 ========== @@ -614,6 +779,20 @@ class AppProvider extends ChangeNotifier { await _bookReviewDao.permanentDeleteReview(id); } + // ========== 游戏评价回收站 ========== + + Future> getDeletedGameReviews() async { + return await _gameReviewDao.getDeletedReviews(); + } + + Future restoreGameReview(String id) async { + await _gameReviewDao.restoreReview(id); + } + + Future permanentDeleteGameReview(String id) async { + await _gameReviewDao.permanentDeleteReview(id); + } + // ========== 摘抄回收站方法 ========== Future> getDeletedBookExcerpts() async { @@ -715,6 +894,15 @@ class AppProvider extends ChangeNotifier { } } + // 游戏类型 + final games = await db.query('games', + where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']); + for (final row in games) { + for (final genre in parseStringListGeneric(row['genres'])) { + await insertTag(genre, 'game_genre'); + } + } + return added; } @@ -726,6 +914,8 @@ class AppProvider extends ChangeNotifier { await loadBooks(); case 'note_tag': await loadNotes(); + case 'game_genre': + await loadGames(); } } } diff --git a/lib/services/sync/backup_service.dart b/lib/services/sync/backup_service.dart index bb1ed62..881c6b5 100644 --- a/lib/services/sync/backup_service.dart +++ b/lib/services/sync/backup_service.dart @@ -33,6 +33,9 @@ class BackupService { final tags = await db.query('tags'); final readerBooks = await db.query('reader_books'); final bookAnnotations = await db.query('book_annotations'); + final games = await db.query('games'); + final gameReviews = await db.query('game_reviews'); + final gameScreenshots = await db.query('game_screenshots'); // 收集图片路径 final imagePaths = {}; @@ -63,6 +66,15 @@ class BackupService { // reader_books 的封面在 epub_books/ 目录下,由 epub_books 归档处理 // 不加入 imagePaths,避免 basename 碰撞导致所有封面变成同一个路径 + for (final g in games) { + final p = g['cover_path'] as String?; + if (p != null && p.isNotEmpty) imagePaths.add(p); + } + for (final s in gameScreenshots) { + final p = s['screenshot_path'] as String?; + if (p != null && p.isNotEmpty) imagePaths.add(p); + } + final userPrefs = UserPrefs(); final userInfo = { 'nickname': userPrefs.nickname, @@ -91,6 +103,9 @@ class BackupService { 'tags': tags, 'reader_books': readerBooks, 'book_annotations': bookAnnotations, + 'games': games, + 'game_reviews': gameReviews, + 'game_screenshots': gameScreenshots, }, }; @@ -315,6 +330,9 @@ class BackupService { final tagsCols = await _getTableColumns(db, 'tags'); final readerBooksCols = await _getTableColumns(db, 'reader_books'); final bookAnnotationsCols = await _getTableColumns(db, 'book_annotations'); + final gamesCols = await _getTableColumns(db, 'games'); + final gameReviewsCols = await _getTableColumns(db, 'game_reviews'); + final gameScreenshotsCols = await _getTableColumns(db, 'game_screenshots'); await db.transaction((txn) async { await txn.delete('movie_reviews'); @@ -322,10 +340,13 @@ class BackupService { await txn.delete('book_reviews'); await txn.delete('book_excerpts'); await txn.delete('book_annotations'); + await txn.delete('game_reviews'); + await txn.delete('game_screenshots'); await txn.delete('movies'); await txn.delete('books'); await txn.delete('notes'); await txn.delete('reader_books'); + await txn.delete('games'); await txn.delete('tags'); if (data.containsKey('movies')) { @@ -375,6 +396,21 @@ class BackupService { await txn.insert('book_annotations', _convertToDbMapSafe(a, bookAnnotationsCols)); } } + if (data.containsKey('games')) { + for (final g in data['games'] as List) { + await txn.insert('games', _updateImagePath(_convertToDbMapSafe(g, gamesCols), 'cover_path', imagePathMap)); + } + } + if (data.containsKey('game_reviews')) { + for (final r in data['game_reviews'] as List) { + await txn.insert('game_reviews', _convertToDbMapSafe(r, gameReviewsCols)); + } + } + if (data.containsKey('game_screenshots')) { + for (final s in data['game_screenshots'] as List) { + await txn.insert('game_screenshots', _updateImagePath(_convertToDbMapSafe(s, gameScreenshotsCols), 'screenshot_path', imagePathMap)); + } + } if (data.containsKey('tags')) { for (final t in data['tags'] as List) { final map = _convertToDbMapSafe(t, tagsCols); @@ -450,6 +486,9 @@ class BackupService { final tagsCols = await _getTableColumns(db, 'tags'); final readerBooksCols = await _getTableColumns(db, 'reader_books'); final bookAnnotationsCols = await _getTableColumns(db, 'book_annotations'); + final gamesCols = await _getTableColumns(db, 'games'); + final gameReviewsCols = await _getTableColumns(db, 'game_reviews'); + final gameScreenshotsCols = await _getTableColumns(db, 'game_screenshots'); await db.transaction((txn) async { await txn.delete('movie_reviews'); @@ -457,10 +496,13 @@ class BackupService { await txn.delete('book_reviews'); await txn.delete('book_excerpts'); await txn.delete('book_annotations'); + await txn.delete('game_reviews'); + await txn.delete('game_screenshots'); await txn.delete('movies'); await txn.delete('books'); await txn.delete('notes'); await txn.delete('reader_books'); + await txn.delete('games'); await txn.delete('tags'); // 修复: 之前漏删 tags 表 if (data.containsKey('movies')) { @@ -510,6 +552,21 @@ class BackupService { await txn.insert('book_annotations', _convertToDbMapSafe(a, bookAnnotationsCols)); } } + if (data.containsKey('games')) { + for (final g in data['games'] as List) { + await txn.insert('games', _updateImagePath(_convertToDbMapSafe(g, gamesCols), 'cover_path', imagePathMap)); + } + } + if (data.containsKey('game_reviews')) { + for (final r in data['game_reviews'] as List) { + await txn.insert('game_reviews', _convertToDbMapSafe(r, gameReviewsCols)); + } + } + if (data.containsKey('game_screenshots')) { + for (final s in data['game_screenshots'] as List) { + await txn.insert('game_screenshots', _updateImagePath(_convertToDbMapSafe(s, gameScreenshotsCols), 'screenshot_path', imagePathMap)); + } + } if (data.containsKey('tags')) { for (final t in data['tags'] as List) { final map = _convertToDbMapSafe(t, tagsCols); @@ -605,6 +662,9 @@ class BackupService { if (data.containsKey('tags')) stats['标签'] = (data['tags'] as List).length; if (data.containsKey('reader_books')) stats['阅读'] = (data['reader_books'] as List).length; if (data.containsKey('book_annotations')) stats['批注'] = (data['book_annotations'] as List).length; + if (data.containsKey('games')) stats['游戏'] = (data['games'] as List).length; + if (data.containsKey('game_reviews')) stats['游戏评价'] = (data['game_reviews'] as List).length; + if (data.containsKey('game_screenshots')) stats['游戏截图'] = (data['game_screenshots'] as List).length; if (imageCount > 0) stats['图片'] = imageCount; return stats; } diff --git a/lib/utils/app_router.dart b/lib/utils/app_router.dart index e7e5827..828f256 100644 --- a/lib/utils/app_router.dart +++ b/lib/utils/app_router.dart @@ -6,9 +6,11 @@ import 'slide_up_page_route.dart'; import '../pages/movies/movie_form_page.dart'; import '../pages/book/book_form_page.dart'; import '../pages/note/note_form_page.dart'; +import '../pages/game/game_form_page.dart'; import '../pages/movies/movie_detail_page.dart'; import '../pages/book/book_detail_page.dart'; import '../pages/note/note_detail_page.dart'; +import '../pages/game/game_detail_page.dart'; import '../pages/movies/douban_webview_page.dart'; /// 路由生成器 @@ -59,6 +61,22 @@ class AppRouter { } return SlideUpPageRoute(page: NoteDetailPage(note: note)); + case '/game-form': + final args = settings.arguments; + final Game? game = args is Game ? args : null; + final String? initialStatus = + args is Map ? (args['initialStatus'] as String?) : null; + return SlideUpPageRoute( + page: GameFormPage(game: game, initialStatus: initialStatus), + ); + + case '/game-detail': + final game = settings.arguments is Game ? settings.arguments as Game : null; + if (game == null) { + return _buildUnknownRoute(settings.name); + } + return SlideUpPageRoute(page: GameDetailPage(game: game)); + case '/douban-webview': final url = settings.arguments is String ? settings.arguments as String : null; if (url == null) { diff --git a/lib/utils/image_path_helper.dart b/lib/utils/image_path_helper.dart index a9ade11..b1f209e 100644 --- a/lib/utils/image_path_helper.dart +++ b/lib/utils/image_path_helper.dart @@ -10,6 +10,8 @@ import 'package:path/path.dart' as p; /// images/movies/{movieId}/posterimgs/xxxx.jpg - 影视海报墙图片 /// images/books/{bookId}/xxxx.jpg - 书籍封面 /// images/notes/{noteId}/xxxx.jpg - 笔记图片 +/// images/games/{gameId}/xxxx.jpg - 游戏封面 +/// images/games/{gameId}/screenshots/xxxx.jpg - 游戏截图 class ImagePathHelper { static final ImagePathHelper instance = ImagePathHelper._init(); @@ -93,6 +95,36 @@ class ImagePathHelper { return p.join(dir, fileName); } + // ==================== 游戏相关路径 ==================== + + /// 获取游戏图片目录 + /// 路径: images/games/{gameId}/ + Future getGameImagesDir(String gameId) async { + final root = await imagesRoot; + return p.join(root, 'games', gameId); + } + + /// 获取游戏封面路径 + /// 路径: images/games/{gameId}/{fileName} + Future getGameCoverPath(String gameId, String fileName) async { + final dir = await getGameImagesDir(gameId); + return p.join(dir, fileName); + } + + /// 获取游戏截图目录 + /// 路径: images/games/{gameId}/screenshots/ + Future getGameScreenshotImgsDir(String gameId) async { + final dir = await getGameImagesDir(gameId); + return p.join(dir, 'screenshots'); + } + + /// 获取游戏截图图片路径 + /// 路径: images/games/{gameId}/screenshots/{fileName} + Future getGameScreenshotImgPath(String gameId, String fileName) async { + final dir = await getGameScreenshotImgsDir(gameId); + return p.join(dir, fileName); + } + // ==================== 目录操作 ==================== /// 确保目录存在 @@ -129,6 +161,13 @@ class ImagePathHelper { await _deleteDirectory(dirPath); } + /// 删除游戏图片目录 + /// 删除路径: images/games/{gameId}/ + Future deleteGameImages(String gameId) async { + final dirPath = await getGameImagesDir(gameId); + await _deleteDirectory(dirPath); + } + /// 删除目录及其内容 Future _deleteDirectory(String dirPath) async { try { diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 0b540ab..d0ed337 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -104,6 +104,10 @@ class UserPrefs { bool get showNoteTab => prefs.getBool('showNoteTab') ?? true; Future setShowNoteTab(bool value) => prefs.setBool('showNoteTab', value); + /// 是否显示游戏标签 + bool get showGameTab => prefs.getBool('showGameTab') ?? false; + Future setShowGameTab(bool value) => prefs.setBool('showGameTab', value); + /// 默认启动标签 (0: 影视, 1: 阅读, 2: 笔记) int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0; Future setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value); @@ -169,6 +173,18 @@ class UserPrefs { bool get bookshelfMode => prefs.getBool('bookshelfMode') ?? false; Future setBookshelfMode(bool value) => prefs.setBool('bookshelfMode', value); + /// 游戏排序方式 (0: 更新时间, 1: 创建时间, 2: 评分) + int get gameSortMode => prefs.getInt('gameSortMode') ?? 0; + Future setGameSortMode(int value) => prefs.setInt('gameSortMode', value); + + /// 游戏布局样式 (0: 网格, 1: 列表, 2: 大图卡片) + int get gameLayoutStyle => prefs.getInt('gameLayoutStyle') ?? 0; + Future setGameLayoutStyle(int value) => prefs.setInt('gameLayoutStyle', value); + + /// 游戏墙模式 + bool get gameWallMode => prefs.getBool('gameWallMode') ?? false; + Future setGameWallMode(bool value) => prefs.setBool('gameWallMode', value); + // ========== 应用图标设置 ========== // ========== Markdown 阅读器 ========== diff --git a/lib/widgets/add_sheet.dart b/lib/widgets/add_sheet.dart index 414b6ec..064f7ca 100644 --- a/lib/widgets/add_sheet.dart +++ b/lib/widgets/add_sheet.dart @@ -20,6 +20,12 @@ void showQuickAddSheet(BuildContext context, AppProvider provider) { arguments: {'initialStatus': currentStatus}); case 2: Navigator.pushNamed(context, '/note-form'); + case 3: + final statusMap = {0: 'completed', 1: 'playing', 2: 'want_to_play', 3: 'abandoned'}; + final currentStatus = + statusMap[provider.gameStatusIndex] ?? 'want_to_play'; + Navigator.pushNamed(context, '/game-form', + arguments: {'initialStatus': currentStatus}); default: showAddSheet(context, provider); } @@ -111,6 +117,25 @@ void showAddSheet(BuildContext context, AppProvider provider) { Navigator.pushNamed(outerContext, '/note-form'); }, ), + _buildOption( + colors: bc, + icon: Icons.sports_esports_outlined, + title: '添加游戏', + subtitle: '记录你玩过的游戏', + onTap: () { + Navigator.pop(ctx); + final statusMap = { + 0: 'completed', + 1: 'playing', + 2: 'want_to_play', + 3: 'abandoned', + }; + final s = + statusMap[provider.gameStatusIndex] ?? 'want_to_play'; + Navigator.pushNamed(outerContext, '/game-form', + arguments: {'initialStatus': s}); + }, + ), ], ), ), diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 385f513..2e8f37e 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -13,6 +13,7 @@ import '../pages/settings/tag_management_page.dart'; import '../pages/movies/movie_detail_page.dart'; import '../pages/book/book_detail_page.dart'; import '../pages/note/note_detail_page.dart'; +import '../pages/game/game_detail_page.dart'; import '../models/data_models.dart'; import 'fade_in_local_image.dart'; @@ -32,6 +33,7 @@ class _CustomDrawerState extends State { List? _cachedMovies; List? _cachedBooks; List? _cachedNotes; + List? _cachedGames; Map? _cachedDailyCounts; int? _cachedMaxCount; @@ -112,6 +114,7 @@ class _CustomDrawerState extends State { final movieCount = provider.movies.where((m) => !m.isDeleted).length; final bookCount = provider.books.length; final noteCount = provider.notes.length; + final gameCount = provider.games.where((g) => !g.isDeleted).length; return Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), @@ -162,6 +165,8 @@ class _CustomDrawerState extends State { _buildProfileStatRow(Icons.menu_book_outlined, bookCount, '阅读'), const SizedBox(height: 12), _buildProfileStatRow(Icons.note_outlined, noteCount, '笔记'), + const SizedBox(height: 12), + _buildProfileStatRow(Icons.sports_esports_outlined, gameCount, '游戏'), ], ), ); @@ -258,10 +263,11 @@ class _CustomDrawerState extends State { // ─── 热力图缓存计算 ─── - (int, Map) _computeDailyCounts(List movies, List books, List notes) { + (int, Map) _computeDailyCounts(List movies, List books, List notes, List games) { if (identical(movies, _cachedMovies) && identical(books, _cachedBooks) && identical(notes, _cachedNotes) && + identical(games, _cachedGames) && _cachedDailyCounts != null) { return (_cachedMaxCount!, _cachedDailyCounts!); } @@ -279,6 +285,10 @@ class _CustomDrawerState extends State { final date = DateTime(note.createdAt.year, note.createdAt.month, note.createdAt.day); dailyCounts[date] = (dailyCounts[date] ?? 0) + 1; } + for (final game in games.where((g) => !g.isDeleted)) { + final date = DateTime(game.createdAt.year, game.createdAt.month, game.createdAt.day); + dailyCounts[date] = (dailyCounts[date] ?? 0) + 1; + } int maxCount = 0; for (final c in dailyCounts.values) { @@ -289,15 +299,17 @@ class _CustomDrawerState extends State { _cachedMovies = movies; _cachedBooks = books; _cachedNotes = notes; + _cachedGames = games; _cachedDailyCounts = dailyCounts; _cachedMaxCount = maxCount; return (maxCount, dailyCounts); } - List<_RecentItem> _computeRecentItems(List movies, List books, List notes) { + List<_RecentItem> _computeRecentItems(List movies, List books, List notes, List games) { if (identical(movies, _cachedMovies) && identical(books, _cachedBooks) && identical(notes, _cachedNotes) && + identical(games, _cachedGames) && _cachedRecentItems != null) { return _cachedRecentItems!; } @@ -312,6 +324,9 @@ class _CustomDrawerState extends State { for (final n in notes.where((n) => !n.isDeleted)) { items.add(_RecentItem(type: 'note', title: n.title.isNotEmpty ? n.title : '随手记', date: n.createdAt, data: n)); } + for (final g in games.where((g) => !g.isDeleted)) { + items.add(_RecentItem(type: 'game', title: g.title, date: g.createdAt, data: g)); + } items.sort((a, b) => b.date.compareTo(a.date)); _cachedRecentItems = items; @@ -322,9 +337,10 @@ class _CustomDrawerState extends State { 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 colors = Theme.of(context).colorScheme; - final (maxCount, dailyCounts) = _computeDailyCounts(movies, books, notes); + final (maxCount, dailyCounts) = _computeDailyCounts(movies, books, notes, games); final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -442,8 +458,9 @@ class _CustomDrawerState extends State { 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 colors = Theme.of(context).colorScheme; - final recent = _computeRecentItems(movies, books, notes); + final recent = _computeRecentItems(movies, books, notes, games); if (recent.isEmpty) return const SizedBox.shrink(); return Container( @@ -469,7 +486,7 @@ class _CustomDrawerState extends State { child: Row( children: [ Icon( - item.type == 'movie' ? Icons.movie_outlined : item.type == 'book' ? Icons.menu_book_outlined : Icons.note_outlined, + item.type == 'movie' ? Icons.movie_outlined : item.type == 'book' ? Icons.menu_book_outlined : item.type == 'game' ? Icons.sports_esports_outlined : Icons.note_outlined, size: 14, color: colors.onSurface.withValues(alpha: 0.3), ), const SizedBox(width: 10), @@ -497,6 +514,8 @@ class _CustomDrawerState extends State { Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book))); case 'note': Navigator.push(context, MaterialPageRoute(builder: (_) => NoteDetailPage(note: item.data as Note))); + case 'game': + Navigator.push(context, MaterialPageRoute(builder: (_) => GameDetailPage(game: item.data as Game))); } } diff --git a/lib/widgets/game_list_item.dart b/lib/widgets/game_list_item.dart new file mode 100644 index 0000000..f36503d --- /dev/null +++ b/lib/widgets/game_list_item.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/data_models.dart'; +import '../providers/app_provider.dart'; +import '../widgets/fade_in_local_image.dart'; +import '../widgets/animated_star_rating.dart'; +import '../utils/toast_util.dart'; + +/// 游戏列表项组件 - 网格布局设计 +class GameListItem extends StatelessWidget { + final Game game; + final bool selected; + final VoidCallback? onTap; + + const GameListItem({super.key, required this.game, this.selected = false, this.onTap}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return GestureDetector( + onTap: onTap ?? () => Navigator.pushNamed(context, '/game-detail', arguments: game), + onLongPress: () => _showDeleteDialog(context), + child: Container( + decoration: selected + ? BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: colors.primary, width: 2), + ) + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _buildCover(colors), + ), + const SizedBox(height: 8), + Text( + game.title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + if (game.rating != null) + AnimatedStarRating(rating: game.rating!, starSize: 12, showNumber: true) + else + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget _buildCover(ColorScheme colors) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + clipBehavior: Clip.antiAlias, + child: FadeInLocalImage( + path: game.coverPath, + fit: BoxFit.cover, + placeholder: Center(child: Icon(Icons.sports_esports_outlined, size: 24, color: colors.onSurface.withValues(alpha: 0.25))), + errorWidget: Center(child: Icon(Icons.sports_esports_outlined, size: 24, color: colors.onSurface.withValues(alpha: 0.25))), + ), + ); + } + + void _showDeleteDialog(BuildContext context) { + final colors = Theme.of(context).colorScheme; + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: colors.surface, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + title: Text( + '确认删除', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + content: Text( + '确定要删除《${game.title}》吗?删除后可在回收站恢复。', + style: TextStyle( + fontSize: 14, + color: colors.onSurface.withValues(alpha: 0.6), + height: 1.5, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + foregroundColor: colors.onSurface.withValues(alpha: 0.6), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: () async { + await context.read().removeGame(game.id); + Navigator.pop(context); + ToastUtil.show(context, '已删除'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + child: const Text('删除'), + ), + ], + actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + } +} diff --git a/lib/widgets/game_status_bar.dart b/lib/widgets/game_status_bar.dart new file mode 100644 index 0000000..7524388 --- /dev/null +++ b/lib/widgets/game_status_bar.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/app_provider.dart'; + +/// 游戏状态选择栏 - 平滑过渡动画 +class GameStatusBar extends StatelessWidget { + const GameStatusBar({super.key}); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Consumer( + builder: (context, provider, child) { + final currentIndex = provider.gameStatusIndex; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: colors.surface, + border: Border(bottom: BorderSide(color: colors.outline, width: 0.5)), + ), + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(24), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final tabWidth = constraints.maxWidth / 4; + return SizedBox( + height: 40, + child: Stack( + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + left: currentIndex * tabWidth, + top: 0, bottom: 0, width: tabWidth, + child: Padding( + padding: const EdgeInsets.all(3), + child: Container( + decoration: BoxDecoration( + color: colors.primary, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + ), + ), + ), + Row( + children: [ + _buildTab(colors, '已通关', Icons.emoji_events_outlined, currentIndex == 0, + () => provider.setGameStatusIndex(0)), + _buildTab(colors, '在玩', Icons.sports_esports_outlined, currentIndex == 1, + () => provider.setGameStatusIndex(1)), + _buildTab(colors, '想玩', Icons.bookmark_outlined, currentIndex == 2, + () => provider.setGameStatusIndex(2)), + _buildTab(colors, '弃游', Icons.cancel_outlined, currentIndex == 3, + () => provider.setGameStatusIndex(3)), + ], + ), + ], + ), + ); + }, + ), + ), + ); + }, + ); + } + + Widget _buildTab(ColorScheme colors, String label, IconData icon, bool isSelected, VoidCallback onTap) { + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: AnimatedOpacity( + opacity: isSelected ? 1.0 : 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: SizedBox.expand( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: isSelected ? colors.onPrimary : colors.onSurface), + const SizedBox(width: 6), + Text(label, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected ? colors.onPrimary : colors.onSurface, + )), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/shimmer_skeleton.dart b/lib/widgets/shimmer_skeleton.dart index 355cbfa..1b7bf57 100644 --- a/lib/widgets/shimmer_skeleton.dart +++ b/lib/widgets/shimmer_skeleton.dart @@ -140,6 +140,46 @@ class BookSkeletonGrid extends StatelessWidget { } } +/// 游戏骨架屏 +class GameSkeletonGrid extends StatelessWidget { + const GameSkeletonGrid({super.key}); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final count = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 110); + return GridView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 100), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: count, + childAspectRatio: 0.55, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + ), + itemCount: count * 3, + itemBuilder: (_, __) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ShimmerSkeleton( + width: double.infinity, + height: double.infinity, + borderRadius: 8, + ), + ), + const SizedBox(height: 8), + const ShimmerSkeleton(width: double.infinity, height: 14), + const SizedBox(height: 4), + const ShimmerSkeleton(width: 70, height: 12), + ], + ), + ); + }, + ); + } +} + /// 笔记列表骨架屏 class NoteSkeletonList extends StatelessWidget { const NoteSkeletonList({super.key});