generated from dellevin/template
windows版本初步完成
This commit is contained in:
@@ -45,7 +45,7 @@ class BookDao {
|
||||
switch (sortMode) {
|
||||
case 1: return 'created_at DESC';
|
||||
case 2: return 'rating DESC NULLS LAST, updated_at DESC';
|
||||
default: return 'updated_at DESC';
|
||||
default: return 'created_at DESC';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../utils/image_path_helper.dart';
|
||||
import '../models/data_models.dart';
|
||||
|
||||
/// 数据库帮助类 - 管理数据库的创建和版本控制
|
||||
@@ -13,9 +15,14 @@ class DatabaseHelper {
|
||||
|
||||
DatabaseHelper._init();
|
||||
|
||||
/// 获取数据库根目录(统一使用 ImagePathHelper.getAppDir)
|
||||
Future<String> _getDbRootPath() async {
|
||||
return await ImagePathHelper.getAppDir();
|
||||
}
|
||||
|
||||
/// 数据库文件路径
|
||||
Future<String?> get databasePath async {
|
||||
final path = await getDatabasesPath();
|
||||
final path = await _getDbRootPath();
|
||||
return join(path, 'mooknote.db');
|
||||
}
|
||||
|
||||
@@ -67,8 +74,10 @@ class DatabaseHelper {
|
||||
}
|
||||
|
||||
Future<Database> _initDB(String filePath) async {
|
||||
final dbPath = await getDatabasesPath();
|
||||
final dbPath = await _getDbRootPath();
|
||||
final path = join(dbPath, filePath);
|
||||
// 确保目录存在
|
||||
await Directory(dbPath).create(recursive: true);
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
|
||||
@@ -45,7 +45,7 @@ class GameDao {
|
||||
switch (sortMode) {
|
||||
case 1: return 'created_at DESC';
|
||||
case 2: return 'rating DESC NULLS LAST, updated_at DESC';
|
||||
default: return 'updated_at DESC';
|
||||
default: return 'created_at DESC';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class MovieDao {
|
||||
switch (sortMode) {
|
||||
case 1: return 'created_at DESC';
|
||||
case 2: return 'rating DESC NULLS LAST, updated_at DESC';
|
||||
default: return 'updated_at DESC';
|
||||
default: return 'created_at DESC';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class NoteDao {
|
||||
switch (sortMode) {
|
||||
case 1: return 'is_pinned DESC, created_at DESC';
|
||||
case 2: return 'is_pinned DESC, title COLLATE NOCASE ASC';
|
||||
default: return 'is_pinned DESC, updated_at DESC';
|
||||
default: return 'is_pinned DESC, created_at DESC';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +551,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildEpubReadButton(book),
|
||||
if (!Platform.isWindows) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildFloatingButton(
|
||||
icon: Icons.share_outlined,
|
||||
@@ -298,6 +561,7 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
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,6 +668,7 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
),
|
||||
if (!Platform.isWindows) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildFloatingButton(
|
||||
icon: Icons.share_outlined,
|
||||
@@ -475,6 +678,7 @@ class _GameDetailPageState extends State<GameDetailPage> {
|
||||
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: [
|
||||
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,6 +593,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
),
|
||||
if (!Platform.isWindows) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildFloatingButton(
|
||||
icon: Icons.share_outlined,
|
||||
@@ -381,6 +603,7 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
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,6 +435,7 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
backgroundColor: colors.error,
|
||||
foregroundColor: colors.onError,
|
||||
),
|
||||
if (!Platform.isWindows) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildFloatingButton(
|
||||
icon: Icons.share_outlined,
|
||||
@@ -333,6 +445,7 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
|
||||
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,9 +335,10 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
final pages = m['pagination'];
|
||||
final coverUrl = cover.toString().isNotEmpty ? _resolveCoverUrl(cover.toString()) : '';
|
||||
|
||||
return Column(children: [
|
||||
// 顶部固定区域
|
||||
Container(
|
||||
return NestedScrollView(
|
||||
headerSliverBuilder: (context, _) => [
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
color: colors.surface,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
@@ -410,10 +412,14 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
||||
// Tab 栏
|
||||
Container(
|
||||
),
|
||||
// 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: [
|
||||
@@ -423,18 +429,17 @@ class _BookDetailPageState extends State<BookDetailPage> {
|
||||
_buildTabButton('书籍目录', 3),
|
||||
]),
|
||||
),
|
||||
|
||||
// 内容区
|
||||
Expanded(
|
||||
child: _currentTab == 0
|
||||
),
|
||||
),
|
||||
],
|
||||
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,9 +329,10 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
final typeParts =
|
||||
[typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / ');
|
||||
|
||||
return Column(children: [
|
||||
// 顶部:AppBar + 海报信息区
|
||||
Container(
|
||||
return NestedScrollView(
|
||||
headerSliverBuilder: (context, _) => [
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
color: colors.surface,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
@@ -374,7 +376,6 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 海报
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
@@ -389,7 +390,6 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -400,7 +400,6 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
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)),
|
||||
@@ -412,7 +411,6 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
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),
|
||||
@@ -432,7 +430,6 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
// 本地状态
|
||||
if (_localMovie != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
_buildLocalStatus(colors),
|
||||
@@ -441,12 +438,17 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
),
|
||||
]),
|
||||
),
|
||||
])),
|
||||
]),
|
||||
),
|
||||
|
||||
// Tab 栏
|
||||
Container(
|
||||
),
|
||||
),
|
||||
// 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: [
|
||||
@@ -454,13 +456,11 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
_buildTabButton('演职人员', 1),
|
||||
]),
|
||||
),
|
||||
|
||||
// 内容区
|
||||
Expanded(
|
||||
child:
|
||||
_currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors),
|
||||
),
|
||||
]);
|
||||
),
|
||||
],
|
||||
body: _currentTab == 0 ? _buildOverview(colors) : _buildStaffTab(colors),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 沉浸式布局 ──────────────────────────────────────────
|
||||
@@ -478,10 +478,10 @@ 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: [
|
||||
// 海报图
|
||||
return NestedScrollView(
|
||||
headerSliverBuilder: (context, _) => [
|
||||
SliverToBoxAdapter(
|
||||
child: Stack(children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 320,
|
||||
@@ -491,7 +491,6 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
: Container(color: colors.surfaceContainerHighest,
|
||||
child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))),
|
||||
),
|
||||
// 渐变遮罩
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
@@ -504,7 +503,6 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 顶部按钮
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
@@ -528,7 +526,6 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
]),
|
||||
),
|
||||
),
|
||||
// 底部信息叠加
|
||||
Positioned(
|
||||
left: 16, right: 16, bottom: 18,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
|
||||
@@ -572,22 +569,24 @@ class _MovieDetailPageState extends State<MovieDetailPage> {
|
||||
]),
|
||||
),
|
||||
]),
|
||||
|
||||
// Tab 栏
|
||||
Container(
|
||||
),
|
||||
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),
|
||||
]),
|
||||
),
|
||||
|
||||
// 内容区
|
||||
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,7 +78,9 @@ class _ProfilePageState extends State<ProfilePage> with RouteAware {
|
||||
AppBar(
|
||||
titleSpacing: 8,
|
||||
leadingWidth: 44,
|
||||
leading: Builder(
|
||||
leading: Breakpoint.isDesktop(context)
|
||||
? const SizedBox.shrink()
|
||||
: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: Icon(Icons.menu, color: colors.onSurface),
|
||||
onPressed: () => Scaffold.of(context).openDrawer(),
|
||||
@@ -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,6 +67,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
if (!Platform.isWindows) ...[
|
||||
_buildNavigationItem(
|
||||
icon: Icons.tune_outlined,
|
||||
title: '功能设置',
|
||||
@@ -89,6 +92,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
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,6 +132,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
if (!Platform.isWindows) ...[
|
||||
_buildSwitchItem(
|
||||
icon: Icons.swipe_vertical_outlined,
|
||||
title: '底部导航栏滚动隐藏',
|
||||
@@ -140,6 +145,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
],
|
||||
_buildSectionHeader('数据管理'),
|
||||
_buildActionItem(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
@@ -152,6 +158,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
indent: 24,
|
||||
endIndent: 24,
|
||||
color: colors.outlineVariant),
|
||||
if (!Platform.isWindows) ...[
|
||||
_buildActionItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: '获取系统权限',
|
||||
@@ -163,6 +170,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
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);
|
||||
}
|
||||
|
||||
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 game in provider.games) {
|
||||
if (game.coverPath?.isNotEmpty == true) paths.add(game.coverPath!);
|
||||
}
|
||||
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,6 +1223,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
if (await cacheDir.exists()) {
|
||||
await for (final entity in cacheDir.list(recursive: true, followLinks: false)) {
|
||||
if (entity is File) {
|
||||
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++;
|
||||
@@ -1190,6 +1236,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return (count, totalSize);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:uuid/uuid.dart';
|
||||
import 'epub_parser.dart';
|
||||
import '../../data/epub/reader_dao.dart';
|
||||
import '../../data/epub/reader_models.dart';
|
||||
import '../../utils/image_path_helper.dart';
|
||||
|
||||
/// EPUB 服务层 - 管理导入、解压、删除
|
||||
class EpubService {
|
||||
@@ -21,8 +22,8 @@ class EpubService {
|
||||
final fileName = p.basename(sourcePath);
|
||||
|
||||
// 复制 EPUB 到永久存储(FilePicker 临时文件会被清理)
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final bookDir = Directory(p.join(appDir.path, 'epub_books', bookId));
|
||||
final appDirPath = await ImagePathHelper.getAppDir();
|
||||
final bookDir = Directory(p.join(appDirPath, 'epub_books', bookId));
|
||||
if (!await bookDir.exists()) await bookDir.create(recursive: true);
|
||||
final permanentPath = p.join(bookDir.path, 'book.epub');
|
||||
await File(sourcePath).copy(permanentPath);
|
||||
@@ -98,8 +99,8 @@ class EpubService {
|
||||
if (!await coverFile.exists()) return null;
|
||||
|
||||
// 保存到 epub_books/{bookId}/ 目录下
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final coverDir = p.join(appDir.path, 'epub_books', bookId);
|
||||
final appDirPath = await ImagePathHelper.getAppDir();
|
||||
final coverDir = p.join(appDirPath, 'epub_books', bookId);
|
||||
await Directory(coverDir).create(recursive: true);
|
||||
final ext = p.extension(coverFile.path).toLowerCase();
|
||||
final destPath = p.join(coverDir, 'cover$ext');
|
||||
@@ -144,8 +145,8 @@ class EpubService {
|
||||
|
||||
// 清理 epub_books/{bookId}/ 目录(epub + 封面)
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final bookDir = Directory(p.join(appDir.path, 'epub_books', bookId));
|
||||
final appDirPath = await ImagePathHelper.getAppDir();
|
||||
final bookDir = Directory(p.join(appDirPath, 'epub_books', bookId));
|
||||
if (await bookDir.exists()) await bookDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'epub_stream_service.dart';
|
||||
import '../../utils/image_path_helper.dart';
|
||||
|
||||
/// Simple file reference with path and optional anchor.
|
||||
class Href {
|
||||
@@ -34,8 +34,8 @@ class EpubWebViewHandler {
|
||||
|
||||
static Future<String> getDocumentsPath() async {
|
||||
if (_documentsPath != null) return _documentsPath!;
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_documentsPath = '${dir.path}/';
|
||||
final appDirPath = await ImagePathHelper.getAppDir();
|
||||
_documentsPath = '$appDirPath/';
|
||||
return _documentsPath!;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../utils/image_path_helper.dart';
|
||||
|
||||
/// 本地字体扫描与加载管理器
|
||||
///
|
||||
@@ -131,8 +131,8 @@ class FontDownloadManager {
|
||||
return fontDir;
|
||||
}
|
||||
// iOS / 桌面端 fallback
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final fontDir = Directory(path.join(appDir.path, 'fonts'));
|
||||
final appDirPath = await ImagePathHelper.getAppDir();
|
||||
final fontDir = Directory(path.join(appDirPath, 'fonts'));
|
||||
if (!await fontDir.exists()) {
|
||||
await fontDir.create(recursive: true);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import '../../data/database_helper.dart';
|
||||
import '../../utils/user_prefs.dart';
|
||||
import '../../utils/image_path_helper.dart';
|
||||
|
||||
/// 数据备份服务 - 支持导出和导入数据(包含图片)
|
||||
class BackupService {
|
||||
@@ -17,6 +18,11 @@ class BackupService {
|
||||
|
||||
BackupService._init();
|
||||
|
||||
/// 获取应用数据根目录(统一路径)
|
||||
Future<String> _getAppDir() async {
|
||||
return await ImagePathHelper.getAppDir();
|
||||
}
|
||||
|
||||
// ─── 共享导出逻辑 ─────────────────────────────────────
|
||||
|
||||
/// 收集所有表数据和图片,构建 ZIP 文件
|
||||
@@ -112,13 +118,25 @@ class BackupService {
|
||||
|
||||
// 阶段2:后台 isolate 执行 JSON 编码 + ZIP 压缩(避免阻塞主线程动画)
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final appDirPath = await _getAppDir();
|
||||
|
||||
// DEBUG: 诊断 Windows 导出图片缺失问题
|
||||
final imagesRootPath = path.join(appDirPath, 'images');
|
||||
debugPrint('[BackupService] DEBUG appDirPath=$appDirPath');
|
||||
debugPrint('[BackupService] DEBUG imagesRoot=$imagesRootPath');
|
||||
debugPrint('[BackupService] DEBUG imagePaths count=${imagePaths.length}');
|
||||
for (final ip in imagePaths) {
|
||||
final f = File(ip);
|
||||
final exists = f.existsSync();
|
||||
final match = ip.startsWith(imagesRootPath);
|
||||
debugPrint('[BackupService] DEBUG path=$ip exists=$exists startsWithImagesRoot=$match');
|
||||
}
|
||||
|
||||
final result = await compute(_buildZipInIsolate, _ZipComputeParams(
|
||||
backupData: backupData,
|
||||
imagePaths: imagePaths.toList(),
|
||||
tempDirPath: tempDir.path,
|
||||
appDirPath: appDir.path,
|
||||
appDirPath: appDirPath,
|
||||
));
|
||||
|
||||
return _ExportData(
|
||||
@@ -144,19 +162,17 @@ class BackupService {
|
||||
|
||||
String? finalPath;
|
||||
try {
|
||||
// FilePicker.saveFile 需要 bytes,这里必须读入内存
|
||||
final zipBytes = await zipFile.readAsBytes();
|
||||
final outputPath = await FilePicker.platform.saveFile(
|
||||
dialogTitle: '保存备份文件',
|
||||
fileName: fileName,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['zip'],
|
||||
bytes: zipBytes,
|
||||
);
|
||||
if (outputPath == null) {
|
||||
await zipFile.delete();
|
||||
return ExportResult.cancelled();
|
||||
}
|
||||
await zipFile.copy(outputPath);
|
||||
finalPath = outputPath;
|
||||
} catch (e) {
|
||||
// FilePicker 不可用时,复制到临时目录
|
||||
@@ -239,8 +255,8 @@ class BackupService {
|
||||
|
||||
backupData = jsonDecode(utf8.decode(dataFile.content as List<int>)) as Map<String, dynamic>;
|
||||
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesDir = Directory(path.join(appDir.path, 'images'));
|
||||
final appDirPath = await _getAppDir();
|
||||
final imagesDir = Directory(path.join(appDirPath, 'images'));
|
||||
if (!await imagesDir.exists()) await imagesDir.create(recursive: true);
|
||||
|
||||
for (final archiveFile in archive) {
|
||||
@@ -254,7 +270,7 @@ class BackupService {
|
||||
imageCount++;
|
||||
} else if (archiveFile.name.startsWith('epub_books/')) {
|
||||
final relativePath = archiveFile.name.substring(12);
|
||||
final epubDir = Directory(path.join(appDir.path, 'epub_books'));
|
||||
final epubDir = Directory(path.join(appDirPath, 'epub_books'));
|
||||
if (!await epubDir.exists()) await epubDir.create(recursive: true);
|
||||
final outputFile = File(path.join(epubDir.path, relativePath));
|
||||
if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true);
|
||||
@@ -401,8 +417,8 @@ class BackupService {
|
||||
final epubFileMap = <String, String>{};
|
||||
int imageCount = 0;
|
||||
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesDir = Directory(path.join(appDir.path, 'images'));
|
||||
final appDirPath = await _getAppDir();
|
||||
final imagesDir = Directory(path.join(appDirPath, 'images'));
|
||||
if (!await imagesDir.exists()) await imagesDir.create(recursive: true);
|
||||
|
||||
for (final archiveFile in archive) {
|
||||
@@ -415,7 +431,7 @@ class BackupService {
|
||||
imageCount++;
|
||||
} else if (archiveFile.name.startsWith('epub_books/')) {
|
||||
final relativePath = archiveFile.name.substring(12);
|
||||
final epubDir = Directory(path.join(appDir.path, 'epub_books'));
|
||||
final epubDir = Directory(path.join(appDirPath, 'epub_books'));
|
||||
if (!await epubDir.exists()) await epubDir.create(recursive: true);
|
||||
final outputFile = File(path.join(epubDir.path, relativePath));
|
||||
if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true);
|
||||
@@ -627,12 +643,11 @@ class BackupService {
|
||||
|
||||
/// 将绝对路径转为 images/ 下的相对路径(用于 imagePathMap key)
|
||||
String _toRelativePath(String absolutePath) {
|
||||
// 统一为正斜杠,避免 Windows 反斜杠与 zip 内正斜杠不匹配
|
||||
final normalized = absolutePath.replaceAll('\\', '/');
|
||||
// 尝试提取 images/ 后面的部分
|
||||
final idx = absolutePath.indexOf('/images/');
|
||||
if (idx >= 0) return absolutePath.substring(idx + 8); // skip '/images/'
|
||||
// Windows 路径
|
||||
final winIdx = absolutePath.indexOf('\\images\\');
|
||||
if (winIdx >= 0) return absolutePath.substring(winIdx + 8);
|
||||
final idx = normalized.indexOf('/images/');
|
||||
if (idx >= 0) return normalized.substring(idx + 8); // skip '/images/'
|
||||
return path.basename(absolutePath);
|
||||
}
|
||||
|
||||
@@ -683,10 +698,9 @@ class BackupService {
|
||||
|
||||
/// 从绝对路径中提取 epub_books/ 下的相对路径
|
||||
String? _toEpubRelativePath(String absolutePath) {
|
||||
final idx = absolutePath.indexOf('/epub_books/');
|
||||
if (idx >= 0) return absolutePath.substring(idx + 13); // skip '/epub_books/'
|
||||
final winIdx = absolutePath.indexOf('\\epub_books\\');
|
||||
if (winIdx >= 0) return absolutePath.substring(winIdx + 13);
|
||||
final normalized = absolutePath.replaceAll('\\', '/');
|
||||
final idx = normalized.indexOf('/epub_books/');
|
||||
if (idx >= 0) return normalized.substring(idx + 13); // skip '/epub_books/'
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -910,14 +924,16 @@ _ZipComputeResult _buildZipInIsolate(_ZipComputeParams params) {
|
||||
dataFile.deleteSync();
|
||||
|
||||
int imageCount = 0;
|
||||
final imagesRoot = path.join(params.appDirPath, 'images');
|
||||
|
||||
for (final imagePath in params.imagePaths) {
|
||||
final file = File(imagePath);
|
||||
if (file.existsSync()) {
|
||||
// 统一用 /images/ 子串匹配提取相对路径,兼容旧路径(路径前缀可能不含 mooknote 子目录)
|
||||
final normalized = imagePath.replaceAll('\\', '/');
|
||||
final idx = normalized.indexOf('/images/');
|
||||
String relativePath;
|
||||
if (imagePath.startsWith(imagesRoot)) {
|
||||
relativePath = imagePath.substring(imagesRoot.length + 1);
|
||||
if (idx >= 0) {
|
||||
relativePath = normalized.substring(idx + 8); // skip '/images/'
|
||||
} else {
|
||||
relativePath = path.basename(imagePath);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../data/database_helper.dart';
|
||||
import '../../utils/image_path_helper.dart';
|
||||
import '../../utils/user_prefs.dart';
|
||||
|
||||
/// 缓存清理服务
|
||||
class CacheCleaner {
|
||||
@@ -11,10 +13,10 @@ class CacheCleaner {
|
||||
static final CacheCleaner instance = CacheCleaner._();
|
||||
|
||||
/// 执行完整缓存清理,返回各分类删除数量
|
||||
Future<CacheCleanResult> clean(AppProvider provider) async {
|
||||
final dbImagePaths = await _getAllDbImagePaths(provider);
|
||||
Future<CacheCleanResult> clean() async {
|
||||
final dbImagePaths = await _getAllDbImagePaths();
|
||||
final deletedImages = await _cleanImageDirectory(dbImagePaths);
|
||||
final deletedEpubs = await _cleanOrphanedEpubBooks(provider);
|
||||
final deletedEpubs = await _cleanOrphanedEpubBooks();
|
||||
final deletedTemp = await _cleanTempDirectory();
|
||||
final deletedEmptyDirs = await _cleanEmptyDirectories();
|
||||
return CacheCleanResult(
|
||||
@@ -25,44 +27,87 @@ class CacheCleaner {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Set<String>> _getAllDbImagePaths(AppProvider provider) async {
|
||||
/// 直接查 DB 收集所有图片路径(含软删除记录,与 BackupService 保持一致)
|
||||
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);
|
||||
}
|
||||
|
||||
// 游戏封面
|
||||
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 game in provider.games) {
|
||||
if (game.coverPath?.isNotEmpty == true) paths.add(game.coverPath!);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/// 规范化路径用于跨平台比较(统一分隔符、去掉末尾分隔符)
|
||||
/// Windows 上 DB 存的路径和文件系统遍历得到的路径分隔符可能不一致,
|
||||
/// 直接字符串比较会漏匹配导致图片被误删。
|
||||
String _normalize(String p) {
|
||||
// 统一为正斜杠后再用 path.normalize 处理 .. 和 . 等
|
||||
final unified = p.replaceAll('\\', '/');
|
||||
return path.normalize(unified);
|
||||
}
|
||||
|
||||
Future<int> _cleanImageDirectory(Set<String> dbImagePaths) async {
|
||||
int deletedCount = 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;
|
||||
// 预先规范化 DB 路径,避免每个文件都做转换
|
||||
final normalizedDbPaths = dbImagePaths.map(_normalize).toSet();
|
||||
await for (final entity in imagesDir.list(recursive: true, followLinks: false)) {
|
||||
if (entity is File &&
|
||||
!dbImagePaths.contains(entity.path) &&
|
||||
!normalizedDbPaths.contains(_normalize(entity.path)) &&
|
||||
!path.basename(entity.path).startsWith('avatar')) {
|
||||
try {
|
||||
await entity.delete();
|
||||
@@ -76,7 +121,7 @@ class CacheCleaner {
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
Future<int> _cleanOrphanedEpubBooks(AppProvider provider) async {
|
||||
Future<int> _cleanOrphanedEpubBooks() async {
|
||||
int deletedCount = 0;
|
||||
try {
|
||||
final db = await DatabaseHelper.instance.database;
|
||||
@@ -91,9 +136,10 @@ class CacheCleaner {
|
||||
_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'),
|
||||
// Android 旧版绝对路径(path.join 在 Windows 上不会破坏它)
|
||||
'/data/user/0/top.iletter.mooknote/app_flutter/epub_books',
|
||||
];
|
||||
|
||||
@@ -118,16 +164,36 @@ class CacheCleaner {
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/// 从路径中提取 epub_books/{bookId} 的 bookId 部分
|
||||
/// 兼容 Windows(\) 和 Unix(/) 分隔符
|
||||
void _collectEpubDirName(String? pathStr, Set<String> dirs) {
|
||||
if (pathStr == null || pathStr.isEmpty) return;
|
||||
// 统一为正斜杠便于查找 marker
|
||||
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);
|
||||
}
|
||||
|
||||
/// mooknote 自己产生的临时文件名前缀
|
||||
static const _tempPrefixes = [
|
||||
'book_poster_',
|
||||
'movie_poster_',
|
||||
'note_share_',
|
||||
'mooknote_download',
|
||||
'mooknote_bidir',
|
||||
];
|
||||
|
||||
bool _isMooknoteTempFile(String name) {
|
||||
for (final prefix in _tempPrefixes) {
|
||||
if (name.startsWith(prefix)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<int> _cleanTempDirectory() async {
|
||||
int deletedCount = 0;
|
||||
final now = DateTime.now();
|
||||
@@ -138,11 +204,7 @@ class CacheCleaner {
|
||||
await for (final entity in tempDir.list(followLinks: false)) {
|
||||
if (entity is File) {
|
||||
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')) {
|
||||
if (_isMooknoteTempFile(name)) {
|
||||
try {
|
||||
final stat = await entity.stat();
|
||||
if (now.difference(stat.modified).inHours >= 1) {
|
||||
@@ -158,11 +220,15 @@ class CacheCleaner {
|
||||
debugPrint('清理临时目录失败: $e');
|
||||
}
|
||||
|
||||
// cacheDir 只删 mooknote 自己产生的临时文件,不再无差别全清
|
||||
// (Windows/Flutter 引擎也在该目录放缓存文件,全清可能误伤)
|
||||
try {
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
if (await cacheDir.exists()) {
|
||||
await for (final entity in cacheDir.list(recursive: true, followLinks: false)) {
|
||||
if (entity is File) {
|
||||
final name = path.basename(entity.path);
|
||||
if (_isMooknoteTempFile(name)) {
|
||||
try {
|
||||
await entity.delete();
|
||||
deletedCount++;
|
||||
@@ -170,6 +236,7 @@ class CacheCleaner {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('清理缓存目录失败: $e');
|
||||
}
|
||||
@@ -180,11 +247,11 @@ class CacheCleaner {
|
||||
Future<int> _cleanEmptyDirectories() async {
|
||||
int deletedCount = 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) {
|
||||
|
||||
@@ -17,19 +17,24 @@ class ImagePathHelper {
|
||||
|
||||
ImagePathHelper._init();
|
||||
|
||||
String? _appDirPath;
|
||||
static String? _appDirPath;
|
||||
|
||||
/// 获取应用文档目录
|
||||
Future<String> get _appDir async {
|
||||
/// 获取应用数据根目录(Windows 下统一到 mooknote 子目录)
|
||||
/// 所有需要访问 images/、epub_books/ 等目录的代码都应使用此方法
|
||||
static Future<String> getAppDir() async {
|
||||
if (_appDirPath != null) return _appDirPath!;
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
if (Platform.isWindows) {
|
||||
_appDirPath = p.join(appDir.path, 'mooknote');
|
||||
} else {
|
||||
_appDirPath = appDir.path;
|
||||
}
|
||||
return _appDirPath!;
|
||||
}
|
||||
|
||||
/// 获取图片根目录
|
||||
Future<String> get imagesRoot async {
|
||||
final appDir = await _appDir;
|
||||
final appDir = await getAppDir();
|
||||
return p.join(appDir, 'images');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 响应式布局断点工具
|
||||
@@ -8,15 +9,24 @@ class Breakpoint {
|
||||
/// ≥900dp: 宽屏内容区(列表-详情并排显示)
|
||||
static const double wideContent = 900.0;
|
||||
|
||||
/// ≥1200dp: 桌面布局(侧边导航 + 宽内容区)
|
||||
static const double desktop = 1200.0;
|
||||
|
||||
static bool isTablet(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).width >= tablet;
|
||||
Platform.isWindows || MediaQuery.sizeOf(context).width >= tablet;
|
||||
|
||||
static bool isPhone(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).width < tablet;
|
||||
!Platform.isWindows && MediaQuery.sizeOf(context).width < tablet;
|
||||
|
||||
/// 是否使用宽屏内容布局(列表+详情并排)
|
||||
/// 桌面模式下始终为 true(列表已在第二栏,第三栏只显示详情)
|
||||
static bool isWideContent(BuildContext context) =>
|
||||
MediaQuery.sizeOf(context).width >= wideContent;
|
||||
Platform.isWindows || MediaQuery.sizeOf(context).width >= wideContent;
|
||||
|
||||
/// 是否使用桌面布局(侧边导航 + 宽内容区)
|
||||
/// Windows 平台始终使用桌面布局
|
||||
static bool isDesktop(BuildContext context) =>
|
||||
Platform.isWindows || MediaQuery.sizeOf(context).width >= desktop;
|
||||
}
|
||||
|
||||
/// 根据可用宽度动态计算网格列数
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../providers/app_provider.dart';
|
||||
import '../utils/user_prefs.dart';
|
||||
|
||||
@@ -96,6 +97,30 @@ void showAddSheet(BuildContext context, AppProvider provider) {
|
||||
));
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
final bc = Theme.of(ctx).colorScheme;
|
||||
return AlertDialog(
|
||||
backgroundColor: bc.surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
titlePadding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 8),
|
||||
title: Text('新增记录',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: bc.onSurface)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: options,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colors.surface,
|
||||
@@ -140,6 +165,7 @@ void showAddSheet(BuildContext context, AppProvider provider) {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildOption({
|
||||
required ColorScheme colors,
|
||||
|
||||
@@ -19,6 +19,10 @@ class MasterDetailScaffold extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 桌面三栏布局下只显示 detail(列表已在侧边栏)
|
||||
if (Breakpoint.isDesktop(context) && detail != null) {
|
||||
return detail!;
|
||||
}
|
||||
if (!Breakpoint.isWideContent(context) || detail == null) {
|
||||
return master;
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
project.set_dart_entrypoint_arguments(std::move(command_line_arguments));
|
||||
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
Win32Window::Point origin(100, 100);
|
||||
Win32Window::Size size(1400, 900);
|
||||
if (!window.Create(L"MookNote", origin, size)) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 9.2 KiB |
@@ -199,7 +199,7 @@ Win32Window::MessageHandler(HWND hwnd,
|
||||
}
|
||||
case WM_GETMINMAXINFO: {
|
||||
auto info = reinterpret_cast<MINMAXINFO*>(lparam);
|
||||
info->ptMinTrackSize.x = 360;
|
||||
info->ptMinTrackSize.x = 900;
|
||||
info->ptMinTrackSize.y = 640;
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user