优化漫步界面

This commit is contained in:
DelLevin-Home
2026-08-09 14:29:17 +08:00
parent 26ec55dc9b
commit ca95a27b2b
4 changed files with 372 additions and 376 deletions

View File

@@ -1,12 +1,15 @@
import 'dart:io'; import 'dart:io' show Platform;
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import '../../data/epub/reader_dao.dart'; import '../../data/epub/reader_dao.dart';
import '../../services/epub/epub_service.dart'; import '../../services/epub/epub_service.dart';
import '../../utils/user_prefs.dart'; import '../../utils/user_prefs.dart';
import '../../utils/toast_util.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 'epub_detail_page.dart';
import 'widgets/book_grid_item.dart';
/// EPUB 书架页面 /// EPUB 书架页面
class EpubLibraryPage extends StatefulWidget { class EpubLibraryPage extends StatefulWidget {
@@ -25,6 +28,7 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
bool _isSearching = false; bool _isSearching = false;
final TextEditingController _searchCtrl = TextEditingController(); final TextEditingController _searchCtrl = TextEditingController();
int _sortMode = UserPrefs().epubSortMode; int _sortMode = UserPrefs().epubSortMode;
int _viewMode = UserPrefs().epubViewMode; // 0=列表 1=网格
@override @override
void initState() { void initState() {
@@ -205,16 +209,6 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
); );
} }
/// 找到最近在读的书(进度 > 0 且 < 1按更新时间排序取第一本
Map<String, dynamic>? 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 @override
void dispose() { void dispose() {
_searchCtrl.dispose(); _searchCtrl.dispose();
@@ -274,8 +268,8 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
]), ]),
), ),
// 主体 // 主体
Expanded(child: _isLoading Expanded(child: _isLoading && _books.isEmpty
? Center(child: CircularProgressIndicator(color: colors.primary)) ? const BookSkeletonGrid()
: _books.isEmpty : _books.isEmpty
? _buildEmpty(colors) ? _buildEmpty(colors)
: _filteredBooks.isEmpty : _filteredBooks.isEmpty
@@ -289,196 +283,44 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
); );
} }
/// 主体内容:继续阅读横幅 + 书架列表 /// 主体内容:书架列表/网格)
Widget _buildContent(ColorScheme colors) { Widget _buildContent(ColorScheme colors) {
final lastBook = _lastReadingBook;
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
// 继续阅读横幅 // 书架分隔标题
if (lastBook != null && !_isSearching) SliverToBoxAdapter(child: _buildSectionHeader(colors)),
SliverToBoxAdapter(child: _buildContinueReading(colors, lastBook)), // 书架列表/网格
// 书架列表 _viewMode == 0 ? _buildSliverListView(colors) : _buildSliverGrid(colors),
_buildSliverListView(colors),
], ],
); );
} }
/// 继续阅读横幅卡片 — 封面背景 + 毛玻璃 /// 书架分隔标题(带计数)
Widget _buildContinueReading(ColorScheme colors, Map<String, dynamic> book) { Widget _buildSectionHeader(ColorScheme colors) {
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();
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Column( child: Text(
crossAxisAlignment: CrossAxisAlignment.start, '书架 (${_filteredBooks.length})',
children: [ style: TextStyle(
GestureDetector( fontSize: 13,
onTap: () => _openBook(book), fontWeight: FontWeight.w600,
child: ClipRRect( color: colors.onSurface.withValues(alpha: 0.4),
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<Color>(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,
),
),
],
),
),
],
), ),
); );
} }
List<Widget> _buildActions(ColorScheme colors) { List<Widget> _buildActions(ColorScheme colors) {
return [ 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) if (!_isSearching)
IconButton( IconButton(
icon: Icon(Icons.search, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), icon: Icon(Icons.search, size: 20, color: colors.onSurface.withValues(alpha: 0.6)),
@@ -551,6 +393,35 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
); );
} }
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<String, dynamic> book) { Widget _buildListItem(ColorScheme colors, Map<String, dynamic> book) {
final title = book['title'] as String? ?? ''; final title = book['title'] as String? ?? '';
final author = book['author'] as String? ?? ''; final author = book['author'] as String? ?? '';
@@ -679,23 +550,7 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
} }
Widget _buildCover(String? path, ColorScheme colors) { Widget _buildCover(String? path, ColorScheme colors) {
if (path != null && path.isNotEmpty && File(path).existsSync()) { final placeholder = Container(
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(
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.outlineVariant, color: colors.outlineVariant,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
@@ -703,6 +558,17 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
child: Icon(Icons.auto_stories_outlined, size: 22, child: Icon(Icons.auto_stories_outlined, size: 22,
color: colors.onSurface.withValues(alpha: 0.25)), 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,
),
);
} }
/// 相对时间格式化 /// 相对时间格式化

View File

@@ -1,5 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../widgets/fade_in_local_image.dart';
/// Display mode for the book grid item. /// Display mode for the book grid item.
enum ViewMode { relaxed, compact } enum ViewMode { relaxed, compact }
@@ -188,8 +188,7 @@ class BookGridItem extends StatelessWidget {
StackFit fit = StackFit.loose, StackFit fit = StackFit.loose,
}) { }) {
final coverPath = book['cover_path'] as String?; final coverPath = book['cover_path'] as String?;
final hasCover = final placeholder = _buildPlaceholder(context);
coverPath != null && coverPath.isNotEmpty && File(coverPath).existsSync();
return Stack( return Stack(
fit: fit, fit: fit,
@@ -197,15 +196,14 @@ class BookGridItem extends StatelessWidget {
Container( Container(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)), decoration: BoxDecoration(borderRadius: BorderRadius.circular(8)),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: hasCover child: FadeInLocalImage(
? Image.file( path: coverPath,
File(coverPath), fit: BoxFit.cover,
fit: BoxFit.cover, width: double.infinity,
width: double.infinity, height: double.infinity,
height: double.infinity, placeholder: placeholder,
errorBuilder: (_, __, ___) => _buildPlaceholder(context), errorWidget: placeholder,
) ),
: _buildPlaceholder(context),
), ),
...extras, ...extras,
], ],

View File

@@ -325,24 +325,30 @@ class _ReviewedPageState extends State<ReviewedPage> {
for (final group in genreGroups) for (final group in genreGroups)
Container( Container(
height: 30, height: 30,
margin: EdgeInsets.fromLTRB(0, genreGroups.indexOf(group) == 0 ? 8 : 4, 0, 0), margin: EdgeInsets.fromLTRB(16, genreGroups.indexOf(group) == 0 ? 8 : 4, 0, 0),
child: _FadeEdgeScrollView( child: Row(
colors: colors, children: [
child: ListView( _buildTypeTag(group.type, colors),
scrollDirection: Axis.horizontal, const SizedBox(width: 5),
padding: const EdgeInsets.symmetric(horizontal: 16), Expanded(
children: [ child: _FadeEdgeScrollView(
_buildTypeTag(group.type, colors), colors: colors,
const SizedBox(width: 5), child: ListView(
for (final genre in group.genres) ...[ scrollDirection: Axis.horizontal,
_buildFilterChip(genre, genre, colors, padding: const EdgeInsets.only(right: 16),
color: group.color, children: [
isSelected: _selectedGenre == genre, for (final genre in group.genres) ...[
onTap: () => setState(() => _selectedGenre = _selectedGenre == genre ? null : genre)), _buildFilterChip(genre, genre, colors,
const SizedBox(width: 5), 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<ReviewedPage> {
Container( Container(
height: 30, height: 30,
margin: const EdgeInsets.only(top: 4), margin: const EdgeInsets.only(top: 4),
child: _FadeEdgeScrollView( child: Row(
colors: colors, children: [
child: ListView( Padding(
scrollDirection: Axis.horizontal, padding: const EdgeInsets.only(left: 16),
padding: const EdgeInsets.symmetric(horizontal: 16), child: _buildTypeTag(null, colors),
children: [ ),
_buildTypeTag(null, colors), const SizedBox(width: 5),
const SizedBox(width: 5), Expanded(
for (final year in years) ...[ child: _FadeEdgeScrollView(
_buildFilterChip(year, '$year', colors, isSelected: _selectedYear == year, onTap: () => setState(() => _selectedYear = _selectedYear == year ? null : year)), colors: colors,
const SizedBox(width: 5), 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),
],
],
),
),
),
],
), ),
), ),
], ],

View File

@@ -8,6 +8,7 @@ import '../../utils/toast_util.dart';
import '../movies/movie_detail_page.dart'; import '../movies/movie_detail_page.dart';
import '../book/book_detail_page.dart'; import '../book/book_detail_page.dart';
import '../note/note_detail_page.dart'; import '../note/note_detail_page.dart';
import '../game/game_detail_page.dart';
/// 漫步页面 - 随机发现内容 /// 漫步页面 - 随机发现内容
class StrollPage extends StatefulWidget { class StrollPage extends StatefulWidget {
@@ -21,22 +22,21 @@ class _StrollPageState extends State<StrollPage> {
final _random = Random(); final _random = Random();
final List<_StrollItem> _items = []; final List<_StrollItem> _items = [];
final Set<String> _seenIds = {}; final Set<String> _seenIds = {};
late PageController _pageController; int _current = 0;
String _filter = 'all'; // all / movie / book / note String _filter = 'all'; // all / movie / book / note
// 拖动手势状态
double _dragX = 0; // 当前水平偏移(正=右滑,负=左滑)
double _dragY = 0; // 当前垂直偏移
bool _isDragging = false;
bool _horizontalLocked = false; // 是否锁定为水平方向
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_pageController = PageController(viewportFraction: 0.78);
_loadBatch(5); _loadBatch(5);
} }
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
// ─── 数据加载 ─── // ─── 数据加载 ───
void _loadBatch(int count) { void _loadBatch(int count) {
@@ -46,6 +46,7 @@ class _StrollPageState extends State<StrollPage> {
final moviePool = <_StrollItem>[]; final moviePool = <_StrollItem>[];
final bookPool = <_StrollItem>[]; final bookPool = <_StrollItem>[];
final notePool = <_StrollItem>[]; final notePool = <_StrollItem>[];
final gamePool = <_StrollItem>[];
if (_filter == 'all' || _filter == 'movie') { if (_filter == 'all' || _filter == 'movie') {
for (final m in provider.movies.where((m) => !m.isDeleted)) { for (final m in provider.movies.where((m) => !m.isDeleted)) {
moviePool.add(_StrollItem( moviePool.add(_StrollItem(
@@ -91,12 +92,28 @@ class _StrollPageState extends State<StrollPage> {
)); ));
} }
} }
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 = <List<_StrollItem>>[]; final pools = <List<_StrollItem>>[];
if (moviePool.isNotEmpty) pools.add(moviePool); if (moviePool.isNotEmpty) pools.add(moviePool);
if (bookPool.isNotEmpty) pools.add(bookPool); if (bookPool.isNotEmpty) pools.add(bookPool);
if (notePool.isNotEmpty) pools.add(notePool); if (notePool.isNotEmpty) pools.add(notePool);
if (gamePool.isNotEmpty) pools.add(gamePool);
if (pools.isEmpty) return; if (pools.isEmpty) return;
// 全部模式下等概率选类别,单类别模式下直接选 // 全部模式下等概率选类别,单类别模式下直接选
@@ -131,14 +148,73 @@ class _StrollPageState extends State<StrollPage> {
return pool.last; return pool.last;
} }
void _reshuffle() { /// 切换到下一张(点击"随机"按钮 / 左滑)
void _next() {
setState(() { setState(() {
_items.clear(); _current++;
_seenIds.clear(); if (_current >= _items.length - 2) {
_loadBatch(5); _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) { String _movieDetail(Movie m) {
@@ -158,6 +234,15 @@ class _StrollPageState extends State<StrollPage> {
return parts.join('\n'); return parts.join('\n');
} }
String _gameDetail(Game g) {
final parts = <String>[];
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) { String _timeAgoText(DateTime date) {
final diff = DateTime.now().difference(date); final diff = DateTime.now().difference(date);
if (diff.inDays >= 365) return '${(diff.inDays / 365).floor()}年前'; if (diff.inDays >= 365) return '${(diff.inDays / 365).floor()}年前';
@@ -172,6 +257,7 @@ class _StrollPageState extends State<StrollPage> {
case 'movie': return '看过'; case 'movie': return '看过';
case 'book': return '读过'; case 'book': return '读过';
case 'note': return '写下'; case 'note': return '写下';
case 'game': return '玩过';
default: return ''; default: return '';
} }
} }
@@ -184,26 +270,17 @@ class _StrollPageState extends State<StrollPage> {
Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book))); Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: item.data as Book)));
case 'note': case 'note':
Navigator.push(context, MaterialPageRoute(builder: (_) => NoteDetailPage(note: item.data as 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<AppProvider>();
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final hasContent = _items.isNotEmpty; final hasContent = _items.isNotEmpty && _current < _items.length;
return Scaffold( return Scaffold(
backgroundColor: colors.surface, backgroundColor: colors.surface,
@@ -213,36 +290,39 @@ class _StrollPageState extends State<StrollPage> {
_buildTopBar(colors), _buildTopBar(colors),
// 类型筛选 // 类型筛选
_buildFilterBar(colors), _buildFilterBar(colors),
// 内容 // 内容 + 随机按钮
Expanded( Expanded(
child: !hasContent child: !hasContent
? _buildEmptyState(colors) ? _buildEmptyState(colors)
: RefreshIndicator( : Column(
onRefresh: () async => _reshuffle(), children: [
color: colors.primary, Expanded(
child: PageView.builder( child: Center(
controller: _pageController, child: ConstrainedBox(
onPageChanged: (index) { constraints: const BoxConstraints(maxWidth: 310),
if (index >= _items.length - 2) { child: AnimatedSwitcher(
setState(() => _loadBatch(3)); duration: const Duration(milliseconds: 280),
} switchInCurve: Curves.easeOut,
}, switchOutCurve: Curves.easeIn,
itemCount: _items.length, transitionBuilder: (child, anim) {
itemBuilder: (context, index) { return FadeTransition(
return AnimatedBuilder( opacity: anim,
animation: _pageController, child: SlideTransition(
builder: (context, child) { position: Tween<Offset>(
double scale = 1.0; begin: const Offset(0, 0.04),
if (_pageController.hasClients && _pageController.page != null) { end: Offset.zero,
final diff = (_pageController.page! - index).abs(); ).animate(anim),
scale = (1 - diff * 0.08).clamp(0.88, 1.0); child: child,
} ),
return Transform.scale(scale: scale, child: child); );
}, },
child: _buildCard(_items[index], colors), child: _buildCard(_items[_current], colors, key: ValueKey(_current)),
); ),
}, ),
), ),
),
_buildNextButton(colors),
],
), ),
), ),
], ],
@@ -264,83 +344,148 @@ class _StrollPageState extends State<StrollPage> {
const Spacer(), const Spacer(),
Text('漫步', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)), Text('漫步', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
const Spacer(), const Spacer(),
GestureDetector( // 占位,保持标题居中
onTap: _reshuffle, const SizedBox(width: 48),
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)),
]),
),
),
], ],
), ),
), ),
); );
} }
/// 卡片下方的"随机"按钮 — 点击切换下一张
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) { Widget _buildFilterBar(ColorScheme colors) {
final filters = [ final filters = [
('all', '全部', Icons.apps_outlined), ('all', '全部', Icons.apps_outlined),
('movie', '影视', Icons.movie_outlined), ('movie', '影视', Icons.movie_outlined),
('book', '书籍', Icons.menu_book_outlined), ('book', '书籍', Icons.menu_book_outlined),
('note', '笔记', Icons.note_outlined), ('note', '笔记', Icons.note_outlined),
('game', '游戏', Icons.sports_esports_outlined),
]; ];
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Row( child: Stack(
children: filters.map((f) { children: [
final selected = _filter == f.$1; SingleChildScrollView(
return Padding( scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.symmetric(horizontal: 16),
child: GestureDetector( child: Row(
onTap: () { children: filters.map((f) {
if (_filter != f.$1) { final selected = _filter == f.$1;
setState(() { return Padding(
_filter = f.$1; padding: const EdgeInsets.only(right: 8),
_items.clear(); child: GestureDetector(
_seenIds.clear(); onTap: () {
_loadBatch(5); if (_filter != f.$1) {
}); setState(() {
} _filter = f.$1;
}, _items.clear();
child: Container( _seenIds.clear();
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), _current = 0;
decoration: BoxDecoration( _loadBatch(5);
color: selected ? colors.primary : colors.surfaceContainerHighest, });
borderRadius: BorderRadius.circular(20), }
},
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; 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( return GestureDetector(
key: key,
behavior: HitTestBehavior.opaque,
onTap: () => _openDetail(item), onTap: () => _openDetail(item),
onDoubleTap: () => ToastUtil.show(context, '已收藏'), 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) { Widget _buildImmersiveCard(_StrollItem item, ColorScheme colors) {
return Container( return Container(
margin: const EdgeInsets.symmetric(vertical: 80, horizontal: 8), margin: const EdgeInsets.symmetric(vertical: 80),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 8))], boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 8))],
@@ -382,7 +527,7 @@ class _StrollPageState extends State<StrollPage> {
/// 无图片的卡片:内容从顶部开始 /// 无图片的卡片:内容从顶部开始
Widget _buildContentCard(_StrollItem item, ColorScheme colors) { Widget _buildContentCard(_StrollItem item, ColorScheme colors) {
return Container( return Container(
margin: const EdgeInsets.symmetric(vertical: 80, horizontal: 8), margin: const EdgeInsets.symmetric(vertical: 80),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colors.surface, color: colors.surface,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -451,8 +596,6 @@ class _StrollPageState extends State<StrollPage> {
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))), style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.3))),
const Spacer(), const Spacer(),
_actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item), colors: colors), _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<StrollPage> {
style: TextStyle(fontSize: 12, color: textColor.withValues(alpha: 0.4))), style: TextStyle(fontSize: 12, color: textColor.withValues(alpha: 0.4))),
const Spacer(), const Spacer(),
_actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item)), _actionBtn(Icons.visibility_outlined, '查看', () => _openDetail(item)),
const SizedBox(width: 12),
_actionBtn(Icons.delete_outline, '删除', () => _showDeleteConfirm(item)),
]), ]),
], ],
); );
@@ -562,30 +703,6 @@ class _StrollPageState extends State<StrollPage> {
); );
} }
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) { Widget _buildEmptyState(ColorScheme colors) {
return Center( return Center(
child: Column( child: Column(