windows版本初步完成

This commit is contained in:
DelLevin-Home
2026-07-13 19:39:01 +08:00
parent 121cbf8b0b
commit 6c30026e32
37 changed files with 5583 additions and 624 deletions

View File

@@ -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';
}
}

View File

@@ -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,

View File

@@ -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';
}
}

View File

@@ -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';
}
}

View File

@@ -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';
}
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,
),
],
],
);
}

View File

@@ -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(),

View File

@@ -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

View File

@@ -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()),
],
);

View File

@@ -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,
),
],
],
);
}

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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)),
),
],
),

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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 (_) {}

View File

@@ -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!;
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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');
}

View File

@@ -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;
}
/// 根据可用宽度动态计算网格列数

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;
}