generated from dellevin/template
v0.2.7
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
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/responsive.dart';
|
||||
import '../../utils/toast_util.dart';
|
||||
import 'epub_detail_page.dart';
|
||||
import 'widgets/book_grid_item.dart';
|
||||
|
||||
/// EPUB 书架页面
|
||||
class EpubLibraryPage extends StatefulWidget {
|
||||
@@ -25,9 +24,6 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
||||
bool _isLoading = true;
|
||||
bool _isSearching = false;
|
||||
final TextEditingController _searchCtrl = TextEditingController();
|
||||
ViewMode _viewMode = UserPrefs().epubViewMode == 1
|
||||
? ViewMode.compact
|
||||
: ViewMode.relaxed;
|
||||
int _sortMode = UserPrefs().epubSortMode;
|
||||
|
||||
@override
|
||||
@@ -156,15 +152,9 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
||||
MaterialPageRoute(
|
||||
builder: (_) => EpubDetailPage(bookId: book['id'], book: book),
|
||||
),
|
||||
).then((_) => _loadBooks());
|
||||
}
|
||||
|
||||
void _toggleViewMode() {
|
||||
setState(() {
|
||||
_viewMode =
|
||||
_viewMode == ViewMode.relaxed ? ViewMode.compact : ViewMode.relaxed;
|
||||
).then((_) {
|
||||
if (mounted) _loadBooks();
|
||||
});
|
||||
UserPrefs().setEpubViewMode(_viewMode == ViewMode.compact ? 1 : 0);
|
||||
}
|
||||
|
||||
void _showSortMenu() {
|
||||
@@ -215,6 +205,16 @@ 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
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
@@ -280,11 +280,203 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
||||
? _buildEmpty(colors)
|
||||
: _filteredBooks.isEmpty
|
||||
? Center(child: Text('无搜索结果', style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.35))))
|
||||
: _buildGrid(colors)),
|
||||
: RefreshIndicator(
|
||||
color: colors.primary,
|
||||
onRefresh: _loadBooks,
|
||||
child: _buildContent(colors),
|
||||
)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// 主体内容:继续阅读横幅 + 书架列表
|
||||
Widget _buildContent(ColorScheme colors) {
|
||||
final lastBook = _lastReadingBook;
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// 继续阅读横幅
|
||||
if (lastBook != null && !_isSearching)
|
||||
SliverToBoxAdapter(child: _buildContinueReading(colors, lastBook)),
|
||||
// 书架列表
|
||||
_buildSliverListView(colors),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 继续阅读横幅卡片 — 封面背景 + 毛玻璃
|
||||
Widget _buildContinueReading(ColorScheme colors, Map<String, dynamic> 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();
|
||||
|
||||
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<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) {
|
||||
return [
|
||||
if (!_isSearching)
|
||||
@@ -292,16 +484,6 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
||||
icon: Icon(Icons.search, size: 20, color: colors.onSurface.withValues(alpha: 0.6)),
|
||||
onPressed: _toggleSearch,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_viewMode == ViewMode.relaxed
|
||||
? Icons.view_compact_outlined
|
||||
: Icons.view_agenda_outlined,
|
||||
size: 20,
|
||||
color: colors.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
onPressed: _toggleViewMode,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.sort, size: 20, color: colors.onSurface.withValues(alpha: 0.6)),
|
||||
onPressed: _showSortMenu,
|
||||
@@ -355,104 +537,90 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGrid(ColorScheme colors) {
|
||||
final bool showList = _viewMode == ViewMode.compact;
|
||||
// ─── Sliver 书架 ────────────────────────────────────────────────────
|
||||
|
||||
if (showList) {
|
||||
return _buildListView(colors);
|
||||
}
|
||||
return _buildGridView(colors);
|
||||
}
|
||||
|
||||
Widget _buildGridView(ColorScheme colors) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final count = responsiveCrossAxisCount(constraints.maxWidth, minItemWidth: 120);
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 100),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: count,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 0.55,
|
||||
),
|
||||
itemCount: _filteredBooks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final book = _filteredBooks[index];
|
||||
return BookGridItem(
|
||||
book: book,
|
||||
viewMode: ViewMode.relaxed,
|
||||
onTap: () => _openBook(book),
|
||||
onLongPress: () => _deleteBook(book),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
Widget _buildSliverListView(ColorScheme colors) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 100),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildListItem(colors, _filteredBooks[index]),
|
||||
childCount: _filteredBooks.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildListView(ColorScheme colors) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 100),
|
||||
itemCount: _filteredBooks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final book = _filteredBooks[index];
|
||||
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;
|
||||
Widget _buildListItem(ColorScheme colors, Map<String, dynamic> 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 updatedAt = book['updated_at'] as String?;
|
||||
|
||||
// 阅读状态推断
|
||||
final String statusLabel;
|
||||
final Color statusColor;
|
||||
if (progress >= 1.0) {
|
||||
statusLabel = '已读';
|
||||
statusColor = const Color(0xFF16A34A);
|
||||
} else if (progress > 0.0) {
|
||||
statusLabel = '在读';
|
||||
statusColor = colors.primary;
|
||||
} else {
|
||||
statusLabel = '未读';
|
||||
statusColor = const Color(0xFFDC2626);
|
||||
}
|
||||
// 阅读状态推断
|
||||
final String statusLabel;
|
||||
final Color statusColor;
|
||||
if (progress >= 1.0) {
|
||||
statusLabel = '已读';
|
||||
statusColor = const Color(0xFF16A34A);
|
||||
} else if (progress > 0.0) {
|
||||
statusLabel = '在读';
|
||||
statusColor = colors.primary;
|
||||
} else {
|
||||
statusLabel = '未读';
|
||||
statusColor = const Color(0xFFDC2626);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _openBook(book),
|
||||
onLongPress: () => _deleteBook(book),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
// 最后阅读时间
|
||||
String? lastReadText;
|
||||
if (updatedAt != null && updatedAt.isNotEmpty) {
|
||||
try {
|
||||
final dt = DateTime.parse(updatedAt);
|
||||
lastReadText = _formatRelativeDate(dt);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: () => _openBook(book),
|
||||
onLongPress: () => _deleteBook(book),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 封面
|
||||
Container(
|
||||
width: 48, height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _buildCover(coverPath, colors),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 封面
|
||||
Container(
|
||||
width: 48, height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _buildCover(coverPath, colors),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
const SizedBox(width: 12),
|
||||
// 信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, 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.35))),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
// 状态标签 + 最后阅读时间
|
||||
Row(
|
||||
children: [
|
||||
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, 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.35))),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
// 状态标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
@@ -462,16 +630,51 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
||||
child: Text(statusLabel,
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: statusColor)),
|
||||
),
|
||||
if (lastReadText != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(lastReadText,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colors.onSurface.withValues(alpha: 0.3),
|
||||
)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.2), size: 20),
|
||||
],
|
||||
// 进度条
|
||||
if (progress > 0 && progress < 1.0) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 3,
|
||||
backgroundColor: colors.outlineVariant,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text('${(progress * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colors.primary,
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.chevron_right, color: colors.onSurface.withValues(alpha: 0.2), size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -479,8 +682,17 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
||||
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),
|
||||
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(
|
||||
@@ -493,4 +705,23 @@ class _EpubLibraryPageState extends State<EpubLibraryPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 相对时间格式化
|
||||
String _formatRelativeDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
if (diff.isNegative) return '${date.month}月${date.day}日';
|
||||
if (diff.inDays == 0) {
|
||||
if (diff.inHours == 0) {
|
||||
if (diff.inMinutes == 0) return '刚刚';
|
||||
return '${diff.inMinutes}分钟前';
|
||||
}
|
||||
return '${diff.inHours}小时前';
|
||||
} else if (diff.inDays < 7) {
|
||||
return '${diff.inDays}天前';
|
||||
} else if (diff.inDays < 30) {
|
||||
return '${(diff.inDays / 7).floor()}周前';
|
||||
} else {
|
||||
return '${date.month}月${date.day}日';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,15 @@ class BookGridItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: switch (viewMode) {
|
||||
ViewMode.relaxed => _buildRelaxed(context),
|
||||
ViewMode.compact => _buildCompact(context),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +43,7 @@ class BookGridItem extends StatelessWidget {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final title = book['title'] as String? ?? '';
|
||||
final author = book['author'] as String? ?? '';
|
||||
final progress = _readingProgress;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -59,8 +62,16 @@ class BookGridItem extends StatelessWidget {
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _buildCoverStack(context, fit: StackFit.expand, extras: [
|
||||
// 阅读状态角标
|
||||
_buildStatusBadge(context),
|
||||
// 阅读状态圆点
|
||||
_buildStatusDot(context),
|
||||
// 底部进度条
|
||||
if (progress > 0 && progress < 1.0)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _buildProgressBar(context, progress, height: 3),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
@@ -95,13 +106,22 @@ class BookGridItem extends StatelessWidget {
|
||||
Widget _buildCompact(BuildContext context) {
|
||||
final title = book['title'] as String? ?? '';
|
||||
final author = book['author'] as String? ?? '';
|
||||
final progress = _readingProgress;
|
||||
|
||||
return _buildCoverStack(
|
||||
context,
|
||||
fit: StackFit.expand,
|
||||
extras: [
|
||||
// 阅读状态角标
|
||||
_buildStatusBadge(context),
|
||||
// 阅读状态圆点
|
||||
_buildStatusDot(context),
|
||||
// 底部进度条
|
||||
if (progress > 0 && progress < 1.0)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _buildProgressBar(context, progress, height: 3),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
@@ -193,23 +213,30 @@ class BookGridItem extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final title = book['title'] as String? ?? '';
|
||||
final initial = title.isNotEmpty ? title.substring(0, 1) : '';
|
||||
// 根据书名首字生成渐变色
|
||||
final gradientColors = _generateGradientColors(initial);
|
||||
return Container(
|
||||
color: colors.surfaceContainerHighest,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: gradientColors,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: initial.isNotEmpty
|
||||
? Text(initial,
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface.withValues(alpha: 0.2),
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
))
|
||||
: Icon(
|
||||
Icons.auto_stories_outlined,
|
||||
size: 36,
|
||||
color: colors.onSurface.withValues(alpha: 0.2),
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -217,44 +244,76 @@ class BookGridItem extends StatelessWidget {
|
||||
|
||||
// ─── badge helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/// 阅读状态角标(左上角,含百分比)
|
||||
Widget _buildStatusBadge(BuildContext context) {
|
||||
/// 阅读状态圆点(左上角,三色)
|
||||
Widget _buildStatusDot(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final progress = _readingProgress;
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color dotColor;
|
||||
if (progress >= 1.0) {
|
||||
label = '已读';
|
||||
color = const Color(0xFF16A34A);
|
||||
dotColor = const Color(0xFF16A34A);
|
||||
} else if (progress > 0.0) {
|
||||
label = '在读 ${(progress * 100).toInt()}%';
|
||||
color = colors.primary;
|
||||
dotColor = colors.primary;
|
||||
} else {
|
||||
label = '未读';
|
||||
color = const Color(0xFFDC2626);
|
||||
dotColor = const Color(0xFFDC2626);
|
||||
}
|
||||
|
||||
return Positioned(
|
||||
top: 6,
|
||||
left: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: dotColor,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: dotColor.withValues(alpha: 0.4),
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 底部进度条
|
||||
Widget _buildProgressBar(BuildContext context, double progress, {double height = 3}) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: height,
|
||||
backgroundColor: Colors.black26,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colors.primary),
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
double get _readingProgress =>
|
||||
(book['reading_percentage'] as num?)?.toDouble() ?? 0.0;
|
||||
|
||||
/// 根据首字符生成渐变色
|
||||
List<Color> _generateGradientColors(String initial) {
|
||||
if (initial.isEmpty) return [const Color(0xFF6B7280), const Color(0xFF9CA3AF)];
|
||||
final code = initial.codeUnitAt(0);
|
||||
final palettes = [
|
||||
[const Color(0xFF6366F1), const Color(0xFF8B5CF6)], // 靛蓝-紫
|
||||
[const Color(0xFF3B82F6), const Color(0xFF06B6D4)], // 蓝-青
|
||||
[const Color(0xFF10B981), const Color(0xFF34D399)], // 绿
|
||||
[const Color(0xFFF59E0B), const Color(0xFFF97316)], // 琥珀-橙
|
||||
[const Color(0xFFEF4444), const Color(0xFFF472B6)], // 红-粉
|
||||
[const Color(0xFF8B5CF6), const Color(0xFFEC4899)], // 紫-粉
|
||||
[const Color(0xFF14B8A6), const Color(0xFF3B82F6)], // 青-蓝
|
||||
[const Color(0xFFF97316), const Color(0xFFEF4444)], // 橙-红
|
||||
];
|
||||
return palettes[code % palettes.length];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user