From ca95a27b2bf5b00b04d71c778bdbbf6df0913161 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sun, 9 Aug 2026 14:29:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=BC=AB=E6=AD=A5=E7=95=8C?= =?UTF-8?q?=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/epub_reader/epub_library_page.dart | 280 ++++--------- .../epub_reader/widgets/book_grid_item.dart | 22 +- lib/pages/explore/reviewed_page.dart | 79 ++-- lib/pages/explore/stroll_page.dart | 367 ++++++++++++------ 4 files changed, 372 insertions(+), 376 deletions(-) diff --git a/lib/pages/epub_reader/epub_library_page.dart b/lib/pages/epub_reader/epub_library_page.dart index 62ebcf8..29f0978 100644 --- a/lib/pages/epub_reader/epub_library_page.dart +++ b/lib/pages/epub_reader/epub_library_page.dart @@ -1,12 +1,15 @@ -import 'dart:io'; -import 'dart:ui'; +import 'dart:io' show Platform; import 'package:flutter/material.dart'; import 'package:file_picker/file_picker.dart'; import '../../data/epub/reader_dao.dart'; import '../../services/epub/epub_service.dart'; import '../../utils/user_prefs.dart'; import '../../utils/toast_util.dart'; +import '../../utils/responsive.dart'; +import '../../widgets/fade_in_local_image.dart'; +import '../../widgets/shimmer_skeleton.dart'; import 'epub_detail_page.dart'; +import 'widgets/book_grid_item.dart'; /// EPUB 书架页面 class EpubLibraryPage extends StatefulWidget { @@ -25,6 +28,7 @@ class _EpubLibraryPageState extends State { bool _isSearching = false; final TextEditingController _searchCtrl = TextEditingController(); int _sortMode = UserPrefs().epubSortMode; + int _viewMode = UserPrefs().epubViewMode; // 0=列表 1=网格 @override void initState() { @@ -205,16 +209,6 @@ class _EpubLibraryPageState extends State { ); } - /// 找到最近在读的书(进度 > 0 且 < 1,按更新时间排序取第一本) - Map? get _lastReadingBook { - final reading = _books.where((b) { - final p = (b['reading_percentage'] as num?)?.toDouble() ?? 0.0; - return p > 0.0 && p < 1.0; - }).toList(); - if (reading.isEmpty) return null; - return reading.first; - } - @override void dispose() { _searchCtrl.dispose(); @@ -274,8 +268,8 @@ class _EpubLibraryPageState extends State { ]), ), // 主体 - Expanded(child: _isLoading - ? Center(child: CircularProgressIndicator(color: colors.primary)) + Expanded(child: _isLoading && _books.isEmpty + ? const BookSkeletonGrid() : _books.isEmpty ? _buildEmpty(colors) : _filteredBooks.isEmpty @@ -289,196 +283,44 @@ class _EpubLibraryPageState extends State { ); } - /// 主体内容:继续阅读横幅 + 书架列表 + /// 主体内容:书架(列表/网格) Widget _buildContent(ColorScheme colors) { - final lastBook = _lastReadingBook; return CustomScrollView( slivers: [ - // 继续阅读横幅 - if (lastBook != null && !_isSearching) - SliverToBoxAdapter(child: _buildContinueReading(colors, lastBook)), - // 书架列表 - _buildSliverListView(colors), + // 书架分隔标题 + SliverToBoxAdapter(child: _buildSectionHeader(colors)), + // 书架列表/网格 + _viewMode == 0 ? _buildSliverListView(colors) : _buildSliverGrid(colors), ], ); } - /// 继续阅读横幅卡片 — 封面背景 + 毛玻璃 - Widget _buildContinueReading(ColorScheme colors, Map book) { - final title = book['title'] as String? ?? ''; - final author = book['author'] as String? ?? ''; - final coverPath = book['cover_path'] as String?; - final progress = (book['reading_percentage'] as num?)?.toDouble() ?? 0.0; - final percentStr = '${(progress * 100).toInt()}%'; - final hasCover = coverPath != null && coverPath.isNotEmpty && File(coverPath).existsSync(); - + /// 书架分隔标题(带计数) + Widget _buildSectionHeader(ColorScheme colors) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - onTap: () => _openBook(book), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Stack( - fit: StackFit.passthrough, - children: [ - // 底层:封面图做背景 - if (hasCover) - SizedBox( - height: 140, - width: double.infinity, - child: Image.file( - File(coverPath!), - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container(color: colors.primaryContainer), - ), - ), - // 毛玻璃遮罩层 - ClipRRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), - child: Container( - height: 140, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: colors.surface.withValues(alpha: 0.35), - borderRadius: hasCover ? BorderRadius.zero : BorderRadius.circular(16), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 封面 - Container( - width: 56, - height: 78, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: colors.outlineVariant, - boxShadow: [ - BoxShadow( - color: colors.shadow.withValues(alpha: 0.2), - blurRadius: 8, - offset: const Offset(2, 3), - ), - ], - ), - clipBehavior: Clip.antiAlias, - child: _buildCover(coverPath, colors), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: colors.onSurface, - )), - if (author.isNotEmpty) ...[ - const SizedBox(height: 3), - Text(author, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - color: colors.onSurface.withValues(alpha: 0.55), - )), - ], - const Spacer(), - // 进度条 + 百分比 - Row( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: progress, - minHeight: 6, - backgroundColor: colors.primary.withValues(alpha: 0.15), - valueColor: AlwaysStoppedAnimation(colors.primary), - ), - ), - ), - const SizedBox(width: 10), - Text(percentStr, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: colors.primary, - )), - ], - ), - const SizedBox(height: 10), - // 继续阅读按钮 - Align( - alignment: Alignment.centerRight, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7), - decoration: BoxDecoration( - color: colors.primary, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.play_arrow_rounded, size: 16, color: colors.onPrimary), - const SizedBox(width: 4), - Text('继续阅读', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: colors.onPrimary, - )), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - ), - // 书架分隔 - Padding( - padding: const EdgeInsets.only(top: 20, left: 4, bottom: 4), - child: Row( - children: [ - Text('书架', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: colors.onSurface.withValues(alpha: 0.45), - )), - const SizedBox(width: 8), - Expanded( - child: Container( - height: 0.5, - color: colors.outlineVariant, - ), - ), - ], - ), - ), - ], + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Text( + '书架 (${_filteredBooks.length})', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: colors.onSurface.withValues(alpha: 0.4), + ), ), ); } List _buildActions(ColorScheme colors) { return [ + if (!_isSearching) + IconButton( + icon: Icon(_viewMode == 0 ? Icons.grid_view_outlined : Icons.view_list_outlined, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), + tooltip: _viewMode == 0 ? '网格视图' : '列表视图', + onPressed: () { + setState(() => _viewMode = _viewMode == 0 ? 1 : 0); + UserPrefs().setEpubViewMode(_viewMode); + }, + ), if (!_isSearching) IconButton( icon: Icon(Icons.search, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), @@ -551,6 +393,35 @@ class _EpubLibraryPageState extends State { ); } + Widget _buildSliverGrid(ColorScheme colors) { + return SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 100), + sliver: SliverLayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = + responsiveCrossAxisCount(constraints.crossAxisExtent, minItemWidth: 110); + return SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + childAspectRatio: 0.55, + crossAxisSpacing: 12, + mainAxisSpacing: 16, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => BookGridItem( + book: _filteredBooks[index], + viewMode: ViewMode.relaxed, + onTap: () => _openBook(_filteredBooks[index]), + onLongPress: () => _deleteBook(_filteredBooks[index]), + ), + childCount: _filteredBooks.length, + ), + ); + }, + ), + ); + } + Widget _buildListItem(ColorScheme colors, Map book) { final title = book['title'] as String? ?? ''; final author = book['author'] as String? ?? ''; @@ -679,23 +550,7 @@ class _EpubLibraryPageState extends State { } Widget _buildCover(String? path, ColorScheme colors) { - if (path != null && path.isNotEmpty && File(path).existsSync()) { - return ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Image.file( - File(path), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - errorBuilder: (_, __, ___) => Container( - color: colors.outlineVariant, - child: Icon(Icons.auto_stories_outlined, size: 22, - color: colors.onSurface.withValues(alpha: 0.25)), - ), - ), - ); - } - return Container( + final placeholder = Container( decoration: BoxDecoration( color: colors.outlineVariant, borderRadius: BorderRadius.circular(6), @@ -703,6 +558,17 @@ class _EpubLibraryPageState extends State { child: Icon(Icons.auto_stories_outlined, size: 22, color: colors.onSurface.withValues(alpha: 0.25)), ); + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: FadeInLocalImage( + path: path, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: placeholder, + errorWidget: placeholder, + ), + ); } /// 相对时间格式化 diff --git a/lib/pages/epub_reader/widgets/book_grid_item.dart b/lib/pages/epub_reader/widgets/book_grid_item.dart index 7303099..3fb5512 100644 --- a/lib/pages/epub_reader/widgets/book_grid_item.dart +++ b/lib/pages/epub_reader/widgets/book_grid_item.dart @@ -1,5 +1,5 @@ -import 'dart:io'; import 'package:flutter/material.dart'; +import '../../../widgets/fade_in_local_image.dart'; /// Display mode for the book grid item. enum ViewMode { relaxed, compact } @@ -188,8 +188,7 @@ class BookGridItem extends StatelessWidget { StackFit fit = StackFit.loose, }) { final coverPath = book['cover_path'] as String?; - final hasCover = - coverPath != null && coverPath.isNotEmpty && File(coverPath).existsSync(); + final placeholder = _buildPlaceholder(context); return Stack( fit: fit, @@ -197,15 +196,14 @@ class BookGridItem extends StatelessWidget { Container( decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)), clipBehavior: Clip.antiAlias, - child: hasCover - ? Image.file( - File(coverPath), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - errorBuilder: (_, __, ___) => _buildPlaceholder(context), - ) - : _buildPlaceholder(context), + child: FadeInLocalImage( + path: coverPath, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + placeholder: placeholder, + errorWidget: placeholder, + ), ), ...extras, ], diff --git a/lib/pages/explore/reviewed_page.dart b/lib/pages/explore/reviewed_page.dart index a32d85d..aee3ccf 100644 --- a/lib/pages/explore/reviewed_page.dart +++ b/lib/pages/explore/reviewed_page.dart @@ -325,24 +325,30 @@ class _ReviewedPageState extends State { for (final group in genreGroups) Container( height: 30, - margin: EdgeInsets.fromLTRB(0, genreGroups.indexOf(group) == 0 ? 8 : 4, 0, 0), - child: _FadeEdgeScrollView( - colors: colors, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - children: [ - _buildTypeTag(group.type, colors), - const SizedBox(width: 5), - for (final genre in group.genres) ...[ - _buildFilterChip(genre, genre, colors, - color: group.color, - isSelected: _selectedGenre == genre, - onTap: () => setState(() => _selectedGenre = _selectedGenre == genre ? null : genre)), - const SizedBox(width: 5), - ], - ], - ), + margin: EdgeInsets.fromLTRB(16, genreGroups.indexOf(group) == 0 ? 8 : 4, 0, 0), + child: Row( + children: [ + _buildTypeTag(group.type, colors), + const SizedBox(width: 5), + Expanded( + child: _FadeEdgeScrollView( + colors: colors, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(right: 16), + children: [ + for (final genre in group.genres) ...[ + _buildFilterChip(genre, genre, colors, + color: group.color, + isSelected: _selectedGenre == genre, + onTap: () => setState(() => _selectedGenre = _selectedGenre == genre ? null : genre)), + const SizedBox(width: 5), + ], + ], + ), + ), + ), + ], ), ), // 年份筛选 @@ -350,20 +356,29 @@ class _ReviewedPageState extends State { Container( height: 30, margin: const EdgeInsets.only(top: 4), - child: _FadeEdgeScrollView( - colors: colors, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - children: [ - _buildTypeTag(null, colors), - const SizedBox(width: 5), - for (final year in years) ...[ - _buildFilterChip(year, '$year', colors, isSelected: _selectedYear == year, onTap: () => setState(() => _selectedYear = _selectedYear == year ? null : year)), - const SizedBox(width: 5), - ], - ], - ), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 16), + child: _buildTypeTag(null, colors), + ), + const SizedBox(width: 5), + Expanded( + child: _FadeEdgeScrollView( + colors: colors, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(right: 16), + children: [ + for (final year in years) ...[ + _buildFilterChip(year, '$year', colors, isSelected: _selectedYear == year, onTap: () => setState(() => _selectedYear = _selectedYear == year ? null : year)), + const SizedBox(width: 5), + ], + ], + ), + ), + ), + ], ), ), ], diff --git a/lib/pages/explore/stroll_page.dart b/lib/pages/explore/stroll_page.dart index 14b5f25..e309de4 100644 --- a/lib/pages/explore/stroll_page.dart +++ b/lib/pages/explore/stroll_page.dart @@ -8,6 +8,7 @@ import '../../utils/toast_util.dart'; import '../movies/movie_detail_page.dart'; import '../book/book_detail_page.dart'; import '../note/note_detail_page.dart'; +import '../game/game_detail_page.dart'; /// 漫步页面 - 随机发现内容 class StrollPage extends StatefulWidget { @@ -21,22 +22,21 @@ class _StrollPageState extends State { final _random = Random(); final List<_StrollItem> _items = []; final Set _seenIds = {}; - late PageController _pageController; + int _current = 0; String _filter = 'all'; // all / movie / book / note + // 拖动手势状态 + double _dragX = 0; // 当前水平偏移(正=右滑,负=左滑) + double _dragY = 0; // 当前垂直偏移 + bool _isDragging = false; + bool _horizontalLocked = false; // 是否锁定为水平方向 + @override void initState() { super.initState(); - _pageController = PageController(viewportFraction: 0.78); _loadBatch(5); } - @override - void dispose() { - _pageController.dispose(); - super.dispose(); - } - // ─── 数据加载 ─── void _loadBatch(int count) { @@ -46,6 +46,7 @@ class _StrollPageState extends State { final moviePool = <_StrollItem>[]; final bookPool = <_StrollItem>[]; final notePool = <_StrollItem>[]; + final gamePool = <_StrollItem>[]; if (_filter == 'all' || _filter == 'movie') { for (final m in provider.movies.where((m) => !m.isDeleted)) { moviePool.add(_StrollItem( @@ -91,12 +92,28 @@ class _StrollPageState extends State { )); } } + if (_filter == 'all' || _filter == 'game') { + for (final g in provider.games.where((g) => !g.isDeleted)) { + gamePool.add(_StrollItem( + type: 'game', data: g, id: 'g_${g.id}', + title: g.title, + subtitle: g.developer.take(2).join(' / '), + detail: _gameDetail(g), + imagePath: g.coverPath, + icon: Icons.sports_esports_outlined, label: '游戏', + rating: g.rating, createdAt: g.createdAt, + tags: g.genres.take(3).toList(), + color: const Color(0xFFEC4899), + )); + } + } // 构建非空类别列表 final pools = >[]; if (moviePool.isNotEmpty) pools.add(moviePool); if (bookPool.isNotEmpty) pools.add(bookPool); if (notePool.isNotEmpty) pools.add(notePool); + if (gamePool.isNotEmpty) pools.add(gamePool); if (pools.isEmpty) return; // 全部模式下等概率选类别,单类别模式下直接选 @@ -131,14 +148,73 @@ class _StrollPageState extends State { return pool.last; } - void _reshuffle() { + /// 切换到下一张(点击"随机"按钮 / 左滑) + void _next() { setState(() { - _items.clear(); - _seenIds.clear(); - _loadBatch(5); + _current++; + if (_current >= _items.length - 2) { + _loadBatch(3); + } + if (_current >= _items.length) { + // 池子耗尽,回到最后一张 + _current = _items.length - 1; + } }); } + /// 切换到上一张(右滑) + void _prev() { + setState(() { + if (_current > 0) { + _current--; + } + }); + } + + // ─── 拖动手势 ─── + + void _onDragStart(DragStartDetails _) { + _dragX = 0; + _dragY = 0; + _isDragging = true; + _horizontalLocked = false; + } + + void _onDragUpdate(DragUpdateDetails d) { + if (!_isDragging) return; + setState(() { + _dragX += d.delta.dx; + _dragY += d.delta.dy; + // 首次明显移动时判定主方向:水平位移绝对值 > 垂直则锁定水平 + if (!_horizontalLocked && + (_dragX.abs() > 8 || _dragY.abs() > 8)) { + _horizontalLocked = _dragX.abs() > _dragY.abs(); + } + }); + } + + void _onDragEnd(DragEndDetails _) { + if (!_isDragging) return; + final dx = _dragX; + final dy = _dragY; + setState(() { + _isDragging = false; + _dragX = 0; + _dragY = 0; + _horizontalLocked = false; + }); + // 非水平主导或距离过小:不切换 + if (dx.abs() <= dy.abs()) return; + const threshold = 60.0; + if (dx < -threshold) { + // 左滑 → 下一张 + _next(); + } else if (dx > threshold) { + // 右滑 → 上一张 + _prev(); + } + } + // ─── 辅助方法 ─── String _movieDetail(Movie m) { @@ -158,6 +234,15 @@ class _StrollPageState extends State { return parts.join('\n'); } + String _gameDetail(Game g) { + final parts = []; + if (g.platforms.isNotEmpty) parts.add(g.platforms.take(2).join(' / ')); + if (g.summary != null && g.summary!.isNotEmpty) { + parts.add(g.summary!.length > 100 ? '${g.summary!.substring(0, 100)}...' : g.summary!); + } + return parts.join('\n'); + } + String _timeAgoText(DateTime date) { final diff = DateTime.now().difference(date); if (diff.inDays >= 365) return '${(diff.inDays / 365).floor()}年前'; @@ -172,6 +257,7 @@ class _StrollPageState extends State { case 'movie': return '看过'; case 'book': return '读过'; case 'note': return '写下'; + case 'game': return '玩过'; default: return ''; } } @@ -184,26 +270,17 @@ class _StrollPageState extends State { Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book))); case 'note': Navigator.push(context, MaterialPageRoute(builder: (_) => NoteDetailPage(note: item.data as Note))); + case 'game': + Navigator.push(context, MaterialPageRoute(builder: (_) => GameDetailPage(game: item.data as Game))); } } - void _deleteItem(_StrollItem item) async { - final provider = context.read(); - switch (item.type) { - case 'movie': await provider.removeMovie(item.data.id); - case 'book': await provider.removeBook(item.data.id); - case 'note': await provider.removeNote(item.data.id); - } - setState(() => _items.remove(item)); - if (mounted) ToastUtil.show(context, '已删除'); - } - // ─── 界面 ─── @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final hasContent = _items.isNotEmpty; + final hasContent = _items.isNotEmpty && _current < _items.length; return Scaffold( backgroundColor: colors.surface, @@ -213,36 +290,39 @@ class _StrollPageState extends State { _buildTopBar(colors), // 类型筛选 _buildFilterBar(colors), - // 内容 + // 内容 + 随机按钮 Expanded( child: !hasContent ? _buildEmptyState(colors) - : RefreshIndicator( - onRefresh: () async => _reshuffle(), - color: colors.primary, - child: PageView.builder( - controller: _pageController, - onPageChanged: (index) { - if (index >= _items.length - 2) { - setState(() => _loadBatch(3)); - } - }, - itemCount: _items.length, - itemBuilder: (context, index) { - return AnimatedBuilder( - animation: _pageController, - builder: (context, child) { - double scale = 1.0; - if (_pageController.hasClients && _pageController.page != null) { - final diff = (_pageController.page! - index).abs(); - scale = (1 - diff * 0.08).clamp(0.88, 1.0); - } - return Transform.scale(scale: scale, child: child); - }, - child: _buildCard(_items[index], colors), - ); - }, - ), + : Column( + children: [ + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 310), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, anim) { + return FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.04), + end: Offset.zero, + ).animate(anim), + child: child, + ), + ); + }, + child: _buildCard(_items[_current], colors, key: ValueKey(_current)), + ), + ), + ), + ), + _buildNextButton(colors), + ], ), ), ], @@ -264,83 +344,148 @@ class _StrollPageState extends State { const Spacer(), Text('漫步', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), const Spacer(), - GestureDetector( - onTap: _reshuffle, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(16)), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.casino_outlined, size: 14, color: colors.onPrimary), - const SizedBox(width: 4), - Text('随机', style: TextStyle(fontSize: 12, color: colors.onPrimary, fontWeight: FontWeight.w500)), - ]), - ), - ), + // 占位,保持标题居中 + const SizedBox(width: 48), ], ), ), ); } + /// 卡片下方的"随机"按钮 — 点击切换下一张 + Widget _buildNextButton(ColorScheme colors) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: GestureDetector( + onTap: _next, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(24)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.casino_outlined, size: 16, color: colors.onPrimary), + const SizedBox(width: 6), + Text('随机一张', style: TextStyle(fontSize: 14, color: colors.onPrimary, fontWeight: FontWeight.w600)), + ]), + ), + ), + ); + } + Widget _buildFilterBar(ColorScheme colors) { final filters = [ ('all', '全部', Icons.apps_outlined), ('movie', '影视', Icons.movie_outlined), ('book', '书籍', Icons.menu_book_outlined), ('note', '笔记', Icons.note_outlined), + ('game', '游戏', Icons.sports_esports_outlined), ]; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: filters.map((f) { - final selected = _filter == f.$1; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: GestureDetector( - onTap: () { - if (_filter != f.$1) { - setState(() { - _filter = f.$1; - _items.clear(); - _seenIds.clear(); - _loadBatch(5); - }); - } - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), - decoration: BoxDecoration( - color: selected ? colors.primary : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), + padding: const EdgeInsets.symmetric(vertical: 8), + child: Stack( + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: filters.map((f) { + final selected = _filter == f.$1; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: GestureDetector( + onTap: () { + if (_filter != f.$1) { + setState(() { + _filter = f.$1; + _items.clear(); + _seenIds.clear(); + _current = 0; + _loadBatch(5); + }); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: selected ? colors.primary : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(f.$3, size: 14, color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5)), + const SizedBox(width: 4), + Text(f.$2, style: TextStyle(fontSize: 12, fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), + ]), + ), + ), + ); + }).toList(), + ), + ), + // 左侧淡出遮罩 + Positioned( + left: 0, top: 0, bottom: 0, width: 16, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [colors.surface, colors.surface.withValues(alpha: 0)], + ), ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(f.$3, size: 14, color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5)), - const SizedBox(width: 4), - Text(f.$2, style: TextStyle(fontSize: 12, fontWeight: selected ? FontWeight.w600 : FontWeight.normal, - color: selected ? colors.onPrimary : colors.onSurface.withValues(alpha: 0.5))), - ]), ), ), - ); - }).toList(), + ), + // 右侧淡出遮罩 + Positioned( + right: 0, top: 0, bottom: 0, width: 16, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerRight, + end: Alignment.centerLeft, + colors: [colors.surface, colors.surface.withValues(alpha: 0)], + ), + ), + ), + ), + ), + ], ), ); } - Widget _buildCard(_StrollItem item, ColorScheme colors) { + Widget _buildCard(_StrollItem item, ColorScheme colors, {Key? key}) { final hasImage = item.imagePath != null && item.imagePath!.isNotEmpty; + // 拖动时透明度:位移越大越淡(最淡 0.3) + final opacity = _isDragging && _horizontalLocked + ? (1.0 - (_dragX.abs() / 300).clamp(0.0, 0.7)) + : 1.0; return GestureDetector( + key: key, + behavior: HitTestBehavior.opaque, onTap: () => _openDetail(item), onDoubleTap: () => ToastUtil.show(context, '已收藏'), - child: hasImage ? _buildImmersiveCard(item, colors) : _buildContentCard(item, colors), + onHorizontalDragStart: _onDragStart, + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: opacity, + child: Transform.translate( + offset: Offset(_horizontalLocked ? _dragX : 0, 0), + child: hasImage ? _buildImmersiveCard(item, colors) : _buildContentCard(item, colors), + ), + ), ); } /// 有图片的卡片:全屏沉浸式 Widget _buildImmersiveCard(_StrollItem item, ColorScheme colors) { return Container( - margin: const EdgeInsets.symmetric(vertical: 80, horizontal: 8), + margin: const EdgeInsets.symmetric(vertical: 80), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 8))], @@ -382,7 +527,7 @@ class _StrollPageState extends State { /// 无图片的卡片:内容从顶部开始 Widget _buildContentCard(_StrollItem item, ColorScheme colors) { return Container( - margin: const EdgeInsets.symmetric(vertical: 80, horizontal: 8), + margin: const EdgeInsets.symmetric(vertical: 80), decoration: BoxDecoration( color: colors.surface, borderRadius: BorderRadius.circular(20), @@ -451,8 +596,6 @@ class _StrollPageState extends State { style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))), const Spacer(), _actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item), colors: colors), - const SizedBox(width: 8), - _actionBtn(Icons.delete_outline, '删除', () => _showDeleteConfirm(item), colors: colors), ]), ], ), @@ -533,8 +676,6 @@ class _StrollPageState extends State { style: TextStyle(fontSize: 12, color: textColor.withValues(alpha: 0.4))), const Spacer(), _actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item)), - const SizedBox(width: 12), - _actionBtn(Icons.delete_outline, '删除', () => _showDeleteConfirm(item)), ]), ], ); @@ -562,30 +703,6 @@ class _StrollPageState extends State { ); } - void _showDeleteConfirm(_StrollItem item) { - final colors = Theme.of(context).colorScheme; - showDialog( - context: context, - builder: (ctx) => AlertDialog( - backgroundColor: colors.surface, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: Text('确认删除', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), - content: Text('确定要删除"${item.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: () { Navigator.pop(ctx); _deleteItem(item); }, - 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 _buildEmptyState(ColorScheme colors) { return Center( child: Column(