generated from dellevin/template
v0.2.9
This commit is contained in:
@@ -81,7 +81,7 @@ class DatabaseHelper {
|
|||||||
|
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 36,
|
version: 38,
|
||||||
onCreate: _createDB,
|
onCreate: _createDB,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
@@ -363,6 +363,44 @@ class DatabaseHelper {
|
|||||||
await db.execute('ALTER TABLE games ADD COLUMN release_date TEXT');
|
await db.execute('ALTER TABLE games ADD COLUMN release_date TEXT');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 37) {
|
||||||
|
// 创建片单表和片单条目表
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS playlists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
cover_path TEXT,
|
||||||
|
item_count INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
is_deleted INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS playlist_items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
added_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (playlist_id) REFERENCES playlists (id)
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_playlist_items_playlist ON playlist_items(playlist_id)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldVersion < 38) {
|
||||||
|
// 片单表添加 sort_order 字段
|
||||||
|
final cols = await db.rawQuery('PRAGMA table_info(playlists)');
|
||||||
|
if (!cols.any((col) => col['name'] == 'sort_order')) {
|
||||||
|
await db.execute('ALTER TABLE playlists ADD COLUMN sort_order INTEGER DEFAULT 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Future<void> _upgradeBooksTableV26(Database db) async {
|
Future<void> _upgradeBooksTableV26(Database db) async {
|
||||||
final columns = await db.rawQuery('PRAGMA table_info(books)');
|
final columns = await db.rawQuery('PRAGMA table_info(books)');
|
||||||
@@ -964,6 +1002,37 @@ class DatabaseHelper {
|
|||||||
FOREIGN KEY (game_id) REFERENCES games (id)
|
FOREIGN KEY (game_id) REFERENCES games (id)
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
|
// 片单表
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE playlists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
cover_path TEXT,
|
||||||
|
item_count INTEGER DEFAULT 0,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
is_deleted INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
// 片单条目表
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE playlist_items (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
item_id TEXT NOT NULL,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
added_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (playlist_id) REFERENCES playlists (id)
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_playlist_items_playlist ON playlist_items(playlist_id)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭数据库
|
// 关闭数据库
|
||||||
|
|||||||
167
lib/data/playlist/playlist_dao.dart
Normal file
167
lib/data/playlist/playlist_dao.dart
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../../models/data_models.dart';
|
||||||
|
import '../database_helper.dart';
|
||||||
|
|
||||||
|
/// 片单数据访问对象
|
||||||
|
class PlaylistDao {
|
||||||
|
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
|
||||||
|
|
||||||
|
Future<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[PlaylistDao] $op error: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有片单(未删除)
|
||||||
|
Future<List<Playlist>> getAllPlaylists() => _wrap('getAllPlaylists', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final maps = await db.query(
|
||||||
|
'playlists',
|
||||||
|
where: 'is_deleted = ?',
|
||||||
|
whereArgs: [0],
|
||||||
|
orderBy: 'sort_order ASC, updated_at DESC',
|
||||||
|
);
|
||||||
|
return maps.map((m) => Playlist.fromJson(m)).toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取单个片单
|
||||||
|
Future<Playlist?> getPlaylistById(String id) => _wrap('getPlaylistById', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final maps = await db.query(
|
||||||
|
'playlists',
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
if (maps.isEmpty) return null;
|
||||||
|
return Playlist.fromJson(maps.first);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建片单
|
||||||
|
Future<int> insertPlaylist(Playlist playlist) => _wrap('insertPlaylist', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
return await db.insert('playlists', playlist.toJson());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新片单
|
||||||
|
Future<int> updatePlaylist(Playlist playlist) => _wrap('updatePlaylist', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
return await db.update(
|
||||||
|
'playlists',
|
||||||
|
playlist.toJson(),
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [playlist.id],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 软删除片单
|
||||||
|
Future<int> deletePlaylist(String id) => _wrap('deletePlaylist', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
// 同时删除片单内条目
|
||||||
|
await db.delete('playlist_items', where: 'playlist_id = ?', whereArgs: [id]);
|
||||||
|
return await db.update(
|
||||||
|
'playlists',
|
||||||
|
{'is_deleted': 1, 'updated_at': DateTime.now().toIso8601String()},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取片单内条目
|
||||||
|
Future<List<PlaylistItem>> getPlaylistItems(String playlistId) => _wrap('getPlaylistItems', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final maps = await db.query(
|
||||||
|
'playlist_items',
|
||||||
|
where: 'playlist_id = ?',
|
||||||
|
whereArgs: [playlistId],
|
||||||
|
orderBy: 'sort_order ASC, added_at DESC',
|
||||||
|
);
|
||||||
|
return maps.map((m) => PlaylistItem.fromJson(m)).toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加条目到片单
|
||||||
|
Future<int> addItem(PlaylistItem item) => _wrap('addItem', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final result = await db.insert('playlist_items', item.toJson());
|
||||||
|
await _updateItemCount(item.playlistId);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 从片单移除条目
|
||||||
|
Future<int> removeItem(String itemId, String playlistId) => _wrap('removeItem', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final result = await db.delete(
|
||||||
|
'playlist_items',
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [itemId],
|
||||||
|
);
|
||||||
|
await _updateItemCount(playlistId);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查条目是否已在片单中
|
||||||
|
Future<bool> isItemInPlaylist(String playlistId, String itemId) => _wrap('isItemInPlaylist', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final maps = await db.query(
|
||||||
|
'playlist_items',
|
||||||
|
where: 'playlist_id = ? AND item_id = ?',
|
||||||
|
whereArgs: [playlistId, itemId],
|
||||||
|
);
|
||||||
|
return maps.isNotEmpty;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取片单内所有条目ID
|
||||||
|
Future<List<String>> getPlaylistItemIds(String playlistId) => _wrap('getPlaylistItemIds', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final maps = await db.query(
|
||||||
|
'playlist_items',
|
||||||
|
columns: ['item_id'],
|
||||||
|
where: 'playlist_id = ?',
|
||||||
|
whereArgs: [playlistId],
|
||||||
|
);
|
||||||
|
return maps.map((m) => m['item_id'].toString()).toList();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同步 item_count
|
||||||
|
Future<void> _updateItemCount(String playlistId) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
final count = (await db.rawQuery(
|
||||||
|
'SELECT COUNT(*) as cnt FROM playlist_items WHERE playlist_id = ?',
|
||||||
|
[playlistId],
|
||||||
|
)).first['cnt'] as int;
|
||||||
|
await db.update(
|
||||||
|
'playlists',
|
||||||
|
{'item_count': count, 'updated_at': DateTime.now().toIso8601String()},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [playlistId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新片单条目排序
|
||||||
|
Future<void> updatePlaylistItemOrder(String playlistId, List<String> itemIds) => _wrap('updatePlaylistItemOrder', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
for (int i = 0; i < itemIds.length; i++) {
|
||||||
|
await db.update(
|
||||||
|
'playlist_items',
|
||||||
|
{'sort_order': i},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [itemIds[i]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 批量更新片单排序
|
||||||
|
Future<void> updatePlaylistOrder(List<String> playlistIds) => _wrap('updatePlaylistOrder', () async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
for (int i = 0; i < playlistIds.length; i++) {
|
||||||
|
await db.update(
|
||||||
|
'playlists',
|
||||||
|
{'sort_order': i},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [playlistIds[i]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -69,6 +69,7 @@ Future<void> _bootstrap(AppProvider appProvider) async {
|
|||||||
// 数据库优先初始化(不被 sync 阻塞)
|
// 数据库优先初始化(不被 sync 阻塞)
|
||||||
try {
|
try {
|
||||||
await appProvider.initDatabase();
|
await appProvider.initDatabase();
|
||||||
|
unawaited(appProvider.loadPlaylists());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Startup] 数据库初始化失败: $e');
|
debugPrint('[Startup] 数据库初始化失败: $e');
|
||||||
appProvider.markDbInitFailed();
|
appProvider.markDbInitFailed();
|
||||||
|
|||||||
@@ -1016,3 +1016,130 @@ class BookExcerpt {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 片单模型
|
||||||
|
class Playlist {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
final String type; // 'movie' / 'book' / 'game'
|
||||||
|
final String? coverPath;
|
||||||
|
final int itemCount;
|
||||||
|
final int sortOrder;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final bool isDeleted;
|
||||||
|
|
||||||
|
Playlist({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.description = '',
|
||||||
|
required this.type,
|
||||||
|
this.coverPath,
|
||||||
|
this.itemCount = 0,
|
||||||
|
this.sortOrder = 0,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
this.isDeleted = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Playlist.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Playlist(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
name: json['name']?.toString() ?? '',
|
||||||
|
description: json['description']?.toString() ?? '',
|
||||||
|
type: json['type']?.toString() ?? 'movie',
|
||||||
|
coverPath: json['cover_path'],
|
||||||
|
itemCount: json['item_count'] ?? 0,
|
||||||
|
sortOrder: json['sort_order'] ?? 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,
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'type': type,
|
||||||
|
'cover_path': coverPath,
|
||||||
|
'item_count': itemCount,
|
||||||
|
'sort_order': sortOrder,
|
||||||
|
'created_at': createdAt.toUtc().toIso8601String(),
|
||||||
|
'updated_at': updatedAt.toUtc().toIso8601String(),
|
||||||
|
'is_deleted': isDeleted ? 1 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Playlist copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
String? description,
|
||||||
|
String? type,
|
||||||
|
String? coverPath,
|
||||||
|
int? itemCount,
|
||||||
|
int? sortOrder,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
bool? isDeleted,
|
||||||
|
}) {
|
||||||
|
return Playlist(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
description: description ?? this.description,
|
||||||
|
type: type ?? this.type,
|
||||||
|
coverPath: coverPath ?? this.coverPath,
|
||||||
|
itemCount: itemCount ?? this.itemCount,
|
||||||
|
sortOrder: sortOrder ?? this.sortOrder,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
isDeleted: isDeleted ?? this.isDeleted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get typeLabel => switch (type) {
|
||||||
|
'movie' => '影视',
|
||||||
|
'book' => '书籍',
|
||||||
|
'game' => '游戏',
|
||||||
|
_ => type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 片单条目模型
|
||||||
|
class PlaylistItem {
|
||||||
|
final String id;
|
||||||
|
final String playlistId;
|
||||||
|
final String itemId;
|
||||||
|
final int sortOrder;
|
||||||
|
final DateTime addedAt;
|
||||||
|
|
||||||
|
PlaylistItem({
|
||||||
|
required this.id,
|
||||||
|
required this.playlistId,
|
||||||
|
required this.itemId,
|
||||||
|
this.sortOrder = 0,
|
||||||
|
required this.addedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PlaylistItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PlaylistItem(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
playlistId: json['playlist_id']?.toString() ?? '',
|
||||||
|
itemId: json['item_id']?.toString() ?? '',
|
||||||
|
sortOrder: json['sort_order'] ?? 0,
|
||||||
|
addedAt: _safeParseDate(json['added_at'], fallback: DateTime.now())!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'playlist_id': playlistId,
|
||||||
|
'item_id': itemId,
|
||||||
|
'sort_order': sortOrder,
|
||||||
|
'added_at': addedAt.toUtc().toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,8 +130,23 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
|||||||
final pic = m['vod_pic'] ?? '';
|
final pic = m['vod_pic'] ?? '';
|
||||||
|
|
||||||
DateTime? releaseDate;
|
DateTime? releaseDate;
|
||||||
if (yearStr.toString().isNotEmpty) {
|
final pubdateStr = m['vod_pubdate'] ?? '';
|
||||||
releaseDate = DateTime.tryParse('${yearStr}-01-01');
|
final dateSource =
|
||||||
|
pubdateStr.toString().isNotEmpty ? pubdateStr : yearStr;
|
||||||
|
if (dateSource.toString().isNotEmpty) {
|
||||||
|
// 提取 yyyy-MM-dd 部分,兼容 "2025-09-08(多伦多电影节)" 等格式
|
||||||
|
final match =
|
||||||
|
RegExp(r'(\d{4}-\d{2}-\d{2})').firstMatch(dateSource.toString());
|
||||||
|
if (match != null) {
|
||||||
|
releaseDate = DateTime.tryParse(match.group(1)!);
|
||||||
|
} else {
|
||||||
|
// 纯年份如 "2025"
|
||||||
|
final yearMatch =
|
||||||
|
RegExp(r'^(\d{4})$').firstMatch(dateSource.toString());
|
||||||
|
if (yearMatch != null) {
|
||||||
|
releaseDate = DateTime.tryParse('${yearMatch.group(1)}-01-01');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final movieId = const Uuid().v4();
|
final movieId = const Uuid().v4();
|
||||||
|
|||||||
275
lib/pages/playlist/playlist_add_item_page.dart
Normal file
275
lib/pages/playlist/playlist_add_item_page.dart
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../../providers/app_provider.dart';
|
||||||
|
import '../../models/data_models.dart';
|
||||||
|
import '../../widgets/fade_in_local_image.dart';
|
||||||
|
import '../../widgets/animated_star_rating.dart';
|
||||||
|
|
||||||
|
class PlaylistAddItemPage extends StatefulWidget {
|
||||||
|
final Playlist playlist;
|
||||||
|
const PlaylistAddItemPage({super.key, required this.playlist});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlaylistAddItemPage> createState() => _PlaylistAddItemPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaylistAddItemPageState extends State<PlaylistAddItemPage> {
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
List<String> _existingItemIds = [];
|
||||||
|
String _keyword = '';
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadExisting();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadExisting() async {
|
||||||
|
final provider = context.read<AppProvider>();
|
||||||
|
final ids = await provider.getPlaylistItemIds(widget.playlist.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_existingItemIds = ids;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final provider = context.watch<AppProvider>();
|
||||||
|
final type = widget.playlist.type;
|
||||||
|
|
||||||
|
// 获取对应类型的条目列表
|
||||||
|
List<_SelectableItem> items;
|
||||||
|
if (type == 'movie') {
|
||||||
|
items = provider.movies
|
||||||
|
.where((m) => !m.isDeleted)
|
||||||
|
.map((m) => _SelectableItem(
|
||||||
|
id: m.id,
|
||||||
|
title: m.title,
|
||||||
|
coverPath: m.posterPath,
|
||||||
|
rating: m.rating,
|
||||||
|
subtitle: m.directors.isNotEmpty ? m.directors.take(2).join(' / ') : '',
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
} else if (type == 'book') {
|
||||||
|
items = provider.books
|
||||||
|
.where((b) => !b.isDeleted)
|
||||||
|
.map((b) => _SelectableItem(
|
||||||
|
id: b.id,
|
||||||
|
title: b.title,
|
||||||
|
coverPath: b.coverPath,
|
||||||
|
rating: b.rating,
|
||||||
|
subtitle: b.authors.isNotEmpty ? b.authors.take(2).join(' / ') : '',
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
items = provider.games
|
||||||
|
.where((g) => !g.isDeleted)
|
||||||
|
.map((g) => _SelectableItem(
|
||||||
|
id: g.id,
|
||||||
|
title: g.title,
|
||||||
|
coverPath: g.coverPath,
|
||||||
|
rating: g.rating,
|
||||||
|
subtitle: g.developer.isNotEmpty ? g.developer.take(2).join(' / ') : '',
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索过滤
|
||||||
|
if (_keyword.isNotEmpty) {
|
||||||
|
items = items.where((i) => i.title.toLowerCase().contains(_keyword.toLowerCase())).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('添加${widget.playlist.typeLabel}'),
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// 搜索框
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
style: TextStyle(fontSize: 15, color: colors.onSurface),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '搜索${widget.playlist.typeLabel}名称',
|
||||||
|
hintStyle: TextStyle(color: colors.onSurface.withValues(alpha: 0.3), fontSize: 15),
|
||||||
|
prefixIcon: Icon(Icons.search, color: colors.onSurface.withValues(alpha: 0.4), size: 22),
|
||||||
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
|
? GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
_searchController.clear();
|
||||||
|
setState(() => _keyword = '');
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.close, color: colors.onSurface.withValues(alpha: 0.5), size: 16),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
filled: true,
|
||||||
|
fillColor: colors.surfaceContainerHighest,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none),
|
||||||
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none),
|
||||||
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(14), borderSide: BorderSide(color: colors.primary, width: 1.5)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
),
|
||||||
|
onChanged: (v) => setState(() => _keyword = v),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 列表
|
||||||
|
Expanded(
|
||||||
|
child: _loading
|
||||||
|
? const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))
|
||||||
|
: items.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text('没有找到${widget.playlist.typeLabel}',
|
||||||
|
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) => _buildItem(context, items[index], provider),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildItem(BuildContext context, _SelectableItem item, AppProvider provider) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final isAdded = _existingItemIds.contains(item.id);
|
||||||
|
final type = widget.playlist.type;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
if (isAdded) {
|
||||||
|
final playlistItems = await provider.getPlaylistItems(widget.playlist.id);
|
||||||
|
final match = playlistItems.firstWhere((pi) => pi.itemId == item.id);
|
||||||
|
await provider.removePlaylistItem(match.id, widget.playlist.id);
|
||||||
|
setState(() {
|
||||||
|
_existingItemIds.remove(item.id);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
final playlistItem = PlaylistItem(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
playlistId: widget.playlist.id,
|
||||||
|
itemId: item.id,
|
||||||
|
addedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
await provider.addPlaylistItem(playlistItem);
|
||||||
|
setState(() {
|
||||||
|
_existingItemIds.add(item.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isAdded ? colors.surfaceContainerHighest.withValues(alpha: 0.5) : colors.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: isAdded ? colors.outlineVariant.withValues(alpha: 0.2) : colors.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// 封面
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 40, height: 56,
|
||||||
|
child: item.coverPath != null && item.coverPath!.isNotEmpty
|
||||||
|
? FadeInLocalImage(
|
||||||
|
path: item.coverPath,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorWidget: _buildPlaceholder(type, colors),
|
||||||
|
)
|
||||||
|
: _buildPlaceholder(type, colors),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
// 信息
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(item.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: isAdded ? colors.onSurface.withValues(alpha: 0.4) : colors.onSurface)),
|
||||||
|
if (item.subtitle.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(item.subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35))),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (item.rating != null)
|
||||||
|
AnimatedStarRating(rating: item.rating!, starSize: 10, showNumber: true)
|
||||||
|
else if (!isAdded)
|
||||||
|
const SizedBox(width: 40),
|
||||||
|
if (isAdded) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Icon(Icons.check_circle, size: 18, color: colors.primary),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlaceholder(String type, ColorScheme colors) {
|
||||||
|
final icon = switch (type) {
|
||||||
|
'movie' => Icons.movie_outlined,
|
||||||
|
'book' => Icons.menu_book_outlined,
|
||||||
|
'game' => Icons.sports_esports_outlined,
|
||||||
|
_ => Icons.list_outlined,
|
||||||
|
};
|
||||||
|
return Container(
|
||||||
|
color: colors.surfaceContainerHighest,
|
||||||
|
child: Center(child: Icon(icon, size: 18, color: colors.onSurface.withValues(alpha: 0.25))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SelectableItem {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String? coverPath;
|
||||||
|
final double? rating;
|
||||||
|
final String subtitle;
|
||||||
|
|
||||||
|
_SelectableItem({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
this.coverPath,
|
||||||
|
this.rating,
|
||||||
|
this.subtitle = '',
|
||||||
|
});
|
||||||
|
}
|
||||||
170
lib/pages/playlist/playlist_create_page.dart
Normal file
170
lib/pages/playlist/playlist_create_page.dart
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../../providers/app_provider.dart';
|
||||||
|
import '../../models/data_models.dart';
|
||||||
|
|
||||||
|
class PlaylistCreatePage extends StatefulWidget {
|
||||||
|
final Playlist? playlist; // 传入则为编辑模式
|
||||||
|
const PlaylistCreatePage({super.key, this.playlist});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlaylistCreatePage> createState() => _PlaylistCreatePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaylistCreatePageState extends State<PlaylistCreatePage> {
|
||||||
|
late final TextEditingController _nameController;
|
||||||
|
late final TextEditingController _descController;
|
||||||
|
late String _selectedType;
|
||||||
|
bool _isSaving = false;
|
||||||
|
|
||||||
|
bool get _isEdit => widget.playlist != null;
|
||||||
|
|
||||||
|
static const _types = [
|
||||||
|
('movie', '影视', Icons.movie_outlined, Colors.blue),
|
||||||
|
('book', '书籍', Icons.menu_book_outlined, Colors.teal),
|
||||||
|
('game', '游戏', Icons.sports_esports_outlined, Colors.orange),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_nameController = TextEditingController(text: widget.playlist?.name ?? '');
|
||||||
|
_descController = TextEditingController(text: widget.playlist?.description ?? '');
|
||||||
|
_selectedType = widget.playlist?.type ?? 'movie';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_descController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
final name = _nameController.text.trim();
|
||||||
|
if (name.isEmpty) return;
|
||||||
|
if (_isSaving) return;
|
||||||
|
setState(() => _isSaving = true);
|
||||||
|
|
||||||
|
final provider = context.read<AppProvider>();
|
||||||
|
|
||||||
|
if (_isEdit) {
|
||||||
|
final updated = widget.playlist!.copyWith(
|
||||||
|
name: name,
|
||||||
|
description: _descController.text.trim(),
|
||||||
|
type: _selectedType,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
await provider.updatePlaylist(updated);
|
||||||
|
} else {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final playlist = Playlist(
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
name: name,
|
||||||
|
description: _descController.text.trim(),
|
||||||
|
type: _selectedType,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
await provider.addPlaylist(playlist);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(_isEdit ? '编辑片单' : '创建片单'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _isSaving ? null : _save,
|
||||||
|
child: Text(_isEdit ? '保存' : '创建', style: TextStyle(
|
||||||
|
color: _nameController.text.trim().isEmpty ? colors.onSurface.withValues(alpha: 0.3) : colors.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// 类型选择
|
||||||
|
if (!_isEdit) ...[
|
||||||
|
Text('片单类型', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
children: _types.map((t) {
|
||||||
|
final (type, label, icon, color) = t;
|
||||||
|
final selected = _selectedType == type;
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() => _selectedType = type),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: selected ? color.withValues(alpha: 0.1) : colors.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: selected ? Border.all(color: color.withValues(alpha: 0.3)) : null,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 22, color: selected ? color : colors.onSurface.withValues(alpha: 0.4)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(label, style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: selected ? color : colors.onSurface.withValues(alpha: 0.5),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
// 片单名称
|
||||||
|
Text('片单名称', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _nameController,
|
||||||
|
maxLength: 30,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '输入片单名称',
|
||||||
|
counterStyle: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colors.surfaceContainerHighest,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
),
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 片单详情
|
||||||
|
Text('片单详情', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _descController,
|
||||||
|
maxLines: 3,
|
||||||
|
maxLength: 200,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '描述一下这个片单(选填)',
|
||||||
|
counterStyle: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: colors.surfaceContainerHighest,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
495
lib/pages/playlist/playlist_detail_page.dart
Normal file
495
lib/pages/playlist/playlist_detail_page.dart
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../providers/app_provider.dart';
|
||||||
|
import '../../models/data_models.dart';
|
||||||
|
import '../../widgets/fade_in_local_image.dart';
|
||||||
|
import '../../widgets/animated_star_rating.dart';
|
||||||
|
import '../movies/movie_detail_page.dart';
|
||||||
|
import '../book/book_detail_page.dart';
|
||||||
|
import '../game/game_detail_page.dart';
|
||||||
|
import 'playlist_add_item_page.dart';
|
||||||
|
|
||||||
|
class PlaylistDetailPage extends StatefulWidget {
|
||||||
|
final Playlist playlist;
|
||||||
|
const PlaylistDetailPage({super.key, required this.playlist});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlaylistDetailPage> createState() => _PlaylistDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaylistDetailPageState extends State<PlaylistDetailPage> {
|
||||||
|
List<PlaylistItem> _items = [];
|
||||||
|
bool _loading = true;
|
||||||
|
final Map<String, double> _slideOffsets = {};
|
||||||
|
double _dragStartX = 0;
|
||||||
|
double _dragStartOffset = 0;
|
||||||
|
bool _isHorizontalDrag = false;
|
||||||
|
static const _actionWidth = 56.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadItems() async {
|
||||||
|
final provider = context.read<AppProvider>();
|
||||||
|
final items = await provider.getPlaylistItems(widget.playlist.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_items = items;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解析条目信息
|
||||||
|
_ResolvedItem? _resolve(PlaylistItem item, AppProvider provider) {
|
||||||
|
final type = widget.playlist.type;
|
||||||
|
String? status;
|
||||||
|
String title = '';
|
||||||
|
String? coverPath;
|
||||||
|
double? rating;
|
||||||
|
String subtitle = '';
|
||||||
|
dynamic entity;
|
||||||
|
|
||||||
|
if (type == 'movie') {
|
||||||
|
final movie = provider.movies.where((m) => m.id == item.itemId).firstOrNull;
|
||||||
|
if (movie == null) return null;
|
||||||
|
entity = movie;
|
||||||
|
status = movie.status;
|
||||||
|
title = movie.title;
|
||||||
|
coverPath = movie.posterPath;
|
||||||
|
rating = movie.rating;
|
||||||
|
subtitle = movie.directors.isNotEmpty ? movie.directors.take(2).join(' / ') : '';
|
||||||
|
} else if (type == 'book') {
|
||||||
|
final book = provider.books.where((b) => b.id == item.itemId).firstOrNull;
|
||||||
|
if (book == null) return null;
|
||||||
|
entity = book;
|
||||||
|
status = book.status;
|
||||||
|
title = book.title;
|
||||||
|
coverPath = book.coverPath;
|
||||||
|
rating = book.rating;
|
||||||
|
subtitle = book.authors.isNotEmpty ? book.authors.take(2).join(' / ') : '';
|
||||||
|
} else if (type == 'game') {
|
||||||
|
final game = provider.games.where((g) => g.id == item.itemId).firstOrNull;
|
||||||
|
if (game == null) return null;
|
||||||
|
entity = game;
|
||||||
|
status = game.status;
|
||||||
|
title = game.title;
|
||||||
|
coverPath = game.coverPath;
|
||||||
|
rating = game.rating;
|
||||||
|
subtitle = game.developer.isNotEmpty ? game.developer.take(2).join(' / ') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return _ResolvedItem(
|
||||||
|
playlistItem: item,
|
||||||
|
entity: entity,
|
||||||
|
title: title,
|
||||||
|
coverPath: coverPath,
|
||||||
|
rating: rating,
|
||||||
|
subtitle: subtitle,
|
||||||
|
status: status ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _statusLabel(String type, String status) {
|
||||||
|
if (type == 'movie') {
|
||||||
|
return switch (status) {
|
||||||
|
'watched' => '已看',
|
||||||
|
'watching' => '在看',
|
||||||
|
'want_to_watch' => '想看',
|
||||||
|
_ => '',
|
||||||
|
};
|
||||||
|
} else if (type == 'book') {
|
||||||
|
return switch (status) {
|
||||||
|
'read' => '已读',
|
||||||
|
'reading' => '在读',
|
||||||
|
'want_to_read' => '想读',
|
||||||
|
_ => '',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return switch (status) {
|
||||||
|
'completed' => '已完',
|
||||||
|
'playing' => '在玩',
|
||||||
|
'want_to_play' => '想玩',
|
||||||
|
'abandoned' => '已弃',
|
||||||
|
_ => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color _statusColor(String status) {
|
||||||
|
return switch (status) {
|
||||||
|
'watched' || 'read' || 'completed' => const Color(0xFF4CAF50),
|
||||||
|
'watching' || 'reading' || 'playing' => const Color(0xFF42A5F5),
|
||||||
|
'want_to_watch' || 'want_to_read' || 'want_to_play' => const Color(0xFFFF9800),
|
||||||
|
'abandoned' => const Color(0xFFEF5350),
|
||||||
|
_ => Colors.grey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = context.watch<AppProvider>();
|
||||||
|
final playlist = provider.playlists.firstWhere(
|
||||||
|
(p) => p.id == widget.playlist.id,
|
||||||
|
orElse: () => widget.playlist,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(playlist.name),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add, size: 22),
|
||||||
|
onPressed: () async {
|
||||||
|
await Navigator.push<bool>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => PlaylistAddItemPage(playlist: playlist)),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
_loadItems();
|
||||||
|
provider.loadPlaylists();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))
|
||||||
|
: _items.isEmpty
|
||||||
|
? _buildEmpty(context)
|
||||||
|
: _buildList(context, provider),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmpty(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 72, height: 72,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.playlist_add_check_outlined, size: 36, color: colors.onSurface.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('还没有添加条目', style: TextStyle(fontSize: 15, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text('点击右上角 + 添加', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildList(BuildContext context, AppProvider provider) {
|
||||||
|
final type = widget.playlist.type;
|
||||||
|
final resolved = _items
|
||||||
|
.map((item) => _resolve(item, provider))
|
||||||
|
.whereType<_ResolvedItem>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return ReorderableListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
itemCount: resolved.length,
|
||||||
|
onReorder: (oldIndex, newIndex) {
|
||||||
|
if (newIndex > oldIndex) newIndex--;
|
||||||
|
final list = List<_ResolvedItem>.from(resolved);
|
||||||
|
final item = list.removeAt(oldIndex);
|
||||||
|
list.insert(newIndex, item);
|
||||||
|
setState(() {
|
||||||
|
_items = list.map((r) => r.playlistItem).toList();
|
||||||
|
});
|
||||||
|
provider.reorderPlaylistItems(
|
||||||
|
widget.playlist.id,
|
||||||
|
_items.map((i) => i.id).toList(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
proxyDecorator: (child, index, animation) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (_, __) {
|
||||||
|
final t = Curves.easeInOut.transform(animation.value);
|
||||||
|
return Transform.scale(
|
||||||
|
scale: 1.0 + 0.03 * t,
|
||||||
|
child: Opacity(opacity: 1.0 - 0.15 * t, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildItem(context, resolved[index], type, provider);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildItem(BuildContext context, _ResolvedItem item, String type, AppProvider provider) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final statusColor = _statusColor(item.status);
|
||||||
|
final statusLabel = _statusLabel(type, item.status);
|
||||||
|
final itemId = item.playlistItem.id;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
key: Key(itemId),
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
children: [
|
||||||
|
// 底层:删除按钮
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
setState(() => _slideOffsets[itemId] = 0.0);
|
||||||
|
await provider.removePlaylistItem(item.playlistItem.id, item.playlistItem.playlistId);
|
||||||
|
if (mounted) _loadItems();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 40, height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.error.withValues(alpha: 0.12),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.delete_outline, size: 20, color: colors.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 上层:卡片内容(左滑 + 长按拖动排序)
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
transform: Matrix4.translationValues(_slideOffsets[itemId] ?? 0, 0, 0),
|
||||||
|
child: Listener(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onPointerDown: (event) {
|
||||||
|
_dragStartX = event.position.dx;
|
||||||
|
_dragStartOffset = _slideOffsets[itemId] ?? 0;
|
||||||
|
_isHorizontalDrag = false;
|
||||||
|
},
|
||||||
|
onPointerMove: (event) {
|
||||||
|
final dx = event.position.dx - _dragStartX;
|
||||||
|
if (!_isHorizontalDrag && dx.abs() > 10) {
|
||||||
|
if (dx < 0 || _dragStartOffset < 0) {
|
||||||
|
_isHorizontalDrag = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_isHorizontalDrag) {
|
||||||
|
setState(() {
|
||||||
|
_slideOffsets[itemId] = (_dragStartOffset + dx).clamp(-_actionWidth * 2, 0.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPointerUp: (_) {
|
||||||
|
if (_isHorizontalDrag) {
|
||||||
|
final offset = _slideOffsets[itemId] ?? 0.0;
|
||||||
|
final target = offset < -_actionWidth ? -_actionWidth * 2 : 0.0;
|
||||||
|
setState(() => _slideOffsets[itemId] = target);
|
||||||
|
}
|
||||||
|
_isHorizontalDrag = false;
|
||||||
|
},
|
||||||
|
child: ReorderableDelayedDragStartListener(
|
||||||
|
index: _items.indexOf(item.playlistItem),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
if ((_slideOffsets[itemId] ?? 0) < 0) {
|
||||||
|
setState(() => _slideOffsets[itemId] = 0.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final page = switch (type) {
|
||||||
|
'movie' => MovieDetailPage(movie: item.entity as Movie),
|
||||||
|
'book' => BookDetailPage(book: item.entity as Book),
|
||||||
|
'game' => GameDetailPage(game: item.entity as Game),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (page != null) Navigator.push(context, MaterialPageRoute(builder: (_) => page));
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 12, 8, 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5), width: 0.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// 封面
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 48, height: 64,
|
||||||
|
child: item.coverPath != null && item.coverPath!.isNotEmpty
|
||||||
|
? FadeInLocalImage(
|
||||||
|
path: item.coverPath,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorWidget: _buildPlaceholder(type, colors),
|
||||||
|
)
|
||||||
|
: _buildPlaceholder(type, colors),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// 信息
|
||||||
|
Expanded(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 64,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(item.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||||
|
if (item.subtitle.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(item.subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||||
|
],
|
||||||
|
const Spacer(),
|
||||||
|
if (item.rating != null)
|
||||||
|
AnimatedStarRating(rating: item.rating!, starSize: 11, showNumber: true)
|
||||||
|
else
|
||||||
|
Text('未评分', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.25))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// 状态章
|
||||||
|
if (statusLabel.isNotEmpty) _buildStamp(statusLabel, statusColor),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 章形状的状态标签 — 双圈外圆 + 内长方框,模拟印章效果
|
||||||
|
Widget _buildStamp(String label, Color color) {
|
||||||
|
return Transform.rotate(
|
||||||
|
angle: -0.12,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 52, height: 52,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _StampPainter(color: color),
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||||
|
margin: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(1.5),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.55), width: 0.8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 7,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: color.withValues(alpha: 0.85),
|
||||||
|
height: 1.1,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 0.5),
|
||||||
|
Text('MOOKNOTE',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 2,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: color.withValues(alpha: 0.45),
|
||||||
|
height: 1,
|
||||||
|
letterSpacing: 1,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlaceholder(String type, ColorScheme colors) {
|
||||||
|
final icon = switch (type) {
|
||||||
|
'movie' => Icons.movie_outlined,
|
||||||
|
'book' => Icons.menu_book_outlined,
|
||||||
|
'game' => Icons.sports_esports_outlined,
|
||||||
|
_ => Icons.list_outlined,
|
||||||
|
};
|
||||||
|
return Container(
|
||||||
|
color: colors.surfaceContainerHighest,
|
||||||
|
child: Center(child: Icon(icon, size: 20, color: colors.onSurface.withValues(alpha: 0.25))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ResolvedItem {
|
||||||
|
final PlaylistItem playlistItem;
|
||||||
|
final dynamic entity;
|
||||||
|
final String title;
|
||||||
|
final String? coverPath;
|
||||||
|
final double? rating;
|
||||||
|
final String subtitle;
|
||||||
|
final String status;
|
||||||
|
|
||||||
|
_ResolvedItem({
|
||||||
|
required this.playlistItem,
|
||||||
|
this.entity,
|
||||||
|
required this.title,
|
||||||
|
this.coverPath,
|
||||||
|
this.rating,
|
||||||
|
this.subtitle = '',
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 印章绘制器 — 双圈外圆
|
||||||
|
class _StampPainter extends CustomPainter {
|
||||||
|
final Color color;
|
||||||
|
_StampPainter({required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final cx = size.width / 2;
|
||||||
|
final cy = size.height / 2;
|
||||||
|
final paint = Paint()
|
||||||
|
..color = color.withValues(alpha: 0.7)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.8;
|
||||||
|
|
||||||
|
// 外圈
|
||||||
|
canvas.drawCircle(Offset(cx, cy), size.width / 2 - 2, paint);
|
||||||
|
|
||||||
|
// 内圈
|
||||||
|
final innerPaint = Paint()
|
||||||
|
..color = color.withValues(alpha: 0.45)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1;
|
||||||
|
canvas.drawCircle(Offset(cx, cy), size.width / 2 - 5, innerPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _StampPainter old) => old.color != color;
|
||||||
|
}
|
||||||
679
lib/pages/playlist/playlist_list_page.dart
Normal file
679
lib/pages/playlist/playlist_list_page.dart
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../providers/app_provider.dart';
|
||||||
|
import '../../models/data_models.dart';
|
||||||
|
import '../../utils/user_prefs.dart';
|
||||||
|
import '../../widgets/fade_in_local_image.dart';
|
||||||
|
import 'playlist_create_page.dart';
|
||||||
|
import 'playlist_detail_page.dart';
|
||||||
|
|
||||||
|
class PlaylistListPage extends StatefulWidget {
|
||||||
|
const PlaylistListPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlaylistListPage> createState() => _PlaylistListPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaylistListPageState extends State<PlaylistListPage> {
|
||||||
|
final Map<String, List<String>> _playlistItemIds = {};
|
||||||
|
int _layoutStyle = UserPrefs().playlistLayoutStyle;
|
||||||
|
final Map<String, double> _slideOffsets = {};
|
||||||
|
double _dragStartX = 0;
|
||||||
|
double _dragStartOffset = 0;
|
||||||
|
bool _isHorizontalDrag = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadAllItemIds();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAllItemIds() async {
|
||||||
|
final provider = context.read<AppProvider>();
|
||||||
|
for (final playlist in provider.playlists) {
|
||||||
|
final ids = await provider.getPlaylistItemIds(playlist.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_playlistItemIds[playlist.id] = ids;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String?> _getCoverPaths(Playlist playlist, AppProvider provider, {int limit = 4}) {
|
||||||
|
final ids = _playlistItemIds[playlist.id] ?? [];
|
||||||
|
final covers = <String?>[];
|
||||||
|
for (final id in ids.take(limit)) {
|
||||||
|
if (playlist.type == 'movie') {
|
||||||
|
final movie = provider.movies.where((m) => m.id == id).firstOrNull;
|
||||||
|
covers.add(movie?.posterPath);
|
||||||
|
} else if (playlist.type == 'book') {
|
||||||
|
final book = provider.books.where((b) => b.id == id).firstOrNull;
|
||||||
|
covers.add(book?.coverPath);
|
||||||
|
} else if (playlist.type == 'game') {
|
||||||
|
final game = provider.games.where((g) => g.id == id).firstOrNull;
|
||||||
|
covers.add(game?.coverPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return covers;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<AppProvider>(
|
||||||
|
builder: (context, provider, _) {
|
||||||
|
final playlists = provider.playlists;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('书影片单'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_layoutStyle == 0 ? Icons.grid_view : Icons.view_list, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
final newStyle = _layoutStyle == 0 ? 1 : 0;
|
||||||
|
setState(() => _layoutStyle = newStyle);
|
||||||
|
UserPrefs().setPlaylistLayoutStyle(newStyle);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add, size: 22),
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await Navigator.push<bool>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const PlaylistCreatePage()),
|
||||||
|
);
|
||||||
|
if (result == true && mounted) {
|
||||||
|
provider.loadPlaylists();
|
||||||
|
_loadAllItemIds();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: playlists.isEmpty
|
||||||
|
? _buildEmpty(context)
|
||||||
|
: _layoutStyle == 0
|
||||||
|
? _buildListView(context, playlists, provider)
|
||||||
|
: _buildGridView(context, playlists, provider),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmpty(BuildContext context) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 72, height: 72,
|
||||||
|
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(18)),
|
||||||
|
child: Icon(Icons.playlist_play, size: 36, color: colors.onSurface.withValues(alpha: 0.25)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('还没有片单', style: TextStyle(fontSize: 15, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text('点击右上角 + 创建一个片单吧', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 列表视图(长按1秒拖动排序 + 左滑操作)──────────────────────────
|
||||||
|
|
||||||
|
Widget _buildListView(BuildContext context, List<Playlist> playlists, AppProvider provider) {
|
||||||
|
return ReorderableListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
itemCount: playlists.length,
|
||||||
|
onReorder: (oldIndex, newIndex) {
|
||||||
|
if (newIndex > oldIndex) newIndex--;
|
||||||
|
final list = List<Playlist>.from(playlists);
|
||||||
|
final item = list.removeAt(oldIndex);
|
||||||
|
list.insert(newIndex, item);
|
||||||
|
provider.reorderPlaylists(list.map((p) => p.id).toList());
|
||||||
|
},
|
||||||
|
proxyDecorator: (child, index, animation) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (_, __) {
|
||||||
|
final t = Curves.easeInOut.transform(animation.value);
|
||||||
|
return Transform.scale(
|
||||||
|
scale: 1.0 + 0.03 * t,
|
||||||
|
child: Opacity(opacity: 1.0 - 0.15 * t, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final playlist = playlists[index];
|
||||||
|
return _buildSlidableCard(context, playlist, index, provider);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSlidableCard(BuildContext context, Playlist playlist, int index, AppProvider provider) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final typeColor = _typeColor(playlist.type, colors);
|
||||||
|
final covers = _getCoverPaths(playlist, provider);
|
||||||
|
const actionWidth = 56.0;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
key: Key(playlist.id),
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
children: [
|
||||||
|
// 底层:操作按钮
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
setState(() => _slideOffsets[playlist.id] = 0.0);
|
||||||
|
final result = await Navigator.push<bool>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => PlaylistCreatePage(playlist: playlist)),
|
||||||
|
);
|
||||||
|
if (result == true && mounted) {
|
||||||
|
provider.loadPlaylists();
|
||||||
|
_loadAllItemIds();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 40, height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.primary.withValues(alpha: 0.12),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.edit_outlined, size: 20, color: colors.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
setState(() => _slideOffsets[playlist.id] = 0.0);
|
||||||
|
await _showDeleteDialog(context, playlist, provider);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 40, height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.error.withValues(alpha: 0.12),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.delete_outline, size: 20, color: colors.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 上层:卡片内容(左滑 + 长按1秒拖动排序)
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
transform: Matrix4.translationValues(_slideOffsets[playlist.id] ?? 0, 0, 0),
|
||||||
|
child: Listener(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onPointerDown: (event) {
|
||||||
|
_dragStartX = event.position.dx;
|
||||||
|
_dragStartOffset = _slideOffsets[playlist.id] ?? 0;
|
||||||
|
_isHorizontalDrag = false;
|
||||||
|
},
|
||||||
|
onPointerMove: (event) {
|
||||||
|
final dx = event.position.dx - _dragStartX;
|
||||||
|
if (!_isHorizontalDrag && dx.abs() > 10) {
|
||||||
|
if (dx < 0 || _dragStartOffset < 0) {
|
||||||
|
_isHorizontalDrag = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (_isHorizontalDrag) {
|
||||||
|
setState(() {
|
||||||
|
_slideOffsets[playlist.id] = (_dragStartOffset + dx).clamp(-actionWidth * 2, 0.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPointerUp: (_) {
|
||||||
|
if (_isHorizontalDrag) {
|
||||||
|
final offset = _slideOffsets[playlist.id] ?? 0.0;
|
||||||
|
final target = offset < -actionWidth ? -actionWidth * 2 : 0.0;
|
||||||
|
setState(() => _slideOffsets[playlist.id] = target);
|
||||||
|
}
|
||||||
|
_isHorizontalDrag = false;
|
||||||
|
},
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
if ((_slideOffsets[playlist.id] ?? 0) < 0) {
|
||||||
|
setState(() => _slideOffsets[playlist.id] = 0.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => PlaylistDetailPage(playlist: playlist)),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
provider.loadPlaylists();
|
||||||
|
_loadAllItemIds();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 长按1秒后可拖动排序
|
||||||
|
child: ReorderableDelayedDragStartListener(
|
||||||
|
index: index,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5), width: 0.5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildCoverWall(context, playlist, covers, typeColor),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(playlist.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildTypeTag(playlist.typeLabel, typeColor),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (playlist.description.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(playlist.description,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.collections_outlined, size: 13, color: colors.onSurface.withValues(alpha: 0.3)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${playlist.itemCount} 个条目',
|
||||||
|
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||||
|
const Spacer(),
|
||||||
|
Text(_formatDate(playlist.updatedAt),
|
||||||
|
style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 网格视图 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Widget _buildGridView(BuildContext context, List<Playlist> playlists, AppProvider provider) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return GridView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
childAspectRatio: 0.75,
|
||||||
|
crossAxisSpacing: 10,
|
||||||
|
mainAxisSpacing: 10,
|
||||||
|
),
|
||||||
|
itemCount: playlists.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final playlist = playlists[index];
|
||||||
|
final typeColor = _typeColor(playlist.type, colors);
|
||||||
|
final covers = _getCoverPaths(playlist, provider, limit: 3);
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => PlaylistDetailPage(playlist: playlist)),
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
provider.loadPlaylists();
|
||||||
|
_loadAllItemIds();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: () => _showActionSheet(context, playlist, provider),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: colors.outlineVariant.withValues(alpha: 0.5), width: 0.5),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _buildGridCoverWall(context, playlist, covers, typeColor),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(playlist.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildTypeTag(playlist.typeLabel, typeColor, compact: true),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(Icons.collections_outlined, size: 10, color: colors.onSurface.withValues(alpha: 0.3)),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text('${playlist.itemCount}',
|
||||||
|
style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.35))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 通用组件 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Widget _buildTypeTag(String label, Color color, {bool compact = false}) {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: compact ? 4 : 6, vertical: compact ? 1 : 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(compact ? 3 : 4),
|
||||||
|
),
|
||||||
|
child: Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: compact ? 9 : 10,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: color,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _typeColor(String type, ColorScheme colors) {
|
||||||
|
return switch (type) {
|
||||||
|
'movie' => Colors.blue,
|
||||||
|
'book' => Colors.teal,
|
||||||
|
'game' => Colors.orange,
|
||||||
|
_ => colors.onSurface,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime dt) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final diff = now.difference(dt);
|
||||||
|
if (diff.inDays == 0) return '今天';
|
||||||
|
if (diff.inDays == 1) return '昨天';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays}天前';
|
||||||
|
return '${dt.month}/${dt.day}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGridCoverWall(BuildContext context, Playlist playlist, List<String?> covers, Color typeColor) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final typeIcon = switch (playlist.type) {
|
||||||
|
'movie' => Icons.movie_outlined,
|
||||||
|
'book' => Icons.menu_book_outlined,
|
||||||
|
'game' => Icons.sports_esports_outlined,
|
||||||
|
_ => Icons.list_outlined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (covers.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
color: typeColor.withValues(alpha: 0.08),
|
||||||
|
child: Center(child: Icon(typeIcon, size: 32, color: typeColor.withValues(alpha: 0.4))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: List.generate(covers.length, (i) {
|
||||||
|
final path = covers[i];
|
||||||
|
return Expanded(
|
||||||
|
child: path != null && path.isNotEmpty
|
||||||
|
? FadeInLocalImage(
|
||||||
|
path: path,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorWidget: Container(
|
||||||
|
color: colors.surfaceContainerHighest,
|
||||||
|
child: Icon(typeIcon, size: 16, color: colors.onSurface.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: colors.surfaceContainerHighest,
|
||||||
|
child: Icon(typeIcon, size: 16, color: colors.onSurface.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCoverWall(BuildContext context, Playlist playlist, List<String?> covers, Color typeColor) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
final typeIcon = switch (playlist.type) {
|
||||||
|
'movie' => Icons.movie_outlined,
|
||||||
|
'book' => Icons.menu_book_outlined,
|
||||||
|
'game' => Icons.sports_esports_outlined,
|
||||||
|
_ => Icons.list_outlined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (covers.isEmpty) {
|
||||||
|
return Container(
|
||||||
|
width: 64, height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: typeColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(typeIcon, size: 28, color: typeColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellSize = 31.0;
|
||||||
|
const gap = 2.0;
|
||||||
|
final wallSize = cellSize * 2 + gap;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: wallSize,
|
||||||
|
height: wallSize,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildCoverCell(covers, 0, cellSize, typeIcon, colors, typeColor),
|
||||||
|
SizedBox(width: gap),
|
||||||
|
_buildCoverCell(covers, 1, cellSize, typeIcon, colors, typeColor),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: gap),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildCoverCell(covers, 2, cellSize, typeIcon, colors, typeColor),
|
||||||
|
SizedBox(width: gap),
|
||||||
|
_buildCoverCell(covers, 3, cellSize, typeIcon, colors, typeColor),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCoverCell(List<String?> covers, int index, double size, IconData typeIcon, ColorScheme colors, Color typeColor) {
|
||||||
|
if (index >= covers.length) {
|
||||||
|
return Container(
|
||||||
|
width: size, height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: typeColor.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final path = covers[index];
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: SizedBox(
|
||||||
|
width: size, height: size,
|
||||||
|
child: path != null && path.isNotEmpty
|
||||||
|
? FadeInLocalImage(
|
||||||
|
path: path,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorWidget: Container(
|
||||||
|
color: typeColor.withValues(alpha: 0.08),
|
||||||
|
child: Icon(typeIcon, size: 12, color: typeColor.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: typeColor.withValues(alpha: 0.08),
|
||||||
|
child: Icon(typeIcon, size: 12, color: typeColor.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 操作菜单 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _showActionSheet(BuildContext context, Playlist playlist, AppProvider provider) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: colors.surface,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10, bottom: 4),
|
||||||
|
child: Container(
|
||||||
|
width: 28, height: 3,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colors.onSurface.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildSheetAction(
|
||||||
|
context: ctx,
|
||||||
|
icon: Icons.edit_outlined,
|
||||||
|
label: '编辑片单',
|
||||||
|
color: colors.primary,
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
final result = await Navigator.push<bool>(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => PlaylistCreatePage(playlist: playlist)),
|
||||||
|
);
|
||||||
|
if (result == true && mounted) {
|
||||||
|
provider.loadPlaylists();
|
||||||
|
_loadAllItemIds();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Divider(height: 0.5, thickness: 0.5, color: colors.outlineVariant.withValues(alpha: 0.3)),
|
||||||
|
_buildSheetAction(
|
||||||
|
context: ctx,
|
||||||
|
icon: Icons.delete_outline,
|
||||||
|
label: '删除片单',
|
||||||
|
color: colors.error,
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
final confirmed = await _showDeleteDialog(context, playlist, provider);
|
||||||
|
if (confirmed == true && mounted) {
|
||||||
|
provider.loadPlaylists();
|
||||||
|
_loadAllItemIds();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSheetAction({
|
||||||
|
required BuildContext context,
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required Color color,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: color),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(label, style: TextStyle(fontSize: 14, color: color)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _showDeleteDialog(BuildContext context, Playlist playlist, AppProvider provider) {
|
||||||
|
final colors = Theme.of(context).colorScheme;
|
||||||
|
return 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('确定要删除片单「${playlist.name}」吗?',
|
||||||
|
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: () async {
|
||||||
|
await provider.removePlaylist(playlist.id);
|
||||||
|
if (!ctx.mounted) return;
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
bool _showEncounter = true;
|
bool _showEncounter = true;
|
||||||
bool _showStroll = true;
|
bool _showStroll = true;
|
||||||
bool _showReviewed = true;
|
bool _showReviewed = true;
|
||||||
|
bool _showPlaylist = true;
|
||||||
bool _showCalendar = true;
|
bool _showCalendar = true;
|
||||||
bool _showPerson = true;
|
bool _showPerson = true;
|
||||||
bool _showTags = true;
|
bool _showTags = true;
|
||||||
@@ -53,6 +54,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
_showEncounter = _userPrefs.showSidebarEncounter;
|
_showEncounter = _userPrefs.showSidebarEncounter;
|
||||||
_showStroll = _userPrefs.showSidebarStroll;
|
_showStroll = _userPrefs.showSidebarStroll;
|
||||||
_showReviewed = _userPrefs.showSidebarReviewed;
|
_showReviewed = _userPrefs.showSidebarReviewed;
|
||||||
|
_showPlaylist = _userPrefs.showSidebarPlaylist;
|
||||||
_showCalendar = _userPrefs.showSidebarCalendar;
|
_showCalendar = _userPrefs.showSidebarCalendar;
|
||||||
_showPerson = _userPrefs.showSidebarPerson;
|
_showPerson = _userPrefs.showSidebarPerson;
|
||||||
_showTags = _userPrefs.showSidebarTags;
|
_showTags = _userPrefs.showSidebarTags;
|
||||||
@@ -263,6 +265,16 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
await _userPrefs.setShowSidebarReviewed(v);
|
await _userPrefs.setShowSidebarReviewed(v);
|
||||||
setState(() => _showReviewed = v);
|
setState(() => _showReviewed = v);
|
||||||
}),
|
}),
|
||||||
|
Divider(
|
||||||
|
height: 0.5,
|
||||||
|
indent: 24,
|
||||||
|
endIndent: 24,
|
||||||
|
color: colors.outlineVariant),
|
||||||
|
_buildSwitchItem(Icons.playlist_play, '书影片单', '创建和管理自定义片单', _showPlaylist,
|
||||||
|
(v) async {
|
||||||
|
await _userPrefs.setShowSidebarPlaylist(v);
|
||||||
|
setState(() => _showPlaylist = v);
|
||||||
|
}),
|
||||||
Divider(
|
Divider(
|
||||||
height: 0.5,
|
height: 0.5,
|
||||||
indent: 24,
|
indent: 24,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../data/book/book_excerpt_dao.dart';
|
|||||||
import '../data/game/game_dao.dart';
|
import '../data/game/game_dao.dart';
|
||||||
import '../data/game/game_review_dao.dart';
|
import '../data/game/game_review_dao.dart';
|
||||||
import '../data/game/game_screenshot_dao.dart';
|
import '../data/game/game_screenshot_dao.dart';
|
||||||
|
import '../data/playlist/playlist_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';
|
||||||
@@ -32,12 +33,14 @@ class AppProvider extends ChangeNotifier {
|
|||||||
final GameDao _gameDao = GameDao();
|
final GameDao _gameDao = GameDao();
|
||||||
final GameReviewDao _gameReviewDao = GameReviewDao();
|
final GameReviewDao _gameReviewDao = GameReviewDao();
|
||||||
final GameScreenshotDao _gameScreenshotDao = GameScreenshotDao();
|
final GameScreenshotDao _gameScreenshotDao = GameScreenshotDao();
|
||||||
|
final PlaylistDao _playlistDao = PlaylistDao();
|
||||||
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 = [];
|
List<Game> _games = [];
|
||||||
|
List<Playlist> _playlists = [];
|
||||||
|
|
||||||
// 当前主界面选中的标签 (0: 观影,1: 阅读,2: 笔记)
|
// 当前主界面选中的标签 (0: 观影,1: 阅读,2: 笔记)
|
||||||
int _mainTabIndex = 0;
|
int _mainTabIndex = 0;
|
||||||
@@ -231,6 +234,12 @@ class AppProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载片单数据
|
||||||
|
Future<void> loadPlaylists() async {
|
||||||
|
_playlists = await _playlistDao.getAllPlaylists();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// 编辑返回后触发列表页重载
|
/// 编辑返回后触发列表页重载
|
||||||
/// [itemId] 被编辑条目的 ID,用于就地更新而非重置分页
|
/// [itemId] 被编辑条目的 ID,用于就地更新而非重置分页
|
||||||
void setEditRefresh([String? itemId]) {
|
void setEditRefresh([String? itemId]) {
|
||||||
@@ -285,6 +294,7 @@ class AppProvider extends ChangeNotifier {
|
|||||||
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<Game> get games => UnmodifiableListView(_games);
|
||||||
|
List<Playlist> get playlists => UnmodifiableListView(_playlists);
|
||||||
|
|
||||||
// 根据状态获取影视列表
|
// 根据状态获取影视列表
|
||||||
List<Movie> getMoviesByStatus(String status) {
|
List<Movie> getMoviesByStatus(String status) {
|
||||||
@@ -588,6 +598,66 @@ class AppProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 片单操作 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Future<void> addPlaylist(Playlist playlist) async {
|
||||||
|
await _playlistDao.insertPlaylist(playlist);
|
||||||
|
_playlists.add(playlist);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removePlaylist(String id) async {
|
||||||
|
await _playlistDao.deletePlaylist(id);
|
||||||
|
_playlists.removeWhere((p) => p.id == id);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updatePlaylist(Playlist playlist) async {
|
||||||
|
await _playlistDao.updatePlaylist(playlist);
|
||||||
|
final idx = _playlists.indexWhere((p) => p.id == playlist.id);
|
||||||
|
if (idx != -1) _playlists[idx] = playlist;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<PlaylistItem>> getPlaylistItems(String playlistId) async {
|
||||||
|
return _playlistDao.getPlaylistItems(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addPlaylistItem(PlaylistItem item) async {
|
||||||
|
await _playlistDao.addItem(item);
|
||||||
|
// 更新片单的 itemCount
|
||||||
|
final playlist = _playlists.firstWhere((p) => p.id == item.playlistId);
|
||||||
|
final updated = playlist.copyWith(itemCount: playlist.itemCount + 1, updatedAt: DateTime.now());
|
||||||
|
final idx = _playlists.indexWhere((p) => p.id == item.playlistId);
|
||||||
|
if (idx != -1) _playlists[idx] = updated;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removePlaylistItem(String itemId, String playlistId) async {
|
||||||
|
await _playlistDao.removeItem(itemId, playlistId);
|
||||||
|
final playlist = _playlists.firstWhere((p) => p.id == playlistId);
|
||||||
|
final updated = playlist.copyWith(itemCount: (playlist.itemCount - 1).clamp(0, 99999), updatedAt: DateTime.now());
|
||||||
|
final idx = _playlists.indexWhere((p) => p.id == playlistId);
|
||||||
|
if (idx != -1) _playlists[idx] = updated;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> getPlaylistItemIds(String playlistId) async {
|
||||||
|
return _playlistDao.getPlaylistItemIds(playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reorderPlaylists(List<String> playlistIds) async {
|
||||||
|
await _playlistDao.updatePlaylistOrder(playlistIds);
|
||||||
|
// 更新内存中的排序
|
||||||
|
final orderMap = {for (int i = 0; i < playlistIds.length; i++) playlistIds[i]: i};
|
||||||
|
_playlists.sort((a, b) => (orderMap[a.id] ?? 0).compareTo(orderMap[b.id] ?? 0));
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reorderPlaylistItems(String playlistId, List<String> itemIds) async {
|
||||||
|
await _playlistDao.updatePlaylistItemOrder(playlistId, itemIds);
|
||||||
|
}
|
||||||
|
|
||||||
/// 仅更新游戏封面偏移量(不触发全量刷新)
|
/// 仅更新游戏封面偏移量(不触发全量刷新)
|
||||||
Future<void> updateGameCoverOffset(String gameId, double offset) async {
|
Future<void> updateGameCoverOffset(String gameId, double offset) async {
|
||||||
await _gameDao.updateCoverOffset(gameId, offset);
|
await _gameDao.updateCoverOffset(gameId, offset);
|
||||||
@@ -923,6 +993,7 @@ class AppProvider extends ChangeNotifier {
|
|||||||
await loadBooks();
|
await loadBooks();
|
||||||
await loadNotes();
|
await loadNotes();
|
||||||
await loadGames();
|
await loadGames();
|
||||||
|
await loadPlaylists();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 影评书评回收站 ==========
|
// ========== 影评书评回收站 ==========
|
||||||
|
|||||||
@@ -133,6 +133,9 @@ class UserPrefs {
|
|||||||
bool get showSidebarReviewed => prefs.getBool('showSidebarReviewed') ?? true;
|
bool get showSidebarReviewed => prefs.getBool('showSidebarReviewed') ?? true;
|
||||||
Future<bool> setShowSidebarReviewed(bool value) => prefs.setBool('showSidebarReviewed', value);
|
Future<bool> setShowSidebarReviewed(bool value) => prefs.setBool('showSidebarReviewed', value);
|
||||||
|
|
||||||
|
bool get showSidebarPlaylist => prefs.getBool('showSidebarPlaylist') ?? true;
|
||||||
|
Future<bool> setShowSidebarPlaylist(bool value) => prefs.setBool('showSidebarPlaylist', value);
|
||||||
|
|
||||||
bool get showSidebarCalendar => prefs.getBool('showSidebarCalendar') ?? true;
|
bool get showSidebarCalendar => prefs.getBool('showSidebarCalendar') ?? true;
|
||||||
Future<bool> setShowSidebarCalendar(bool value) => prefs.setBool('showSidebarCalendar', value);
|
Future<bool> setShowSidebarCalendar(bool value) => prefs.setBool('showSidebarCalendar', value);
|
||||||
|
|
||||||
@@ -211,6 +214,10 @@ class UserPrefs {
|
|||||||
int get reviewedLayoutStyle => prefs.getInt('reviewedLayoutStyle') ?? 0;
|
int get reviewedLayoutStyle => prefs.getInt('reviewedLayoutStyle') ?? 0;
|
||||||
Future<bool> setReviewedLayoutStyle(int value) => prefs.setInt('reviewedLayoutStyle', value);
|
Future<bool> setReviewedLayoutStyle(int value) => prefs.setInt('reviewedLayoutStyle', value);
|
||||||
|
|
||||||
|
/// 片单页布局样式 (0: 列表, 1: 网格)
|
||||||
|
int get playlistLayoutStyle => prefs.getInt('playlistLayoutStyle') ?? 0;
|
||||||
|
Future<bool> setPlaylistLayoutStyle(int value) => prefs.setInt('playlistLayoutStyle', value);
|
||||||
|
|
||||||
// ========== 应用图标设置 ==========
|
// ========== 应用图标设置 ==========
|
||||||
|
|
||||||
// ========== Markdown 阅读器 ==========
|
// ========== Markdown 阅读器 ==========
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
64
lib/widgets/reviewed_stamp_icon.dart
Normal file
64
lib/widgets/reviewed_stamp_icon.dart
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 已阅印章图标 — 双圈外圆 + 内框 + "已阅"文字
|
||||||
|
class ReviewedStampIcon extends StatelessWidget {
|
||||||
|
final double size;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const ReviewedStampIcon({super.key, this.size = 20, required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CustomPaint(
|
||||||
|
size: Size(size, size),
|
||||||
|
painter: _ReviewedStampPainter(color: color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReviewedStampPainter extends CustomPainter {
|
||||||
|
final Color color;
|
||||||
|
_ReviewedStampPainter({required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final cx = size.width / 2;
|
||||||
|
final cy = size.height / 2;
|
||||||
|
final r = size.width / 2 - 1;
|
||||||
|
|
||||||
|
// 外圈
|
||||||
|
final outerPaint = Paint()
|
||||||
|
..color = color
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = size.width * 0.06;
|
||||||
|
canvas.drawCircle(Offset(cx, cy), r, outerPaint);
|
||||||
|
|
||||||
|
// 内圈
|
||||||
|
final innerPaint = Paint()
|
||||||
|
..color = color.withValues(alpha: 0.5)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = size.width * 0.03;
|
||||||
|
canvas.drawCircle(Offset(cx, cy), r * 0.82, innerPaint);
|
||||||
|
|
||||||
|
// "已阅" 文字
|
||||||
|
final fontSize = size.width * 0.32;
|
||||||
|
final textPainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: '已阅',
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
height: 1.1,
|
||||||
|
letterSpacing: fontSize * 0.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
);
|
||||||
|
textPainter.layout();
|
||||||
|
textPainter.paint(canvas, Offset(cx - textPainter.width / 2, cy - textPainter.height / 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _ReviewedStampPainter old) => old.color != color;
|
||||||
|
}
|
||||||
40
pubspec.lock
40
pubspec.lock
@@ -331,6 +331,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.0"
|
version: "0.7.0"
|
||||||
|
flutter_svg:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_svg
|
||||||
|
sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -613,6 +621,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
version: "1.9.1"
|
||||||
|
path_parsing:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_parsing
|
||||||
|
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1066,6 +1082,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.3"
|
version: "4.5.3"
|
||||||
|
vector_graphics:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_graphics
|
||||||
|
sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.3"
|
||||||
|
vector_graphics_codec:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_graphics_codec
|
||||||
|
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.13"
|
||||||
|
vector_graphics_compiler:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_graphics_compiler
|
||||||
|
sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ dependencies:
|
|||||||
device_info_plus: ^11.2.0
|
device_info_plus: ^11.2.0
|
||||||
crypto: ^3.0.6
|
crypto: ^3.0.6
|
||||||
window_manager: ^0.4.3
|
window_manager: ^0.4.3
|
||||||
|
flutter_svg: ^2.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user