generated from dellevin/template
windows版本初步完成
This commit is contained in:
@@ -8,6 +8,7 @@ import '../../providers/app_provider.dart';
|
||||
import '../../models/data_models.dart';
|
||||
import '../../utils/toast_util.dart';
|
||||
import '../../utils/user_prefs.dart';
|
||||
import '../../utils/responsive.dart';
|
||||
import 'book_reviews_page.dart';
|
||||
import 'book_excerpts_page.dart';
|
||||
import 'book_share_page.dart';
|
||||
@@ -59,12 +60,273 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
.where((b) => b.id == widget.book.id)
|
||||
.firstOrNull ?? widget.book;
|
||||
|
||||
if (Breakpoint.isDesktop(context)) {
|
||||
return _buildDesktopStyle(book, colors);
|
||||
}
|
||||
if (_detailStyle == 1) {
|
||||
return _buildOverlayStyle(book, colors);
|
||||
}
|
||||
return _buildStandardStyle(book, colors);
|
||||
}
|
||||
|
||||
/// 桌面端左右分栏布局
|
||||
Widget _buildDesktopStyle(Book book, ColorScheme colors) {
|
||||
final hasCover = book.coverPath != null && book.coverPath!.isNotEmpty;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
body: Column(
|
||||
children: [
|
||||
// 顶栏
|
||||
Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)),
|
||||
),
|
||||
child: Row(children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: colors.onSurface, size: 18),
|
||||
onPressed: widget.embedded
|
||||
? () => context.read<AppProvider>().selectBook(null)
|
||||
: () => Navigator.pop(context),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(book.title,
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
]),
|
||||
),
|
||||
// 主体:左封面 + 右信息
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 左侧封面
|
||||
Container(
|
||||
width: 240,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 200,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: hasCover
|
||||
? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 12, offset: const Offset(0, 4))]
|
||||
: null,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: hasCover
|
||||
? FadeInLocalImage(path: book.coverPath, fit: BoxFit.cover)
|
||||
: Center(child: Icon(Icons.menu_book, size: 48, color: colors.onSurface.withValues(alpha: 0.25))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 右侧信息(可滚动)
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(0, 20, 24, 80),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(book.title,
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.3)),
|
||||
if (book.alternateTitles.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(book.alternateTitles.join(' / '),
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4), height: 1.4)),
|
||||
],
|
||||
_buildEpubProgressBar(book),
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
if (book.rating != null) ...[
|
||||
Icon(Icons.star, size: 20, color: colors.onSurface),
|
||||
const SizedBox(width: 4),
|
||||
Text(book.rating!.toStringAsFixed(1),
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
_buildStatusTag(book),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Text('添加于 ${_formatDate(book.createdAt)}',
|
||||
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
||||
// 详细信息
|
||||
_buildDesktopInfoRow('作者', book.authors.join(','), colors),
|
||||
if (book.translators.isNotEmpty)
|
||||
_buildDesktopInfoRow('译者', book.translators.join(','), colors),
|
||||
if (book.genres.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SizedBox(width: 56, child: Text('类型', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
|
||||
Expanded(child: Wrap(spacing: 8, runSpacing: 8,
|
||||
children: book.genres.map((g) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(16)),
|
||||
child: Text(g, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
)).toList(),
|
||||
)),
|
||||
]),
|
||||
],
|
||||
if (book.isbn != null && book.isbn!.isNotEmpty)
|
||||
_buildDesktopInfoRow('ISBN', book.isbn!, colors),
|
||||
if (book.publisher != null && book.publisher!.isNotEmpty)
|
||||
_buildDesktopInfoRow('出版社', book.publisher!, colors),
|
||||
if (book.publishDate != null)
|
||||
_buildDesktopInfoRow('出版时间', '${book.publishDate!.year}年${book.publishDate!.month.toString().padLeft(2, '0')}月', colors),
|
||||
if (book.startDate != null || book.finishDate != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SizedBox(width: 56, child: Text('阅读日期', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
|
||||
Expanded(child: Wrap(spacing: 12, runSpacing: 8, children: [
|
||||
if (book.startDate != null) _buildDateChip('开始', book.startDate!, false),
|
||||
if (book.finishDate != null) _buildDateChip('读完', book.finishDate!, false),
|
||||
])),
|
||||
]),
|
||||
],
|
||||
if (book.summary != null && book.summary!.isNotEmpty) ...[
|
||||
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
||||
Row(children: [
|
||||
Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 8),
|
||||
Text('简介', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Text(book.summary!, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.8)),
|
||||
],
|
||||
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
||||
Row(children: [
|
||||
Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 8),
|
||||
Text('更多', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.rate_review_outlined,
|
||||
title: '书评',
|
||||
subtitleFuture: context.read<AppProvider>().getBookReviewCount(book.id),
|
||||
emptyText: '暂无书评',
|
||||
unit: '条书评',
|
||||
onTap: () => _navigateToReviews(book),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.format_quote_outlined,
|
||||
title: '摘抄',
|
||||
subtitleFuture: context.read<AppProvider>().getBookExcerptCount(book.id),
|
||||
emptyText: '暂无摘抄',
|
||||
unit: '条摘抄',
|
||||
onTap: () => _navigateToExcerpts(book),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.highlight_outlined,
|
||||
title: '句读',
|
||||
subtitleFuture: _getEpubHighlightCount(book.id),
|
||||
emptyText: '暂无句读',
|
||||
unit: '条句读',
|
||||
onTap: () => _navigateToEpubHighlights(book),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 底部操作栏
|
||||
Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
_buildEpubReadButtonBar(book, colors),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _showDeleteDialog(context),
|
||||
icon: Icon(Icons.delete_outline, size: 16, color: colors.error),
|
||||
label: Text('删除', style: TextStyle(color: colors.error)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: colors.error.withValues(alpha: 0.3)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _navigateToEdit(context),
|
||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||
label: const Text('编辑'),
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDesktopInfoRow(String label, String value, ColorScheme colors) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 56, child: Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
|
||||
Expanded(child: Text(value, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.5))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// EPUB 阅读按钮(底部栏样式)
|
||||
Widget _buildEpubReadButtonBar(Book book, ColorScheme colors) {
|
||||
return FutureBuilder<Map<String, dynamic>?>(
|
||||
future: ReaderDao().getReaderBookByBookId(book.id),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final readerBook = snapshot.data!;
|
||||
return Row(children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () {
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (_) => ReaderScreen(
|
||||
bookId: readerBook['id'] as String,
|
||||
filePath: readerBook['file_path'] as String,
|
||||
title: readerBook['title'] as String? ?? '',
|
||||
coverPath: readerBook['cover_path'] as String?,
|
||||
bookData: readerBook,
|
||||
),
|
||||
));
|
||||
},
|
||||
icon: const Icon(Icons.auto_stories_outlined, size: 16),
|
||||
label: const Text('EPUB 阅读'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF6750A4).withValues(alpha: 0.15),
|
||||
foregroundColor: const Color(0xFF6750A4),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 标准样式
|
||||
Widget _buildStandardStyle(Book book, ColorScheme colors) {
|
||||
final topSafe = MediaQuery.of(context).padding.top;
|
||||
@@ -156,7 +418,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
child: Row(children: [
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: Icon(widget.embedded ? Icons.close : Icons.arrow_back_ios_new, color: Colors.white, size: 18),
|
||||
icon: Icon(widget.embedded ? Icons.arrow_back : Icons.arrow_back_ios_new, color: Colors.white, size: 18),
|
||||
onPressed: widget.embedded
|
||||
? () => context.read<AppProvider>().selectBook(null)
|
||||
: () => Navigator.pop(context),
|
||||
@@ -289,14 +551,16 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildEpubReadButton(book),
|
||||
const SizedBox(height: 12),
|
||||
_buildFloatingButton(
|
||||
icon: Icons.share_outlined,
|
||||
onPressed: () => _showSharePoster(book),
|
||||
tooltip: '分享海报',
|
||||
backgroundColor: const Color(0xFF4CAF50),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
if (!Platform.isWindows) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildFloatingButton(
|
||||
icon: Icons.share_outlined,
|
||||
onPressed: () => _showSharePoster(book),
|
||||
tooltip: '分享海报',
|
||||
backgroundColor: const Color(0xFF4CAF50),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -378,7 +642,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
child: Row(children: [
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: Icon(widget.embedded ? Icons.close : Icons.arrow_back_ios_new, color: colors.onSurface, size: 18),
|
||||
icon: Icon(widget.embedded ? Icons.arrow_back : Icons.arrow_back_ios_new, color: colors.onSurface, size: 18),
|
||||
onPressed: widget.embedded
|
||||
? () => context.read<AppProvider>().selectBook(null)
|
||||
: () => Navigator.pop(context),
|
||||
|
||||
@@ -649,6 +649,7 @@ class _BookFormPageState extends State<BookFormPage> {
|
||||
publishDate: _publishDate, startDate: _startDate, finishDate: _finishDate, createdAt: now, updatedAt: now,
|
||||
);
|
||||
await context.read<AppProvider>().addBook(newBook);
|
||||
await context.read<AppProvider>().loadBooks();
|
||||
} else {
|
||||
final updatedBook = widget.book!.copyWith(
|
||||
title: _titleController.text.trim(), coverPath: _coverPath,
|
||||
|
||||
@@ -52,6 +52,7 @@ class _BookTabPageState extends State<BookTabPage> {
|
||||
_layoutStyle = UserPrefs().bookLayoutStyle;
|
||||
_scrollController = ScrollController()..addListener(_onScroll);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<AppProvider>();
|
||||
_provider = provider;
|
||||
provider.addListener(_onDataChanged);
|
||||
|
||||
@@ -5,13 +5,13 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../data/epub/reader_dao.dart';
|
||||
import '../../widgets/genre_selector_page.dart';
|
||||
import '../../widgets/text_input_panel.dart';
|
||||
import '../../utils/image_path_helper.dart';
|
||||
|
||||
/// EPUB 书籍编辑页
|
||||
class EpubEditPage extends StatefulWidget {
|
||||
@@ -93,8 +93,8 @@ class _EpubEditPageState extends State<EpubEditPage> {
|
||||
final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85);
|
||||
if (picked == null || !mounted) return;
|
||||
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final bookDir = Directory(p.join(appDir.path, 'epub_books', widget.bookId));
|
||||
final appDirPath = await ImagePathHelper.getAppDir();
|
||||
final bookDir = Directory(p.join(appDirPath, 'epub_books', widget.bookId));
|
||||
if (!await bookDir.exists()) await bookDir.create(recursive: true);
|
||||
|
||||
final existing = bookDir.listSync().whereType<File>().where((f) {
|
||||
@@ -122,8 +122,8 @@ class _EpubEditPageState extends State<EpubEditPage> {
|
||||
}
|
||||
|
||||
Future<void> _revertCover() async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final bookDir = Directory(p.join(appDir.path, 'epub_books', widget.bookId));
|
||||
final appDirPath = await ImagePathHelper.getAppDir();
|
||||
final bookDir = Directory(p.join(appDirPath, 'epub_books', widget.bookId));
|
||||
|
||||
final existing = bookDir.listSync().whereType<File>().where((f) {
|
||||
final name = p.basenameWithoutExtension(f.path);
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../providers/app_provider.dart';
|
||||
import '../../models/data_models.dart';
|
||||
import '../../utils/user_prefs.dart';
|
||||
import '../../utils/toast_util.dart';
|
||||
import '../../utils/responsive.dart';
|
||||
import 'game_reviews_page.dart';
|
||||
import 'game_screenshots_page.dart';
|
||||
import 'game_share_page.dart';
|
||||
@@ -73,11 +74,212 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
||||
.where((g) => g.id == widget.game.id)
|
||||
.firstOrNull ?? widget.game;
|
||||
|
||||
if (Breakpoint.isDesktop(context)) {
|
||||
return _buildDesktopStyle(game, colors);
|
||||
}
|
||||
return _detailStyle == 1
|
||||
? _buildOverlayStyle(game, colors)
|
||||
: _buildStandardStyle(game, colors);
|
||||
}
|
||||
|
||||
/// 桌面端左右分栏布局
|
||||
Widget _buildDesktopStyle(Game game, ColorScheme colors) {
|
||||
final hasCover = game.coverPath != null && game.coverPath!.isNotEmpty;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
body: Column(
|
||||
children: [
|
||||
// 顶栏
|
||||
Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)),
|
||||
),
|
||||
child: Row(children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: colors.onSurface, size: 18),
|
||||
onPressed: widget.embedded
|
||||
? () => context.read<AppProvider>().selectGame(null)
|
||||
: () => Navigator.pop(context),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(game.title,
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
]),
|
||||
),
|
||||
// 主体:左封面 + 右信息
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 左侧封面
|
||||
Container(
|
||||
width: 240,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 200,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: hasCover
|
||||
? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 12, offset: const Offset(0, 4))]
|
||||
: null,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: hasCover
|
||||
? FadeInLocalImage(path: game.coverPath, fit: BoxFit.cover)
|
||||
: Center(child: Icon(Icons.sports_esports_outlined, size: 48, color: colors.onSurface.withValues(alpha: 0.25))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 右侧信息(可滚动)
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(0, 20, 24, 80),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(game.title,
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.3)),
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
if (game.rating != null) ...[
|
||||
Icon(Icons.star, size: 20, color: colors.onSurface),
|
||||
const SizedBox(width: 4),
|
||||
Text(game.rating!.toStringAsFixed(1),
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
_buildStatusTag(game),
|
||||
const SizedBox(width: 6),
|
||||
_buildCategoryTag(game),
|
||||
]),
|
||||
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
||||
// 详细信息
|
||||
if (game.platforms.isNotEmpty)
|
||||
_buildDesktopInfoRow('平台', game.platforms.join('、'), colors),
|
||||
if (game.versions.isNotEmpty)
|
||||
_buildDesktopInfoRow('版本', game.versions.join('、'), colors),
|
||||
if (game.genres.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SizedBox(width: 56, child: Text('类型', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
|
||||
Expanded(child: Wrap(spacing: 8, runSpacing: 8,
|
||||
children: game.genres.map((g) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(16)),
|
||||
child: Text(g, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
)).toList(),
|
||||
)),
|
||||
]),
|
||||
],
|
||||
if (game.playTimeHours > 0 || game.playTimeMinutes > 0)
|
||||
_buildDesktopInfoRow('游玩时长', '${game.playTimeHours}小时${game.playTimeMinutes}分钟', colors),
|
||||
if (game.purchasePlatforms.isNotEmpty)
|
||||
_buildDesktopInfoRow('购买平台', game.purchasePlatforms.join('、'), colors),
|
||||
if (game.purchaseDate != null)
|
||||
_buildDesktopInfoRow('购买时间', _formatDate(game.purchaseDate!), colors),
|
||||
if (game.purchasePrice != null && game.purchasePrice!.isNotEmpty)
|
||||
_buildDesktopInfoRow('购买价格', game.purchasePrice!, colors),
|
||||
if (game.summary != null && game.summary!.isNotEmpty) ...[
|
||||
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
||||
Row(children: [
|
||||
Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 8),
|
||||
Text('游戏简介', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Text(game.summary!, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.8)),
|
||||
],
|
||||
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
||||
Row(children: [
|
||||
Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 8),
|
||||
Text('更多', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.rate_review_outlined,
|
||||
title: '游戏评价',
|
||||
subtitleFuture: context.read<AppProvider>().getGameReviewCount(game.id),
|
||||
emptyText: '暂无评价',
|
||||
unit: '条评价',
|
||||
onTap: () => _navigateToReviews(game),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.photo_library_outlined,
|
||||
title: '游戏截图',
|
||||
subtitleFuture: context.read<AppProvider>().getGameScreenshotCount(game.id),
|
||||
emptyText: '暂无截图',
|
||||
unit: '张截图',
|
||||
onTap: () => _navigateToScreenshots(game),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 底部操作栏
|
||||
Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _showDeleteDialog(context),
|
||||
icon: Icon(Icons.delete_outline, size: 16, color: colors.error),
|
||||
label: Text('删除', style: TextStyle(color: colors.error)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: colors.error.withValues(alpha: 0.3)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _navigateToEdit(context),
|
||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||
label: const Text('编辑'),
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDesktopInfoRow(String label, String value, ColorScheme colors) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 56, child: Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
|
||||
Expanded(child: Text(value, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.5))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStandardStyle(Game game, ColorScheme colors) {
|
||||
final topSafe = MediaQuery.of(context).padding.top;
|
||||
return Scaffold(
|
||||
@@ -131,7 +333,7 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: widget.embedded
|
||||
? Icon(Icons.close, color: colors.onSurface, size: 18)
|
||||
? Icon(Icons.arrow_back, color: colors.onSurface, size: 18)
|
||||
: Icon(Icons.arrow_back_ios_new, color: colors.onSurface, size: 18),
|
||||
onPressed: widget.embedded
|
||||
? () => context.read<AppProvider>().selectGame(null)
|
||||
@@ -204,7 +406,7 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: widget.embedded
|
||||
? const Icon(Icons.close, color: Colors.white, size: 18)
|
||||
? const Icon(Icons.arrow_back, color: Colors.white, size: 18)
|
||||
: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18),
|
||||
onPressed: widget.embedded
|
||||
? () => context.read<AppProvider>().selectGame(null)
|
||||
@@ -466,14 +668,16 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildFloatingButton(
|
||||
icon: Icons.share_outlined,
|
||||
onPressed: () => _showSharePoster(game),
|
||||
tooltip: '分享海报',
|
||||
backgroundColor: const Color(0xFF4CAF50),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
if (!Platform.isWindows) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildFloatingButton(
|
||||
icon: Icons.share_outlined,
|
||||
onPressed: () => _showSharePoster(game),
|
||||
tooltip: '分享海报',
|
||||
backgroundColor: const Color(0xFF4CAF50),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1115,6 +1115,7 @@ class _GameFormPageState extends State<GameFormPage> {
|
||||
);
|
||||
|
||||
await context.read<AppProvider>().addGame(newGame);
|
||||
await context.read<AppProvider>().loadGames();
|
||||
} else {
|
||||
final updatedGame = widget.game!.copyWith(
|
||||
title: _titleController.text.trim(),
|
||||
|
||||
@@ -43,6 +43,7 @@ class _GameTabPageState extends State<GameTabPage> {
|
||||
super.initState();
|
||||
_scrollController = ScrollController()..addListener(_onScroll);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<AppProvider>();
|
||||
_provider = provider;
|
||||
provider.addListener(_onDataChanged);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../utils/user_prefs.dart';
|
||||
import '../../utils/responsive.dart';
|
||||
import '../../services/sync/webdav_service.dart';
|
||||
import '../movies/movie_tab_page.dart';
|
||||
import '../book/book_tab_page.dart';
|
||||
@@ -87,8 +88,10 @@ class _MainContentPageState extends State<MainContentPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildAppBar(context),
|
||||
_buildTabBar(context),
|
||||
if (!Breakpoint.isDesktop(context)) ...[
|
||||
_buildAppBar(context),
|
||||
_buildTabBar(context),
|
||||
],
|
||||
Expanded(child: _buildTabContent()),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../../providers/app_provider.dart';
|
||||
import '../../models/data_models.dart';
|
||||
import '../../utils/user_prefs.dart';
|
||||
import '../../utils/toast_util.dart';
|
||||
import '../../utils/responsive.dart';
|
||||
import 'movie_reviews_page.dart';
|
||||
import 'movie_posters_page.dart';
|
||||
import 'movie_share_page.dart';
|
||||
@@ -63,11 +64,231 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
.where((m) => m.id == widget.movie.id)
|
||||
.firstOrNull ?? widget.movie;
|
||||
|
||||
if (Breakpoint.isDesktop(context)) {
|
||||
return _buildDesktopStyle(movie, colors);
|
||||
}
|
||||
return _detailStyle == 1
|
||||
? _buildOverlayStyle(movie, colors)
|
||||
: _buildStandardStyle(movie, colors);
|
||||
}
|
||||
|
||||
/// 桌面端左右分栏布局
|
||||
Widget _buildDesktopStyle(Movie movie, ColorScheme colors) {
|
||||
final hasPoster = movie.posterPath != null && movie.posterPath!.isNotEmpty;
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
body: Column(
|
||||
children: [
|
||||
// 顶栏
|
||||
Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)),
|
||||
),
|
||||
child: Row(children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: colors.onSurface, size: 18),
|
||||
onPressed: widget.embedded
|
||||
? () => context.read<AppProvider>().selectMovie(null)
|
||||
: () => Navigator.pop(context),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(movie.title,
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
]),
|
||||
),
|
||||
// 主体:左封面 + 右信息
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 左侧封面
|
||||
Container(
|
||||
width: 240,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 200,
|
||||
height: 280,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: hasPoster
|
||||
? [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 12, offset: const Offset(0, 4))]
|
||||
: null,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: hasPoster
|
||||
? FadeInLocalImage(path: movie.posterPath, fit: BoxFit.cover)
|
||||
: Center(child: Icon(Icons.movie_outlined, size: 48, color: colors.onSurface.withValues(alpha: 0.25))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 右侧信息(可滚动)
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(0, 20, 24, 80),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Text(movie.title,
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: colors.onSurface, height: 1.3)),
|
||||
if (movie.alternateTitles.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(movie.alternateTitles.join(' / '),
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4), height: 1.4)),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
// 评分 + 状态 + 分类
|
||||
Row(children: [
|
||||
if (movie.rating != null) ...[
|
||||
Icon(Icons.star, size: 20, color: colors.onSurface),
|
||||
const SizedBox(width: 4),
|
||||
Text(movie.rating!.toStringAsFixed(1),
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
_buildStatusTag(movie),
|
||||
const SizedBox(width: 6),
|
||||
_buildCategoryTag(movie),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
if (movie.releaseDate != null)
|
||||
GestureDetector(
|
||||
onTap: _toggleDateDisplay,
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(
|
||||
_showExactDate
|
||||
? '${movie.releaseDate!.year}年${movie.releaseDate!.month.toString().padLeft(2, '0')}月${movie.releaseDate!.day.toString().padLeft(2, '0')}日上映'
|
||||
: '${movie.releaseDate!.year}年${movie.releaseDate!.month.toString().padLeft(2, '0')}月上映',
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4)),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.tune, size: 14, color: colors.onSurface.withValues(alpha: 0.2)),
|
||||
]),
|
||||
),
|
||||
if (movie.watchDate != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('观看于 ${_formatDate(movie.watchDate!)}',
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
||||
// 详细信息
|
||||
if (movie.directors.isNotEmpty) _buildDesktopInfoRow('导演', movie.directors.join(','), colors),
|
||||
if (movie.writers.isNotEmpty) _buildDesktopInfoRow('编剧', movie.writers.join(','), colors),
|
||||
if (movie.actors.isNotEmpty) _buildDesktopInfoRow('主演', movie.actors.join(','), colors),
|
||||
if (movie.genres.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SizedBox(width: 56, child: Text('类型', style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
|
||||
Expanded(child: Wrap(spacing: 8, runSpacing: 8,
|
||||
children: movie.genres.map((g) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colors.surfaceContainerHighest, borderRadius: BorderRadius.circular(16)),
|
||||
child: Text(g, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
)).toList(),
|
||||
)),
|
||||
]),
|
||||
],
|
||||
if (movie.summary != null && movie.summary!.isNotEmpty) ...[
|
||||
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
||||
Row(children: [
|
||||
Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 8),
|
||||
Text('简介', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Text(movie.summary!, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.8)),
|
||||
],
|
||||
Divider(height: 32, thickness: 0.5, color: colors.outline),
|
||||
// 更多
|
||||
Row(children: [
|
||||
Container(width: 4, height: 16, decoration: BoxDecoration(color: colors.onSurface, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 8),
|
||||
Text('更多', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.rate_review_outlined,
|
||||
title: '影评',
|
||||
subtitleFuture: context.read<AppProvider>().getMovieReviewCount(movie.id),
|
||||
emptyText: '暂无影评',
|
||||
unit: '条影评',
|
||||
onTap: () => _navigateToReviews(movie),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildExtraSectionItem(
|
||||
icon: Icons.photo_library_outlined,
|
||||
title: '海报墙',
|
||||
subtitleFuture: context.read<AppProvider>().getMoviePosterCount(movie.id),
|
||||
emptyText: '暂无海报',
|
||||
unit: '张海报',
|
||||
onTap: () => _navigateToPosters(movie),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 底部操作栏
|
||||
Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _showDeleteDialog(context),
|
||||
icon: Icon(Icons.delete_outline, size: 16, color: colors.error),
|
||||
label: Text('删除', style: TextStyle(color: colors.error)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: colors.error.withValues(alpha: 0.3)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _navigateToEdit(context),
|
||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||
label: const Text('编辑'),
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDesktopInfoRow(String label, String value, ColorScheme colors) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(width: 56, child: Text(label, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4)))),
|
||||
Expanded(child: Text(value, style: TextStyle(fontSize: 15, color: colors.onSurface, height: 1.5))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 标准样式
|
||||
Widget _buildStandardStyle(Movie movie, ColorScheme colors) {
|
||||
final topSafe = MediaQuery.of(context).padding.top;
|
||||
@@ -121,7 +342,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: widget.embedded
|
||||
? Icon(Icons.close, color: colors.onSurface, size: 18)
|
||||
? Icon(Icons.arrow_back, color: colors.onSurface, size: 18)
|
||||
: Icon(Icons.arrow_back_ios_new, color: colors.onSurface, size: 18),
|
||||
onPressed: widget.embedded
|
||||
? () => context.read<AppProvider>().selectMovie(null)
|
||||
@@ -192,7 +413,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: widget.embedded
|
||||
? const Icon(Icons.close, color: Colors.white, size: 18)
|
||||
? const Icon(Icons.arrow_back, color: Colors.white, size: 18)
|
||||
: const Icon(Icons.arrow_back_ios_new, color: Colors.white, size: 18),
|
||||
onPressed: widget.embedded
|
||||
? () => context.read<AppProvider>().selectMovie(null)
|
||||
@@ -372,14 +593,16 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildFloatingButton(
|
||||
icon: Icons.share_outlined,
|
||||
onPressed: () => _showSharePoster(movie),
|
||||
tooltip: '分享海报',
|
||||
backgroundColor: const Color(0xFF4CAF50),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
if (!Platform.isWindows) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildFloatingButton(
|
||||
icon: Icons.share_outlined,
|
||||
onPressed: () => _showSharePoster(movie),
|
||||
tooltip: '分享海报',
|
||||
backgroundColor: const Color(0xFF4CAF50),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1362,6 +1362,7 @@ class _MovieFormPageState extends State<MovieFormPage> {
|
||||
);
|
||||
|
||||
await context.read<AppProvider>().addMovie(newMovie);
|
||||
await context.read<AppProvider>().loadMovies();
|
||||
} else {
|
||||
final updatedMovie = widget.movie!.copyWith(
|
||||
title: _titleController.text.trim(),
|
||||
|
||||
@@ -46,6 +46,7 @@ class _MovieTabPageState extends State<MovieTabPage> {
|
||||
super.initState();
|
||||
_scrollController = ScrollController()..addListener(_onScroll);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<AppProvider>();
|
||||
_provider = provider;
|
||||
provider.addListener(_onDataChanged);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../widgets/fade_in_local_image.dart';
|
||||
import '../../models/data_models.dart';
|
||||
import '../../utils/responsive.dart';
|
||||
import 'note_share_page.dart';
|
||||
|
||||
/// 笔记详情页
|
||||
@@ -28,12 +30,15 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
orElse: () => widget.note,
|
||||
);
|
||||
|
||||
if (Breakpoint.isDesktop(context)) {
|
||||
return _buildDesktopStyle(note, colors);
|
||||
}
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
appBar: AppBar(
|
||||
leading: widget.embedded
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.read<AppProvider>().selectNote(null),
|
||||
)
|
||||
: null,
|
||||
@@ -123,6 +128,112 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 桌面端布局
|
||||
Widget _buildDesktopStyle(Note note, ColorScheme colors) {
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
body: Column(
|
||||
children: [
|
||||
// 顶栏
|
||||
Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)),
|
||||
),
|
||||
child: Row(children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: colors.onSurface, size: 18),
|
||||
onPressed: widget.embedded
|
||||
? () => context.read<AppProvider>().selectNote(null)
|
||||
: () => Navigator.pop(context),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
note.title.isNotEmpty ? note.title : _truncateContent(note.content),
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: colors.onSurface),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
]),
|
||||
),
|
||||
// 日期信息栏
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text('${note.createdAt.day}',
|
||||
style: TextStyle(fontSize: 30, fontWeight: FontWeight.w200, color: colors.onSurface.withValues(alpha: 0.75), height: 1.0)),
|
||||
const SizedBox(width: 8),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||
Text('${note.createdAt.year}/${note.createdAt.month.toString().padLeft(2, '0')} 周${_weekdays[note.createdAt.weekday - 1]}',
|
||||
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.onSurface.withValues(alpha: 0.55))),
|
||||
const SizedBox(height: 1),
|
||||
Text('${note.createdAt.hour.toString().padLeft(2, '0')}:${note.createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
]),
|
||||
const Spacer(),
|
||||
Text('${note.content.length} 字',
|
||||
style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.35))),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (note.tags.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, left: 24, right: 24),
|
||||
child: _buildTagRow(note.tags),
|
||||
),
|
||||
// 内容
|
||||
Expanded(
|
||||
child: Markdown(
|
||||
data: note.content,
|
||||
styleSheet: _buildMarkdownStyleSheet(colors),
|
||||
padding: const EdgeInsets.all(24),
|
||||
// ignore: deprecated_member_use
|
||||
imageBuilder: (uri, title, alt) => _buildMarkdownImage(uri, note),
|
||||
),
|
||||
),
|
||||
if (note.images.isNotEmpty) _buildImageRow(note.images),
|
||||
// 底部操作栏
|
||||
Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _showDeleteDialog(context),
|
||||
icon: Icon(Icons.delete_outline, size: 16, color: colors.error),
|
||||
label: Text('删除', style: TextStyle(color: colors.error)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: colors.error.withValues(alpha: 0.3)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: () => _navigateToEdit(context),
|
||||
icon: const Icon(Icons.edit_outlined, size: 16),
|
||||
label: const Text('编辑'),
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTagRow(List<String> tags) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
@@ -324,14 +435,16 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildFloatingButton(
|
||||
icon: Icons.share_outlined,
|
||||
onPressed: _shareNote,
|
||||
tooltip: '分享',
|
||||
backgroundColor: const Color(0xFF4CAF50),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
if (!Platform.isWindows) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildFloatingButton(
|
||||
icon: Icons.share_outlined,
|
||||
onPressed: _shareNote,
|
||||
tooltip: '分享',
|
||||
backgroundColor: const Color(0xFF4CAF50),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -368,6 +481,7 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
}
|
||||
|
||||
void _showDeleteDialog(BuildContext context) {
|
||||
final errorColor = Theme.of(context).colorScheme.error;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
@@ -388,7 +502,7 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Text('删除', style: TextStyle(color: Theme.of(context).colorScheme.error)),
|
||||
child: Text('删除', style: TextStyle(color: errorColor)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -38,6 +38,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
|
||||
_layoutStyle = UserPrefs().noteLayoutStyle;
|
||||
_scrollController = ScrollController()..addListener(_onScroll);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<AppProvider>();
|
||||
_provider = provider;
|
||||
provider.addListener(_onDataChanged);
|
||||
|
||||
@@ -196,6 +196,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<AppProvider>();
|
||||
await provider.addBook(book);
|
||||
await provider.loadBooks();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -334,107 +335,111 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
final pages = m['pagination'];
|
||||
final coverUrl = cover.toString().isNotEmpty ? _resolveCoverUrl(cover.toString()) : '';
|
||||
|
||||
return Column(children: [
|
||||
// 顶部固定区域
|
||||
Container(
|
||||
color: colors.surface,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
shape: BoxShape.circle),
|
||||
child: Icon(Icons.arrow_back, size: 20, color: colors.onSurface)),
|
||||
return NestedScrollView(
|
||||
headerSliverBuilder: (context, _) => [
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
color: colors.surface,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
shape: BoxShape.circle),
|
||||
child: Icon(Icons.arrow_back, size: 20, color: colors.onSurface)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
width: 110,
|
||||
height: 160,
|
||||
child: coverUrl.isNotEmpty
|
||||
? Image.network(coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _coverPlaceholder(colors))
|
||||
: _coverPlaceholder(colors),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: colors.onSurface)),
|
||||
if (author.toString().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(author, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
],
|
||||
if (press.toString().isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(press, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.45))),
|
||||
],
|
||||
if (year.toString().isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text('出版年份:$year', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
if (isbn.toString().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('ISBN:$isbn', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
if (pages != null && pages != 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('页数:$pages', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
if (_localBook != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_buildLocalStatus(colors),
|
||||
],
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
width: 110,
|
||||
height: 160,
|
||||
child: coverUrl.isNotEmpty
|
||||
? Image.network(coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => _coverPlaceholder(colors))
|
||||
: _coverPlaceholder(colors),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: colors.onSurface)),
|
||||
if (author.toString().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(author, style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
],
|
||||
if (press.toString().isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(press, style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.45))),
|
||||
],
|
||||
if (year.toString().isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text('出版年份:$year', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
if (isbn.toString().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('ISBN:$isbn', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
if (pages != null && pages != 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('页数:$pages', style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
],
|
||||
if (_localBook != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_buildLocalStatus(colors),
|
||||
],
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Tab 栏
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
|
||||
child: Row(children: [
|
||||
_buildTabButton('基础信息', 0),
|
||||
_buildTabButton('国图信息', 1),
|
||||
_buildTabButton('网购地址', 2),
|
||||
_buildTabButton('书籍目录', 3),
|
||||
]),
|
||||
),
|
||||
|
||||
// 内容区
|
||||
Expanded(
|
||||
child: _currentTab == 0
|
||||
? _buildBasicInfo(colors)
|
||||
: _currentTab == 1
|
||||
? _buildOpacTab(colors)
|
||||
: _currentTab == 2
|
||||
? _buildOnlineTab(colors)
|
||||
: _buildCatalogTab(colors),
|
||||
),
|
||||
]);
|
||||
// Tab 栏:吸顶
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: _StickyTabBarDelegate(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
|
||||
child: Row(children: [
|
||||
_buildTabButton('基础信息', 0),
|
||||
_buildTabButton('国图信息', 1),
|
||||
_buildTabButton('网购地址', 2),
|
||||
_buildTabButton('书籍目录', 3),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: _currentTab == 0
|
||||
? _buildBasicInfo(colors)
|
||||
: _currentTab == 1
|
||||
? _buildOpacTab(colors)
|
||||
: _currentTab == 2
|
||||
? _buildOnlineTab(colors)
|
||||
: _buildCatalogTab(colors),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabButton(String label, int index) {
|
||||
@@ -753,3 +758,20 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class _StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
final Widget child;
|
||||
_StickyTabBarDelegate({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child;
|
||||
|
||||
@override
|
||||
double get minExtent => 44;
|
||||
|
||||
@override
|
||||
double get maxExtent => 44;
|
||||
|
||||
@override
|
||||
bool shouldRebuild(_StickyTabBarDelegate oldDelegate) => child != oldDelegate.child;
|
||||
}
|
||||
|
||||
@@ -173,6 +173,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
if (!mounted) return;
|
||||
final provider = context.read<AppProvider>();
|
||||
await provider.addMovie(movie);
|
||||
await provider.loadMovies();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -328,139 +329,138 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
final typeParts =
|
||||
[typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / ');
|
||||
|
||||
return Column(children: [
|
||||
// 顶部:AppBar + 海报信息区
|
||||
Container(
|
||||
color: colors.surface,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(children: [
|
||||
// AppBar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
shape: BoxShape.circle),
|
||||
child: Icon(Icons.arrow_back,
|
||||
size: 20, color: colors.onSurface)),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _detailStyle = _detailStyle == 0 ? 1 : 0),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height:36,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
shape: BoxShape.circle),
|
||||
child: Icon(
|
||||
_detailStyle == 0
|
||||
? Icons.crop_landscape_rounded
|
||||
: Icons.grid_view_rounded,
|
||||
size: 18,
|
||||
color: colors.onSurface)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
// 海报 + 信息
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 海报
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
height: 170,
|
||||
child: pic.toString().isNotEmpty
|
||||
? Image.network(pic,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
_posterPlaceholder(colors))
|
||||
: _posterPlaceholder(colors),
|
||||
return NestedScrollView(
|
||||
headerSliverBuilder: (context, _) => [
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
color: colors.surface,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(children: [
|
||||
// AppBar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
shape: BoxShape.circle),
|
||||
child: Icon(Icons.arrow_back,
|
||||
size: 20, color: colors.onSurface)),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _detailStyle = _detailStyle == 0 ? 1 : 0),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
shape: BoxShape.circle),
|
||||
child: Icon(
|
||||
_detailStyle == 0
|
||||
? Icons.crop_landscape_rounded
|
||||
: Icons.grid_view_rounded,
|
||||
size: 18,
|
||||
color: colors.onSurface)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
// 海报 + 信息
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
height: 170,
|
||||
child: pic.toString().isNotEmpty
|
||||
? Image.network(pic,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
_posterPlaceholder(colors))
|
||||
: _posterPlaceholder(colors),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.onSurface)),
|
||||
const SizedBox(height: 8),
|
||||
// 评分
|
||||
if (score.toString().isNotEmpty && score != '0.0') ...[
|
||||
Row(children: [
|
||||
Icon(Icons.star_rounded, size: 16, color: const Color(0xFFF59E0B)),
|
||||
const SizedBox(width: 3),
|
||||
Text('$score', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
Text(' /10', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
]),
|
||||
const SizedBox(height: 2),
|
||||
Text('评分来源于网络资源收集,并非官方评分', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// 完结状态
|
||||
_endTag(isEnd),
|
||||
if (metaParts.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(metaParts,
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colors.onSurface
|
||||
.withValues(alpha: 0.5))),
|
||||
],
|
||||
if (typeParts.isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(typeParts,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colors.onSurface
|
||||
.withValues(alpha: 0.4)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
// 本地状态
|
||||
if (_localMovie != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_buildLocalStatus(colors),
|
||||
],
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
])),
|
||||
),
|
||||
|
||||
// Tab 栏
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
|
||||
child: Row(children: [
|
||||
_buildTabButton('概要', 0),
|
||||
_buildTabButton('演职人员', 1),
|
||||
]),
|
||||
),
|
||||
|
||||
// 内容区
|
||||
Expanded(
|
||||
child:
|
||||
_currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors),
|
||||
),
|
||||
]);
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colors.onSurface)),
|
||||
const SizedBox(height: 8),
|
||||
if (score.toString().isNotEmpty && score != '0.0') ...[
|
||||
Row(children: [
|
||||
Icon(Icons.star_rounded, size: 16, color: const Color(0xFFF59E0B)),
|
||||
const SizedBox(width: 3),
|
||||
Text('$score', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
Text(' /10', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
|
||||
]),
|
||||
const SizedBox(height: 2),
|
||||
Text('评分来源于网络资源收集,并非官方评分', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
_endTag(isEnd),
|
||||
if (metaParts.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(metaParts,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colors.onSurface
|
||||
.withValues(alpha: 0.5))),
|
||||
],
|
||||
if (typeParts.isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(typeParts,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colors.onSurface
|
||||
.withValues(alpha: 0.4)),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
if (_localMovie != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_buildLocalStatus(colors),
|
||||
],
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Tab 栏:吸顶
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: _StickyTabBarDelegate(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
|
||||
child: Row(children: [
|
||||
_buildTabButton('概要', 0),
|
||||
_buildTabButton('演职人员', 1),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: _currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 沉浸式布局 ──────────────────────────────────────────
|
||||
@@ -478,116 +478,115 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
final metaParts = [year, area].where((s) => s.toString().isNotEmpty).join(' · ');
|
||||
final typeParts = [typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / ');
|
||||
|
||||
return Column(children: [
|
||||
// 全宽海报区
|
||||
Stack(children: [
|
||||
// 海报图
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 320,
|
||||
child: pic.toString().isNotEmpty
|
||||
? Image.network(pic, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest))
|
||||
: Container(color: colors.surfaceContainerHighest,
|
||||
child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))),
|
||||
),
|
||||
// 渐变遮罩
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.8)],
|
||||
stops: const [0.35, 1.0],
|
||||
return NestedScrollView(
|
||||
headerSliverBuilder: (context, _) => [
|
||||
SliverToBoxAdapter(
|
||||
child: Stack(children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 320,
|
||||
child: pic.toString().isNotEmpty
|
||||
? Image.network(pic, fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest))
|
||||
: Container(color: colors.surfaceContainerHighest,
|
||||
child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.8)],
|
||||
stops: const [0.35, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.arrow_back, size: 20, color: Colors.white)),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _detailStyle = 0),
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.grid_view_rounded, size: 18, color: Colors.white)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 16, right: 16, bottom: 18,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(name, maxLines: 2, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
if (score.toString().isNotEmpty && score != '0.0') ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.star_rounded, size: 18, color: Colors.amber.shade400),
|
||||
const SizedBox(width: 3),
|
||||
Text('$score', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
_endTag(isEnd),
|
||||
if (metaParts.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(metaParts, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))),
|
||||
],
|
||||
]),
|
||||
if (typeParts.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(typeParts, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5))),
|
||||
],
|
||||
if (_localMovie != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildLocalStatus(colors),
|
||||
],
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
SliverPersistentHeader(
|
||||
pinned: true,
|
||||
delegate: _StickyTabBarDelegate(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
|
||||
child: Row(children: [
|
||||
_buildTabButton('概要', 0),
|
||||
_buildTabButton('演职人员', 1),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 顶部按钮
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.arrow_back, size: 20, color: Colors.white)),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _detailStyle = 0),
|
||||
child: Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle),
|
||||
child: const Icon(Icons.grid_view_rounded, size: 18, color: Colors.white)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
// 底部信息叠加
|
||||
Positioned(
|
||||
left: 16, right: 16, bottom: 18,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(name, maxLines: 2, overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
const SizedBox(height: 8),
|
||||
Row(children: [
|
||||
if (score.toString().isNotEmpty && score != '0.0') ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.star_rounded, size: 18, color: Colors.amber.shade400),
|
||||
const SizedBox(width: 3),
|
||||
Text('$score', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
_endTag(isEnd),
|
||||
if (metaParts.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(metaParts, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))),
|
||||
],
|
||||
]),
|
||||
if (typeParts.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(typeParts, maxLines: 1, overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5))),
|
||||
],
|
||||
if (_localMovie != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildLocalStatus(colors),
|
||||
],
|
||||
]),
|
||||
),
|
||||
]),
|
||||
|
||||
// Tab 栏
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
|
||||
child: Row(children: [
|
||||
_buildTabButton('概要', 0),
|
||||
_buildTabButton('演职人员', 1),
|
||||
]),
|
||||
),
|
||||
|
||||
// 内容区
|
||||
Expanded(
|
||||
child: _currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors),
|
||||
),
|
||||
]);
|
||||
],
|
||||
body: _currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _posterPlaceholder(ColorScheme colors) {
|
||||
@@ -908,3 +907,20 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
|
||||
final Widget child;
|
||||
_StickyTabBarDelegate({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => child;
|
||||
|
||||
@override
|
||||
double get minExtent => 44;
|
||||
|
||||
@override
|
||||
double get maxExtent => 44;
|
||||
|
||||
@override
|
||||
bool shouldRebuild(_StickyTabBarDelegate oldDelegate) => child != oldDelegate.child;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../main.dart' show routeObserver;
|
||||
import '../../models/data_models.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../utils/user_prefs.dart';
|
||||
import '../../utils/responsive.dart';
|
||||
import '../../utils/toast_util.dart';
|
||||
import '../../utils/image_path_helper.dart';
|
||||
import '../settings/recycle_bin_page.dart';
|
||||
import '../sync/backup_page.dart';
|
||||
import '../../widgets/fade_in_local_image.dart';
|
||||
@@ -77,12 +78,14 @@ class _ProfilePageState extends State<ProfilePage> with RouteAware {
|
||||
AppBar(
|
||||
titleSpacing: 8,
|
||||
leadingWidth: 44,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: Icon(Icons.menu, color: colors.onSurface),
|
||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||
),
|
||||
),
|
||||
leading: Breakpoint.isDesktop(context)
|
||||
? const SizedBox.shrink()
|
||||
: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: Icon(Icons.menu, color: colors.onSurface),
|
||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||
),
|
||||
),
|
||||
title: const Text('我的'),
|
||||
),
|
||||
Expanded(
|
||||
@@ -998,10 +1001,10 @@ class _ProfilePageState extends State<ProfilePage> with RouteAware {
|
||||
maxHeight: 400,
|
||||
imageQuality: 85);
|
||||
if (pickedFile != null) {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final appDirPath = await ImagePathHelper.getAppDir();
|
||||
final fileName = 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
final savedPath = path.join(appDir.path, 'avatars', fileName);
|
||||
final avatarDir = Directory(path.join(appDir.path, 'avatars'));
|
||||
final savedPath = path.join(appDirPath, 'avatars', fileName);
|
||||
final avatarDir = Directory(path.join(appDirPath, 'avatars'));
|
||||
if (!await avatarDir.exists()) await avatarDir.create(recursive: true);
|
||||
await File(pickedFile.path).copy(savedPath);
|
||||
await _userPrefs.setAvatarPath(savedPath);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -10,6 +11,7 @@ import '../../providers/app_provider.dart';
|
||||
import '../../utils/user_prefs.dart';
|
||||
import '../../utils/theme/app_theme.dart';
|
||||
import '../../utils/toast_util.dart';
|
||||
import '../../utils/image_path_helper.dart';
|
||||
import '../../data/database_helper.dart';
|
||||
import '../../services/sync/cache_cleaner.dart';
|
||||
import '../online_search/enhanced_search_settings_page.dart';
|
||||
@@ -65,30 +67,32 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
_buildNavigationItem(
|
||||
icon: Icons.tune_outlined,
|
||||
title: '功能设置',
|
||||
subtitle: '启动标签、模块开关、侧边栏功能',
|
||||
onTap: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const FeatureSettingsPage())),
|
||||
),
|
||||
Divider(
|
||||
height: 0.5,
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
_buildNavigationItem(
|
||||
icon: Icons.dashboard_outlined,
|
||||
title: '布局设置',
|
||||
subtitle: '影视、阅读、笔记的展示样式',
|
||||
onTap: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const LayoutSettingsPage())),
|
||||
),
|
||||
Divider(
|
||||
height: 0.5,
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
if (!Platform.isWindows) ...[
|
||||
_buildNavigationItem(
|
||||
icon: Icons.tune_outlined,
|
||||
title: '功能设置',
|
||||
subtitle: '启动标签、模块开关、侧边栏功能',
|
||||
onTap: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const FeatureSettingsPage())),
|
||||
),
|
||||
Divider(
|
||||
height: 0.5,
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
_buildNavigationItem(
|
||||
icon: Icons.dashboard_outlined,
|
||||
title: '布局设置',
|
||||
subtitle: '影视、阅读、笔记的展示样式',
|
||||
onTap: () => Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => const LayoutSettingsPage())),
|
||||
),
|
||||
Divider(
|
||||
height: 0.5,
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
],
|
||||
_buildThemeModeSelector(),
|
||||
Divider(
|
||||
height: 0.5,
|
||||
@@ -101,7 +105,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
_buildFontSelector(),
|
||||
if (!Platform.isWindows) _buildFontSelector(),
|
||||
_buildSectionHeader('其他设置'),
|
||||
_buildActionItem(
|
||||
icon: Icons.person_outline,
|
||||
@@ -128,18 +132,20 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
_buildSwitchItem(
|
||||
icon: Icons.swipe_vertical_outlined,
|
||||
title: '底部导航栏滚动隐藏',
|
||||
subtitle: '下滑时自动隐藏底部导航栏',
|
||||
value: _hideBottomNavOnScroll,
|
||||
onChanged: _toggleHideBottomNavOnScroll,
|
||||
),
|
||||
Divider(
|
||||
height: 0.5,
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
if (!Platform.isWindows) ...[
|
||||
_buildSwitchItem(
|
||||
icon: Icons.swipe_vertical_outlined,
|
||||
title: '底部导航栏滚动隐藏',
|
||||
subtitle: '下滑时自动隐藏底部导航栏',
|
||||
value: _hideBottomNavOnScroll,
|
||||
onChanged: _toggleHideBottomNavOnScroll,
|
||||
),
|
||||
Divider(
|
||||
height: 0.5,
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
],
|
||||
_buildSectionHeader('数据管理'),
|
||||
_buildActionItem(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
@@ -152,17 +158,19 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
_buildActionItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: '获取系统权限',
|
||||
subtitle: '前往系统设置开启存储权限',
|
||||
onTap: _showStoragePermissionDialog,
|
||||
),
|
||||
Divider(
|
||||
height: 0.5,
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
if (!Platform.isWindows) ...[
|
||||
_buildActionItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: '获取系统权限',
|
||||
subtitle: '前往系统设置开启存储权限',
|
||||
onTap: _showStoragePermissionDialog,
|
||||
),
|
||||
Divider(
|
||||
height: 0.5,
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
],
|
||||
_buildSectionHeader('帮助'),
|
||||
_buildActionItem(
|
||||
icon: Icons.language_outlined,
|
||||
@@ -944,11 +952,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
builder: (_) => Center(child: CircularProgressIndicator(color: colors.primary)),
|
||||
);
|
||||
|
||||
final appProvider = pageContext.read<AppProvider>();
|
||||
final dbImagePaths = await _getAllDbImagePaths(appProvider);
|
||||
final dbImagePaths = await _getAllDbImagePaths();
|
||||
|
||||
final imageInfo = await _scanImageDirectory(dbImagePaths);
|
||||
final epubInfo = await _scanOrphanedEpubBooks(appProvider);
|
||||
final epubInfo = await _scanOrphanedEpubBooks();
|
||||
final tempInfo = await _scanTempDirectory();
|
||||
final emptyDirInfo = await _scanEmptyDirectories();
|
||||
|
||||
@@ -1036,7 +1043,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(child: CircularProgressIndicator()));
|
||||
final result = await CacheCleaner.instance.clean(context.read<AppProvider>());
|
||||
final result = await CacheCleaner.instance.clean();
|
||||
Navigator.pop(context);
|
||||
if (context.mounted) {
|
||||
if (result.total == 0) {
|
||||
@@ -1051,42 +1058,69 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> _getAllDbImagePaths(AppProvider provider) async {
|
||||
/// 直接查 DB 收集所有图片路径(含软删除记录,与 CacheCleaner 保持一致)
|
||||
Future<Set<String>> _getAllDbImagePaths() async {
|
||||
final db = await DatabaseHelper.instance.database;
|
||||
final paths = <String>{};
|
||||
for (final movie in provider.movies) {
|
||||
if (movie.posterPath?.isNotEmpty == true) paths.add(movie.posterPath!);
|
||||
|
||||
final movies = await db.query('movies', columns: ['poster_path']);
|
||||
for (final m in movies) {
|
||||
final p = m['poster_path'] as String?;
|
||||
if (p != null && p.isNotEmpty) paths.add(p);
|
||||
}
|
||||
for (final book in provider.books) {
|
||||
if (book.coverPath?.isNotEmpty == true) paths.add(book.coverPath!);
|
||||
|
||||
final books = await db.query('books', columns: ['cover_path']);
|
||||
for (final b in books) {
|
||||
final p = b['cover_path'] as String?;
|
||||
if (p != null && p.isNotEmpty) paths.add(p);
|
||||
}
|
||||
for (final note in provider.notes) {
|
||||
for (final p in note.images) {
|
||||
if (p.isNotEmpty) paths.add(p);
|
||||
|
||||
final notes = await db.query('notes', columns: ['images']);
|
||||
for (final n in notes) {
|
||||
final imagesJson = n['images'] as String?;
|
||||
if (imagesJson != null && imagesJson.isNotEmpty) {
|
||||
try {
|
||||
for (final ip in jsonDecode(imagesJson) as List<dynamic>) {
|
||||
if (ip is String && ip.isNotEmpty) paths.add(ip);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
for (final movieId in provider.movies.map((m) => m.id)) {
|
||||
for (final poster in await provider.getMoviePosters(movieId)) {
|
||||
if (poster.posterPath.isNotEmpty) paths.add(poster.posterPath);
|
||||
}
|
||||
|
||||
final moviePosters = await db.query('movie_posters', columns: ['poster_path']);
|
||||
for (final p in moviePosters) {
|
||||
final pp = p['poster_path'] as String?;
|
||||
if (pp != null && pp.isNotEmpty) paths.add(pp);
|
||||
}
|
||||
for (final game in provider.games) {
|
||||
if (game.coverPath?.isNotEmpty == true) paths.add(game.coverPath!);
|
||||
|
||||
final games = await db.query('games', columns: ['cover_path']);
|
||||
for (final g in games) {
|
||||
final p = g['cover_path'] as String?;
|
||||
if (p != null && p.isNotEmpty) paths.add(p);
|
||||
}
|
||||
for (final gameId in provider.games.map((g) => g.id)) {
|
||||
for (final screenshot in await provider.getGameScreenshots(gameId)) {
|
||||
if (screenshot.screenshotPath.isNotEmpty) paths.add(screenshot.screenshotPath);
|
||||
}
|
||||
|
||||
final gameScreenshots = await db.query('game_screenshots', columns: ['screenshot_path']);
|
||||
for (final s in gameScreenshots) {
|
||||
final p = s['screenshot_path'] as String?;
|
||||
if (p != null && p.isNotEmpty) paths.add(p);
|
||||
}
|
||||
|
||||
final userPrefs = UserPrefs();
|
||||
final avatarPath = userPrefs.avatarPath;
|
||||
if (avatarPath != null && avatarPath.isNotEmpty) paths.add(avatarPath);
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/// 从绝对路径中提取 epub_books/ 下的目录名
|
||||
/// 兼容 Windows(\) 和 Unix(/) 分隔符
|
||||
void _collectEpubDirName(String? pathStr, Set<String> dirs) {
|
||||
if (pathStr == null || pathStr.isEmpty) return;
|
||||
final unified = pathStr.replaceAll('\\', '/');
|
||||
final marker = '/epub_books/';
|
||||
final idx = pathStr.indexOf(marker);
|
||||
final idx = unified.indexOf(marker);
|
||||
if (idx < 0) return;
|
||||
final rest = pathStr.substring(idx + marker.length);
|
||||
final rest = unified.substring(idx + marker.length);
|
||||
final slashIdx = rest.indexOf('/');
|
||||
dirs.add(slashIdx >= 0 ? rest.substring(0, slashIdx) : rest);
|
||||
}
|
||||
@@ -1097,12 +1131,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
Future<(int, int)> _scanImageDirectory(Set<String> dbImagePaths) async {
|
||||
int count = 0, totalSize = 0;
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesDir = Directory('${appDir.path}/images');
|
||||
final appDirPath = await ImagePathHelper.getAppDir();
|
||||
final imagesDir = Directory(path.join(appDirPath, 'images'));
|
||||
if (!await imagesDir.exists()) return (0, 0);
|
||||
final normalizedDbPaths = dbImagePaths.map(_normalizePath).toSet();
|
||||
await for (final entity in imagesDir.list(recursive: true, followLinks: false)) {
|
||||
if (entity is File &&
|
||||
!dbImagePaths.contains(entity.path) &&
|
||||
!normalizedDbPaths.contains(_normalizePath(entity.path)) &&
|
||||
!path.basename(entity.path).startsWith('avatar')) {
|
||||
try {
|
||||
totalSize += await entity.length();
|
||||
@@ -1114,7 +1149,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
return (count, totalSize);
|
||||
}
|
||||
|
||||
Future<(int, int)> _scanOrphanedEpubBooks(AppProvider provider) async {
|
||||
/// 规范化路径用于跨平台比较(统一分隔符)
|
||||
String _normalizePath(String p) {
|
||||
return path.normalize(p.replaceAll('\\', '/'));
|
||||
}
|
||||
|
||||
Future<(int, int)> _scanOrphanedEpubBooks() async {
|
||||
int count = 0, totalSize = 0;
|
||||
try {
|
||||
final db = await DatabaseHelper.instance.database;
|
||||
@@ -1128,9 +1168,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_collectEpubDirName(r['file_path'] as String?, usedDirs);
|
||||
_collectEpubDirName(r['cover_path'] as String?, usedDirs);
|
||||
}
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final appDirPath = await ImagePathHelper.getAppDir();
|
||||
final possiblePaths = [
|
||||
'${appDir.path}/epub_books',
|
||||
path.join(appDirPath, 'epub_books'),
|
||||
'/data/user/0/top.iletter.mooknote/app_flutter/epub_books',
|
||||
];
|
||||
for (final epubPath in possiblePaths) {
|
||||
@@ -1183,10 +1223,17 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
if (await cacheDir.exists()) {
|
||||
await for (final entity in cacheDir.list(recursive: true, followLinks: false)) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
totalSize += await entity.length();
|
||||
count++;
|
||||
} catch (_) {}
|
||||
final name = path.basename(entity.path);
|
||||
if (name.startsWith('book_poster_') ||
|
||||
name.startsWith('movie_poster_') ||
|
||||
name.startsWith('note_share_') ||
|
||||
name.startsWith('mooknote_download') ||
|
||||
name.startsWith('mooknote_bidir')) {
|
||||
try {
|
||||
totalSize += await entity.length();
|
||||
count++;
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1197,11 +1244,11 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
Future<(int, int)> _scanEmptyDirectories() async {
|
||||
int count = 0;
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final appDirPath = await ImagePathHelper.getAppDir();
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
final dirs = [
|
||||
Directory('${appDir.path}/images'),
|
||||
Directory('${appDir.path}/epub_books'),
|
||||
Directory(path.join(appDirPath, 'images')),
|
||||
Directory(path.join(appDirPath, 'epub_books')),
|
||||
cacheDir,
|
||||
];
|
||||
for (final dir in dirs) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../services/sync/backup_service.dart';
|
||||
import '../../services/sync/cache_cleaner.dart';
|
||||
import '../../utils/toast_util.dart';
|
||||
|
||||
/// 本地备份页面
|
||||
@@ -335,9 +334,6 @@ class _BackupPageState extends State<BackupPage> {
|
||||
setState(() => _isExporting = true);
|
||||
|
||||
try {
|
||||
// 先清理缓存
|
||||
await CacheCleaner.instance.clean(context.read<AppProvider>());
|
||||
|
||||
final result = await BackupService.instance.exportDataWithImages();
|
||||
|
||||
if (!mounted) return;
|
||||
@@ -435,9 +431,6 @@ class _BackupPageState extends State<BackupPage> {
|
||||
setState(() => _isImporting = true);
|
||||
|
||||
try {
|
||||
// 先清理缓存
|
||||
await CacheCleaner.instance.clean(context.read<AppProvider>());
|
||||
|
||||
final result = await BackupService.instance.importData();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../utils/toast_util.dart';
|
||||
import '../../services/sync/webdav_service.dart';
|
||||
import '../../services/sync/cache_cleaner.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
|
||||
/// WebDAV 备份页面
|
||||
@@ -127,11 +126,6 @@ class _WebDAVSyncPageState extends State<WebDAVSyncPage> {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
// 先清理缓存
|
||||
setState(() => _syncStep = '正在清理缓存...');
|
||||
await Future.delayed(Duration.zero);
|
||||
await CacheCleaner.instance.clean(context.read<AppProvider>());
|
||||
|
||||
SyncResult result;
|
||||
|
||||
if (_syncDirection == SyncDirection.upload) {
|
||||
|
||||
Reference in New Issue
Block a user