This commit is contained in:
DelLevin-Home
2026-08-07 21:26:10 +08:00
parent 44e7431574
commit 103afaa65e
16 changed files with 2253 additions and 31 deletions

View 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 = '',
});
}

View 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),
),
),
],
),
);
}
}

View 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;
}

View 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),
),
);
}
}