From 103afaa65ec95841f983abfa0e1e001e0d2524fb Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Fri, 7 Aug 2026 21:26:10 +0800 Subject: [PATCH] v0.2.9 --- lib/data/database_helper.dart | 71 +- lib/data/playlist/playlist_dao.dart | 167 +++++ lib/main.dart | 1 + lib/models/data_models.dart | 127 ++++ .../online_search/movie_detail_page.dart | 19 +- .../playlist/playlist_add_item_page.dart | 275 +++++++ lib/pages/playlist/playlist_create_page.dart | 170 +++++ lib/pages/playlist/playlist_detail_page.dart | 495 +++++++++++++ lib/pages/playlist/playlist_list_page.dart | 679 ++++++++++++++++++ lib/pages/profile/feature_settings_page.dart | 12 + lib/providers/app_provider.dart | 71 ++ lib/utils/user_prefs.dart | 7 + lib/widgets/custom_drawer.dart | 85 ++- lib/widgets/reviewed_stamp_icon.dart | 64 ++ pubspec.lock | 40 ++ pubspec.yaml | 1 + 16 files changed, 2253 insertions(+), 31 deletions(-) create mode 100644 lib/data/playlist/playlist_dao.dart create mode 100644 lib/pages/playlist/playlist_add_item_page.dart create mode 100644 lib/pages/playlist/playlist_create_page.dart create mode 100644 lib/pages/playlist/playlist_detail_page.dart create mode 100644 lib/pages/playlist/playlist_list_page.dart create mode 100644 lib/widgets/reviewed_stamp_icon.dart diff --git a/lib/data/database_helper.dart b/lib/data/database_helper.dart index 8d59e75..f345a49 100644 --- a/lib/data/database_helper.dart +++ b/lib/data/database_helper.dart @@ -81,7 +81,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 36, + version: 38, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -363,6 +363,44 @@ class DatabaseHelper { 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 _upgradeBooksTableV26(Database db) async { final columns = await db.rawQuery('PRAGMA table_info(books)'); @@ -964,6 +1002,37 @@ class DatabaseHelper { 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)', + ); } // 关闭数据库 diff --git a/lib/data/playlist/playlist_dao.dart b/lib/data/playlist/playlist_dao.dart new file mode 100644 index 0000000..fac1882 --- /dev/null +++ b/lib/data/playlist/playlist_dao.dart @@ -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 _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[PlaylistDao] $op error: $e'); + rethrow; + } + } + + // 获取所有片单(未删除) + Future> 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 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 insertPlaylist(Playlist playlist) => _wrap('insertPlaylist', () async { + final db = await _dbHelper.database; + return await db.insert('playlists', playlist.toJson()); + }); + + // 更新片单 + Future updatePlaylist(Playlist playlist) => _wrap('updatePlaylist', () async { + final db = await _dbHelper.database; + return await db.update( + 'playlists', + playlist.toJson(), + where: 'id = ?', + whereArgs: [playlist.id], + ); + }); + + // 软删除片单 + Future 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> 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 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 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 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> 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 _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 updatePlaylistItemOrder(String playlistId, List 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 updatePlaylistOrder(List 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]], + ); + } + }); +} diff --git a/lib/main.dart b/lib/main.dart index 6613d6e..f1b2524 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -69,6 +69,7 @@ Future _bootstrap(AppProvider appProvider) async { // 数据库优先初始化(不被 sync 阻塞) try { await appProvider.initDatabase(); + unawaited(appProvider.loadPlaylists()); } catch (e) { debugPrint('[Startup] 数据库初始化失败: $e'); appProvider.markDbInitFailed(); diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index a40f416..5065830 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -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 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 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 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 toJson() { + return { + 'id': id, + 'playlist_id': playlistId, + 'item_id': itemId, + 'sort_order': sortOrder, + 'added_at': addedAt.toUtc().toIso8601String(), + }; + } +} + diff --git a/lib/pages/online_search/movie_detail_page.dart b/lib/pages/online_search/movie_detail_page.dart index 2effa5c..451ed7e 100644 --- a/lib/pages/online_search/movie_detail_page.dart +++ b/lib/pages/online_search/movie_detail_page.dart @@ -130,8 +130,23 @@ class _MovieDetailPageState extends State { final pic = m['vod_pic'] ?? ''; DateTime? releaseDate; - if (yearStr.toString().isNotEmpty) { - releaseDate = DateTime.tryParse('${yearStr}-01-01'); + final pubdateStr = m['vod_pubdate'] ?? ''; + 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(); diff --git a/lib/pages/playlist/playlist_add_item_page.dart b/lib/pages/playlist/playlist_add_item_page.dart new file mode 100644 index 0000000..a80e73a --- /dev/null +++ b/lib/pages/playlist/playlist_add_item_page.dart @@ -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 createState() => _PlaylistAddItemPageState(); +} + +class _PlaylistAddItemPageState extends State { + final _searchController = TextEditingController(); + List _existingItemIds = []; + String _keyword = ''; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadExisting(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + Future _loadExisting() async { + final provider = context.read(); + 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(); + 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 = '', + }); +} diff --git a/lib/pages/playlist/playlist_create_page.dart b/lib/pages/playlist/playlist_create_page.dart new file mode 100644 index 0000000..e46afc6 --- /dev/null +++ b/lib/pages/playlist/playlist_create_page.dart @@ -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 createState() => _PlaylistCreatePageState(); +} + +class _PlaylistCreatePageState extends State { + 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 _save() async { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + if (_isSaving) return; + setState(() => _isSaving = true); + + final provider = context.read(); + + 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), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/playlist/playlist_detail_page.dart b/lib/pages/playlist/playlist_detail_page.dart new file mode 100644 index 0000000..366abb7 --- /dev/null +++ b/lib/pages/playlist/playlist_detail_page.dart @@ -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 createState() => _PlaylistDetailPageState(); +} + +class _PlaylistDetailPageState extends State { + List _items = []; + bool _loading = true; + final Map _slideOffsets = {}; + double _dragStartX = 0; + double _dragStartOffset = 0; + bool _isHorizontalDrag = false; + static const _actionWidth = 56.0; + + @override + void initState() { + super.initState(); + _loadItems(); + } + + Future _loadItems() async { + final provider = context.read(); + 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(); + 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( + 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; +} diff --git a/lib/pages/playlist/playlist_list_page.dart b/lib/pages/playlist/playlist_list_page.dart new file mode 100644 index 0000000..a7c67e6 --- /dev/null +++ b/lib/pages/playlist/playlist_list_page.dart @@ -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 createState() => _PlaylistListPageState(); +} + +class _PlaylistListPageState extends State { + final Map> _playlistItemIds = {}; + int _layoutStyle = UserPrefs().playlistLayoutStyle; + final Map _slideOffsets = {}; + double _dragStartX = 0; + double _dragStartOffset = 0; + bool _isHorizontalDrag = false; + + @override + void initState() { + super.initState(); + _loadAllItemIds(); + } + + Future _loadAllItemIds() async { + final provider = context.read(); + for (final playlist in provider.playlists) { + final ids = await provider.getPlaylistItemIds(playlist.id); + if (!mounted) return; + setState(() { + _playlistItemIds[playlist.id] = ids; + }); + } + } + + List _getCoverPaths(Playlist playlist, AppProvider provider, {int limit = 4}) { + final ids = _playlistItemIds[playlist.id] ?? []; + final covers = []; + 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( + 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( + 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 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.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( + 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 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 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 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 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( + 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 _showDeleteDialog(BuildContext context, Playlist playlist, AppProvider provider) { + final colors = Theme.of(context).colorScheme; + return 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('确定要删除片单「${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), + ), + ); + } +} diff --git a/lib/pages/profile/feature_settings_page.dart b/lib/pages/profile/feature_settings_page.dart index 6dd1615..b077e3e 100644 --- a/lib/pages/profile/feature_settings_page.dart +++ b/lib/pages/profile/feature_settings_page.dart @@ -27,6 +27,7 @@ class _FeatureSettingsPageState extends State { bool _showEncounter = true; bool _showStroll = true; bool _showReviewed = true; + bool _showPlaylist = true; bool _showCalendar = true; bool _showPerson = true; bool _showTags = true; @@ -53,6 +54,7 @@ class _FeatureSettingsPageState extends State { _showEncounter = _userPrefs.showSidebarEncounter; _showStroll = _userPrefs.showSidebarStroll; _showReviewed = _userPrefs.showSidebarReviewed; + _showPlaylist = _userPrefs.showSidebarPlaylist; _showCalendar = _userPrefs.showSidebarCalendar; _showPerson = _userPrefs.showSidebarPerson; _showTags = _userPrefs.showSidebarTags; @@ -263,6 +265,16 @@ class _FeatureSettingsPageState extends State { await _userPrefs.setShowSidebarReviewed(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( height: 0.5, indent: 24, diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 6d9376d..c9e1ecf 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -12,6 +12,7 @@ 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/playlist/playlist_dao.dart'; import '../data/tag/tag_dao.dart'; import '../data/database_helper.dart'; import '../utils/image_path_helper.dart'; @@ -32,12 +33,14 @@ class AppProvider extends ChangeNotifier { final GameDao _gameDao = GameDao(); final GameReviewDao _gameReviewDao = GameReviewDao(); final GameScreenshotDao _gameScreenshotDao = GameScreenshotDao(); + final PlaylistDao _playlistDao = PlaylistDao(); final TagDao _tagDao = TagDao(); // 数据列表 List _movies = []; List _books = []; List _notes = []; List _games = []; + List _playlists = []; // 当前主界面选中的标签 (0: 观影,1: 阅读,2: 笔记) int _mainTabIndex = 0; @@ -231,6 +234,12 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + // 加载片单数据 + Future loadPlaylists() async { + _playlists = await _playlistDao.getAllPlaylists(); + notifyListeners(); + } + /// 编辑返回后触发列表页重载 /// [itemId] 被编辑条目的 ID,用于就地更新而非重置分页 void setEditRefresh([String? itemId]) { @@ -285,6 +294,7 @@ class AppProvider extends ChangeNotifier { List get books => UnmodifiableListView(_books); List get notes => UnmodifiableListView(_notes); List get games => UnmodifiableListView(_games); + List get playlists => UnmodifiableListView(_playlists); // 根据状态获取影视列表 List getMoviesByStatus(String status) { @@ -588,6 +598,66 @@ class AppProvider extends ChangeNotifier { notifyListeners(); } + // ─── 片单操作 ───────────────────────────────────────────────── + + Future addPlaylist(Playlist playlist) async { + await _playlistDao.insertPlaylist(playlist); + _playlists.add(playlist); + notifyListeners(); + } + + Future removePlaylist(String id) async { + await _playlistDao.deletePlaylist(id); + _playlists.removeWhere((p) => p.id == id); + notifyListeners(); + } + + Future 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> getPlaylistItems(String playlistId) async { + return _playlistDao.getPlaylistItems(playlistId); + } + + Future 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 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> getPlaylistItemIds(String playlistId) async { + return _playlistDao.getPlaylistItemIds(playlistId); + } + + Future reorderPlaylists(List 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 reorderPlaylistItems(String playlistId, List itemIds) async { + await _playlistDao.updatePlaylistItemOrder(playlistId, itemIds); + } + /// 仅更新游戏封面偏移量(不触发全量刷新) Future updateGameCoverOffset(String gameId, double offset) async { await _gameDao.updateCoverOffset(gameId, offset); @@ -923,6 +993,7 @@ class AppProvider extends ChangeNotifier { await loadBooks(); await loadNotes(); await loadGames(); + await loadPlaylists(); } // ========== 影评书评回收站 ========== diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index bc34651..96ebcf3 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -133,6 +133,9 @@ class UserPrefs { bool get showSidebarReviewed => prefs.getBool('showSidebarReviewed') ?? true; Future setShowSidebarReviewed(bool value) => prefs.setBool('showSidebarReviewed', value); + bool get showSidebarPlaylist => prefs.getBool('showSidebarPlaylist') ?? true; + Future setShowSidebarPlaylist(bool value) => prefs.setBool('showSidebarPlaylist', value); + bool get showSidebarCalendar => prefs.getBool('showSidebarCalendar') ?? true; Future setShowSidebarCalendar(bool value) => prefs.setBool('showSidebarCalendar', value); @@ -211,6 +214,10 @@ class UserPrefs { int get reviewedLayoutStyle => prefs.getInt('reviewedLayoutStyle') ?? 0; Future setReviewedLayoutStyle(int value) => prefs.setInt('reviewedLayoutStyle', value); + /// 片单页布局样式 (0: 列表, 1: 网格) + int get playlistLayoutStyle => prefs.getInt('playlistLayoutStyle') ?? 0; + Future setPlaylistLayoutStyle(int value) => prefs.setInt('playlistLayoutStyle', value); + // ========== 应用图标设置 ========== // ========== Markdown 阅读器 ========== diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index 9664a61..d48a660 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -3,9 +3,12 @@ import 'package:provider/provider.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../providers/app_provider.dart'; import '../utils/user_prefs.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'reviewed_stamp_icon.dart'; import '../pages/explore/encounter_page.dart'; import '../pages/explore/stroll_page.dart'; import '../pages/explore/reviewed_page.dart'; +import '../pages/playlist/playlist_list_page.dart'; import '../pages/explore/media_calendar_page.dart'; import '../pages/explore/person_list_page.dart'; import '../pages/markdown_reader/md_reader_tab_page.dart'; @@ -90,6 +93,7 @@ class _CustomDrawerState extends State { final showTools = userPrefs.showSidebarEncounter || userPrefs.showSidebarStroll || userPrefs.showSidebarReviewed || + userPrefs.showSidebarPlaylist || userPrefs.showSidebarCalendar || userPrefs.showSidebarPerson || userPrefs.showSidebarTags || @@ -318,17 +322,18 @@ class _CustomDrawerState extends State { final colors = Theme.of(context).colorScheme; final userPrefs = UserPrefs(); - final exploreItems = <(IconData, String, Widget)>[]; - if (userPrefs.showSidebarEncounter) exploreItems.add((Icons.favorite_border, '统计', const EncounterPage())); - if (userPrefs.showSidebarStroll) exploreItems.add((Icons.explore_outlined, '漫步', const StrollPage())); - if (userPrefs.showSidebarReviewed) exploreItems.add((Icons.done_all, '已阅', const ReviewedPage())); - if (userPrefs.showSidebarCalendar) exploreItems.add((Icons.calendar_month_outlined, '书影日历', const MediaCalendarPage())); + final exploreItems = <(Widget, String, Widget)>[]; + if (userPrefs.showSidebarEncounter) exploreItems.add((SvgPicture.string('', color: colors.onSurface), '统计', const EncounterPage())); + if (userPrefs.showSidebarStroll) exploreItems.add((SvgPicture.string('', color: colors.onSurface), '漫步', const StrollPage())); + if (userPrefs.showSidebarReviewed) exploreItems.add((SvgPicture.string('', color: colors.onSurface), '已阅', const ReviewedPage())); + if (userPrefs.showSidebarPlaylist) exploreItems.add((SvgPicture.string('', color: colors.onSurface), '书影片单', const PlaylistListPage())); + if (userPrefs.showSidebarCalendar) exploreItems.add((SvgPicture.string('', color: colors.onSurface), '书影日历', const MediaCalendarPage())); - final toolItems = <(IconData, String, Widget)>[]; - if (userPrefs.showSidebarPerson) toolItems.add((Icons.people_outline, '角色信息', const PersonListPage())); - if (userPrefs.showSidebarTags) toolItems.add((Icons.label_outline, '标签管理', const TagManagementPage())); - if (userPrefs.showSidebarMdReader) toolItems.add((Icons.description_outlined, 'MD阅读', const MdReaderTabPage())); - if (userPrefs.showSidebarEpub) toolItems.add((Icons.auto_stories_outlined, 'EPUB阅读', const EpubLibraryPage())); + final toolItems = <(Widget, String, Widget)>[]; + if (userPrefs.showSidebarPerson) toolItems.add((SvgPicture.string('', color: colors.onSurface), '角色信息', const PersonListPage())); + if (userPrefs.showSidebarTags) toolItems.add((SvgPicture.string('', color: colors.onSurface), '标签管理', const TagManagementPage())); + if (userPrefs.showSidebarMdReader) toolItems.add((Icon(Icons.description_outlined, size: 20, color: colors.onSurface), 'MD阅读', const MdReaderTabPage())); + if (userPrefs.showSidebarEpub) toolItems.add((SvgPicture.string('', color: colors.onSurface), 'EPUB阅读', const EpubLibraryPage())); if (exploreItems.isEmpty && toolItems.isEmpty) return const SizedBox.shrink(); @@ -357,7 +362,7 @@ class _CustomDrawerState extends State { ); } - Widget _buildGroupCard(BuildContext context, List<(IconData, String, Widget)> items) { + Widget _buildGroupCard(BuildContext context, List<(Widget, String, Widget)> items) { final colors = Theme.of(context).colorScheme; return Container( margin: const EdgeInsets.symmetric(horizontal: 16), @@ -381,7 +386,7 @@ class _CustomDrawerState extends State { ); } - Widget _buildToolItem(IconData icon, String title, VoidCallback onTap, {bool topRounded = false, bool bottomRounded = false, bool enabled = true}) { + Widget _buildToolItem(Widget icon, String title, VoidCallback onTap, {bool topRounded = false, bool bottomRounded = false, bool enabled = true}) { final colors = Theme.of(context).colorScheme; final effectiveOnTap = enabled ? onTap : null; final iconOpacity = enabled ? 0.7 : 0.25; @@ -398,7 +403,7 @@ class _CustomDrawerState extends State { padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 20), child: Row( children: [ - Icon(icon, size: 20, color: colors.onSurface.withValues(alpha: iconOpacity)), + SizedBox(width: 20, height: 20, child: Opacity(opacity: iconOpacity, child: icon)), const SizedBox(width: 12), Expanded(child: Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: textOpacity)))), Icon(Icons.chevron_right, size: 16, color: colors.onSurface.withValues(alpha: 0.2)), @@ -457,16 +462,16 @@ class _CustomDrawerState extends State { final items = <_RecentItem>[]; for (final m in movies.where((m) => !m.isDeleted)) { - items.add(_RecentItem(type: 'movie', title: m.title, date: m.createdAt, data: m)); + items.add(_RecentItem(type: 'movie', title: m.title, date: m.createdAt, data: m, imagePath: m.posterPath)); } for (final b in books.where((b) => !b.isDeleted)) { - items.add(_RecentItem(type: 'book', title: b.title, date: b.createdAt, data: b)); + items.add(_RecentItem(type: 'book', title: b.title, date: b.createdAt, data: b, imagePath: b.coverPath)); } for (final n in notes.where((n) => !n.isDeleted)) { items.add(_RecentItem(type: 'note', title: n.title.isNotEmpty ? n.title : '随手记', date: n.createdAt, data: n)); } for (final g in games.where((g) => !g.isDeleted)) { - items.add(_RecentItem(type: 'game', title: g.title, date: g.createdAt, data: g)); + items.add(_RecentItem(type: 'game', title: g.title, date: g.createdAt, data: g, imagePath: g.coverPath)); } items.sort((a, b) => b.date.compareTo(a.date)); @@ -729,17 +734,7 @@ class _CustomDrawerState extends State { padding: const EdgeInsets.only(bottom: 10, top: 2), child: Row( children: [ - Container( - width: 22, height: 22, - decoration: BoxDecoration( - color: _typeColor(item.type).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(5), - ), - child: Icon( - item.type == 'movie' ? Icons.movie_outlined : item.type == 'book' ? Icons.menu_book_outlined : item.type == 'game' ? Icons.sports_esports_outlined : Icons.note_outlined, - size: 12, color: _typeColor(item.type), - ), - ), + _buildRecentLeading(item, colors), const SizedBox(width: 10), Expanded( child: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, @@ -779,6 +774,39 @@ class _CustomDrawerState extends State { return '刚刚'; } + Widget _buildRecentLeading(_RecentItem item, ColorScheme colors) { + final imagePath = item.imagePath; + if (imagePath != null && imagePath.isNotEmpty) { + return Container( + width: 22, height: 22, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + ), + clipBehavior: Clip.antiAlias, + child: FadeInLocalImage( + path: imagePath, + fit: BoxFit.cover, + errorWidget: _buildRecentTypeIcon(item, colors), + ), + ); + } + return _buildRecentTypeIcon(item, colors); + } + + Widget _buildRecentTypeIcon(_RecentItem item, ColorScheme colors) { + return Container( + width: 22, height: 22, + decoration: BoxDecoration( + color: _typeColor(item.type).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(5), + ), + child: Icon( + item.type == 'movie' ? Icons.movie_outlined : item.type == 'book' ? Icons.menu_book_outlined : item.type == 'game' ? Icons.sports_esports_outlined : Icons.note_outlined, + size: 12, color: _typeColor(item.type), + ), + ); + } + Color _typeColor(String type) { return switch (type) { 'movie' => const Color(0xFF2563EB), @@ -795,5 +823,6 @@ class _RecentItem { final String title; final DateTime date; final dynamic data; - _RecentItem({required this.type, required this.title, required this.date, required this.data}); + final String? imagePath; + _RecentItem({required this.type, required this.title, required this.date, required this.data, this.imagePath}); } diff --git a/lib/widgets/reviewed_stamp_icon.dart b/lib/widgets/reviewed_stamp_icon.dart new file mode 100644 index 0000000..6380bc7 --- /dev/null +++ b/lib/widgets/reviewed_stamp_icon.dart @@ -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; +} diff --git a/pubspec.lock b/pubspec.lock index 6e2f760..de865e9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -331,6 +331,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: flutter @@ -613,6 +621,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -1066,6 +1082,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d4b1491..ad9692d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: device_info_plus: ^11.2.0 crypto: ^3.0.6 window_manager: ^0.4.3 + flutter_svg: ^2.3.0 dev_dependencies: flutter_test: