generated from dellevin/template
新增游戏功能记录模块
This commit is contained in:
@@ -72,7 +72,7 @@ class DatabaseHelper {
|
|||||||
|
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 30,
|
version: 34,
|
||||||
onCreate: _createDB,
|
onCreate: _createDB,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
@@ -278,6 +278,67 @@ class DatabaseHelper {
|
|||||||
await db.execute("ALTER TABLE reader_books ADD COLUMN authors TEXT DEFAULT ''");
|
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(添加阅读始末日期字段)
|
/// 升级books表到V27(添加阅读始末日期字段)
|
||||||
@@ -831,6 +892,59 @@ class DatabaseHelper {
|
|||||||
updated_at TEXT NOT NULL
|
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)
|
||||||
|
)
|
||||||
|
''');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭数据库
|
// 关闭数据库
|
||||||
|
|||||||
153
lib/data/game/game_dao.dart
Normal file
153
lib/data/game/game_dao.dart
Normal file
@@ -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<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[GameDao] $op error: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有游戏记录(未删除的)
|
||||||
|
Future<List<Game>> getAllGames() => _wrap('getAllGames', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final List<Map<String, dynamic>> 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<List<Game>> 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<dynamic> 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<List<Game>> searchGames(String keyword) => _wrap('searchGames', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final likeKeyword = '%$keyword%';
|
||||||
|
final List<Map<String, dynamic>> 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<int> insertGame(Game game) => _wrap('insertGame', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
return await db.insert('games', game.toJson());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新游戏记录
|
||||||
|
Future<int> updateGame(Game game) => _wrap('updateGame', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
return await db.update(
|
||||||
|
'games',
|
||||||
|
game.toJson(),
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [game.id],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 仅更新封面偏移量
|
||||||
|
Future<void> 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<int> 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<List<String>> getAllGenres() => _wrap('getAllGenres', () async {
|
||||||
|
final games = await getAllGames();
|
||||||
|
final genres = <String>{};
|
||||||
|
for (final game in games) {
|
||||||
|
genres.addAll(game.genres);
|
||||||
|
}
|
||||||
|
return genres.toList()..sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取所有平台(去重)
|
||||||
|
Future<List<String>> getAllPlatforms() => _wrap('getAllPlatforms', () async {
|
||||||
|
final games = await getAllGames();
|
||||||
|
final platforms = <String>{};
|
||||||
|
for (final game in games) {
|
||||||
|
platforms.addAll(game.platforms);
|
||||||
|
}
|
||||||
|
return platforms.toList()..sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 回收站相关方法 ==========
|
||||||
|
|
||||||
|
// 获取已删除的游戏
|
||||||
|
Future<List<Game>> getDeletedGames() => _wrap('getDeletedGames', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final List<Map<String, dynamic>> 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<int> 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<int> permanentDeleteGame(String id) => _wrap('permanentDeleteGame', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
return await db.delete(
|
||||||
|
'games',
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
131
lib/data/game/game_review_dao.dart
Normal file
131
lib/data/game/game_review_dao.dart
Normal file
@@ -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<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[GameReviewDao] $op error: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取游戏的所有评价
|
||||||
|
Future<List<GameReview>> getReviewsByGameId(String gameId) => _wrap('getReviewsByGameId', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final List<Map<String, dynamic>> 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<GameReview?> getReviewById(String id) => _wrap('getReviewById', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final List<Map<String, dynamic>> 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<int> insertReview(GameReview review) => _wrap('insertReview', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
return await db.insert('game_reviews', review.toJson());
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 更新评价
|
||||||
|
Future<int> 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<int> 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<int> 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<List<GameReview>> getShortReviews(String gameId) => _wrap('getShortReviews', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final List<Map<String, dynamic>> 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<List<GameReview>> getLongReviews(String gameId) => _wrap('getLongReviews', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final List<Map<String, dynamic>> 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<List<GameReview>> getDeletedReviews() => _wrap('getDeletedReviews', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final List<Map<String, dynamic>> 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<int> 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<int> permanentDeleteReview(String id) => _wrap('permanentDeleteReview', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
return await db.delete('game_reviews', where: 'id = ?', whereArgs: [id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
68
lib/data/game/game_screenshot_dao.dart
Normal file
68
lib/data/game/game_screenshot_dao.dart
Normal file
@@ -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<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[GameScreenshotDao] $op error: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取游戏的所有截图
|
||||||
|
Future<List<GameScreenshot>> getScreenshotsByGameId(String gameId) => _wrap('getScreenshotsByGameId', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final List<Map<String, dynamic>> 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<GameScreenshot?> getScreenshotById(String id) => _wrap('getScreenshotById', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final List<Map<String, dynamic>> 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<int> insertScreenshot(GameScreenshot screenshot) => _wrap('insertScreenshot', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
return await db.insert('game_screenshots', screenshot.toJson());
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 软删除截图
|
||||||
|
Future<int> 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<int> 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -637,6 +637,272 @@ class BookReview {
|
|||||||
String get typeText => reviewType == 1 ? '短评' : '长评';
|
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<String> platforms; // 平台列表
|
||||||
|
final List<String> versions; // 版本列表
|
||||||
|
final List<String> genres; // 类型
|
||||||
|
final int playTimeHours; // 游玩时长(小时)
|
||||||
|
final int playTimeMinutes; // 游玩时长(分钟)
|
||||||
|
final List<String> 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<String, dynamic> 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<String, dynamic> 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<String>? platforms,
|
||||||
|
List<String>? versions,
|
||||||
|
List<String>? genres,
|
||||||
|
int? playTimeHours,
|
||||||
|
int? playTimeMinutes,
|
||||||
|
List<String>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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 {
|
class BookExcerpt {
|
||||||
final String id;
|
final String id;
|
||||||
|
|||||||
986
lib/pages/game/game_detail_page.dart
Normal file
986
lib/pages/game/game_detail_page.dart
Normal file
@@ -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<GameDetailPage> createState() => _GameDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GameDetailPageState extends State<GameDetailPage> {
|
||||||
|
final ValueNotifier<double> _coverOffset = ValueNotifier(0.0);
|
||||||
|
double _coverDragStartOffset = 0.0;
|
||||||
|
final ValueNotifier<bool> _draggingCover = ValueNotifier(false);
|
||||||
|
final GlobalKey _coverImageKey = GlobalKey();
|
||||||
|
double _coverImageHeight = 0.0;
|
||||||
|
bool _isLandscapeCover = false;
|
||||||
|
late int _detailStyle;
|
||||||
|
final ValueNotifier<bool> _showTitle = ValueNotifier(false);
|
||||||
|
ScrollController? _overlayScrollController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_detailStyle = UserPrefs().detailPageStyle;
|
||||||
|
_coverOffset.value = UserPrefs().getCoverOffset(widget.game.id);
|
||||||
|
_detectCoverAspect();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<AppProvider>().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<AppProvider>().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<AppProvider>().selectGame(null)
|
||||||
|
: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
ValueListenableBuilder<bool>(
|
||||||
|
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<AppProvider>().getGameReviewCount(game.id),
|
||||||
|
emptyText: '暂无评价',
|
||||||
|
unit: '条评价',
|
||||||
|
onTap: () => _navigateToReviews(game),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildFrostedExtraItem(
|
||||||
|
icon: Icons.photo_library_outlined,
|
||||||
|
title: '游戏截图',
|
||||||
|
subtitleFuture: context.read<AppProvider>().getGameScreenshotCount(game.id),
|
||||||
|
emptyText: '暂无截图',
|
||||||
|
unit: '张截图',
|
||||||
|
onTap: () => _navigateToScreenshots(game),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFrostedExtraItem({
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required Future<int> 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<int>(
|
||||||
|
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<AppProvider>().updateGameCoverOffset(widget.game.id, offset);
|
||||||
|
} : null,
|
||||||
|
child: ValueListenableBuilder<double>(
|
||||||
|
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<bool>(
|
||||||
|
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<AppProvider>().getGameReviewCount(game.id),
|
||||||
|
emptyText: '暂无评价',
|
||||||
|
unit: '条评价',
|
||||||
|
onTap: () => _navigateToReviews(game),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildExtraSectionItem(
|
||||||
|
icon: Icons.photo_library_outlined,
|
||||||
|
title: '游戏截图',
|
||||||
|
subtitleFuture: context.read<AppProvider>().getGameScreenshotCount(game.id),
|
||||||
|
emptyText: '暂无截图',
|
||||||
|
unit: '张截图',
|
||||||
|
onTap: () => _navigateToScreenshots(game),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildExtraSectionItem({
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required Future<int> 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<int>(
|
||||||
|
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<AppProvider>();
|
||||||
|
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<AppProvider>();
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1237
lib/pages/game/game_form_page.dart
Normal file
1237
lib/pages/game/game_form_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
179
lib/pages/game/game_review_detail_page.dart
Normal file
179
lib/pages/game/game_review_detail_page.dart
Normal file
@@ -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<GameReviewDetailPage> createState() => _GameReviewDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GameReviewDetailPageState extends State<GameReviewDetailPage> {
|
||||||
|
late GameReview _review;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_review = widget.review;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshReviewData() async {
|
||||||
|
final provider = context.read<AppProvider>();
|
||||||
|
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<AppProvider>().games.where((g) => g.id == widget.gameId).firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteReview() async {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<AppProvider>().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)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
266
lib/pages/game/game_review_form_page.dart
Normal file
266
lib/pages/game/game_review_form_page.dart
Normal file
@@ -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<GameReviewFormPage> createState() => _GameReviewFormPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GameReviewFormPageState extends State<GameReviewFormPage> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<AppProvider>().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<int>(
|
||||||
|
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<void> _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<AppProvider>().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<AppProvider>().updateGameReview(updatedReview);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
ToastUtil.show(context, widget.review == null ? '添加成功' : '更新成功');
|
||||||
|
Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ToastUtil.show(context, '保存失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
262
lib/pages/game/game_reviews_page.dart
Normal file
262
lib/pages/game/game_reviews_page.dart
Normal file
@@ -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<GameReviewsPage> createState() => _GameReviewsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GameReviewsPageState extends State<GameReviewsPage> {
|
||||||
|
List<GameReview> _reviews = [];
|
||||||
|
List<GameReview> _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<void> _loadReviews() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
final reviews = await context.read<AppProvider>().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<AppProvider>().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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
366
lib/pages/game/game_screenshots_page.dart
Normal file
366
lib/pages/game/game_screenshots_page.dart
Normal file
@@ -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<GameScreenshotsPage> createState() => _GameScreenshotsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GameScreenshotsPageState extends State<GameScreenshotsPage> {
|
||||||
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
List<GameScreenshot> _screenshots = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadScreenshots();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadScreenshots() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
final screenshots = await context.read<AppProvider>().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<void> _pickScreenshot() async {
|
||||||
|
final result = await showModalBottomSheet<int>(
|
||||||
|
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<void> _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<AppProvider>().addGameScreenshot(newScreenshot);
|
||||||
|
_loadScreenshots();
|
||||||
|
if (mounted) ToastUtil.show(context, '添加成功');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) ToastUtil.show(context, '添加截图失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickFromUrl() async {
|
||||||
|
final urlController = TextEditingController();
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
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<void> _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<AppProvider>().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<AppProvider>().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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
262
lib/pages/game/game_share_page.dart
Normal file
262
lib/pages/game/game_share_page.dart
Normal file
@@ -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<GameSharePage> createState() => _GameSharePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GameSharePageState extends State<GameSharePage> {
|
||||||
|
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<void> _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;
|
||||||
|
}
|
||||||
503
lib/pages/game/game_tab_page.dart
Normal file
503
lib/pages/game/game_tab_page.dart
Normal file
@@ -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<GameTabPage> createState() => _GameTabPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GameTabPageState extends State<GameTabPage> {
|
||||||
|
final List<Game> _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<AppProvider>();
|
||||||
|
_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<AppProvider>();
|
||||||
|
|
||||||
|
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<void> _loadFirst() async {
|
||||||
|
final provider = context.read<AppProvider>();
|
||||||
|
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<void> _loadMore() async {
|
||||||
|
if (_isLoading || !_hasMore) return;
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
final provider = context.read<AppProvider>();
|
||||||
|
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<void> _refresh() async {
|
||||||
|
final provider = context.read<AppProvider>();
|
||||||
|
await provider.loadGames();
|
||||||
|
await _loadFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onGameTap(Game game) {
|
||||||
|
if (Breakpoint.isWideContent(context)) {
|
||||||
|
context.read<AppProvider>().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<AppProvider>();
|
||||||
|
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<AppProvider>(
|
||||||
|
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<double>(
|
||||||
|
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<AppProvider>();
|
||||||
|
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 = <String>[];
|
||||||
|
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<AppProvider>().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<AppProvider>().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<AppProvider>();
|
||||||
|
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))),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
102
lib/pages/game/screenshot_gallery_page.dart
Normal file
102
lib/pages/game/screenshot_gallery_page.dart
Normal file
@@ -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<GameScreenshot> screenshots;
|
||||||
|
final int initialIndex;
|
||||||
|
|
||||||
|
const ScreenshotGalleryPage({super.key, required this.screenshots, required this.initialIndex});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ScreenshotGalleryPage> createState() => _ScreenshotGalleryPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScreenshotGalleryPageState extends State<ScreenshotGalleryPage> {
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import '../../services/sync/webdav_service.dart';
|
|||||||
import '../movies/movie_tab_page.dart';
|
import '../movies/movie_tab_page.dart';
|
||||||
import '../book/book_tab_page.dart';
|
import '../book/book_tab_page.dart';
|
||||||
import '../note/note_tab_page.dart';
|
import '../note/note_tab_page.dart';
|
||||||
|
import '../game/game_tab_page.dart';
|
||||||
import '../online_search/search_page.dart';
|
import '../online_search/search_page.dart';
|
||||||
import '../online_search/online_search_page.dart';
|
import '../online_search/online_search_page.dart';
|
||||||
import '../sync/webdav_sync_page.dart';
|
import '../sync/webdav_sync_page.dart';
|
||||||
@@ -24,6 +25,7 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
bool _showMovieTab = true;
|
bool _showMovieTab = true;
|
||||||
bool _showBookTab = true;
|
bool _showBookTab = true;
|
||||||
bool _showNoteTab = true;
|
bool _showNoteTab = true;
|
||||||
|
bool _showGameTab = true;
|
||||||
|
|
||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
bool _isTabTap = false;
|
bool _isTabTap = false;
|
||||||
@@ -47,6 +49,7 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
_showMovieTab = _userPrefs.showMovieTab;
|
_showMovieTab = _userPrefs.showMovieTab;
|
||||||
_showBookTab = _userPrefs.showBookTab;
|
_showBookTab = _userPrefs.showBookTab;
|
||||||
_showNoteTab = _userPrefs.showNoteTab;
|
_showNoteTab = _userPrefs.showNoteTab;
|
||||||
|
_showGameTab = _userPrefs.showGameTab;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +64,7 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
if (_showMovieTab) tabs.add(_TabItem('影视', 0));
|
if (_showMovieTab) tabs.add(_TabItem('影视', 0));
|
||||||
if (_showBookTab) tabs.add(_TabItem('阅读', 1));
|
if (_showBookTab) tabs.add(_TabItem('阅读', 1));
|
||||||
if (_showNoteTab) tabs.add(_TabItem('笔记', 2));
|
if (_showNoteTab) tabs.add(_TabItem('笔记', 2));
|
||||||
|
if (_showGameTab) tabs.add(_TabItem('游戏', 3));
|
||||||
return tabs;
|
return tabs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +138,7 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
case 0: return '影视';
|
case 0: return '影视';
|
||||||
case 1: return '阅读';
|
case 1: return '阅读';
|
||||||
case 2: return '笔记';
|
case 2: return '笔记';
|
||||||
|
case 3: return '游戏';
|
||||||
default: return 'MookNote';
|
default: return 'MookNote';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,6 +227,7 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
await provider.loadMovies();
|
await provider.loadMovies();
|
||||||
await provider.loadBooks();
|
await provider.loadBooks();
|
||||||
await provider.loadNotes();
|
await provider.loadNotes();
|
||||||
|
await provider.loadGames();
|
||||||
}
|
}
|
||||||
if (context.mounted) {
|
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});
|
_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<MainContentPage> {
|
|||||||
(0, '按更新时间排序', Icons.update),
|
(0, '按更新时间排序', Icons.update),
|
||||||
(1, '按创建时间排序', Icons.calendar_today_outlined),
|
(1, '按创建时间排序', Icons.calendar_today_outlined),
|
||||||
], (v) { UserPrefs().setNoteSortMode(v); context.read<AppProvider>().loadNotes(); })
|
], (v) { UserPrefs().setNoteSortMode(v); context.read<AppProvider>().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<AppProvider>().loadGames(); });
|
||||||
|
}
|
||||||
|
: null,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [
|
child: Row(mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [
|
||||||
@@ -434,6 +449,7 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
if (_showMovieTab) const MovieTabPage(),
|
if (_showMovieTab) const MovieTabPage(),
|
||||||
if (_showBookTab) const BookTabPage(),
|
if (_showBookTab) const BookTabPage(),
|
||||||
if (_showNoteTab) const NoteTabPage(),
|
if (_showNoteTab) const NoteTabPage(),
|
||||||
|
if (_showGameTab) const GameTabPage(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -445,6 +461,7 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
case '影视': return Icons.movie_outlined;
|
case '影视': return Icons.movie_outlined;
|
||||||
case '阅读': return Icons.menu_book_outlined;
|
case '阅读': return Icons.menu_book_outlined;
|
||||||
case '笔记': return Icons.note_outlined;
|
case '笔记': return Icons.note_outlined;
|
||||||
|
case '游戏': return Icons.sports_esports_outlined;
|
||||||
default: return Icons.circle;
|
default: return Icons.circle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../models/data_models.dart';
|
|||||||
import '../movies/movie_detail_page.dart';
|
import '../movies/movie_detail_page.dart';
|
||||||
import '../book/book_detail_page.dart';
|
import '../book/book_detail_page.dart';
|
||||||
import '../note/note_detail_page.dart';
|
import '../note/note_detail_page.dart';
|
||||||
|
import '../game/game_detail_page.dart';
|
||||||
import '../../widgets/fade_in_local_image.dart';
|
import '../../widgets/fade_in_local_image.dart';
|
||||||
|
|
||||||
/// 搜索页面
|
/// 搜索页面
|
||||||
@@ -23,6 +24,7 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
bool _showMovies = true;
|
bool _showMovies = true;
|
||||||
bool _showBooks = true;
|
bool _showBooks = true;
|
||||||
bool _showNotes = true;
|
bool _showNotes = true;
|
||||||
|
bool _showGames = true;
|
||||||
|
|
||||||
List<_SearchResult> _results = [];
|
List<_SearchResult> _results = [];
|
||||||
bool _hasSearched = false;
|
bool _hasSearched = false;
|
||||||
@@ -92,6 +94,16 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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(() {
|
setState(() {
|
||||||
_results = results;
|
_results = results;
|
||||||
_hasSearched = true;
|
_hasSearched = true;
|
||||||
@@ -120,6 +132,11 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
if (t.toLowerCase().contains(lowerKeyword)) tagSet.add(t);
|
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();
|
return tagSet.toList()..sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +155,9 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
for (final n in provider.notes.where((n) => !n.isDeleted)) {
|
for (final n in provider.notes.where((n) => !n.isDeleted)) {
|
||||||
if (n.tags.contains(tag)) results.add(_SearchResult(type: 'note', data: n));
|
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;
|
_results = results;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -203,12 +223,13 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
Widget _buildFilterRow() {
|
Widget _buildFilterRow() {
|
||||||
final keyword = _searchController.text.trim();
|
final keyword = _searchController.text.trim();
|
||||||
final provider = context.read<AppProvider>();
|
final provider = context.read<AppProvider>();
|
||||||
int movieCount = 0, bookCount = 0, noteCount = 0;
|
int movieCount = 0, bookCount = 0, noteCount = 0, gameCount = 0;
|
||||||
if (keyword.isNotEmpty) {
|
if (keyword.isNotEmpty) {
|
||||||
final kw = keyword.toLowerCase();
|
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;
|
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;
|
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;
|
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(
|
return Padding(
|
||||||
@@ -219,6 +240,8 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
_filterChip('书籍', Icons.menu_book_outlined, _showBooks, bookCount, () { setState(() { _showBooks = !_showBooks; _performSearch(); }); }),
|
_filterChip('书籍', Icons.menu_book_outlined, _showBooks, bookCount, () { setState(() { _showBooks = !_showBooks; _performSearch(); }); }),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_filterChip('笔记', Icons.note_outlined, _showNotes, noteCount, () { setState(() { _showNotes = !_showNotes; _performSearch(); }); }),
|
_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<SearchPage> {
|
|||||||
case 'movie': return _buildMovieItem(item.data as Movie);
|
case 'movie': return _buildMovieItem(item.data as Movie);
|
||||||
case 'book': return _buildBookItem(item.data as Book);
|
case 'book': return _buildBookItem(item.data as Book);
|
||||||
case 'note': return _buildNoteItem(item.data as Note);
|
case 'note': return _buildNoteItem(item.data as Note);
|
||||||
|
case 'game': return _buildGameItem(item.data as Game);
|
||||||
default: return const SizedBox.shrink();
|
default: return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -479,6 +503,46 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
Widget _posterThumb(String? path, IconData fallback) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
return Container(
|
return Container(
|
||||||
@@ -503,9 +567,10 @@ class _SearchPageState extends State<SearchPage> {
|
|||||||
|
|
||||||
Widget _statusBadge(String status, ColorScheme colors) {
|
Widget _statusBadge(String status, ColorScheme colors) {
|
||||||
final (label, bg, fg) = switch (status) {
|
final (label, bg, fg) = switch (status) {
|
||||||
'watched' || 'read' => ('已看' , colors.primary, colors.onPrimary),
|
'watched' || 'read' || 'completed' => ('已看' , colors.primary, colors.onPrimary),
|
||||||
'watching' || 'reading' => ('在看', colors.outlineVariant, colors.onSurface.withValues(alpha: 0.6)),
|
'watching' || 'reading' || 'playing' => ('在看', colors.outlineVariant, colors.onSurface.withValues(alpha: 0.6)),
|
||||||
'want_to_watch' || 'want_to_read' => ('想看', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.4)),
|
'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)),
|
_ => ('', colors.surfaceContainerHighest, colors.onSurface.withValues(alpha: 0.3)),
|
||||||
};
|
};
|
||||||
if (label.isEmpty) return const SizedBox.shrink();
|
if (label.isEmpty) return const SizedBox.shrink();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
bool _showMovieTab = true;
|
bool _showMovieTab = true;
|
||||||
bool _showBookTab = true;
|
bool _showBookTab = true;
|
||||||
bool _showNoteTab = true;
|
bool _showNoteTab = true;
|
||||||
|
bool _showGameTab = true;
|
||||||
int _defaultTabIndex = 0;
|
int _defaultTabIndex = 0;
|
||||||
|
|
||||||
// 侧边栏
|
// 侧边栏
|
||||||
@@ -40,6 +41,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
_showMovieTab = _userPrefs.showMovieTab;
|
_showMovieTab = _userPrefs.showMovieTab;
|
||||||
_showBookTab = _userPrefs.showBookTab;
|
_showBookTab = _userPrefs.showBookTab;
|
||||||
_showNoteTab = _userPrefs.showNoteTab;
|
_showNoteTab = _userPrefs.showNoteTab;
|
||||||
|
_showGameTab = _userPrefs.showGameTab;
|
||||||
_defaultTabIndex = _userPrefs.defaultMainTabIndex;
|
_defaultTabIndex = _userPrefs.defaultMainTabIndex;
|
||||||
_showHeatmap = _userPrefs.showSidebarHeatmap;
|
_showHeatmap = _userPrefs.showSidebarHeatmap;
|
||||||
_showRecent = _userPrefs.showSidebarRecent;
|
_showRecent = _userPrefs.showSidebarRecent;
|
||||||
@@ -58,6 +60,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
if (_showMovieTab) count++;
|
if (_showMovieTab) count++;
|
||||||
if (_showBookTab) count++;
|
if (_showBookTab) count++;
|
||||||
if (_showNoteTab) count++;
|
if (_showNoteTab) count++;
|
||||||
|
if (_showGameTab) count++;
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,12 +69,14 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
(0, '影视', Icons.movie_outlined),
|
(0, '影视', Icons.movie_outlined),
|
||||||
(1, '阅读', Icons.menu_book_outlined),
|
(1, '阅读', Icons.menu_book_outlined),
|
||||||
(2, '笔记', Icons.note_outlined),
|
(2, '笔记', Icons.note_outlined),
|
||||||
|
(3, '游戏', Icons.sports_esports_outlined),
|
||||||
];
|
];
|
||||||
return all.where((t) {
|
return all.where((t) {
|
||||||
return switch (t.$1) {
|
return switch (t.$1) {
|
||||||
0 => _showMovieTab,
|
0 => _showMovieTab,
|
||||||
1 => _showBookTab,
|
1 => _showBookTab,
|
||||||
2 => _showNoteTab,
|
2 => _showNoteTab,
|
||||||
|
3 => _showGameTab,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -121,6 +126,18 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleGameTab(bool value) async {
|
||||||
|
if (!value && _enabledTabCount <= 1) {
|
||||||
|
ToastUtil.show(context, '至少保留一个标签页');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _userPrefs.setShowGameTab(value);
|
||||||
|
setState(() {
|
||||||
|
_showGameTab = value;
|
||||||
|
_fixDefaultTabIndex();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -162,6 +179,13 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
indent: 24,
|
indent: 24,
|
||||||
endIndent: 24,
|
endIndent: 24,
|
||||||
color: colors.outlineVariant),
|
color: colors.outlineVariant),
|
||||||
|
_buildSwitchItem(Icons.sports_esports_outlined, '游戏', '记录和管理游戏记录', _showGameTab,
|
||||||
|
_toggleGameTab),
|
||||||
|
Divider(
|
||||||
|
height: 0.5,
|
||||||
|
indent: 24,
|
||||||
|
endIndent: 24,
|
||||||
|
color: colors.outlineVariant),
|
||||||
// ── 侧边栏:信息模块 ──
|
// ── 侧边栏:信息模块 ──
|
||||||
_buildSectionHeader('侧边栏 · 信息模块'),
|
_buildSectionHeader('侧边栏 · 信息模块'),
|
||||||
_buildSwitchItem(
|
_buildSwitchItem(
|
||||||
@@ -383,28 +407,34 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
color: colors.onSurface)))),
|
color: colors.onSurface)))),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
for (final t in enabled)
|
for (final t in enabled)
|
||||||
ListTile(
|
InkWell(
|
||||||
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,
|
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
await _userPrefs.setDefaultMainTabIndex(t.$1);
|
await _userPrefs.setDefaultMainTabIndex(t.$1);
|
||||||
setState(() => _defaultTabIndex = t.$1);
|
setState(() => _defaultTabIndex = t.$1);
|
||||||
Navigator.pop(ctx);
|
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),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1085,6 +1085,14 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
if (poster.posterPath.isNotEmpty) paths.add(poster.posterPath);
|
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;
|
return paths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class RecycleBinPage extends StatefulWidget {
|
|||||||
State<RecycleBinPage> createState() => _RecycleBinPageState();
|
State<RecycleBinPage> createState() => _RecycleBinPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _ItemType { movie, book, note, movieReview, bookReview, bookExcerpt }
|
enum _ItemType { movie, book, note, game, movieReview, bookReview, bookExcerpt, gameReview }
|
||||||
|
|
||||||
class _DeletedItem {
|
class _DeletedItem {
|
||||||
final _ItemType type;
|
final _ItemType type;
|
||||||
@@ -70,6 +70,22 @@ class _DeletedItem {
|
|||||||
icon = Icons.format_quote_outlined,
|
icon = Icons.format_quote_outlined,
|
||||||
typeLabel = '书摘';
|
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<RecycleBinPage> {
|
class _RecycleBinPageState extends State<RecycleBinPage> {
|
||||||
@@ -92,18 +108,22 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
final movies = await provider.getDeletedMovies();
|
final movies = await provider.getDeletedMovies();
|
||||||
final books = await provider.getDeletedBooks();
|
final books = await provider.getDeletedBooks();
|
||||||
final notes = await provider.getDeletedNotes();
|
final notes = await provider.getDeletedNotes();
|
||||||
|
final games = await provider.getDeletedGames();
|
||||||
final movieReviews = await provider.getDeletedMovieReviews();
|
final movieReviews = await provider.getDeletedMovieReviews();
|
||||||
final bookReviews = await provider.getDeletedBookReviews();
|
final bookReviews = await provider.getDeletedBookReviews();
|
||||||
final bookExcerpts = await provider.getDeletedBookExcerpts();
|
final bookExcerpts = await provider.getDeletedBookExcerpts();
|
||||||
|
final gameReviews = await provider.getDeletedGameReviews();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_allItems = [
|
_allItems = [
|
||||||
for (final m in movies) _DeletedItem.movie(m),
|
for (final m in movies) _DeletedItem.movie(m),
|
||||||
for (final b in books) _DeletedItem.book(b),
|
for (final b in books) _DeletedItem.book(b),
|
||||||
for (final n in notes) _DeletedItem.note(n),
|
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 movieReviews) _DeletedItem.movieReview(r),
|
||||||
for (final r in bookReviews) _DeletedItem.bookReview(r),
|
for (final r in bookReviews) _DeletedItem.bookReview(r),
|
||||||
for (final e in bookExcerpts) _DeletedItem.bookExcerpt(e),
|
for (final e in bookExcerpts) _DeletedItem.bookExcerpt(e),
|
||||||
|
for (final r in gameReviews) _DeletedItem.gameReview(r),
|
||||||
];
|
];
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
@@ -175,9 +195,11 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
_filterChip('影视', _ItemType.movie),
|
_filterChip('影视', _ItemType.movie),
|
||||||
_filterChip('书籍', _ItemType.book),
|
_filterChip('书籍', _ItemType.book),
|
||||||
_filterChip('笔记', _ItemType.note),
|
_filterChip('笔记', _ItemType.note),
|
||||||
|
_filterChip('游戏', _ItemType.game),
|
||||||
_filterChip('影评', _ItemType.movieReview),
|
_filterChip('影评', _ItemType.movieReview),
|
||||||
_filterChip('书评', _ItemType.bookReview),
|
_filterChip('书评', _ItemType.bookReview),
|
||||||
_filterChip('书摘', _ItemType.bookExcerpt),
|
_filterChip('书摘', _ItemType.bookExcerpt),
|
||||||
|
_filterChip('游戏评价', _ItemType.gameReview),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -391,6 +413,9 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
case _ItemType.note:
|
case _ItemType.note:
|
||||||
await provider.restoreNote(item.id);
|
await provider.restoreNote(item.id);
|
||||||
if (mounted) ToastUtil.show(context, '笔记已恢复');
|
if (mounted) ToastUtil.show(context, '笔记已恢复');
|
||||||
|
case _ItemType.game:
|
||||||
|
await provider.restoreGame(item.id);
|
||||||
|
if (mounted) ToastUtil.show(context, '游戏已恢复');
|
||||||
case _ItemType.movieReview:
|
case _ItemType.movieReview:
|
||||||
await provider.restoreMovieReview(item.id);
|
await provider.restoreMovieReview(item.id);
|
||||||
if (mounted) ToastUtil.show(context, '影评已恢复');
|
if (mounted) ToastUtil.show(context, '影评已恢复');
|
||||||
@@ -400,6 +425,9 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
case _ItemType.bookExcerpt:
|
case _ItemType.bookExcerpt:
|
||||||
await provider.restoreBookExcerpt(item.id);
|
await provider.restoreBookExcerpt(item.id);
|
||||||
if (mounted) ToastUtil.show(context, '书摘已恢复');
|
if (mounted) ToastUtil.show(context, '书摘已恢复');
|
||||||
|
case _ItemType.gameReview:
|
||||||
|
await provider.restoreGameReview(item.id);
|
||||||
|
if (mounted) ToastUtil.show(context, '游戏评价已恢复');
|
||||||
}
|
}
|
||||||
_loadDeletedItems();
|
_loadDeletedItems();
|
||||||
}
|
}
|
||||||
@@ -415,12 +443,16 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
await provider.permanentDeleteBook(item.id);
|
await provider.permanentDeleteBook(item.id);
|
||||||
case _ItemType.note:
|
case _ItemType.note:
|
||||||
await provider.permanentDeleteNote(item.id);
|
await provider.permanentDeleteNote(item.id);
|
||||||
|
case _ItemType.game:
|
||||||
|
await provider.permanentDeleteGame(item.id);
|
||||||
case _ItemType.movieReview:
|
case _ItemType.movieReview:
|
||||||
await provider.permanentDeleteMovieReview(item.id);
|
await provider.permanentDeleteMovieReview(item.id);
|
||||||
case _ItemType.bookReview:
|
case _ItemType.bookReview:
|
||||||
await provider.permanentDeleteBookReview(item.id);
|
await provider.permanentDeleteBookReview(item.id);
|
||||||
case _ItemType.bookExcerpt:
|
case _ItemType.bookExcerpt:
|
||||||
await provider.permanentDeleteBookExcerpt(item.id);
|
await provider.permanentDeleteBookExcerpt(item.id);
|
||||||
|
case _ItemType.gameReview:
|
||||||
|
await provider.permanentDeleteGameReview(item.id);
|
||||||
}
|
}
|
||||||
_loadDeletedItems();
|
_loadDeletedItems();
|
||||||
if (mounted) ToastUtil.show(context, '已彻底删除');
|
if (mounted) ToastUtil.show(context, '已彻底删除');
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
|||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
bool _isSyncing = false;
|
bool _isSyncing = false;
|
||||||
|
|
||||||
static const _tabTypes = ['movie_genre', 'book_genre', 'note_tag'];
|
static const _tabTypes = ['movie_genre', 'book_genre', 'note_tag', 'game_genre'];
|
||||||
static const _typeLabels = ['影视类型', '书籍类型', '笔记标签'];
|
static const _typeLabels = ['影视类型', '书籍类型', '笔记标签', '游戏类型'];
|
||||||
static const _typeIcons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined];
|
static const _typeIcons = [Icons.movie_outlined, Icons.menu_book_outlined, Icons.note_outlined, Icons.sports_esports_outlined];
|
||||||
|
|
||||||
final Map<String, List<Map<String, dynamic>>> _tagCache = {};
|
final Map<String, List<Map<String, dynamic>>> _tagCache = {};
|
||||||
Map<String, int> _usageCounts = {};
|
Map<String, int> _usageCounts = {};
|
||||||
@@ -63,6 +63,11 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
|||||||
counts[t] = (counts[t] ?? 0) + 1;
|
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;
|
_usageCounts = counts;
|
||||||
}
|
}
|
||||||
@@ -121,7 +126,7 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
|||||||
children: [
|
children: [
|
||||||
// 弹出的类别胶囊按钮
|
// 弹出的类别胶囊按钮
|
||||||
if (_showTypePicker) ...[
|
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),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -420,7 +425,7 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
|||||||
final idx = _tabTypes.indexOf(type);
|
final idx = _tabTypes.indexOf(type);
|
||||||
final icon = _typeIcons[idx];
|
final icon = _typeIcons[idx];
|
||||||
final label = _typeLabels[idx];
|
final label = _typeLabels[idx];
|
||||||
final hints = ['同步或手动添加影视类型', '同步或手动添加书籍类型', '同步或手动添加笔记标签'];
|
final hints = ['同步或手动添加影视类型', '同步或手动添加书籍类型', '同步或手动添加笔记标签', '同步或手动添加游戏类型'];
|
||||||
|
|
||||||
return Center(
|
return Center(
|
||||||
key: ValueKey('empty_$type'),
|
key: ValueKey('empty_$type'),
|
||||||
@@ -544,6 +549,10 @@ class _TagManagementPageState extends State<TagManagementPage> {
|
|||||||
for (final b in provider.books.where((b) => !b.isDeleted && b.genres.contains(tagName))) {
|
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: '书籍'));
|
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 {
|
} else {
|
||||||
for (final n in provider.notes.where((n) => !n.isDeleted && n.tags.contains(tagName))) {
|
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: '笔记'));
|
items.add((title: n.title.isNotEmpty ? n.title : '随手记', subtitle: null, type: '笔记'));
|
||||||
|
|||||||
@@ -471,6 +471,7 @@ class _BackupPageState extends State<BackupPage> {
|
|||||||
await context.read<AppProvider>().loadMovies();
|
await context.read<AppProvider>().loadMovies();
|
||||||
await context.read<AppProvider>().loadBooks();
|
await context.read<AppProvider>().loadBooks();
|
||||||
await context.read<AppProvider>().loadNotes();
|
await context.read<AppProvider>().loadNotes();
|
||||||
|
await context.read<AppProvider>().loadGames();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
|||||||
await provider.loadMovies();
|
await provider.loadMovies();
|
||||||
await provider.loadBooks();
|
await provider.loadBooks();
|
||||||
await provider.loadNotes();
|
await provider.loadNotes();
|
||||||
|
await provider.loadGames();
|
||||||
if (mounted) _showResultDialog('同步成功', details);
|
if (mounted) _showResultDialog('同步成功', details);
|
||||||
} else {
|
} else {
|
||||||
_showResultDialog('同步成功', details);
|
_showResultDialog('同步成功', details);
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import '../data/movie/movie_review_dao.dart';
|
|||||||
import '../data/movie/movie_poster_dao.dart';
|
import '../data/movie/movie_poster_dao.dart';
|
||||||
import '../data/book/book_review_dao.dart';
|
import '../data/book/book_review_dao.dart';
|
||||||
import '../data/book/book_excerpt_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/tag/tag_dao.dart';
|
||||||
import '../data/database_helper.dart';
|
import '../data/database_helper.dart';
|
||||||
import '../utils/image_path_helper.dart';
|
import '../utils/image_path_helper.dart';
|
||||||
@@ -25,11 +28,15 @@ class AppProvider extends ChangeNotifier {
|
|||||||
final MoviePosterDao _posterDao = MoviePosterDao();
|
final MoviePosterDao _posterDao = MoviePosterDao();
|
||||||
final BookReviewDao _bookReviewDao = BookReviewDao();
|
final BookReviewDao _bookReviewDao = BookReviewDao();
|
||||||
final BookExcerptDao _bookExcerptDao = BookExcerptDao();
|
final BookExcerptDao _bookExcerptDao = BookExcerptDao();
|
||||||
|
final GameDao _gameDao = GameDao();
|
||||||
|
final GameReviewDao _gameReviewDao = GameReviewDao();
|
||||||
|
final GameScreenshotDao _gameScreenshotDao = GameScreenshotDao();
|
||||||
final TagDao _tagDao = TagDao();
|
final TagDao _tagDao = TagDao();
|
||||||
// 数据列表
|
// 数据列表
|
||||||
List<Movie> _movies = [];
|
List<Movie> _movies = [];
|
||||||
List<Book> _books = [];
|
List<Book> _books = [];
|
||||||
List<Note> _notes = [];
|
List<Note> _notes = [];
|
||||||
|
List<Game> _games = [];
|
||||||
|
|
||||||
// 当前主界面选中的标签 (0: 观影,1: 阅读,2: 笔记)
|
// 当前主界面选中的标签 (0: 观影,1: 阅读,2: 笔记)
|
||||||
int _mainTabIndex = 0;
|
int _mainTabIndex = 0;
|
||||||
@@ -44,6 +51,7 @@ class AppProvider extends ChangeNotifier {
|
|||||||
Movie? _selectedMovie;
|
Movie? _selectedMovie;
|
||||||
Book? _selectedBook;
|
Book? _selectedBook;
|
||||||
Note? _selectedNote;
|
Note? _selectedNote;
|
||||||
|
Game? _selectedGame;
|
||||||
|
|
||||||
// 主题模式
|
// 主题模式
|
||||||
ThemeMode _themeMode = ThemeMode.system;
|
ThemeMode _themeMode = ThemeMode.system;
|
||||||
@@ -68,6 +76,15 @@ class AppProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
// 书架模式(不显示分类,按创建时间排序)
|
// 书架模式(不显示分类,按创建时间排序)
|
||||||
bool _bookshelfMode = false;
|
bool _bookshelfMode = false;
|
||||||
|
|
||||||
|
// 游戏选中的状态 (0: 已通关,1: 在玩,2: 想玩,3: 弃游)
|
||||||
|
int _gameStatusIndex = 0;
|
||||||
|
|
||||||
|
// 游戏列表布局样式 (0: 网格, 1: 列表, 2: 大图卡片)
|
||||||
|
int _gameLayoutStyle = 0;
|
||||||
|
|
||||||
|
// 游戏墙模式
|
||||||
|
bool _gameWallMode = false;
|
||||||
|
|
||||||
// 侧边菜单是否打开
|
// 侧边菜单是否打开
|
||||||
bool _drawerOpen = false;
|
bool _drawerOpen = false;
|
||||||
@@ -91,11 +108,13 @@ class AppProvider extends ChangeNotifier {
|
|||||||
_movieDao.getAllMovies(),
|
_movieDao.getAllMovies(),
|
||||||
_bookDao.getAllBooks(),
|
_bookDao.getAllBooks(),
|
||||||
_noteDao.getAllNotes(),
|
_noteDao.getAllNotes(),
|
||||||
|
_gameDao.getAllGames(),
|
||||||
]);
|
]);
|
||||||
_movies = results[0] as List<Movie>;
|
_movies = results[0] as List<Movie>;
|
||||||
_books = results[1] as List<Book>;
|
_books = results[1] as List<Book>;
|
||||||
_notes = results[2] as List<Note>;
|
_notes = results[2] as List<Note>;
|
||||||
debugPrint('[AppProvider] 本地数据: movies=${_movies.length}, books=${_books.length}, notes=${_notes.length}');
|
_games = results[3] as List<Game>;
|
||||||
|
debugPrint('[AppProvider] 本地数据: movies=${_movies.length}, books=${_books.length}, notes=${_notes.length}, games=${_games.length}');
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +124,15 @@ class AppProvider extends ChangeNotifier {
|
|||||||
_movieLayoutStyle = userPrefs.movieLayoutStyle;
|
_movieLayoutStyle = userPrefs.movieLayoutStyle;
|
||||||
_movieWallMode = userPrefs.movieWallMode;
|
_movieWallMode = userPrefs.movieWallMode;
|
||||||
_bookshelfMode = userPrefs.bookshelfMode;
|
_bookshelfMode = userPrefs.bookshelfMode;
|
||||||
|
_gameLayoutStyle = userPrefs.gameLayoutStyle;
|
||||||
|
_gameWallMode = userPrefs.gameWallMode;
|
||||||
final defaultIndex = userPrefs.defaultMainTabIndex;
|
final defaultIndex = userPrefs.defaultMainTabIndex;
|
||||||
// 确保选中的标签是启用的
|
// 确保选中的标签是启用的
|
||||||
final showMovie = userPrefs.showMovieTab;
|
final showMovie = userPrefs.showMovieTab;
|
||||||
final showBook = userPrefs.showBookTab;
|
final showBook = userPrefs.showBookTab;
|
||||||
final showNote = userPrefs.showNoteTab;
|
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]) {
|
if (defaultIndex >= 0 && defaultIndex < enabled.length && enabled[defaultIndex]) {
|
||||||
_mainTabIndex = defaultIndex;
|
_mainTabIndex = defaultIndex;
|
||||||
} else {
|
} else {
|
||||||
@@ -119,8 +141,10 @@ class AppProvider extends ChangeNotifier {
|
|||||||
_mainTabIndex = 0;
|
_mainTabIndex = 0;
|
||||||
} else if (showBook) {
|
} else if (showBook) {
|
||||||
_mainTabIndex = 1;
|
_mainTabIndex = 1;
|
||||||
} else {
|
} else if (showNote) {
|
||||||
_mainTabIndex = 2;
|
_mainTabIndex = 2;
|
||||||
|
} else if (showGame) {
|
||||||
|
_mainTabIndex = 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -144,6 +168,12 @@ class AppProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载游戏数据
|
||||||
|
Future<void> loadGames() async {
|
||||||
|
_games = await _gameDao.getAllGames();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// 编辑返回后触发列表页重载
|
/// 编辑返回后触发列表页重载
|
||||||
/// [itemId] 被编辑条目的 ID,用于就地更新而非重置分页
|
/// [itemId] 被编辑条目的 ID,用于就地更新而非重置分页
|
||||||
void setEditRefresh([String? itemId]) {
|
void setEditRefresh([String? itemId]) {
|
||||||
@@ -168,17 +198,25 @@ class AppProvider extends ChangeNotifier {
|
|||||||
return _noteDao.getNotesPaged(limit: _pageSize, offset: offset, sortMode: sortMode);
|
return _noteDao.getNotesPaged(limit: _pageSize, offset: offset, sortMode: sortMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Game>> loadGamesPaged({String? status, required int offset, int sortMode = 0}) async {
|
||||||
|
return _gameDao.getGamesPaged(status: status, limit: _pageSize, offset: offset, sortMode: sortMode);
|
||||||
|
}
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
int get mainTabIndex => _mainTabIndex;
|
int get mainTabIndex => _mainTabIndex;
|
||||||
int get bottomNavIndex => _bottomNavIndex;
|
int get bottomNavIndex => _bottomNavIndex;
|
||||||
Movie? get selectedMovie => _selectedMovie;
|
Movie? get selectedMovie => _selectedMovie;
|
||||||
Book? get selectedBook => _selectedBook;
|
Book? get selectedBook => _selectedBook;
|
||||||
Note? get selectedNote => _selectedNote;
|
Note? get selectedNote => _selectedNote;
|
||||||
|
Game? get selectedGame => _selectedGame;
|
||||||
int get movieStatusIndex => _movieStatusIndex;
|
int get movieStatusIndex => _movieStatusIndex;
|
||||||
int get movieLayoutStyle => _movieLayoutStyle;
|
int get movieLayoutStyle => _movieLayoutStyle;
|
||||||
bool get movieWallMode => _movieWallMode;
|
bool get movieWallMode => _movieWallMode;
|
||||||
int get bookStatusIndex => _bookStatusIndex;
|
int get bookStatusIndex => _bookStatusIndex;
|
||||||
bool get bookshelfMode => _bookshelfMode;
|
bool get bookshelfMode => _bookshelfMode;
|
||||||
|
int get gameStatusIndex => _gameStatusIndex;
|
||||||
|
int get gameLayoutStyle => _gameLayoutStyle;
|
||||||
|
bool get gameWallMode => _gameWallMode;
|
||||||
bool get drawerOpen => _drawerOpen;
|
bool get drawerOpen => _drawerOpen;
|
||||||
bool get bottomNavVisible => _bottomNavVisible;
|
bool get bottomNavVisible => _bottomNavVisible;
|
||||||
ThemeMode get themeMode => _themeMode;
|
ThemeMode get themeMode => _themeMode;
|
||||||
@@ -187,6 +225,7 @@ class AppProvider extends ChangeNotifier {
|
|||||||
List<Movie> get movies => UnmodifiableListView(_movies);
|
List<Movie> get movies => UnmodifiableListView(_movies);
|
||||||
List<Book> get books => UnmodifiableListView(_books);
|
List<Book> get books => UnmodifiableListView(_books);
|
||||||
List<Note> get notes => UnmodifiableListView(_notes);
|
List<Note> get notes => UnmodifiableListView(_notes);
|
||||||
|
List<Game> get games => UnmodifiableListView(_games);
|
||||||
|
|
||||||
// 根据状态获取影视列表
|
// 根据状态获取影视列表
|
||||||
List<Movie> getMoviesByStatus(String status) {
|
List<Movie> getMoviesByStatus(String status) {
|
||||||
@@ -231,6 +270,11 @@ class AppProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void selectGame(Game? game) {
|
||||||
|
_selectedGame = game;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void setBottomNavVisible(bool visible) {
|
void setBottomNavVisible(bool visible) {
|
||||||
if (_bottomNavVisible != visible) {
|
if (_bottomNavVisible != visible) {
|
||||||
_bottomNavVisible = visible;
|
_bottomNavVisible = visible;
|
||||||
@@ -311,6 +355,23 @@ class AppProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
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() {
|
void toggleDrawer() {
|
||||||
_drawerOpen = !_drawerOpen;
|
_drawerOpen = !_drawerOpen;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -389,6 +450,31 @@ class AppProvider extends ChangeNotifier {
|
|||||||
await loadNotes();
|
await loadNotes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> addGame(Game game) async {
|
||||||
|
await _gameDao.insertGame(game);
|
||||||
|
await loadGames();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateGame(Game game) async {
|
||||||
|
await _gameDao.updateGame(game);
|
||||||
|
await loadGames();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeGame(String id) async {
|
||||||
|
await _gameDao.deleteGame(id);
|
||||||
|
await loadGames();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 仅更新游戏封面偏移量(不触发全量刷新)
|
||||||
|
Future<void> 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<void> toggleNotePin(String id, bool isPinned) async {
|
Future<void> toggleNotePin(String id, bool isPinned) async {
|
||||||
await _noteDao.togglePin(id, isPinned);
|
await _noteDao.togglePin(id, isPinned);
|
||||||
await loadNotes();
|
await loadNotes();
|
||||||
@@ -448,6 +534,59 @@ class AppProvider extends ChangeNotifier {
|
|||||||
return await _posterDao.getPosterCount(movieId);
|
return await _posterDao.getPosterCount(movieId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 游戏评价相关方法 ==========
|
||||||
|
|
||||||
|
/// 获取游戏的所有评价
|
||||||
|
Future<List<GameReview>> getGameReviews(String gameId) async {
|
||||||
|
return await _gameReviewDao.getReviewsByGameId(gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加游戏评价
|
||||||
|
Future<void> addGameReview(GameReview review) async {
|
||||||
|
await _gameReviewDao.insertReview(review);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新游戏评价
|
||||||
|
Future<void> updateGameReview(GameReview review) async {
|
||||||
|
await _gameReviewDao.updateReview(review);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除游戏评价
|
||||||
|
Future<void> removeGameReview(String id) async {
|
||||||
|
await _gameReviewDao.deleteReview(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取游戏的评价数量
|
||||||
|
Future<int> getGameReviewCount(String gameId) async {
|
||||||
|
return await _gameReviewDao.getReviewCount(gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 游戏截图相关方法 ==========
|
||||||
|
|
||||||
|
/// 获取游戏的所有截图
|
||||||
|
Future<List<GameScreenshot>> getGameScreenshots(String gameId) async {
|
||||||
|
return await _gameScreenshotDao.getScreenshotsByGameId(gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加游戏截图
|
||||||
|
Future<void> addGameScreenshot(GameScreenshot screenshot) async {
|
||||||
|
await _gameScreenshotDao.insertScreenshot(screenshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除游戏截图
|
||||||
|
Future<void> removeGameScreenshot(String id) async {
|
||||||
|
final screenshot = await _gameScreenshotDao.getScreenshotById(id);
|
||||||
|
if (screenshot != null) {
|
||||||
|
await ImagePathHelper.instance.deleteFile(screenshot.screenshotPath);
|
||||||
|
}
|
||||||
|
await _gameScreenshotDao.deleteScreenshot(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取游戏的截图数量
|
||||||
|
Future<int> getGameScreenshotCount(String gameId) async {
|
||||||
|
return await _gameScreenshotDao.getScreenshotCount(gameId);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 书评相关方法 ==========
|
// ========== 书评相关方法 ==========
|
||||||
|
|
||||||
/// 获取书籍的所有书评
|
/// 获取书籍的所有书评
|
||||||
@@ -554,15 +693,34 @@ class AppProvider extends ChangeNotifier {
|
|||||||
await ImagePathHelper.instance.deleteNoteImages(id);
|
await ImagePathHelper.instance.deleteNoteImages(id);
|
||||||
await _noteDao.permanentDeleteNote(id);
|
await _noteDao.permanentDeleteNote(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取已删除的游戏
|
||||||
|
Future<List<Game>> getDeletedGames() async {
|
||||||
|
return await _gameDao.getDeletedGames();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 恢复游戏
|
||||||
|
Future<void> restoreGame(String id) async {
|
||||||
|
await _gameDao.restoreGame(id);
|
||||||
|
await loadGames();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 彻底删除游戏
|
||||||
|
Future<void> permanentDeleteGame(String id) async {
|
||||||
|
await ImagePathHelper.instance.deleteGameImages(id);
|
||||||
|
await _gameDao.permanentDeleteGame(id);
|
||||||
|
}
|
||||||
|
|
||||||
/// 清空回收站
|
/// 清空回收站
|
||||||
Future<void> clearRecycleBin() async {
|
Future<void> clearRecycleBin() async {
|
||||||
final deletedMovies = await getDeletedMovies();
|
final deletedMovies = await getDeletedMovies();
|
||||||
final deletedBooks = await getDeletedBooks();
|
final deletedBooks = await getDeletedBooks();
|
||||||
final deletedNotes = await getDeletedNotes();
|
final deletedNotes = await getDeletedNotes();
|
||||||
|
final deletedGames = await getDeletedGames();
|
||||||
final deletedMovieReviews = await getDeletedMovieReviews();
|
final deletedMovieReviews = await getDeletedMovieReviews();
|
||||||
final deletedBookReviews = await getDeletedBookReviews();
|
final deletedBookReviews = await getDeletedBookReviews();
|
||||||
final deletedBookExcerpts = await getDeletedBookExcerpts();
|
final deletedBookExcerpts = await getDeletedBookExcerpts();
|
||||||
|
final deletedGameReviews = await getDeletedGameReviews();
|
||||||
|
|
||||||
for (final movie in deletedMovies) {
|
for (final movie in deletedMovies) {
|
||||||
await permanentDeleteMovie(movie.id);
|
await permanentDeleteMovie(movie.id);
|
||||||
@@ -573,6 +731,9 @@ class AppProvider extends ChangeNotifier {
|
|||||||
for (final note in deletedNotes) {
|
for (final note in deletedNotes) {
|
||||||
await permanentDeleteNote(note.id);
|
await permanentDeleteNote(note.id);
|
||||||
}
|
}
|
||||||
|
for (final game in deletedGames) {
|
||||||
|
await permanentDeleteGame(game.id);
|
||||||
|
}
|
||||||
for (final review in deletedMovieReviews) {
|
for (final review in deletedMovieReviews) {
|
||||||
await _reviewDao.permanentDeleteReview(review.id);
|
await _reviewDao.permanentDeleteReview(review.id);
|
||||||
}
|
}
|
||||||
@@ -582,10 +743,14 @@ class AppProvider extends ChangeNotifier {
|
|||||||
for (final excerpt in deletedBookExcerpts) {
|
for (final excerpt in deletedBookExcerpts) {
|
||||||
await _bookExcerptDao.permanentDeleteExcerpt(excerpt.id);
|
await _bookExcerptDao.permanentDeleteExcerpt(excerpt.id);
|
||||||
}
|
}
|
||||||
|
for (final review in deletedGameReviews) {
|
||||||
|
await _gameReviewDao.permanentDeleteReview(review.id);
|
||||||
|
}
|
||||||
|
|
||||||
await loadMovies();
|
await loadMovies();
|
||||||
await loadBooks();
|
await loadBooks();
|
||||||
await loadNotes();
|
await loadNotes();
|
||||||
|
await loadGames();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 影评书评回收站 ==========
|
// ========== 影评书评回收站 ==========
|
||||||
@@ -614,6 +779,20 @@ class AppProvider extends ChangeNotifier {
|
|||||||
await _bookReviewDao.permanentDeleteReview(id);
|
await _bookReviewDao.permanentDeleteReview(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 游戏评价回收站 ==========
|
||||||
|
|
||||||
|
Future<List<GameReview>> getDeletedGameReviews() async {
|
||||||
|
return await _gameReviewDao.getDeletedReviews();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> restoreGameReview(String id) async {
|
||||||
|
await _gameReviewDao.restoreReview(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> permanentDeleteGameReview(String id) async {
|
||||||
|
await _gameReviewDao.permanentDeleteReview(id);
|
||||||
|
}
|
||||||
|
|
||||||
// ========== 摘抄回收站方法 ==========
|
// ========== 摘抄回收站方法 ==========
|
||||||
|
|
||||||
Future<List<BookExcerpt>> getDeletedBookExcerpts() async {
|
Future<List<BookExcerpt>> 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;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,6 +914,8 @@ class AppProvider extends ChangeNotifier {
|
|||||||
await loadBooks();
|
await loadBooks();
|
||||||
case 'note_tag':
|
case 'note_tag':
|
||||||
await loadNotes();
|
await loadNotes();
|
||||||
|
case 'game_genre':
|
||||||
|
await loadGames();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ class BackupService {
|
|||||||
final tags = await db.query('tags');
|
final tags = await db.query('tags');
|
||||||
final readerBooks = await db.query('reader_books');
|
final readerBooks = await db.query('reader_books');
|
||||||
final bookAnnotations = await db.query('book_annotations');
|
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 = <String>{};
|
final imagePaths = <String>{};
|
||||||
@@ -63,6 +66,15 @@ class BackupService {
|
|||||||
// reader_books 的封面在 epub_books/ 目录下,由 epub_books 归档处理
|
// reader_books 的封面在 epub_books/ 目录下,由 epub_books 归档处理
|
||||||
// 不加入 imagePaths,避免 basename 碰撞导致所有封面变成同一个路径
|
// 不加入 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 userPrefs = UserPrefs();
|
||||||
final userInfo = {
|
final userInfo = {
|
||||||
'nickname': userPrefs.nickname,
|
'nickname': userPrefs.nickname,
|
||||||
@@ -91,6 +103,9 @@ class BackupService {
|
|||||||
'tags': tags,
|
'tags': tags,
|
||||||
'reader_books': readerBooks,
|
'reader_books': readerBooks,
|
||||||
'book_annotations': bookAnnotations,
|
'book_annotations': bookAnnotations,
|
||||||
|
'games': games,
|
||||||
|
'game_reviews': gameReviews,
|
||||||
|
'game_screenshots': gameScreenshots,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -315,6 +330,9 @@ class BackupService {
|
|||||||
final tagsCols = await _getTableColumns(db, 'tags');
|
final tagsCols = await _getTableColumns(db, 'tags');
|
||||||
final readerBooksCols = await _getTableColumns(db, 'reader_books');
|
final readerBooksCols = await _getTableColumns(db, 'reader_books');
|
||||||
final bookAnnotationsCols = await _getTableColumns(db, 'book_annotations');
|
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 db.transaction((txn) async {
|
||||||
await txn.delete('movie_reviews');
|
await txn.delete('movie_reviews');
|
||||||
@@ -322,10 +340,13 @@ class BackupService {
|
|||||||
await txn.delete('book_reviews');
|
await txn.delete('book_reviews');
|
||||||
await txn.delete('book_excerpts');
|
await txn.delete('book_excerpts');
|
||||||
await txn.delete('book_annotations');
|
await txn.delete('book_annotations');
|
||||||
|
await txn.delete('game_reviews');
|
||||||
|
await txn.delete('game_screenshots');
|
||||||
await txn.delete('movies');
|
await txn.delete('movies');
|
||||||
await txn.delete('books');
|
await txn.delete('books');
|
||||||
await txn.delete('notes');
|
await txn.delete('notes');
|
||||||
await txn.delete('reader_books');
|
await txn.delete('reader_books');
|
||||||
|
await txn.delete('games');
|
||||||
await txn.delete('tags');
|
await txn.delete('tags');
|
||||||
|
|
||||||
if (data.containsKey('movies')) {
|
if (data.containsKey('movies')) {
|
||||||
@@ -375,6 +396,21 @@ class BackupService {
|
|||||||
await txn.insert('book_annotations', _convertToDbMapSafe(a, bookAnnotationsCols));
|
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')) {
|
if (data.containsKey('tags')) {
|
||||||
for (final t in data['tags'] as List) {
|
for (final t in data['tags'] as List) {
|
||||||
final map = _convertToDbMapSafe(t, tagsCols);
|
final map = _convertToDbMapSafe(t, tagsCols);
|
||||||
@@ -450,6 +486,9 @@ class BackupService {
|
|||||||
final tagsCols = await _getTableColumns(db, 'tags');
|
final tagsCols = await _getTableColumns(db, 'tags');
|
||||||
final readerBooksCols = await _getTableColumns(db, 'reader_books');
|
final readerBooksCols = await _getTableColumns(db, 'reader_books');
|
||||||
final bookAnnotationsCols = await _getTableColumns(db, 'book_annotations');
|
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 db.transaction((txn) async {
|
||||||
await txn.delete('movie_reviews');
|
await txn.delete('movie_reviews');
|
||||||
@@ -457,10 +496,13 @@ class BackupService {
|
|||||||
await txn.delete('book_reviews');
|
await txn.delete('book_reviews');
|
||||||
await txn.delete('book_excerpts');
|
await txn.delete('book_excerpts');
|
||||||
await txn.delete('book_annotations');
|
await txn.delete('book_annotations');
|
||||||
|
await txn.delete('game_reviews');
|
||||||
|
await txn.delete('game_screenshots');
|
||||||
await txn.delete('movies');
|
await txn.delete('movies');
|
||||||
await txn.delete('books');
|
await txn.delete('books');
|
||||||
await txn.delete('notes');
|
await txn.delete('notes');
|
||||||
await txn.delete('reader_books');
|
await txn.delete('reader_books');
|
||||||
|
await txn.delete('games');
|
||||||
await txn.delete('tags'); // 修复: 之前漏删 tags 表
|
await txn.delete('tags'); // 修复: 之前漏删 tags 表
|
||||||
|
|
||||||
if (data.containsKey('movies')) {
|
if (data.containsKey('movies')) {
|
||||||
@@ -510,6 +552,21 @@ class BackupService {
|
|||||||
await txn.insert('book_annotations', _convertToDbMapSafe(a, bookAnnotationsCols));
|
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')) {
|
if (data.containsKey('tags')) {
|
||||||
for (final t in data['tags'] as List) {
|
for (final t in data['tags'] as List) {
|
||||||
final map = _convertToDbMapSafe(t, tagsCols);
|
final map = _convertToDbMapSafe(t, tagsCols);
|
||||||
@@ -605,6 +662,9 @@ class BackupService {
|
|||||||
if (data.containsKey('tags')) stats['标签'] = (data['tags'] as List).length;
|
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('reader_books')) stats['阅读'] = (data['reader_books'] as List).length;
|
||||||
if (data.containsKey('book_annotations')) stats['批注'] = (data['book_annotations'] 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;
|
if (imageCount > 0) stats['图片'] = imageCount;
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import 'slide_up_page_route.dart';
|
|||||||
import '../pages/movies/movie_form_page.dart';
|
import '../pages/movies/movie_form_page.dart';
|
||||||
import '../pages/book/book_form_page.dart';
|
import '../pages/book/book_form_page.dart';
|
||||||
import '../pages/note/note_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/movies/movie_detail_page.dart';
|
||||||
import '../pages/book/book_detail_page.dart';
|
import '../pages/book/book_detail_page.dart';
|
||||||
import '../pages/note/note_detail_page.dart';
|
import '../pages/note/note_detail_page.dart';
|
||||||
|
import '../pages/game/game_detail_page.dart';
|
||||||
import '../pages/movies/douban_webview_page.dart';
|
import '../pages/movies/douban_webview_page.dart';
|
||||||
|
|
||||||
/// 路由生成器
|
/// 路由生成器
|
||||||
@@ -59,6 +61,22 @@ class AppRouter {
|
|||||||
}
|
}
|
||||||
return SlideUpPageRoute(page: NoteDetailPage(note: note));
|
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<String, dynamic> ? (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':
|
case '/douban-webview':
|
||||||
final url = settings.arguments is String ? settings.arguments as String : null;
|
final url = settings.arguments is String ? settings.arguments as String : null;
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import 'package:path/path.dart' as p;
|
|||||||
/// images/movies/{movieId}/posterimgs/xxxx.jpg - 影视海报墙图片
|
/// images/movies/{movieId}/posterimgs/xxxx.jpg - 影视海报墙图片
|
||||||
/// images/books/{bookId}/xxxx.jpg - 书籍封面
|
/// images/books/{bookId}/xxxx.jpg - 书籍封面
|
||||||
/// images/notes/{noteId}/xxxx.jpg - 笔记图片
|
/// images/notes/{noteId}/xxxx.jpg - 笔记图片
|
||||||
|
/// images/games/{gameId}/xxxx.jpg - 游戏封面
|
||||||
|
/// images/games/{gameId}/screenshots/xxxx.jpg - 游戏截图
|
||||||
class ImagePathHelper {
|
class ImagePathHelper {
|
||||||
static final ImagePathHelper instance = ImagePathHelper._init();
|
static final ImagePathHelper instance = ImagePathHelper._init();
|
||||||
|
|
||||||
@@ -93,6 +95,36 @@ class ImagePathHelper {
|
|||||||
return p.join(dir, fileName);
|
return p.join(dir, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 游戏相关路径 ====================
|
||||||
|
|
||||||
|
/// 获取游戏图片目录
|
||||||
|
/// 路径: images/games/{gameId}/
|
||||||
|
Future<String> getGameImagesDir(String gameId) async {
|
||||||
|
final root = await imagesRoot;
|
||||||
|
return p.join(root, 'games', gameId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取游戏封面路径
|
||||||
|
/// 路径: images/games/{gameId}/{fileName}
|
||||||
|
Future<String> getGameCoverPath(String gameId, String fileName) async {
|
||||||
|
final dir = await getGameImagesDir(gameId);
|
||||||
|
return p.join(dir, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取游戏截图目录
|
||||||
|
/// 路径: images/games/{gameId}/screenshots/
|
||||||
|
Future<String> getGameScreenshotImgsDir(String gameId) async {
|
||||||
|
final dir = await getGameImagesDir(gameId);
|
||||||
|
return p.join(dir, 'screenshots');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取游戏截图图片路径
|
||||||
|
/// 路径: images/games/{gameId}/screenshots/{fileName}
|
||||||
|
Future<String> 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);
|
await _deleteDirectory(dirPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删除游戏图片目录
|
||||||
|
/// 删除路径: images/games/{gameId}/
|
||||||
|
Future<void> deleteGameImages(String gameId) async {
|
||||||
|
final dirPath = await getGameImagesDir(gameId);
|
||||||
|
await _deleteDirectory(dirPath);
|
||||||
|
}
|
||||||
|
|
||||||
/// 删除目录及其内容
|
/// 删除目录及其内容
|
||||||
Future<void> _deleteDirectory(String dirPath) async {
|
Future<void> _deleteDirectory(String dirPath) async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ class UserPrefs {
|
|||||||
bool get showNoteTab => prefs.getBool('showNoteTab') ?? true;
|
bool get showNoteTab => prefs.getBool('showNoteTab') ?? true;
|
||||||
Future<bool> setShowNoteTab(bool value) => prefs.setBool('showNoteTab', value);
|
Future<bool> setShowNoteTab(bool value) => prefs.setBool('showNoteTab', value);
|
||||||
|
|
||||||
|
/// 是否显示游戏标签
|
||||||
|
bool get showGameTab => prefs.getBool('showGameTab') ?? false;
|
||||||
|
Future<bool> setShowGameTab(bool value) => prefs.setBool('showGameTab', value);
|
||||||
|
|
||||||
/// 默认启动标签 (0: 影视, 1: 阅读, 2: 笔记)
|
/// 默认启动标签 (0: 影视, 1: 阅读, 2: 笔记)
|
||||||
int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0;
|
int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0;
|
||||||
Future<bool> setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value);
|
Future<bool> setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value);
|
||||||
@@ -169,6 +173,18 @@ class UserPrefs {
|
|||||||
bool get bookshelfMode => prefs.getBool('bookshelfMode') ?? false;
|
bool get bookshelfMode => prefs.getBool('bookshelfMode') ?? false;
|
||||||
Future<bool> setBookshelfMode(bool value) => prefs.setBool('bookshelfMode', value);
|
Future<bool> setBookshelfMode(bool value) => prefs.setBool('bookshelfMode', value);
|
||||||
|
|
||||||
|
/// 游戏排序方式 (0: 更新时间, 1: 创建时间, 2: 评分)
|
||||||
|
int get gameSortMode => prefs.getInt('gameSortMode') ?? 0;
|
||||||
|
Future<bool> setGameSortMode(int value) => prefs.setInt('gameSortMode', value);
|
||||||
|
|
||||||
|
/// 游戏布局样式 (0: 网格, 1: 列表, 2: 大图卡片)
|
||||||
|
int get gameLayoutStyle => prefs.getInt('gameLayoutStyle') ?? 0;
|
||||||
|
Future<bool> setGameLayoutStyle(int value) => prefs.setInt('gameLayoutStyle', value);
|
||||||
|
|
||||||
|
/// 游戏墙模式
|
||||||
|
bool get gameWallMode => prefs.getBool('gameWallMode') ?? false;
|
||||||
|
Future<bool> setGameWallMode(bool value) => prefs.setBool('gameWallMode', value);
|
||||||
|
|
||||||
// ========== 应用图标设置 ==========
|
// ========== 应用图标设置 ==========
|
||||||
|
|
||||||
// ========== Markdown 阅读器 ==========
|
// ========== Markdown 阅读器 ==========
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ void showQuickAddSheet(BuildContext context, AppProvider provider) {
|
|||||||
arguments: {'initialStatus': currentStatus});
|
arguments: {'initialStatus': currentStatus});
|
||||||
case 2:
|
case 2:
|
||||||
Navigator.pushNamed(context, '/note-form');
|
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:
|
default:
|
||||||
showAddSheet(context, provider);
|
showAddSheet(context, provider);
|
||||||
}
|
}
|
||||||
@@ -111,6 +117,25 @@ void showAddSheet(BuildContext context, AppProvider provider) {
|
|||||||
Navigator.pushNamed(outerContext, '/note-form');
|
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});
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../pages/settings/tag_management_page.dart';
|
|||||||
import '../pages/movies/movie_detail_page.dart';
|
import '../pages/movies/movie_detail_page.dart';
|
||||||
import '../pages/book/book_detail_page.dart';
|
import '../pages/book/book_detail_page.dart';
|
||||||
import '../pages/note/note_detail_page.dart';
|
import '../pages/note/note_detail_page.dart';
|
||||||
|
import '../pages/game/game_detail_page.dart';
|
||||||
import '../models/data_models.dart';
|
import '../models/data_models.dart';
|
||||||
import 'fade_in_local_image.dart';
|
import 'fade_in_local_image.dart';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ class _CustomDrawerState extends State<CustomDrawer> {
|
|||||||
List<Movie>? _cachedMovies;
|
List<Movie>? _cachedMovies;
|
||||||
List<Book>? _cachedBooks;
|
List<Book>? _cachedBooks;
|
||||||
List<Note>? _cachedNotes;
|
List<Note>? _cachedNotes;
|
||||||
|
List<Game>? _cachedGames;
|
||||||
Map<DateTime, int>? _cachedDailyCounts;
|
Map<DateTime, int>? _cachedDailyCounts;
|
||||||
int? _cachedMaxCount;
|
int? _cachedMaxCount;
|
||||||
|
|
||||||
@@ -112,6 +114,7 @@ class _CustomDrawerState extends State<CustomDrawer> {
|
|||||||
final movieCount = provider.movies.where((m) => !m.isDeleted).length;
|
final movieCount = provider.movies.where((m) => !m.isDeleted).length;
|
||||||
final bookCount = provider.books.length;
|
final bookCount = provider.books.length;
|
||||||
final noteCount = provider.notes.length;
|
final noteCount = provider.notes.length;
|
||||||
|
final gameCount = provider.games.where((g) => !g.isDeleted).length;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||||
@@ -162,6 +165,8 @@ class _CustomDrawerState extends State<CustomDrawer> {
|
|||||||
_buildProfileStatRow(Icons.menu_book_outlined, bookCount, '阅读'),
|
_buildProfileStatRow(Icons.menu_book_outlined, bookCount, '阅读'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildProfileStatRow(Icons.note_outlined, noteCount, '笔记'),
|
_buildProfileStatRow(Icons.note_outlined, noteCount, '笔记'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildProfileStatRow(Icons.sports_esports_outlined, gameCount, '游戏'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -258,10 +263,11 @@ class _CustomDrawerState extends State<CustomDrawer> {
|
|||||||
|
|
||||||
// ─── 热力图缓存计算 ───
|
// ─── 热力图缓存计算 ───
|
||||||
|
|
||||||
(int, Map<DateTime, int>) _computeDailyCounts(List<Movie> movies, List<Book> books, List<Note> notes) {
|
(int, Map<DateTime, int>) _computeDailyCounts(List<Movie> movies, List<Book> books, List<Note> notes, List<Game> games) {
|
||||||
if (identical(movies, _cachedMovies) &&
|
if (identical(movies, _cachedMovies) &&
|
||||||
identical(books, _cachedBooks) &&
|
identical(books, _cachedBooks) &&
|
||||||
identical(notes, _cachedNotes) &&
|
identical(notes, _cachedNotes) &&
|
||||||
|
identical(games, _cachedGames) &&
|
||||||
_cachedDailyCounts != null) {
|
_cachedDailyCounts != null) {
|
||||||
return (_cachedMaxCount!, _cachedDailyCounts!);
|
return (_cachedMaxCount!, _cachedDailyCounts!);
|
||||||
}
|
}
|
||||||
@@ -279,6 +285,10 @@ class _CustomDrawerState extends State<CustomDrawer> {
|
|||||||
final date = DateTime(note.createdAt.year, note.createdAt.month, note.createdAt.day);
|
final date = DateTime(note.createdAt.year, note.createdAt.month, note.createdAt.day);
|
||||||
dailyCounts[date] = (dailyCounts[date] ?? 0) + 1;
|
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;
|
int maxCount = 0;
|
||||||
for (final c in dailyCounts.values) {
|
for (final c in dailyCounts.values) {
|
||||||
@@ -289,15 +299,17 @@ class _CustomDrawerState extends State<CustomDrawer> {
|
|||||||
_cachedMovies = movies;
|
_cachedMovies = movies;
|
||||||
_cachedBooks = books;
|
_cachedBooks = books;
|
||||||
_cachedNotes = notes;
|
_cachedNotes = notes;
|
||||||
|
_cachedGames = games;
|
||||||
_cachedDailyCounts = dailyCounts;
|
_cachedDailyCounts = dailyCounts;
|
||||||
_cachedMaxCount = maxCount;
|
_cachedMaxCount = maxCount;
|
||||||
return (maxCount, dailyCounts);
|
return (maxCount, dailyCounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<_RecentItem> _computeRecentItems(List<Movie> movies, List<Book> books, List<Note> notes) {
|
List<_RecentItem> _computeRecentItems(List<Movie> movies, List<Book> books, List<Note> notes, List<Game> games) {
|
||||||
if (identical(movies, _cachedMovies) &&
|
if (identical(movies, _cachedMovies) &&
|
||||||
identical(books, _cachedBooks) &&
|
identical(books, _cachedBooks) &&
|
||||||
identical(notes, _cachedNotes) &&
|
identical(notes, _cachedNotes) &&
|
||||||
|
identical(games, _cachedGames) &&
|
||||||
_cachedRecentItems != null) {
|
_cachedRecentItems != null) {
|
||||||
return _cachedRecentItems!;
|
return _cachedRecentItems!;
|
||||||
}
|
}
|
||||||
@@ -312,6 +324,9 @@ class _CustomDrawerState extends State<CustomDrawer> {
|
|||||||
for (final n in notes.where((n) => !n.isDeleted)) {
|
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));
|
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));
|
items.sort((a, b) => b.date.compareTo(a.date));
|
||||||
|
|
||||||
_cachedRecentItems = items;
|
_cachedRecentItems = items;
|
||||||
@@ -322,9 +337,10 @@ class _CustomDrawerState extends State<CustomDrawer> {
|
|||||||
final movies = context.select<AppProvider, List<Movie>>((p) => p.movies);
|
final movies = context.select<AppProvider, List<Movie>>((p) => p.movies);
|
||||||
final books = context.select<AppProvider, List<Book>>((p) => p.books);
|
final books = context.select<AppProvider, List<Book>>((p) => p.books);
|
||||||
final notes = context.select<AppProvider, List<Note>>((p) => p.notes);
|
final notes = context.select<AppProvider, List<Note>>((p) => p.notes);
|
||||||
|
final games = context.select<AppProvider, List<Game>>((p) => p.games);
|
||||||
final colors = Theme.of(context).colorScheme;
|
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 now = DateTime.now();
|
||||||
final today = DateTime(now.year, now.month, now.day);
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
@@ -442,8 +458,9 @@ class _CustomDrawerState extends State<CustomDrawer> {
|
|||||||
final movies = context.select<AppProvider, List<Movie>>((p) => p.movies);
|
final movies = context.select<AppProvider, List<Movie>>((p) => p.movies);
|
||||||
final books = context.select<AppProvider, List<Book>>((p) => p.books);
|
final books = context.select<AppProvider, List<Book>>((p) => p.books);
|
||||||
final notes = context.select<AppProvider, List<Note>>((p) => p.notes);
|
final notes = context.select<AppProvider, List<Note>>((p) => p.notes);
|
||||||
|
final games = context.select<AppProvider, List<Game>>((p) => p.games);
|
||||||
final colors = Theme.of(context).colorScheme;
|
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();
|
if (recent.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -469,7 +486,7 @@ class _CustomDrawerState extends State<CustomDrawer> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
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),
|
size: 14, color: colors.onSurface.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
@@ -497,6 +514,8 @@ class _CustomDrawerState extends State<CustomDrawer> {
|
|||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book)));
|
Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book)));
|
||||||
case 'note':
|
case 'note':
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => NoteDetailPage(note: item.data as 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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
lib/widgets/game_list_item.dart
Normal file
129
lib/widgets/game_list_item.dart
Normal file
@@ -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<AppProvider>().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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
104
lib/widgets/game_status_bar.dart
Normal file
104
lib/widgets/game_status_bar.dart
Normal file
@@ -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<AppProvider>(
|
||||||
|
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,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
class NoteSkeletonList extends StatelessWidget {
|
||||||
const NoteSkeletonList({super.key});
|
const NoteSkeletonList({super.key});
|
||||||
|
|||||||
Reference in New Issue
Block a user