generated from dellevin/template
代码优化2
This commit is contained in:
@@ -48,7 +48,7 @@ Future<void> _initAutoBackup() async {
|
||||
await AutoBackupService.instance.start();
|
||||
}
|
||||
} catch (e) {
|
||||
print('初始化自动备份失败: $e');
|
||||
debugPrint('初始化自动备份失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ Future<void> _initUsageStats() async {
|
||||
try {
|
||||
await UsageStatsService.instance.start();
|
||||
} catch (e) {
|
||||
print('初始化用户统计失败: $e');
|
||||
debugPrint('初始化用户统计失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,14 +240,6 @@ class _AppIconWrapper extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AppIconWrapperState extends State<_AppIconWrapper> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_updateSystemIcon();
|
||||
}
|
||||
|
||||
Future<void> _updateSystemIcon() async {}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
|
||||
@@ -7,6 +7,31 @@ class _CopyWithNullSentinel {
|
||||
}
|
||||
const _copyWithNull = _CopyWithNullSentinel();
|
||||
|
||||
/// 安全解析日期字符串,失败时返回 fallback
|
||||
DateTime? _safeParseDate(String? str, {DateTime? fallback}) {
|
||||
if (str == null || str.isEmpty) return fallback;
|
||||
return DateTime.tryParse(str) ?? fallback;
|
||||
}
|
||||
|
||||
/// 解析字符串列表(通用工具函数,不限于 Movie)
|
||||
List<String> parseStringListGeneric(dynamic data) {
|
||||
if (data == null) return [];
|
||||
if (data is List) {
|
||||
return data.map((e) => e.toString()).toList();
|
||||
}
|
||||
if (data is String) {
|
||||
try {
|
||||
final decoded = jsonDecode(data);
|
||||
if (decoded is List) {
|
||||
return decoded.map((e) => e.toString()).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
return data.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// 影视条目模型
|
||||
class Movie {
|
||||
final String id;
|
||||
@@ -52,9 +77,7 @@ class Movie {
|
||||
id: json['id'] ?? '',
|
||||
title: json['title'] ?? '',
|
||||
posterPath: json['poster_path'],
|
||||
releaseDate: json['release_date'] != null
|
||||
? DateTime.parse(json['release_date'])
|
||||
: null,
|
||||
releaseDate: _safeParseDate(json['release_date']),
|
||||
directors: _parseStringList(json['directors']),
|
||||
writers: _parseStringList(json['writers']),
|
||||
actors: _parseStringList(json['actors']),
|
||||
@@ -63,15 +86,9 @@ class Movie {
|
||||
summary: json['summary'],
|
||||
rating: json['rating']?.toDouble(),
|
||||
status: json['status'] ?? 'want_to_watch',
|
||||
watchDate: json['watch_date'] != null
|
||||
? DateTime.parse(json['watch_date'])
|
||||
: null,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'])
|
||||
: DateTime.now(),
|
||||
watchDate: _safeParseDate(json['watch_date']),
|
||||
createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!,
|
||||
updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!,
|
||||
isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true,
|
||||
coverOffset: (json['cover_offset'] ?? 0.0).toDouble(),
|
||||
);
|
||||
@@ -105,29 +122,11 @@ class Movie {
|
||||
return File(posterPath!);
|
||||
}
|
||||
|
||||
/// 解析字符串列表(公共静态方法,供Book使用)
|
||||
static List<String> parseStringList(dynamic data) {
|
||||
if (data == null) return [];
|
||||
if (data is List) {
|
||||
return data.map((e) => e.toString()).toList();
|
||||
}
|
||||
if (data is String) {
|
||||
try {
|
||||
// 尝试解析JSON字符串
|
||||
final decoded = jsonDecode(data);
|
||||
if (decoded is List) {
|
||||
return decoded.map((e) => e.toString()).toList();
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果解析失败,按逗号分割
|
||||
return data.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// 解析字符串列表(公共静态方法,供外部使用)
|
||||
static List<String> parseStringList(dynamic data) => parseStringListGeneric(data);
|
||||
|
||||
/// 解析字符串列表(私有别名,保持兼容性)
|
||||
static List<String> _parseStringList(dynamic data) => parseStringList(data);
|
||||
static List<String> _parseStringList(dynamic data) => parseStringListGeneric(data);
|
||||
|
||||
/// 复制并修改
|
||||
Movie copyWith({
|
||||
@@ -222,15 +221,9 @@ class Book {
|
||||
rating: json['rating']?.toDouble(),
|
||||
status: json['status'] ?? 'want_to_read',
|
||||
isbn: json['isbn'],
|
||||
publishDate: json['publish_date'] != null
|
||||
? DateTime.parse(json['publish_date'])
|
||||
: null,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'])
|
||||
: DateTime.now(),
|
||||
publishDate: _safeParseDate(json['publish_date']),
|
||||
createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!,
|
||||
updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!,
|
||||
isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true,
|
||||
coverOffset: (json['cover_offset'] ?? 0.0).toDouble(),
|
||||
);
|
||||
@@ -337,12 +330,8 @@ class Note {
|
||||
contentType: json['content_type'] ?? 'markdown',
|
||||
tags: Movie.parseStringList(json['tags']),
|
||||
images: Movie.parseStringList(json['images']),
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'])
|
||||
: DateTime.now(),
|
||||
createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!,
|
||||
updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!,
|
||||
isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true,
|
||||
isPinned: json['is_pinned'] == 1 || json['is_pinned'] == true,
|
||||
);
|
||||
@@ -430,12 +419,8 @@ class MovieReview {
|
||||
source: json['source'] ?? '',
|
||||
reviewType: json['review_type'] ?? 1,
|
||||
isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'])
|
||||
: DateTime.now(),
|
||||
createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!,
|
||||
updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -510,9 +495,7 @@ class MoviePoster {
|
||||
movieId: json['movie_id']?.toString() ?? '',
|
||||
posterPath: json['poster_path'] ?? '',
|
||||
isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'])
|
||||
: DateTime.now(),
|
||||
createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -566,12 +549,8 @@ class BookReview {
|
||||
source: json['source'] ?? '',
|
||||
reviewType: json['review_type'] ?? 1,
|
||||
isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'])
|
||||
: DateTime.now(),
|
||||
createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!,
|
||||
updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -654,12 +633,8 @@ class BookExcerpt {
|
||||
content: json['content'] ?? '',
|
||||
comment: json['comment'] ?? '',
|
||||
isDeleted: json['is_deleted'] == 1 || json['is_deleted'] == true,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'])
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'])
|
||||
: DateTime.now(),
|
||||
createdAt: _safeParseDate(json['created_at'], fallback: DateTime.now())!,
|
||||
updatedAt: _safeParseDate(json['updated_at'], fallback: DateTime.now())!,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ class _BookSharePageState extends State<BookSharePage> {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
|
||||
@@ -104,7 +104,7 @@ class _HomePageState extends State<HomePage> {
|
||||
right: Radius.circular(28)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
spreadRadius: 0,
|
||||
|
||||
@@ -91,15 +91,26 @@ class _MediaCalendarPageState extends State<MediaCalendarPage> {
|
||||
_buildMonthHeader(colors),
|
||||
_buildWeekdayLabels(colors),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildCalendarGrid(colors, today),
|
||||
if (_selectedDay != null) _buildSelectedDayDetail(colors),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: _selectedDay != null && (_dayItems[_selectedDay]?.isNotEmpty ?? false)
|
||||
? Column(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
child: _buildCalendarGrid(colors, today),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildSelectedDayDetail(colors),
|
||||
),
|
||||
],
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildCalendarGrid(colors, today),
|
||||
if (_selectedDay != null) _buildSelectedDayDetail(colors),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -299,7 +310,6 @@ class _MediaCalendarPageState extends State<MediaCalendarPage> {
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(top: BorderSide(color: colors.outlineVariant, width: 0.5)),
|
||||
),
|
||||
@@ -322,9 +332,8 @@ class _MediaCalendarPageState extends State<MediaCalendarPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 0.5, color: colors.outlineVariant),
|
||||
|
||||
@@ -99,7 +99,7 @@ class _DoubanWebViewPageState extends State<DoubanWebViewPage> {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Material(
|
||||
@@ -130,7 +130,7 @@ class _DoubanWebViewPageState extends State<DoubanWebViewPage> {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Material(
|
||||
|
||||
@@ -147,7 +147,7 @@ class _MoviePostersPageState extends State<MoviePostersPage> {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.08),
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -176,7 +176,7 @@ class _MoviePostersPageState extends State<MoviePostersPage> {
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withOpacity(0.3),
|
||||
Colors.black.withValues(alpha: 0.3),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -76,7 +76,7 @@ class _PosterGalleryPageState extends State<PosterGalleryPage> {
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.7),
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
@@ -126,7 +126,7 @@ class _PosterGalleryPageState extends State<PosterGalleryPage> {
|
||||
shape: BoxShape.circle,
|
||||
color: index == _currentIndex
|
||||
? Colors.white
|
||||
: Colors.white.withOpacity(0.4),
|
||||
: Colors.white.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -33,6 +33,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
||||
String? _tempNoteId; // 新建模式时使用的临时笔记ID
|
||||
String _editorMode = 'edit'; // 'edit' | 'preview'
|
||||
Timer? _autoSaveTimer;
|
||||
Timer? _saveStatusTimer;
|
||||
String _saveStatus = ''; // '', 'saved'
|
||||
Note? _savedNote; // 新建模式首次自动保存后的笔记引用
|
||||
|
||||
@@ -56,6 +57,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
_autoSaveTimer?.cancel();
|
||||
_saveStatusTimer?.cancel();
|
||||
_titleController.dispose();
|
||||
_contentController.dispose();
|
||||
super.dispose();
|
||||
@@ -116,7 +118,8 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _saveStatus = 'saved');
|
||||
Timer(const Duration(seconds: 3), () {
|
||||
_saveStatusTimer?.cancel();
|
||||
_saveStatusTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted && _saveStatus == 'saved') setState(() => _saveStatus = '');
|
||||
});
|
||||
}
|
||||
@@ -875,7 +878,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
||||
builder: (context) => GestureDetector(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.9),
|
||||
color: Colors.black.withValues(alpha: 0.9),
|
||||
child: Center(
|
||||
child: InteractiveViewer(
|
||||
panEnabled: true,
|
||||
|
||||
@@ -95,7 +95,7 @@ class _NoteSharePageState extends State<NoteSharePage> {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
|
||||
@@ -24,8 +24,10 @@ class NotePlusDetailPage extends StatelessWidget {
|
||||
builder: (context, provider, _) {
|
||||
final doc = provider.currentDocument;
|
||||
if (doc == null || doc.id != documentId) {
|
||||
// 加载文档
|
||||
provider.loadDocumentById(documentId);
|
||||
// 加载文档(延迟到 build 完成后执行,避免副作用)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
provider.loadDocumentById(documentId);
|
||||
});
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
|
||||
@@ -1660,7 +1660,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: colors.primary,
|
||||
activeTrackColor: colors.primary.withOpacity(0.3),
|
||||
activeTrackColor: colors.primary.withValues(alpha: 0.3),
|
||||
inactiveThumbColor: colors.surface,
|
||||
inactiveTrackColor: colors.outline),
|
||||
],
|
||||
@@ -2110,7 +2110,7 @@ class _MainContentSettingsPageState extends State<MainContentSettingsPage> {
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: colors.primary,
|
||||
activeTrackColor: colors.primary.withOpacity(0.3),
|
||||
activeTrackColor: colors.primary.withValues(alpha: 0.3),
|
||||
inactiveThumbColor: colors.surface,
|
||||
inactiveTrackColor: colors.outline),
|
||||
);
|
||||
|
||||
@@ -88,7 +88,7 @@ class AppProvider extends ChangeNotifier {
|
||||
final showBook = userPrefs.showBookTab;
|
||||
final showNote = userPrefs.showNoteTab;
|
||||
final enabled = [showMovie, showBook, showNote];
|
||||
if (enabled[defaultIndex]) {
|
||||
if (defaultIndex >= 0 && defaultIndex < enabled.length && enabled[defaultIndex]) {
|
||||
_mainTabIndex = defaultIndex;
|
||||
} else {
|
||||
// 回退到第一个启用的标签
|
||||
@@ -568,7 +568,7 @@ class AppProvider extends ChangeNotifier {
|
||||
final movies = await db.query('movies',
|
||||
where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']);
|
||||
for (final row in movies) {
|
||||
for (final genre in Movie.parseStringList(row['genres'])) {
|
||||
for (final genre in parseStringListGeneric(row['genres'])) {
|
||||
await insertTag(genre, 'movie_genre');
|
||||
}
|
||||
}
|
||||
@@ -577,7 +577,7 @@ class AppProvider extends ChangeNotifier {
|
||||
final books = await db.query('books',
|
||||
where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']);
|
||||
for (final row in books) {
|
||||
for (final genre in Movie.parseStringList(row['genres'])) {
|
||||
for (final genre in parseStringListGeneric(row['genres'])) {
|
||||
await insertTag(genre, 'book_genre');
|
||||
}
|
||||
}
|
||||
@@ -587,7 +587,7 @@ class AppProvider extends ChangeNotifier {
|
||||
where: 'tags IS NOT NULL AND tags != ? AND tags != ?',
|
||||
whereArgs: ['[]', '']);
|
||||
for (final row in notes) {
|
||||
for (final tag in Movie.parseStringList(row['tags'])) {
|
||||
for (final tag in parseStringListGeneric(row['tags'])) {
|
||||
await insertTag(tag, 'note_tag');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,9 @@ class BookDao {
|
||||
// 彻底删除书籍
|
||||
Future<int> permanentDeleteBook(String id) => _wrap('permanentDeleteBook', () async {
|
||||
final db = await _dbHelper.database;
|
||||
// 清理子记录,防止孤儿数据
|
||||
await db.delete('book_reviews', where: 'book_id = ?', whereArgs: [id]);
|
||||
await db.delete('book_excerpts', where: 'book_id = ?', whereArgs: [id]);
|
||||
return await db.delete(
|
||||
'books',
|
||||
where: 'id = ?',
|
||||
|
||||
@@ -67,7 +67,7 @@ class BookExcerptDao {
|
||||
final db = await _dbHelper.database;
|
||||
await db.update(
|
||||
'book_excerpts',
|
||||
{'is_deleted': 1},
|
||||
{'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ class BookReviewDao {
|
||||
final db = await _dbHelper.database;
|
||||
await db.update(
|
||||
'book_reviews',
|
||||
{'is_deleted': 1},
|
||||
{'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../models/data_models.dart';
|
||||
class DatabaseHelper {
|
||||
static final DatabaseHelper instance = DatabaseHelper._init();
|
||||
static Database? _database;
|
||||
static bool _isReopening = false;
|
||||
|
||||
DatabaseHelper._init();
|
||||
|
||||
@@ -18,16 +19,25 @@ class DatabaseHelper {
|
||||
|
||||
/// 重新打开数据库(用于 WebDAV 同步后)
|
||||
Future<void> reopenDatabase() async {
|
||||
// 关闭现有连接
|
||||
if (_database != null) {
|
||||
await _database!.close();
|
||||
_database = null;
|
||||
// 防止并发重开
|
||||
if (_isReopening) return;
|
||||
_isReopening = true;
|
||||
try {
|
||||
if (_database != null) {
|
||||
await _database!.close();
|
||||
_database = null;
|
||||
}
|
||||
_database = await _initDB('mooknote.db');
|
||||
} finally {
|
||||
_isReopening = false;
|
||||
}
|
||||
// 重新初始化
|
||||
_database = await _initDB('mooknote.db');
|
||||
}
|
||||
|
||||
Future<Database> get database async {
|
||||
// 等待重开完成
|
||||
while (_isReopening) {
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
if (_database != null) return _database!;
|
||||
_database = await _initDB('mooknote.db');
|
||||
return _database!;
|
||||
@@ -157,7 +167,9 @@ class DatabaseHelper {
|
||||
await db.execute("ALTER TABLE note_plus ADD COLUMN parent_id TEXT DEFAULT ''");
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
debugPrint('Migration v20 failed: $e');
|
||||
}
|
||||
}
|
||||
if (oldVersion < 21) {
|
||||
try {
|
||||
@@ -165,7 +177,9 @@ class DatabaseHelper {
|
||||
if (!cols.any((col) => col['name'] == 'sort_index')) {
|
||||
await db.execute("ALTER TABLE note_plus ADD COLUMN sort_index INTEGER DEFAULT 0");
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
debugPrint('Migration v21 failed: $e');
|
||||
}
|
||||
}
|
||||
if (oldVersion < 22) {
|
||||
// 为笔记表添加置顶字段
|
||||
@@ -285,7 +299,7 @@ class DatabaseHelper {
|
||||
final movies = await db.query('movies',
|
||||
where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']);
|
||||
for (final row in movies) {
|
||||
for (final genre in Movie.parseStringList(row['genres'])) {
|
||||
for (final genre in parseStringListGeneric(row['genres'])) {
|
||||
await insertTag(genre, 'movie_genre');
|
||||
}
|
||||
}
|
||||
@@ -294,7 +308,7 @@ class DatabaseHelper {
|
||||
final books = await db.query('books',
|
||||
where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']);
|
||||
for (final row in books) {
|
||||
for (final genre in Movie.parseStringList(row['genres'])) {
|
||||
for (final genre in parseStringListGeneric(row['genres'])) {
|
||||
await insertTag(genre, 'book_genre');
|
||||
}
|
||||
}
|
||||
@@ -304,7 +318,7 @@ class DatabaseHelper {
|
||||
where: 'tags IS NOT NULL AND tags != ? AND tags != ?',
|
||||
whereArgs: ['[]', '']);
|
||||
for (final row in notes) {
|
||||
for (final tag in Movie.parseStringList(row['tags'])) {
|
||||
for (final tag in parseStringListGeneric(row['tags'])) {
|
||||
await insertTag(tag, 'note_tag');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,9 @@ class MovieDao {
|
||||
// 彻底删除影视
|
||||
Future<int> permanentDeleteMovie(String id) => _wrap('permanentDeleteMovie', () async {
|
||||
final db = await _dbHelper.database;
|
||||
// 清理子记录,防止孤儿数据
|
||||
await db.delete('movie_reviews', where: 'movie_id = ?', whereArgs: [id]);
|
||||
await db.delete('movie_posters', where: 'movie_id = ?', whereArgs: [id]);
|
||||
return await db.delete(
|
||||
'movies',
|
||||
where: 'id = ?',
|
||||
|
||||
@@ -61,7 +61,7 @@ class MovieReviewDao {
|
||||
final db = await _dbHelper.database;
|
||||
return await db.update(
|
||||
'movie_reviews',
|
||||
{'is_deleted': 1},
|
||||
{'is_deleted': 1, 'updated_at': DateTime.now().toUtc().toIso8601String()},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
@@ -44,16 +44,16 @@ class AutoBackupService {
|
||||
/// 启动自动备份
|
||||
Future<void> start() async {
|
||||
if (_isRunning) return;
|
||||
|
||||
|
||||
_isRunning = true;
|
||||
|
||||
// 立即执行一次备份
|
||||
await _performBackup();
|
||||
|
||||
|
||||
// 启动定时器
|
||||
_timer = Timer.periodic(_backupInterval, (_) async {
|
||||
await _performBackup();
|
||||
});
|
||||
|
||||
_isRunning = true;
|
||||
}
|
||||
|
||||
/// 停止自动备份
|
||||
|
||||
@@ -114,6 +114,21 @@ class BackupService {
|
||||
}
|
||||
}
|
||||
|
||||
// 收集 epub_books 目录下的 epub 文件
|
||||
int epubCount = 0;
|
||||
final epubRoot = path.join(appDir.path, 'epub_books');
|
||||
final epubDir = Directory(epubRoot);
|
||||
if (await epubDir.exists()) {
|
||||
await for (final entity in epubDir.list(recursive: true)) {
|
||||
if (entity is File) {
|
||||
final bytes = await entity.readAsBytes();
|
||||
final relativePath = entity.path.substring(epubRoot.length + 1);
|
||||
archive.addFile(ArchiveFile('epub_books/$relativePath', bytes.length, bytes));
|
||||
epubCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final zipBytes = ZipEncoder().encode(archive);
|
||||
if (zipBytes == null) throw Exception('压缩备份文件失败');
|
||||
|
||||
@@ -123,6 +138,7 @@ class BackupService {
|
||||
bookCount: books.length,
|
||||
noteCount: notes.length,
|
||||
imageCount: imageCount,
|
||||
epubCount: epubCount,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,6 +203,7 @@ class BackupService {
|
||||
bookCount: data.bookCount,
|
||||
noteCount: data.noteCount,
|
||||
imageCount: data.imageCount,
|
||||
epubCount: data.epubCount,
|
||||
);
|
||||
} catch (e) {
|
||||
return AutoBackupExportResult.error('导出失败: $e');
|
||||
@@ -239,6 +256,13 @@ class BackupService {
|
||||
// 用完整相对路径做 key,避免不同目录下同名文件碰撞
|
||||
imagePathMap[relativePath] = outputFile.path;
|
||||
imageCount++;
|
||||
} else if (archiveFile.name.startsWith('epub_books/')) {
|
||||
final relativePath = archiveFile.name.substring(12);
|
||||
final epubDir = Directory(path.join(appDir.path, '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);
|
||||
await outputFile.writeAsBytes(archiveFile.content as List<int>);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -354,6 +378,13 @@ class BackupService {
|
||||
await outputFile.writeAsBytes(archiveFile.content as List<int>);
|
||||
imagePathMap[relativePath] = outputFile.path;
|
||||
imageCount++;
|
||||
} else if (archiveFile.name.startsWith('epub_books/')) {
|
||||
final relativePath = archiveFile.name.substring(12);
|
||||
final epubDir = Directory(path.join(appDir.path, '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);
|
||||
await outputFile.writeAsBytes(archiveFile.content as List<int>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,14 +506,21 @@ class BackupService {
|
||||
return map;
|
||||
}
|
||||
|
||||
/// 恢复 SharedPreferences(保留当前 avatarPath,因为已在上面用 imagePathMap 更新过)
|
||||
/// 恢复 SharedPreferences(保留当前设备的同步和路径配置)
|
||||
Future<void> _restoreSharedPrefs(Map<String, dynamic> data) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// 这些键是设备特定的,不应从备份恢复
|
||||
const skipKeys = {
|
||||
'avatarPath',
|
||||
'webdav_config',
|
||||
'webdav_last_sync',
|
||||
'webdav_auto_sync',
|
||||
'webdav_auto_sync_interval',
|
||||
};
|
||||
for (final entry in data.entries) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
// avatarPath 已单独处理,跳过
|
||||
if (key == 'avatarPath') continue;
|
||||
if (skipKeys.contains(key)) continue;
|
||||
if (value is String) {
|
||||
await prefs.setString(key, value);
|
||||
} else if (value is int) {
|
||||
@@ -592,6 +630,7 @@ class _ExportData {
|
||||
final int bookCount;
|
||||
final int noteCount;
|
||||
final int imageCount;
|
||||
final int epubCount;
|
||||
|
||||
_ExportData({
|
||||
required this.zipBytes,
|
||||
@@ -599,6 +638,7 @@ class _ExportData {
|
||||
required this.bookCount,
|
||||
required this.noteCount,
|
||||
required this.imageCount,
|
||||
this.epubCount = 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -612,6 +652,7 @@ class AutoBackupExportResult {
|
||||
final int bookCount;
|
||||
final int noteCount;
|
||||
final int imageCount;
|
||||
final int epubCount;
|
||||
|
||||
AutoBackupExportResult._({
|
||||
required this.success,
|
||||
@@ -621,6 +662,7 @@ class AutoBackupExportResult {
|
||||
this.bookCount = 0,
|
||||
this.noteCount = 0,
|
||||
this.imageCount = 0,
|
||||
this.epubCount = 0,
|
||||
});
|
||||
|
||||
factory AutoBackupExportResult.success({
|
||||
@@ -629,11 +671,13 @@ class AutoBackupExportResult {
|
||||
required int bookCount,
|
||||
required int noteCount,
|
||||
required int imageCount,
|
||||
int epubCount = 0,
|
||||
}) {
|
||||
return AutoBackupExportResult._(
|
||||
success: true, zipBytes: zipBytes,
|
||||
movieCount: movieCount, bookCount: bookCount,
|
||||
noteCount: noteCount, imageCount: imageCount,
|
||||
epubCount: epubCount,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ class WebDAVService {
|
||||
Timer? _autoSyncTimer;
|
||||
bool _isAutoSyncEnabled = false;
|
||||
int _autoSyncIntervalMinutes = _defaultAutoSyncInterval;
|
||||
bool _isSyncing = false;
|
||||
|
||||
/// 获取配置
|
||||
Future<Map<String, String>?> getConfig() async {
|
||||
@@ -195,8 +196,15 @@ class WebDAVService {
|
||||
|
||||
/// 同步数据 — 完整备份 zip 格式,与本地备份完全一致
|
||||
Future<SyncResult> syncData({SyncDirection direction = SyncDirection.bidirectional}) async {
|
||||
// 防止并发同步
|
||||
if (_isSyncing) {
|
||||
return SyncResult(success: false, message: '同步正在进行中,请稍后再试');
|
||||
}
|
||||
_isSyncing = true;
|
||||
|
||||
final config = await getConfig();
|
||||
if (config == null) {
|
||||
_isSyncing = false;
|
||||
return SyncResult(success: false, message: '未配置 WebDAV');
|
||||
}
|
||||
|
||||
@@ -255,27 +263,42 @@ class WebDAVService {
|
||||
}
|
||||
|
||||
} else if (direction == SyncDirection.bidirectional) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempZip = File(p.join(tempDir.path, 'mooknote_bidir.zip'));
|
||||
// 获取远程备份的修改时间
|
||||
final remoteModTime = await _getRemoteFileModifiedTime(client, zipUrl, username, password);
|
||||
|
||||
final downloadSuccess = await _downloadFile(client, zipUrl, username, password, tempZip);
|
||||
if (downloadSuccess && await tempZip.exists()) {
|
||||
final bytes = await tempZip.readAsBytes();
|
||||
final importResult = await BackupService.instance.restoreFromZipBytes(bytes);
|
||||
await tempZip.delete();
|
||||
// 获取上次同步时间
|
||||
final syncPrefs = await SharedPreferences.getInstance();
|
||||
final lastSyncStr = syncPrefs.getString(_lastSyncKey);
|
||||
final lastSyncTime = lastSyncStr != null ? DateTime.tryParse(lastSyncStr) : null;
|
||||
|
||||
if (importResult.success) {
|
||||
downloadedFiles = 1;
|
||||
downloadedImages = importResult.stats?['图片'] ?? 0;
|
||||
needReload = true;
|
||||
debugPrint('[WebDAV] 远程备份已恢复: ${importResult.statsText}');
|
||||
final bool remoteIsNewer = remoteModTime != null &&
|
||||
(lastSyncTime == null || remoteModTime.isAfter(lastSyncTime));
|
||||
|
||||
if (remoteIsNewer) {
|
||||
// 远程更新,下载并恢复
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempZip = File(p.join(tempDir.path, 'mooknote_bidir.zip'));
|
||||
|
||||
final downloadSuccess = await _downloadFile(client, zipUrl, username, password, tempZip);
|
||||
if (downloadSuccess && await tempZip.exists()) {
|
||||
final bytes = await tempZip.readAsBytes();
|
||||
final importResult = await BackupService.instance.restoreFromZipBytes(bytes);
|
||||
await tempZip.delete();
|
||||
|
||||
if (importResult.success) {
|
||||
downloadedFiles = 1;
|
||||
downloadedImages = importResult.stats?['图片'] ?? 0;
|
||||
needReload = true;
|
||||
debugPrint('[WebDAV] 远程备份较新,已恢复: ${importResult.statsText}');
|
||||
}
|
||||
} else {
|
||||
try { await tempZip.delete(); } catch (_) {}
|
||||
}
|
||||
} else {
|
||||
try { await tempZip.delete(); } catch (_) {}
|
||||
debugPrint('[WebDAV] 服务器无备份,仅上传本地数据');
|
||||
debugPrint('[WebDAV] 本地数据已是最新或远程无更新,跳过下载');
|
||||
}
|
||||
|
||||
// 始终上传本地,确保远程是最新的
|
||||
// 上传本地备份(无论是否下载,确保远程有最新数据)
|
||||
final exportResult = await BackupService.instance.exportDataForAutoBackup();
|
||||
if (exportResult.success && exportResult.zipBytes != null) {
|
||||
final uploadSuccess = await _uploadBytes(client, zipUrl, username, password, exportResult.zipBytes!);
|
||||
@@ -305,6 +328,8 @@ class WebDAVService {
|
||||
}
|
||||
} catch (e) {
|
||||
return SyncResult(success: false, message: '同步失败: $e');
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +371,11 @@ class WebDAVService {
|
||||
Duration(minutes: _autoSyncIntervalMinutes),
|
||||
(timer) async {
|
||||
if (_isAutoSyncEnabled) {
|
||||
await syncData(direction: SyncDirection.bidirectional);
|
||||
try {
|
||||
await syncData(direction: SyncDirection.bidirectional);
|
||||
} catch (e) {
|
||||
debugPrint('[WebDAV] 自动同步异常: $e');
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -456,6 +485,43 @@ class WebDAVService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取远程文件的修改时间
|
||||
Future<DateTime?> _getRemoteFileModifiedTime(
|
||||
http.Client client,
|
||||
String url,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
try {
|
||||
var request = http.Request('HEAD', Uri.parse(url));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
|
||||
var response = await client.send(request);
|
||||
|
||||
// 处理重定向
|
||||
if (response.statusCode == 301 || response.statusCode == 302 ||
|
||||
response.statusCode == 307 || response.statusCode == 308) {
|
||||
final location = response.headers['location'];
|
||||
if (location != null) {
|
||||
request = http.Request('HEAD', Uri.parse(location));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
response = await client.send(request);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final lastModified = response.headers['last-modified'];
|
||||
if (lastModified != null) {
|
||||
return HttpDate.parse(lastModified);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('[WebDAV] 获取远程文件时间失败: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Basic Auth 编码
|
||||
String _basicAuth(String username, String password) {
|
||||
final credentials = base64Encode(utf8.encode('$username:$password'));
|
||||
|
||||
@@ -84,9 +84,8 @@ class AppTheme {
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: scheme.surface, elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.zero,
|
||||
side: BorderSide(color: scheme.outlineVariant, width: 0.5),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
@@ -22,7 +22,7 @@ class ToastUtil {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A).withOpacity(0.9),
|
||||
color: const Color(0xFF1A1A1A).withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Text(
|
||||
|
||||
@@ -159,7 +159,7 @@ class UserPrefs {
|
||||
List<String> get searchHistory => prefs.getStringList('searchHistory') ?? [];
|
||||
Future<bool> setSearchHistory(List<String> value) => prefs.setStringList('searchHistory', value);
|
||||
|
||||
/// 添加搜索记录(最多 20 条)
|
||||
/// 添加搜索记录(最多 50 条)
|
||||
Future<void> addSearchHistory(String keyword) async {
|
||||
final list = searchHistory;
|
||||
list.remove(keyword);
|
||||
|
||||
@@ -53,7 +53,6 @@ class BookListItem extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: colors.outline, width: 0.5),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: FadeInLocalImage(
|
||||
|
||||
@@ -28,13 +28,13 @@ class CustomBottomNavBar extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(isDark ? 0.3 : 0.08),
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.3 : 0.08),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
spreadRadius: 0,
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(isDark ? 0.15 : 0.04),
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.15 : 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
spreadRadius: -2,
|
||||
@@ -156,6 +156,7 @@ class CustomBottomNavBar extends StatelessWidget {
|
||||
|
||||
void _showAddDialog(BuildContext context, AppProvider provider) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final outerContext = context;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colors.surface,
|
||||
@@ -209,7 +210,7 @@ class CustomBottomNavBar extends StatelessWidget {
|
||||
};
|
||||
final currentStatus = statusMap[provider.movieStatusIndex] ?? 'want_to_watch';
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
outerContext,
|
||||
'/movie-form',
|
||||
arguments: {'initialStatus': currentStatus},
|
||||
);
|
||||
@@ -229,7 +230,7 @@ class CustomBottomNavBar extends StatelessWidget {
|
||||
};
|
||||
final currentStatus = statusMap[provider.bookStatusIndex] ?? 'want_to_read';
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
outerContext,
|
||||
'/book-form',
|
||||
arguments: {'initialStatus': currentStatus},
|
||||
);
|
||||
@@ -242,7 +243,7 @@ class CustomBottomNavBar extends StatelessWidget {
|
||||
subtitle: '记录你的想法和笔记',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.pushNamed(context, '/note-form');
|
||||
Navigator.pushNamed(outerContext, '/note-form');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -53,7 +53,6 @@ class MovieListItem extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colors.outline, width: 0.5),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: FadeInLocalImage(
|
||||
|
||||
@@ -39,7 +39,6 @@ class _NoteListItemContent extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.5),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -61,7 +60,6 @@ class _NoteListItemContent extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
'MD',
|
||||
@@ -157,7 +155,6 @@ class _NoteListItemContent extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: colors.outlineVariant, width: 0.5),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
|
||||
@@ -56,6 +56,19 @@ class _NotePlusEditorState extends State<NotePlusEditor> {
|
||||
});
|
||||
}
|
||||
|
||||
/// 清理已删除 block 对应的控制器和焦点节点
|
||||
void _cleanupStaleEntries(List<NoteBlock> currentBlocks) {
|
||||
final currentIds = currentBlocks.map((b) => b.id).toSet();
|
||||
final staleIds = _controllers.keys.where((id) => !currentIds.contains(id)).toList();
|
||||
for (final id in staleIds) {
|
||||
_controllers.remove(id)?.dispose();
|
||||
}
|
||||
final staleFocusIds = _focusNodes.keys.where((id) => !currentIds.contains(id)).toList();
|
||||
for (final id in staleFocusIds) {
|
||||
_focusNodes.remove(id)?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
void _onControllerChanged(NoteBlock block) {
|
||||
final provider = context.read<NotePlusProvider>();
|
||||
final idx = provider.blocks.indexWhere((b) => b.id == block.id);
|
||||
@@ -230,6 +243,9 @@ class _NotePlusEditorState extends State<NotePlusEditor> {
|
||||
builder: (context, provider, _) {
|
||||
final blocks = provider.blocks;
|
||||
|
||||
// 清理已删除 block 对应的控制器和焦点节点,防止内存泄漏
|
||||
_cleanupStaleEntries(blocks);
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: blocks.length,
|
||||
|
||||
Reference in New Issue
Block a user