generated from dellevin/template
优化漫步界面
This commit is contained in:
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相对时间格式化
|
/// 相对时间格式化
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user