Files
MookNote/lib/pages/movies/movie_tab_page.dart
2026-08-12 22:28:33 +08:00

607 lines
23 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/data_models.dart';
import '../../providers/app_provider.dart';
import '../../utils/user_prefs.dart';
import '../../widgets/movie_status_bar.dart';
import '../../widgets/movie_category_bar.dart';
import '../../widgets/movie_list_item.dart';
import '../../widgets/animated_star_rating.dart';
import '../../widgets/shimmer_skeleton.dart';
import '../../widgets/top_fade_scrim.dart';
import '../../widgets/fade_in_local_image.dart';
import '../../utils/responsive.dart';
import '../../widgets/master_detail_scaffold.dart';
import '../../widgets/detail_placeholder.dart';
import 'movie_detail_page.dart';
import 'movie_add_page.dart';
import '../../widgets/app_overlay.dart';
/// 状态索引 → 状态值
const _statusMap = {0: 'watched', 1: 'watching', 2: 'want_to_watch'};
/// 观影标签页PageView 分页 + 触底加载),左右滑动丝滑切换
class MovieTabPage extends StatefulWidget {
const MovieTabPage({super.key});
@override
State<MovieTabPage> createState() => _MovieTabPageState();
}
class _MovieTabPageState extends State<MovieTabPage> {
late PageController _pageController;
int _currentPage = 0; // PageView 当前页的唯一真源
int? _pendingTarget; // 待跟随的页,避免重复调度动画
int _lastModeSignature = -1; // 编码 wall+displayMode检测模式切换
bool _modeInitialized = false; // 吞掉首次构建的伪"变化"
@override
void initState() {
super.initState();
_pageController = PageController();
// 应用启动时保存的初始索引(可能 > 0
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final p = context.read<AppProvider>();
final initial = _activeIndexFor(p).clamp(0, _pageCountFor(p) - 1);
_currentPage = initial;
if (_pageController.hasClients && initial != 0) _pageController.jumpToPage(initial);
});
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
int _pageCountFor(AppProvider p) =>
p.movieWallMode ? 1 : (p.movieDisplayMode == 1 ? MovieCategoryBar.count : 3);
int _activeIndexFor(AppProvider p) =>
p.movieWallMode ? 0 : (p.movieDisplayMode == 1 ? p.movieCategoryIndex : p.movieStatusIndex);
int _modeSignature(AppProvider p) =>
(p.movieWallMode ? 1 : 0) * 1000 + (p.movieDisplayMode == 1 ? 1 : 0);
@override
Widget build(BuildContext context) {
final isWideContent = Breakpoint.isWideContent(context);
final provider = context.watch<AppProvider>();
final isWallMode = provider.movieWallMode;
final masterContent = Column(
children: [
if (!isWallMode)
provider.movieDisplayMode == 1
? const MovieCategoryBar()
: const MovieStatusBar(),
Expanded(
child: Stack(
children: [
_buildPageView(provider),
if (!isWallMode) const TopFadeScrim(),
],
),
),
],
);
if (!isWideContent) return masterContent;
final selectedMovie = provider.selectedMovie;
final detailWidget = provider.isAdding && provider.addingType == 0
? MovieAddPage(onCancel: () => provider.cancelAdding())
: selectedMovie != null
? MovieDetailPage(movie: selectedMovie, embedded: true)
: const DetailPlaceholder(icon: Icons.movie_outlined, message: '选择一部影片查看详情');
return MasterDetailScaffold(
master: masterContent,
detail: detailWidget,
);
}
Widget _buildPageView(AppProvider provider) {
final wall = provider.movieWallMode;
final isCategory = provider.movieDisplayMode == 1;
final pageCount = _pageCountFor(provider);
final mode = wall ? 2 : (isCategory ? 1 : 0);
// (a) 首次构建作为基线,不当成模式切换
if (!_modeInitialized) {
_modeInitialized = true;
_lastModeSignature = _modeSignature(provider);
}
// (b) 模式切换wall 切换 / displayMode 0<->1跳到第 0 页 + 重置索引
else if (_modeSignature(provider) != _lastModeSignature) {
_lastModeSignature = _modeSignature(provider);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_pageController.hasClients) return;
_currentPage = 0;
_pendingTarget = null;
if (isCategory) provider.setMovieCategoryIndex(0);
else if (!wall) provider.setMovieStatusIndex(0);
_pageController.jumpToPage(0);
});
}
// (c) 外部索引变化bar 点击等):动画跟随
final target = _activeIndexFor(provider).clamp(0, pageCount - 1);
if (pageCount > 1 && _pageController.hasClients && target != _currentPage && _pendingTarget != target) {
_pendingTarget = target;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_pageController.hasClients) return;
_pageController.animateToPage(
target,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
});
}
return PageView.builder(
controller: _pageController,
itemCount: pageCount,
allowImplicitScrolling: true, // 拖动时预构建相邻页 → 无白色空隙
onPageChanged: (index) => _onPageChanged(index, provider),
itemBuilder: (context, index) => _MovieTabView(
key: ValueKey('$mode-$index'), // 模式切换时全部重建
index: index,
mode: mode,
),
);
}
void _onPageChanged(int index, AppProvider provider) {
_currentPage = index;
_pendingTarget = null;
final wall = provider.movieWallMode;
final isCategory = provider.movieDisplayMode == 1;
final cur = wall ? 0 : (isCategory ? provider.movieCategoryIndex : provider.movieStatusIndex);
// 回显守卫:仅在真实拖动导致索引变化时推送,避免死循环
if (cur != index) {
if (isCategory) provider.setMovieCategoryIndex(index);
else if (!wall) provider.setMovieStatusIndex(index);
}
}
}
/// 单页:独立持有列表数据、滚动与分页状态,滑走再滑回保留状态
class _MovieTabView extends StatefulWidget {
final int index; // 0..pageCount-1
final int mode; // 0=status, 1=category, 2=wall
const _MovieTabView({super.key, required this.index, required this.mode});
@override
State<_MovieTabView> createState() => _MovieTabViewState();
}
class _MovieTabViewState extends State<_MovieTabView>
with AutomaticKeepAliveClientMixin {
final List<Movie> _items = [];
bool _hasMore = true;
bool _isLoading = false;
int _offset = 0;
bool _initialized = false;
late ScrollController _scrollController;
int _lastScrollSignal = 0;
int _lastEditRefreshCounter = 0;
int _prevMovieCount = -1;
int _prevSortMode = -1;
AppProvider? _provider;
String? get _status => widget.mode == 0 ? _statusMap[widget.index] : null;
String? get _category => widget.mode == 1 ? MovieCategoryBar.categoryValue(widget.index) : null;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
_scrollController = ScrollController()..addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final p = context.read<AppProvider>();
_provider = p;
_lastEditRefreshCounter = p.editRefreshCounter;
_lastScrollSignal = p.scrollToTopSignal;
_prevMovieCount = p.movies.length;
_prevSortMode = UserPrefs().movieSortMode;
p.addListener(_onDataChanged);
_loadFirst();
});
}
@override
void dispose() {
_provider?.removeListener(_onDataChanged);
_scrollController.dispose();
super.dispose();
}
void _onDataChanged() {
if (!_initialized || !mounted) return;
final p = context.read<AppProvider>();
// 回到顶部信号:每页滚自己的 controller
if (p.scrollToTopSignal != _lastScrollSignal) {
_lastScrollSignal = p.scrollToTopSignal;
if (_scrollController.hasClients) {
_scrollController.animateTo(0,
duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
}
}
// 就地编辑:更新本页匹配项,不重置分页
if (p.editRefreshCounter > _lastEditRefreshCounter && p.lastEditedItemId != null) {
_lastEditRefreshCounter = p.editRefreshCounter;
_prevMovieCount = p.movies.length;
final id = p.lastEditedItemId!;
final i = _items.indexWhere((m) => m.id == id);
if (i != -1) {
final u = p.movies.where((m) => m.id == id).firstOrNull;
if (u != null) setState(() => _items[i] = u);
}
return;
}
// 排序/数量变化才重新拉取(布局变化由 context.select 原地重渲染)
final sortChanged = UserPrefs().movieSortMode != _prevSortMode;
final countChanged = p.movies.length != _prevMovieCount;
if (sortChanged || countChanged) {
_prevSortMode = UserPrefs().movieSortMode;
_prevMovieCount = p.movies.length;
_loadFirst();
}
}
void _onScroll() {
if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
}
Future<void> _loadFirst() async {
final provider = context.read<AppProvider>();
final sortMode = UserPrefs().movieSortMode;
_initialized = true;
setState(() { _isLoading = true; _offset = 0; _hasMore = true; });
final list = await provider.loadMoviesPaged(
status: _status, category: _category, offset: 0, sortMode: sortMode);
if (!mounted) return;
setState(() {
_items.clear();
_items.addAll(list);
_offset = list.length;
_hasMore = list.length >= 20;
_isLoading = false;
});
}
Future<void> _loadMore() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
final provider = context.read<AppProvider>();
final sortMode = UserPrefs().movieSortMode;
final list = await provider.loadMoviesPaged(
status: _status, category: _category, offset: _offset, sortMode: sortMode);
if (!mounted) return;
setState(() {
_items.addAll(list);
_offset += list.length;
_hasMore = list.length >= 20;
_isLoading = false;
});
}
Future<void> _refresh() async {
final provider = context.read<AppProvider>();
await provider.loadMovies();
await _loadFirst();
}
void _onMovieTap(Movie movie) {
if (Breakpoint.isWideContent(context)) {
context.read<AppProvider>().selectMovie(movie);
} else {
Navigator.pushNamed(context, '/movie-detail', arguments: movie);
}
}
@override
Widget build(BuildContext context) {
super.build(context);
final colors = Theme.of(context).colorScheme;
final layoutStyle = context.select<AppProvider, int>((p) => p.movieLayoutStyle);
final content = () {
if (_items.isEmpty && _isLoading) return _buildSkeleton(layoutStyle);
if (_items.isEmpty) {
return RefreshIndicator(
onRefresh: _refresh,
color: colors.primary,
backgroundColor: colors.surface,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [_buildEmptyState()],
),
);
}
return RefreshIndicator(
onRefresh: _refresh,
color: colors.primary,
backgroundColor: colors.surface,
child: layoutStyle == 1
? _buildListView()
: layoutStyle == 2
? _buildCoverCardView()
: _buildGridView(),
);
}();
return content;
}
Widget _buildGridView() {
return LayoutBuilder(
builder: (context, constraints) {
final crossAxisCount = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 110);
final isWideContent = Breakpoint.isWideContent(context);
final provider = context.read<AppProvider>();
return GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount, childAspectRatio: 0.55, crossAxisSpacing: 12, mainAxisSpacing: 16,
),
itemCount: _items.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) return _buildLoadMoreIndicator();
final item = _items[index];
return MovieListItem(
movie: item,
selected: isWideContent && provider.selectedMovie?.id == item.id,
onTap: () => _onMovieTap(item),
);
},
);
},
);
}
Widget _buildListView() {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 100),
itemCount: _items.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) return _buildLoadMoreIndicator();
return _buildListCard(_items[index]);
},
);
}
Widget _buildLoadMoreIndicator() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Center(
child: _isLoading
? SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Theme.of(context).colorScheme.primary))
: Text('没有更多了', style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3))),
),
);
}
Widget _buildListCard(Movie movie) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => _onMovieTap(movie),
onLongPress: () => _showDeleteDialog(context, movie),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)),
child: Row(children: [
Container(
width: 48, height: 64,
decoration: BoxDecoration(color: colors.outlineVariant, borderRadius: BorderRadius.circular(6)),
clipBehavior: Clip.antiAlias,
child: movie.posterPath != null && movie.posterPath!.isNotEmpty
? FadeInLocalImage(path: movie.posterPath, fit: BoxFit.cover,
errorWidget: Icon(Icons.movie_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)))
: Icon(Icons.movie_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(movie.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
...[
const SizedBox(height: 3),
Text(_buildSubtitle(movie), maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35))),
],
const SizedBox(height: 6),
if (movie.rating != null) AnimatedStarRating(rating: movie.rating!, starSize: 12, showNumber: true)
else const SizedBox(height: 14),
])),
const SizedBox(width: 8),
Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.2), size: 20),
]),
),
);
}
String _buildSubtitle(Movie movie) {
final parts = <String>[];
if (movie.directors.isNotEmpty) parts.add(movie.directors.first);
if (movie.actors.isNotEmpty) parts.add(movie.actors.take(2).join(''));
if (movie.genres.isNotEmpty) parts.add(movie.genres.take(2).join(''));
return parts.join(' · ');
}
// ─── 大图卡片样式 ───────────────────────────────────────
Widget _buildCoverCardView() {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
itemCount: _items.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) return _buildLoadMoreIndicator();
return _buildCoverCard(_items[index]);
},
);
}
Widget _buildCoverCard(Movie movie) {
final colors = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => _onMovieTap(movie),
onLongPress: () => _showDeleteDialog(context, movie),
child: Container(
height: 200,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
color: colors.surfaceContainerHigh,
),
clipBehavior: Clip.antiAlias,
child: Stack(fit: StackFit.expand, children: [
// 海报背景
if (movie.posterPath != null && movie.posterPath!.isNotEmpty)
FadeInLocalImage(path: movie.posterPath, fit: BoxFit.cover,
errorWidget: Container(color: colors.surfaceContainerHighest))
else
Container(color: colors.surfaceContainerHighest,
child: Icon(Icons.movie_outlined, size: 48, color: colors.onSurface.withValues(alpha: 0.15))),
// 底部渐变遮罩
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.75)],
stops: const [0.4, 1.0],
),
),
),
),
// 底部信息
Positioned(
left: 14, right: 14, bottom: 14,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(movie.title, maxLines: 1, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Colors.white)),
const SizedBox(height: 4),
Row(children: [
Expanded(
child: Text(_buildSubtitle(movie), maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))),
),
if (movie.rating != null) ...[
const SizedBox(width: 8),
Icon(Icons.star_rounded, size: 16, color: Colors.amber.shade400),
const SizedBox(width: 2),
Text(movie.rating!.toStringAsFixed(1),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)),
],
]),
]),
),
]),
),
);
}
Widget _buildCoverCardSkeleton() {
final colors = Theme.of(context).colorScheme;
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 100),
itemCount: 4,
itemBuilder: (_, __) => Container(
height: 200,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
),
),
);
}
void _showDeleteDialog(BuildContext context, Movie movie) {
final colors = Theme.of(context).colorScheme;
appDialog(
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('确定要删除《${movie.title}》吗?删除后可在回收站恢复。',
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6), height: 1.5)),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx),
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
ElevatedButton(
onPressed: () async {
await context.read<AppProvider>().removeMovie(movie.id);
if (!ctx.mounted) return;
Navigator.pop(ctx);
if (mounted) _loadFirst();
},
style: ElevatedButton.styleFrom(backgroundColor: colors.error, foregroundColor: colors.onError, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)),
child: const Text('删除'),
),
],
actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
);
}
Widget _buildSkeleton(int layoutStyle) {
if (layoutStyle == 1) return _buildListSkeleton();
if (layoutStyle == 2) return _buildCoverCardSkeleton();
return const MovieSkeletonGrid();
}
Widget _buildListSkeleton() {
final colors = Theme.of(context).colorScheme;
return ListView.builder(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 100), itemCount: 6,
itemBuilder: (_, __) => Container(
margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: colors.surfaceContainerHigh, borderRadius: BorderRadius.circular(12)),
child: const Row(children: [
ShimmerSkeleton(width: 48, height: 64, borderRadius: 6), SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ShimmerSkeleton(width: 160, height: 16), SizedBox(height: 6),
ShimmerSkeleton(width: 100, height: 12), SizedBox(height: 6),
ShimmerSkeleton(width: 70, height: 12),
])),
SizedBox(width: 8), ShimmerSkeleton(width: 20, height: 20, borderRadius: 10),
]),
),
);
}
Widget _buildEmptyState() {
final colors = Theme.of(context).colorScheme;
final String emptyText;
if (widget.mode == 2) {
emptyText = '暂无影片';
} else if (widget.mode == 1) {
emptyText = '暂无${MovieCategoryBar.categoryLabel(widget.index)}';
} else {
emptyText = '暂无${['已看', '在看', '想看'][widget.index]}的影片';
}
return Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Container(width: 80, height: 80,
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
child: Icon(Icons.movie_outlined, size: 40, color: colors.onSurface.withValues(alpha: 0.25))),
const SizedBox(height: 20),
Text(emptyText, style: TextStyle(fontSize: 16, color: colors.onSurface.withValues(alpha: 0.4))),
]));
}
}