generated from dellevin/template
优化打包体积
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 746 KiB |
BIN
assets/icon/app_icon.webp
Normal file
BIN
assets/icon/app_icon.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 883 KiB |
BIN
assets/icon/app_icon2.webp
Normal file
BIN
assets/icon/app_icon2.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -6,7 +6,6 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:dynamic_color/dynamic_color.dart';
|
import 'package:dynamic_color/dynamic_color.dart';
|
||||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
import 'package:sqflite_common_ffi/sqflite_ffi.dart';
|
||||||
@@ -18,7 +17,6 @@ import 'utils/changelog_service.dart';
|
|||||||
import 'utils/sync/auto_backup_service.dart';
|
import 'utils/sync/auto_backup_service.dart';
|
||||||
import 'utils/usage_stats_service.dart';
|
import 'utils/usage_stats_service.dart';
|
||||||
import 'providers/app_provider.dart';
|
import 'providers/app_provider.dart';
|
||||||
import 'providers/note_plus_provider.dart';
|
|
||||||
|
|
||||||
final RouteObserver<ModalRoute<void>> routeObserver = RouteObserver<ModalRoute<void>>();
|
final RouteObserver<ModalRoute<void>> routeObserver = RouteObserver<ModalRoute<void>>();
|
||||||
|
|
||||||
@@ -252,7 +250,6 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider.value(value: widget.appProvider),
|
ChangeNotifierProvider.value(value: widget.appProvider),
|
||||||
ChangeNotifierProvider(create: (_) => NotePlusProvider()),
|
|
||||||
],
|
],
|
||||||
child: DynamicColorBuilder(
|
child: DynamicColorBuilder(
|
||||||
builder: (lightScheme, darkScheme) {
|
builder: (lightScheme, darkScheme) {
|
||||||
@@ -305,7 +302,6 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
quill.FlutterQuillLocalizations.delegate,
|
|
||||||
],
|
],
|
||||||
supportedLocales: const [
|
supportedLocales: const [
|
||||||
Locale('zh', 'CN'),
|
Locale('zh', 'CN'),
|
||||||
|
|||||||
@@ -1,318 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:uuid/uuid.dart';
|
|
||||||
|
|
||||||
/// 块类型枚举
|
|
||||||
enum NoteBlockType {
|
|
||||||
paragraph,
|
|
||||||
heading1,
|
|
||||||
heading2,
|
|
||||||
heading3,
|
|
||||||
bulletList,
|
|
||||||
numberedList,
|
|
||||||
checklist,
|
|
||||||
quote,
|
|
||||||
codeBlock,
|
|
||||||
divider;
|
|
||||||
|
|
||||||
String toJson() => name;
|
|
||||||
|
|
||||||
static NoteBlockType fromString(String value) {
|
|
||||||
return NoteBlockType.values.firstWhere(
|
|
||||||
(t) => t.name == value,
|
|
||||||
orElse: () => NoteBlockType.paragraph,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 中文标签
|
|
||||||
String get label {
|
|
||||||
switch (this) {
|
|
||||||
case NoteBlockType.paragraph:
|
|
||||||
return '正文';
|
|
||||||
case NoteBlockType.heading1:
|
|
||||||
return '标题1';
|
|
||||||
case NoteBlockType.heading2:
|
|
||||||
return '标题2';
|
|
||||||
case NoteBlockType.heading3:
|
|
||||||
return '标题3';
|
|
||||||
case NoteBlockType.bulletList:
|
|
||||||
return '无序列表';
|
|
||||||
case NoteBlockType.numberedList:
|
|
||||||
return '有序列表';
|
|
||||||
case NoteBlockType.checklist:
|
|
||||||
return '待办';
|
|
||||||
case NoteBlockType.quote:
|
|
||||||
return '引用';
|
|
||||||
case NoteBlockType.codeBlock:
|
|
||||||
return '代码块';
|
|
||||||
case NoteBlockType.divider:
|
|
||||||
return '分割线';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 内联格式类型
|
|
||||||
enum InlineFormatType {
|
|
||||||
bold,
|
|
||||||
italic,
|
|
||||||
underline,
|
|
||||||
strikethrough,
|
|
||||||
inlineCode;
|
|
||||||
|
|
||||||
String toJson() => name;
|
|
||||||
|
|
||||||
static InlineFormatType fromString(String value) {
|
|
||||||
return InlineFormatType.values.firstWhere(
|
|
||||||
(t) => t.name == value,
|
|
||||||
orElse: () => InlineFormatType.bold,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 内联格式区间
|
|
||||||
class InlineFormatSpan {
|
|
||||||
final int start;
|
|
||||||
final int end;
|
|
||||||
final Set<InlineFormatType> formats;
|
|
||||||
|
|
||||||
const InlineFormatSpan({
|
|
||||||
required this.start,
|
|
||||||
required this.end,
|
|
||||||
required this.formats,
|
|
||||||
});
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
's': start,
|
|
||||||
'e': end,
|
|
||||||
'f': formats.map((f) => f.toJson()).toList(),
|
|
||||||
};
|
|
||||||
|
|
||||||
factory InlineFormatSpan.fromJson(Map<String, dynamic> json) {
|
|
||||||
return InlineFormatSpan(
|
|
||||||
start: json['s'] as int? ?? 0,
|
|
||||||
end: json['e'] as int? ?? 0,
|
|
||||||
formats: (json['f'] as List<dynamic>?)
|
|
||||||
?.map((f) => InlineFormatType.fromString(f as String))
|
|
||||||
.toSet() ??
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
InlineFormatSpan copyWith(
|
|
||||||
{int? start, int? end, Set<InlineFormatType>? formats}) {
|
|
||||||
return InlineFormatSpan(
|
|
||||||
start: start ?? this.start,
|
|
||||||
end: end ?? this.end,
|
|
||||||
formats: formats ?? Set.from(this.formats),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 单个内容块
|
|
||||||
class NoteBlock {
|
|
||||||
final String id;
|
|
||||||
final NoteBlockType type;
|
|
||||||
final String text;
|
|
||||||
final Map<String, dynamic> metadata;
|
|
||||||
final List<InlineFormatSpan> formatting;
|
|
||||||
|
|
||||||
NoteBlock({
|
|
||||||
String? id,
|
|
||||||
this.type = NoteBlockType.paragraph,
|
|
||||||
this.text = '',
|
|
||||||
Map<String, dynamic>? metadata,
|
|
||||||
List<InlineFormatSpan>? formatting,
|
|
||||||
}) : id = id ?? const Uuid().v4(),
|
|
||||||
metadata = metadata ?? {},
|
|
||||||
formatting = formatting ?? [];
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'id': id,
|
|
||||||
'type': type.toJson(),
|
|
||||||
'text': text,
|
|
||||||
'metadata': metadata,
|
|
||||||
'formatting': formatting.map((f) => f.toJson()).toList(),
|
|
||||||
};
|
|
||||||
|
|
||||||
factory NoteBlock.fromJson(Map<String, dynamic> json) {
|
|
||||||
return NoteBlock(
|
|
||||||
id: json['id'] as String?,
|
|
||||||
type: NoteBlockType.fromString(json['type'] as String? ?? 'paragraph'),
|
|
||||||
text: json['text'] as String? ?? '',
|
|
||||||
metadata: (json['metadata'] as Map<String, dynamic>?) ?? {},
|
|
||||||
formatting: (json['formatting'] as List<dynamic>?)
|
|
||||||
?.map(
|
|
||||||
(f) => InlineFormatSpan.fromJson(f as Map<String, dynamic>))
|
|
||||||
.toList() ??
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
NoteBlock copyWith({
|
|
||||||
NoteBlockType? type,
|
|
||||||
String? text,
|
|
||||||
Map<String, dynamic>? metadata,
|
|
||||||
List<InlineFormatSpan>? formatting,
|
|
||||||
}) {
|
|
||||||
return NoteBlock(
|
|
||||||
id: id,
|
|
||||||
type: type ?? this.type,
|
|
||||||
text: text ?? this.text,
|
|
||||||
metadata: metadata ?? Map.from(this.metadata),
|
|
||||||
formatting: formatting ?? List.from(this.formatting),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 深拷贝
|
|
||||||
NoteBlock deepCopy() {
|
|
||||||
return NoteBlock(
|
|
||||||
id: id,
|
|
||||||
type: type,
|
|
||||||
text: text,
|
|
||||||
metadata: Map.from(metadata),
|
|
||||||
formatting: formatting
|
|
||||||
.map((f) => InlineFormatSpan(
|
|
||||||
start: f.start,
|
|
||||||
end: f.end,
|
|
||||||
formats: Set.from(f.formats),
|
|
||||||
))
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Note Plus 文档
|
|
||||||
///
|
|
||||||
/// 仿 AppFlowy 的 View 模型:
|
|
||||||
/// - parentId 指向父文档 ID(空字符串 = 根级文档)
|
|
||||||
/// - 每个文档既是页面也是容器,支持无限嵌套
|
|
||||||
class NotePlusDocument {
|
|
||||||
final String id;
|
|
||||||
final String title;
|
|
||||||
final String parentId; // 父文档 ID,空字符串 = 根级
|
|
||||||
final int sortIndex; // 同级排序序号
|
|
||||||
final List<NoteBlock> blocks;
|
|
||||||
final String? blocksJson; // 原始 Delta JSON(flutter_quill 模式优先使用)
|
|
||||||
final List<String> tags;
|
|
||||||
final List<String> images;
|
|
||||||
final DateTime createdAt;
|
|
||||||
final DateTime updatedAt;
|
|
||||||
final bool isDeleted;
|
|
||||||
|
|
||||||
NotePlusDocument({
|
|
||||||
String? id,
|
|
||||||
this.title = '',
|
|
||||||
this.parentId = '',
|
|
||||||
this.sortIndex = 0,
|
|
||||||
List<NoteBlock>? blocks,
|
|
||||||
this.blocksJson,
|
|
||||||
List<String>? tags,
|
|
||||||
List<String>? images,
|
|
||||||
DateTime? createdAt,
|
|
||||||
DateTime? updatedAt,
|
|
||||||
this.isDeleted = false,
|
|
||||||
}) : id = id ?? const Uuid().v4(),
|
|
||||||
blocks = blocks ?? [NoteBlock()],
|
|
||||||
tags = tags ?? [],
|
|
||||||
images = images ?? [],
|
|
||||||
createdAt = createdAt ?? DateTime.now(),
|
|
||||||
updatedAt = updatedAt ?? DateTime.now();
|
|
||||||
|
|
||||||
/// 从 JSON(DB 行)创建
|
|
||||||
factory NotePlusDocument.fromJson(Map<String, dynamic> json) {
|
|
||||||
final blocksJson = json['blocks_json'] as String? ?? '[]';
|
|
||||||
List<NoteBlock> blocksList;
|
|
||||||
try {
|
|
||||||
blocksList = (jsonDecode(blocksJson) as List<dynamic>)
|
|
||||||
.map((b) => NoteBlock.fromJson(b as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[NotePlusDocument] blocks_json 解析失败,使用默认空文档: $e');
|
|
||||||
blocksList = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return NotePlusDocument(
|
|
||||||
id: json['id'] as String?,
|
|
||||||
title: json['title'] as String? ?? '',
|
|
||||||
parentId: json['parent_id'] as String? ?? '',
|
|
||||||
sortIndex: json['sort_index'] as int? ?? 0,
|
|
||||||
blocks: blocksList.isEmpty ? [NoteBlock()] : blocksList,
|
|
||||||
blocksJson: json['blocks_json'] as String?,
|
|
||||||
tags: _parseStringList(json['tags'] as String?),
|
|
||||||
images: _parseStringList(json['images'] as String?),
|
|
||||||
createdAt: json['created_at'] != null
|
|
||||||
? (DateTime.tryParse(json['created_at'] as String)?.toLocal() ?? DateTime.now())
|
|
||||||
: DateTime.now(),
|
|
||||||
updatedAt: json['updated_at'] != null
|
|
||||||
? (DateTime.tryParse(json['updated_at'] as String)?.toLocal() ?? DateTime.now())
|
|
||||||
: DateTime.now(),
|
|
||||||
isDeleted: (json['is_deleted'] as int?) == 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 序列化为 DB 行
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'id': id,
|
|
||||||
'title': title,
|
|
||||||
'parent_id': parentId,
|
|
||||||
'sort_index': sortIndex,
|
|
||||||
'blocks_json': blocksJson ?? jsonEncode(blocks.map((b) => b.toJson()).toList()),
|
|
||||||
'tags': tags.isEmpty ? null : jsonEncode(tags),
|
|
||||||
'images': images.isEmpty ? null : jsonEncode(images),
|
|
||||||
'created_at': createdAt.toUtc().toIso8601String(),
|
|
||||||
'updated_at': updatedAt.toUtc().toIso8601String(),
|
|
||||||
'is_deleted': isDeleted ? 1 : 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
NotePlusDocument copyWith({
|
|
||||||
String? title,
|
|
||||||
String? parentId,
|
|
||||||
int? sortIndex,
|
|
||||||
List<NoteBlock>? blocks,
|
|
||||||
String? blocksJson,
|
|
||||||
List<String>? tags,
|
|
||||||
List<String>? images,
|
|
||||||
DateTime? updatedAt,
|
|
||||||
bool? isDeleted,
|
|
||||||
}) {
|
|
||||||
return NotePlusDocument(
|
|
||||||
id: id,
|
|
||||||
title: title ?? this.title,
|
|
||||||
parentId: parentId ?? this.parentId,
|
|
||||||
sortIndex: sortIndex ?? this.sortIndex,
|
|
||||||
blocks: blocks ?? List.from(this.blocks),
|
|
||||||
blocksJson: blocksJson ?? this.blocksJson,
|
|
||||||
tags: tags ?? List.from(this.tags),
|
|
||||||
images: images ?? List.from(this.images),
|
|
||||||
createdAt: createdAt,
|
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
|
||||||
isDeleted: isDeleted ?? this.isDeleted,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 深拷贝
|
|
||||||
NotePlusDocument deepCopy() {
|
|
||||||
return NotePlusDocument(
|
|
||||||
id: id,
|
|
||||||
title: title,
|
|
||||||
parentId: parentId,
|
|
||||||
sortIndex: sortIndex,
|
|
||||||
blocks: blocks.map((b) => b.deepCopy()).toList(),
|
|
||||||
tags: List.from(tags),
|
|
||||||
images: List.from(images),
|
|
||||||
createdAt: createdAt,
|
|
||||||
updatedAt: updatedAt,
|
|
||||||
isDeleted: isDeleted,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<String> _parseStringList(String? json) {
|
|
||||||
if (json == null || json.isEmpty) return [];
|
|
||||||
try {
|
|
||||||
final list = jsonDecode(json) as List<dynamic>;
|
|
||||||
return list.map((e) => e.toString()).toList();
|
|
||||||
} catch (_) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -88,7 +88,7 @@ class _AppIconPickerPageState extends State<AppIconPickerPage> {
|
|||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/icon/${icon['name']}.png',
|
'assets/icon/${icon['name']}${icon['name'] == 'app_icon_m' ? '.png' : '.webp'}',
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import '../utils/sync/webdav_service.dart';
|
|||||||
import 'movies/movie_tab_page.dart';
|
import 'movies/movie_tab_page.dart';
|
||||||
import 'book/book_tab_page.dart';
|
import 'book/book_tab_page.dart';
|
||||||
import 'note/note_tab_page.dart';
|
import 'note/note_tab_page.dart';
|
||||||
import 'note_plus/note_plus_tab_page.dart';
|
|
||||||
import 'online_search/search_page.dart';
|
import 'online_search/search_page.dart';
|
||||||
import 'online_search/online_search_page.dart';
|
import 'online_search/online_search_page.dart';
|
||||||
import 'sync/webdav_sync_page.dart';
|
import 'sync/webdav_sync_page.dart';
|
||||||
@@ -25,7 +24,6 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
bool _showMovieTab = true;
|
bool _showMovieTab = true;
|
||||||
bool _showBookTab = true;
|
bool _showBookTab = true;
|
||||||
bool _showNoteTab = true;
|
bool _showNoteTab = true;
|
||||||
bool _showNotePlusTab = false;
|
|
||||||
|
|
||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
bool _isTabTap = false;
|
bool _isTabTap = false;
|
||||||
@@ -49,7 +47,6 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
_showMovieTab = _userPrefs.showMovieTab;
|
_showMovieTab = _userPrefs.showMovieTab;
|
||||||
_showBookTab = _userPrefs.showBookTab;
|
_showBookTab = _userPrefs.showBookTab;
|
||||||
_showNoteTab = _userPrefs.showNoteTab;
|
_showNoteTab = _userPrefs.showNoteTab;
|
||||||
_showNotePlusTab = _userPrefs.showNotePlusTab;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +61,6 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
if (_showMovieTab) tabs.add(_TabItem('影视', 0));
|
if (_showMovieTab) tabs.add(_TabItem('影视', 0));
|
||||||
if (_showBookTab) tabs.add(_TabItem('阅读', 1));
|
if (_showBookTab) tabs.add(_TabItem('阅读', 1));
|
||||||
if (_showNoteTab) tabs.add(_TabItem('笔记', 2));
|
if (_showNoteTab) tabs.add(_TabItem('笔记', 2));
|
||||||
if (_showNotePlusTab) tabs.add(_TabItem('Plus', 3));
|
|
||||||
return tabs;
|
return tabs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +134,6 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
case 0: return '影视';
|
case 0: return '影视';
|
||||||
case 1: return '阅读';
|
case 1: return '阅读';
|
||||||
case 2: return '笔记';
|
case 2: return '笔记';
|
||||||
case 3: return 'Note Plus';
|
|
||||||
default: return 'MookNote';
|
default: return 'MookNote';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,7 +434,6 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
if (_showMovieTab) const MovieTabPage(),
|
if (_showMovieTab) const MovieTabPage(),
|
||||||
if (_showBookTab) const BookTabPage(),
|
if (_showBookTab) const BookTabPage(),
|
||||||
if (_showNoteTab) const NoteTabPage(),
|
if (_showNoteTab) const NoteTabPage(),
|
||||||
if (_showNotePlusTab) const NotePlusTabPage(),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -451,7 +445,6 @@ class _MainContentPageState extends State<MainContentPage> {
|
|||||||
case '影视': return Icons.movie_outlined;
|
case '影视': return Icons.movie_outlined;
|
||||||
case '阅读': return Icons.menu_book_outlined;
|
case '阅读': return Icons.menu_book_outlined;
|
||||||
case '笔记': return Icons.note_outlined;
|
case '笔记': return Icons.note_outlined;
|
||||||
case 'Plus': return Icons.edit_note;
|
|
||||||
default: return Icons.circle;
|
default: return Icons.circle;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
|
|
||||||
/// 豆瓣影视WebView页面 - 用于抓取影视信息
|
/// 豆瓣影视WebView页面 - 用于抓取影视信息
|
||||||
class DoubanWebViewPage extends StatefulWidget {
|
class DoubanWebViewPage extends StatefulWidget {
|
||||||
@@ -13,47 +13,10 @@ class DoubanWebViewPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DoubanWebViewPageState extends State<DoubanWebViewPage> {
|
class _DoubanWebViewPageState extends State<DoubanWebViewPage> {
|
||||||
late WebViewController _controller;
|
InAppWebViewController? _controller;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isExtracting = false; // 防止重复提取
|
bool _isExtracting = false; // 防止重复提取
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_initWebView();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
// 清理 WebView 资源
|
|
||||||
_controller.loadRequest(Uri.parse('about:blank'));
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _initWebView() {
|
|
||||||
_controller = WebViewController()
|
|
||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
|
||||||
..setNavigationDelegate(
|
|
||||||
NavigationDelegate(
|
|
||||||
onPageStarted: (String url) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onPageFinished: (String url) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
..loadRequest(Uri.parse(widget.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = Theme.of(context).colorScheme;
|
final colors = Theme.of(context).colorScheme;
|
||||||
@@ -74,7 +37,7 @@ class _DoubanWebViewPageState extends State<DoubanWebViewPage> {
|
|||||||
_buildActionButton(
|
_buildActionButton(
|
||||||
colors: colors,
|
colors: colors,
|
||||||
icon: Icons.refresh,
|
icon: Icons.refresh,
|
||||||
onPressed: () => _controller.reload(),
|
onPressed: () => _controller?.reload(),
|
||||||
tooltip: '刷新',
|
tooltip: '刷新',
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -82,7 +45,29 @@ class _DoubanWebViewPageState extends State<DoubanWebViewPage> {
|
|||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
WebViewWidget(controller: _controller),
|
InAppWebView(
|
||||||
|
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
|
||||||
|
initialSettings: InAppWebViewSettings(
|
||||||
|
javaScriptEnabled: true,
|
||||||
|
),
|
||||||
|
onWebViewCreated: (controller) {
|
||||||
|
_controller = controller;
|
||||||
|
},
|
||||||
|
onLoadStart: (_, __) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoadStop: (_, __) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
// 加载指示器
|
// 加载指示器
|
||||||
if (_isLoading) const Center(
|
if (_isLoading) const Center(
|
||||||
child: CircularProgressIndicator(),
|
child: CircularProgressIndicator(),
|
||||||
@@ -105,7 +90,8 @@ class _DoubanWebViewPageState extends State<DoubanWebViewPage> {
|
|||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// 停止加载并返回
|
// 停止加载并返回
|
||||||
_controller.loadRequest(Uri.parse('about:blank'));
|
_controller?.loadUrl(
|
||||||
|
urlRequest: URLRequest(url: WebUri('about:blank')));
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -250,7 +236,7 @@ class _DoubanWebViewPageState extends State<DoubanWebViewPage> {
|
|||||||
/// 提取影视信息
|
/// 提取影视信息
|
||||||
Future<Map<String, dynamic>?> _extractMovieInfo() async {
|
Future<Map<String, dynamic>?> _extractMovieInfo() async {
|
||||||
// 检查是否已提取过,避免重复点击
|
// 检查是否已提取过,避免重复点击
|
||||||
if (_isExtracting) return null;
|
if (_isExtracting || _controller == null) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_isExtracting = true;
|
_isExtracting = true;
|
||||||
@@ -265,7 +251,7 @@ class _DoubanWebViewPageState extends State<DoubanWebViewPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 执行JavaScript代码提取页面信息
|
// 执行JavaScript代码提取页面信息
|
||||||
final result = await _controller.runJavaScriptReturningResult(r'''
|
final result = await _controller!.evaluateJavascript(source: r'''
|
||||||
(function() {
|
(function() {
|
||||||
const info = {};
|
const info = {};
|
||||||
|
|
||||||
@@ -384,9 +370,11 @@ class _DoubanWebViewPageState extends State<DoubanWebViewPage> {
|
|||||||
if (mounted) Navigator.pop(context);
|
if (mounted) Navigator.pop(context);
|
||||||
|
|
||||||
// 解析提取的信息
|
// 解析提取的信息
|
||||||
// result 是 JavaScript 执行结果,已经是 JSON 字符串(带引号的)
|
// evaluateJavascript 返回 JS 值,JSON.stringify 的结果是字符串
|
||||||
final String jsonStr = result.toString();
|
final String jsonStr = result?.toString() ?? '';
|
||||||
// 去除 Dart 字符串转义后外层可能多余的引号
|
if (jsonStr.isEmpty) return null;
|
||||||
|
|
||||||
|
// result 是 JSON.stringify 的输出,可能带外层引号
|
||||||
final String cleanJson = jsonStr.startsWith('"') && jsonStr.endsWith('"')
|
final String cleanJson = jsonStr.startsWith('"') && jsonStr.endsWith('"')
|
||||||
? jsonDecode(jsonStr) as String
|
? jsonDecode(jsonStr) as String
|
||||||
: jsonStr;
|
: jsonStr;
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../../models/note_plus_models.dart';
|
|
||||||
import '../../providers/note_plus_provider.dart';
|
|
||||||
|
|
||||||
/// Note Plus 只读查看页
|
|
||||||
class NotePlusDetailPage extends StatelessWidget {
|
|
||||||
final String documentId;
|
|
||||||
|
|
||||||
const NotePlusDetailPage({super.key, required this.documentId});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return FutureBuilder<NotePlusDocument?>(
|
|
||||||
future: context.read<NotePlusProvider>().getDeletedDocuments().then(
|
|
||||||
(_) => context.read<NotePlusProvider>().currentDocument?.id == documentId
|
|
||||||
? context.read<NotePlusProvider>().currentDocument
|
|
||||||
: null),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
// 直接从 provider 取
|
|
||||||
return Consumer<NotePlusProvider>(
|
|
||||||
builder: (context, provider, _) {
|
|
||||||
final doc = provider.currentDocument;
|
|
||||||
if (doc == null || doc.id != documentId) {
|
|
||||||
// 加载文档(延迟到 build 完成后执行,避免副作用)
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
provider.loadDocumentById(documentId);
|
|
||||||
});
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
body: const Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(doc.title.isEmpty ? '无标题' : doc.title),
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
surfaceTintColor: Colors.transparent,
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pushNamed(context, '/note-plus-form',
|
|
||||||
arguments: doc.id);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: ListView.builder(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
|
|
||||||
itemCount: doc.blocks.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
return _buildBlock(doc.blocks[index], index, doc.blocks, colors);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBlock(NoteBlock block, int index, List<NoteBlock> allBlocks,
|
|
||||||
ColorScheme colors) {
|
|
||||||
if (block.type == NoteBlockType.divider) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
child: Container(height: 1, color: colors.outlineVariant),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final style = _getTextStyle(block, colors);
|
|
||||||
final text = block.text.isEmpty ? '(空)' : block.text;
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: _getBlockPadding(block),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_buildPrefix(block, index, allBlocks, colors),
|
|
||||||
Expanded(
|
|
||||||
child: block.type == NoteBlockType.codeBlock
|
|
||||||
? Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
|
||||||
child: block.text.isEmpty
|
|
||||||
? Text('(空)', style: style.copyWith(
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.2)))
|
|
||||||
: _buildRichText(text, style, block, colors),
|
|
||||||
)
|
|
||||||
: block.text.isEmpty
|
|
||||||
? Text(text, style: style.copyWith(
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.2)))
|
|
||||||
: _buildRichText(text, style, block, colors),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPrefix(NoteBlock block, int index, List<NoteBlock> allBlocks,
|
|
||||||
ColorScheme colors) {
|
|
||||||
switch (block.type) {
|
|
||||||
case NoteBlockType.bulletList:
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8, right: 8),
|
|
||||||
child: Container(
|
|
||||||
width: 6, height: 6,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.5),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case NoteBlockType.numberedList:
|
|
||||||
int num = 1;
|
|
||||||
for (int i = index - 1; i >= 0; i--) {
|
|
||||||
if (allBlocks[i].type == NoteBlockType.numberedList) {
|
|
||||||
num++;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6, right: 8),
|
|
||||||
child: Text('$num.', style: TextStyle(
|
|
||||||
fontSize: 14, color: colors.onSurface.withValues(alpha: 0.5))),
|
|
||||||
);
|
|
||||||
case NoteBlockType.checklist:
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 4, right: 8),
|
|
||||||
child: Icon(
|
|
||||||
block.metadata['checked'] == true
|
|
||||||
? Icons.check_box
|
|
||||||
: Icons.check_box_outline_blank,
|
|
||||||
size: 20,
|
|
||||||
color: block.metadata['checked'] == true
|
|
||||||
? colors.primary
|
|
||||||
: colors.onSurface.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case NoteBlockType.quote:
|
|
||||||
return Container(
|
|
||||||
width: 4,
|
|
||||||
margin: const EdgeInsets.only(top: 6, bottom: 6, right: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildRichText(String text, TextStyle style, NoteBlock block,
|
|
||||||
ColorScheme colors) {
|
|
||||||
if (block.formatting.isEmpty) return Text(text, style: style);
|
|
||||||
|
|
||||||
final spans = <InlineSpan>[];
|
|
||||||
final events = <_FmtEvent>[];
|
|
||||||
for (final span in block.formatting) {
|
|
||||||
if (span.start < text.length) {
|
|
||||||
events.add(_FmtEvent(span.start, true, span.formats));
|
|
||||||
events.add(_FmtEvent(
|
|
||||||
span.end > text.length ? text.length : span.end, false, span.formats));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
events.sort((a, b) => a.pos.compareTo(b.pos));
|
|
||||||
|
|
||||||
int lastPos = 0;
|
|
||||||
final active = <InlineFormatType>{};
|
|
||||||
for (final e in events) {
|
|
||||||
if (e.pos > lastPos) {
|
|
||||||
spans.add(TextSpan(
|
|
||||||
text: text.substring(lastPos, e.pos),
|
|
||||||
style: _applyFormats(style, active, colors),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if (e.isStart) {
|
|
||||||
active.addAll(e.formats);
|
|
||||||
} else {
|
|
||||||
active.removeAll(e.formats);
|
|
||||||
}
|
|
||||||
lastPos = e.pos;
|
|
||||||
}
|
|
||||||
if (lastPos < text.length) {
|
|
||||||
spans.add(TextSpan(
|
|
||||||
text: text.substring(lastPos),
|
|
||||||
style: _applyFormats(style, active, colors),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Text.rich(TextSpan(children: spans));
|
|
||||||
}
|
|
||||||
|
|
||||||
TextStyle _applyFormats(TextStyle style, Set<InlineFormatType> formats,
|
|
||||||
ColorScheme colors) {
|
|
||||||
if (formats.isEmpty) return style;
|
|
||||||
return style.copyWith(
|
|
||||||
fontWeight: formats.contains(InlineFormatType.bold) ? FontWeight.w700 : null,
|
|
||||||
fontStyle: formats.contains(InlineFormatType.italic) ? FontStyle.italic : null,
|
|
||||||
decoration: _getDecoration(formats),
|
|
||||||
fontFamily: formats.contains(InlineFormatType.inlineCode) ? 'monospace' : null,
|
|
||||||
backgroundColor: formats.contains(InlineFormatType.inlineCode)
|
|
||||||
? colors.surfaceContainerHighest : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TextDecoration? _getDecoration(Set<InlineFormatType> formats) {
|
|
||||||
final list = <TextDecoration>[];
|
|
||||||
if (formats.contains(InlineFormatType.underline)) list.add(TextDecoration.underline);
|
|
||||||
if (formats.contains(InlineFormatType.strikethrough)) list.add(TextDecoration.lineThrough);
|
|
||||||
return list.isEmpty ? null : TextDecoration.combine(list);
|
|
||||||
}
|
|
||||||
|
|
||||||
TextStyle _getTextStyle(NoteBlock block, ColorScheme colors) {
|
|
||||||
// AppFlowy base: 18px, w300, height 1.3, letter-spacing 0.6
|
|
||||||
final base = TextStyle(
|
|
||||||
color: colors.onSurface,
|
|
||||||
fontSize: 18, fontWeight: FontWeight.w300, height: 1.3, letterSpacing: 0.6,
|
|
||||||
);
|
|
||||||
switch (block.type) {
|
|
||||||
case NoteBlockType.heading1:
|
|
||||||
return base.copyWith(fontSize: 34, fontWeight: FontWeight.w300, height: 1.15,
|
|
||||||
letterSpacing: 0, color: colors.onSurface.withValues(alpha: 0.7));
|
|
||||||
case NoteBlockType.heading2:
|
|
||||||
return base.copyWith(fontSize: 24, fontWeight: FontWeight.w400, height: 1.15,
|
|
||||||
letterSpacing: 0, color: colors.onSurface.withValues(alpha: 0.7));
|
|
||||||
case NoteBlockType.heading3:
|
|
||||||
return base.copyWith(fontSize: 20, fontWeight: FontWeight.w500, height: 1.25,
|
|
||||||
letterSpacing: 0, color: colors.onSurface.withValues(alpha: 0.7));
|
|
||||||
case NoteBlockType.quote:
|
|
||||||
return base.copyWith(color: colors.onSurface.withValues(alpha: 0.6));
|
|
||||||
case NoteBlockType.codeBlock:
|
|
||||||
return base.copyWith(fontSize: 13, fontWeight: FontWeight.w400, fontFamily: 'monospace',
|
|
||||||
height: 1.15, letterSpacing: 0, color: Colors.blue.shade900.withValues(alpha: 0.9));
|
|
||||||
case NoteBlockType.checklist:
|
|
||||||
return base.copyWith(
|
|
||||||
decoration: block.metadata['checked'] == true ? TextDecoration.lineThrough : null,
|
|
||||||
color: block.metadata['checked'] == true ? colors.onSurface.withValues(alpha: 0.4) : null,
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
EdgeInsets _getBlockPadding(NoteBlock block) {
|
|
||||||
switch (block.type) {
|
|
||||||
case NoteBlockType.heading1:
|
|
||||||
return const EdgeInsets.only(top: 16);
|
|
||||||
case NoteBlockType.heading2:
|
|
||||||
return const EdgeInsets.only(top: 8);
|
|
||||||
case NoteBlockType.heading3:
|
|
||||||
return const EdgeInsets.only(top: 8);
|
|
||||||
case NoteBlockType.codeBlock:
|
|
||||||
return const EdgeInsets.symmetric(vertical: 6);
|
|
||||||
case NoteBlockType.quote:
|
|
||||||
return const EdgeInsets.only(top: 6, bottom: 2);
|
|
||||||
default:
|
|
||||||
return const EdgeInsets.only(top: 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FmtEvent {
|
|
||||||
final int pos;
|
|
||||||
final bool isStart;
|
|
||||||
final Set<InlineFormatType> formats;
|
|
||||||
_FmtEvent(this.pos, this.isStart, this.formats);
|
|
||||||
}
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_quill/flutter_quill.dart' as quill;
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../../providers/note_plus_provider.dart';
|
|
||||||
import '../../utils/toast_util.dart';
|
|
||||||
|
|
||||||
/// Note Plus 编辑页
|
|
||||||
class NotePlusFormPage extends StatefulWidget {
|
|
||||||
final String documentId;
|
|
||||||
|
|
||||||
const NotePlusFormPage({super.key, required this.documentId});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<NotePlusFormPage> createState() => _NotePlusFormPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NotePlusFormPageState extends State<NotePlusFormPage> {
|
|
||||||
final _titleController = TextEditingController();
|
|
||||||
final _titleFocus = FocusNode();
|
|
||||||
final _scrollController = ScrollController();
|
|
||||||
final _editorFocus = FocusNode();
|
|
||||||
quill.QuillController? _controller;
|
|
||||||
bool _isInitialized = false;
|
|
||||||
Timer? _autoSaveTimer;
|
|
||||||
int _charCount = 0;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_autoSaveTimer?.cancel();
|
|
||||||
_titleController.dispose();
|
|
||||||
_titleFocus.dispose();
|
|
||||||
_scrollController.dispose();
|
|
||||||
_editorFocus.dispose();
|
|
||||||
_controller?.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 初始化 ─────────────────────────────────────
|
|
||||||
|
|
||||||
void _initDocument(NotePlusProvider provider) {
|
|
||||||
if (_isInitialized) return;
|
|
||||||
_isInitialized = true;
|
|
||||||
|
|
||||||
provider.loadDocumentById(widget.documentId).then((_) {
|
|
||||||
if (!mounted || provider.currentDocument == null) return;
|
|
||||||
|
|
||||||
_titleController.text = provider.currentDocument!.title;
|
|
||||||
|
|
||||||
quill.Document doc;
|
|
||||||
final raw = provider.currentDocument!.blocksJson;
|
|
||||||
if (raw != null && raw.isNotEmpty && raw.startsWith('[')) {
|
|
||||||
try {
|
|
||||||
final parsed = jsonDecode(raw) as List;
|
|
||||||
if (parsed.isNotEmpty &&
|
|
||||||
parsed.first is Map &&
|
|
||||||
(parsed.first as Map).containsKey('insert')) {
|
|
||||||
doc = quill.Document.fromJson(parsed);
|
|
||||||
} else {
|
|
||||||
doc = quill.Document();
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
doc = quill.Document();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
doc = quill.Document();
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_controller = quill.QuillController(
|
|
||||||
document: doc,
|
|
||||||
selection: const TextSelection.collapsed(offset: 0),
|
|
||||||
);
|
|
||||||
_controller!.addListener(_onDocChanged);
|
|
||||||
_charCount = _controller!.document.length - 1; // 去掉末尾 \n
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 文档变化监听 ─────────────────────────────────
|
|
||||||
|
|
||||||
void _onDocChanged() {
|
|
||||||
if (_controller == null) return;
|
|
||||||
final newCount = _controller!.document.length - 1;
|
|
||||||
if (newCount != _charCount) {
|
|
||||||
setState(() => _charCount = newCount);
|
|
||||||
}
|
|
||||||
// 自动保存(防抖 2 秒)
|
|
||||||
_autoSaveTimer?.cancel();
|
|
||||||
_autoSaveTimer = Timer(const Duration(seconds: 2), () {
|
|
||||||
if (mounted) _autoSave();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _autoSave() {
|
|
||||||
final provider = context.read<NotePlusProvider>();
|
|
||||||
if (_controller == null) return;
|
|
||||||
provider.setTitle(_titleController.text);
|
|
||||||
final deltaJson = jsonEncode(_controller!.document.toDelta().toJson());
|
|
||||||
provider.saveDocument(deltaJson: deltaJson);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _save(NotePlusProvider provider) async {
|
|
||||||
_autoSaveTimer?.cancel();
|
|
||||||
if (_controller == null) return;
|
|
||||||
provider.setTitle(_titleController.text);
|
|
||||||
final deltaJson = jsonEncode(_controller!.document.toDelta().toJson());
|
|
||||||
await provider.saveDocument(deltaJson: deltaJson);
|
|
||||||
if (mounted) ToastUtil.show(context, '已保存');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 构建 ─────────────────────────────────────
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Consumer<NotePlusProvider>(
|
|
||||||
builder: (context, provider, _) {
|
|
||||||
_initDocument(provider);
|
|
||||||
|
|
||||||
return PopScope(
|
|
||||||
canPop: !provider.isDirty,
|
|
||||||
onPopInvokedWithResult: (didPop, _) {
|
|
||||||
if (!didPop && provider.isDirty) {
|
|
||||||
_showSaveDialog(provider);
|
|
||||||
} else {
|
|
||||||
_autoSave(); // 退出时自动保存
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
appBar: _buildAppBar(provider, colors),
|
|
||||||
body: _controller == null
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: GestureDetector(
|
|
||||||
// 点击空白区域聚焦编辑器
|
|
||||||
onTap: () => _editorFocus.requestFocus(),
|
|
||||||
behavior: HitTestBehavior.translucent,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// 标题区
|
|
||||||
_buildTitleArea(colors),
|
|
||||||
// 分隔线
|
|
||||||
Divider(height: 1, color: colors.outlineVariant.withValues(alpha: 0.3)),
|
|
||||||
// 编辑器
|
|
||||||
Expanded(child: _buildEditor(colors)),
|
|
||||||
// 工具栏
|
|
||||||
_buildToolbar(colors),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── AppBar ─────────────────────────────────────
|
|
||||||
|
|
||||||
PreferredSizeWidget _buildAppBar(NotePlusProvider provider, ColorScheme colors) {
|
|
||||||
return AppBar(
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
surfaceTintColor: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back, size: 22),
|
|
||||||
onPressed: () {
|
|
||||||
_autoSave();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'$_charCount 字',
|
|
||||||
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
actions: [
|
|
||||||
// 未保存指示
|
|
||||||
if (provider.isDirty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 4),
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
width: 6, height: 6,
|
|
||||||
decoration: BoxDecoration(color: colors.error, shape: BoxShape.circle),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.undo, size: 20),
|
|
||||||
onPressed: _controller != null ? () => _controller!.undo() : null,
|
|
||||||
tooltip: '撤销',
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.redo, size: 20),
|
|
||||||
onPressed: _controller != null ? () => _controller!.redo() : null,
|
|
||||||
tooltip: '重做',
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.check, size: 22, color: colors.primary),
|
|
||||||
onPressed: () => _save(provider),
|
|
||||||
tooltip: '保存',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 标题区 ─────────────────────────────────────
|
|
||||||
|
|
||||||
Widget _buildTitleArea(ColorScheme colors) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
|
||||||
child: TextField(
|
|
||||||
controller: _titleController,
|
|
||||||
focusNode: _titleFocus,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: colors.onSurface,
|
|
||||||
height: 1.3,
|
|
||||||
),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: '输入标题...',
|
|
||||||
hintStyle: TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.15),
|
|
||||||
),
|
|
||||||
border: InputBorder.none,
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onChanged: (_) => context.read<NotePlusProvider>().setTitle(_titleController.text),
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
onSubmitted: (_) => _editorFocus.requestFocus(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 编辑器 ─────────────────────────────────────
|
|
||||||
|
|
||||||
Widget _buildEditor(ColorScheme colors) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: TextSelectionTheme(
|
|
||||||
data: TextSelectionThemeData(
|
|
||||||
selectionColor: colors.primary.withValues(alpha: 0.12),
|
|
||||||
selectionHandleColor: colors.primary,
|
|
||||||
cursorColor: colors.primary,
|
|
||||||
),
|
|
||||||
child: quill.QuillEditor.basic(
|
|
||||||
controller: _controller!,
|
|
||||||
focusNode: _editorFocus,
|
|
||||||
scrollController: _scrollController,
|
|
||||||
config: const quill.QuillEditorConfig(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
|
||||||
autoFocus: false,
|
|
||||||
expands: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 工具栏 ─────────────────────────────────────
|
|
||||||
|
|
||||||
Widget _buildToolbar(ColorScheme colors) {
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.surface,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.04),
|
|
||||||
blurRadius: 8,
|
|
||||||
offset: const Offset(0, -2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 6),
|
|
||||||
child: SafeArea(
|
|
||||||
top: false,
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
|
|
||||||
child: quill.QuillSimpleToolbar(
|
|
||||||
controller: _controller!,
|
|
||||||
config: quill.QuillSimpleToolbarConfig(
|
|
||||||
multiRowsDisplay: false,
|
|
||||||
color: Colors.transparent,
|
|
||||||
toolbarSize: 30,
|
|
||||||
buttonOptions: const quill.QuillSimpleToolbarButtonOptions(
|
|
||||||
base: quill.QuillToolbarBaseButtonOptions(iconSize: 17),
|
|
||||||
),
|
|
||||||
showBoldButton: true,
|
|
||||||
showItalicButton: true,
|
|
||||||
showUnderLineButton: true,
|
|
||||||
showStrikeThrough: true,
|
|
||||||
showHeaderStyle: false,
|
|
||||||
showListBullets: true,
|
|
||||||
showListNumbers: true,
|
|
||||||
showListCheck: true,
|
|
||||||
showCodeBlock: true,
|
|
||||||
showQuote: true,
|
|
||||||
showInlineCode: true,
|
|
||||||
showUndo: false,
|
|
||||||
showRedo: false,
|
|
||||||
showLink: false,
|
|
||||||
showSearchButton: false,
|
|
||||||
showFontSize: false,
|
|
||||||
showFontFamily: false,
|
|
||||||
showColorButton: false,
|
|
||||||
showBackgroundColorButton: false,
|
|
||||||
showClearFormat: false,
|
|
||||||
showAlignmentButtons: false,
|
|
||||||
showDirection: false,
|
|
||||||
showIndent: false,
|
|
||||||
showSubscript: false,
|
|
||||||
showSuperscript: false,
|
|
||||||
customButtons: [
|
|
||||||
quill.QuillToolbarCustomButtonOptions(
|
|
||||||
icon: const Icon(Icons.text_fields, size: 17, color: Color(0xFF555555)),
|
|
||||||
onPressed: () => _showHeaderPicker(colors),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 标题选择 ─────────────────────────────────
|
|
||||||
|
|
||||||
void _showHeaderPicker(ColorScheme colors) {
|
|
||||||
final current = _controller?.getSelectionStyle().attributes ?? {};
|
|
||||||
final currentHeader = current['header']?.value;
|
|
||||||
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (ctx) => Container(
|
|
||||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.surface,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// 拖拽指示条
|
|
||||||
Container(
|
|
||||||
width: 36, height: 4,
|
|
||||||
margin: const EdgeInsets.only(top: 10, bottom: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_headerOption('正文', null, currentHeader == null, colors),
|
|
||||||
_headerOption('标题 1', 1, currentHeader == 1, colors),
|
|
||||||
_headerOption('标题 2', 2, currentHeader == 2, colors),
|
|
||||||
_headerOption('标题 3', 3, currentHeader == 3, colors),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _headerOption(String label, int? level, bool isActive, ColorScheme colors) {
|
|
||||||
final sizes = {null: 15.0, 1: 22.0, 2: 18.0, 3: 15.0};
|
|
||||||
return InkWell(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
if (_controller == null) return;
|
|
||||||
if (level == null) {
|
|
||||||
_controller!.formatSelection(quill.Attribute.header);
|
|
||||||
} else {
|
|
||||||
_controller!.formatSelection(quill.Attribute.clone(quill.Attribute.header, level));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(label, style: TextStyle(
|
|
||||||
fontSize: sizes[level] ?? 15,
|
|
||||||
fontWeight: level == null ? FontWeight.w400 : FontWeight.w600,
|
|
||||||
color: isActive ? colors.primary : colors.onSurface,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
if (isActive)
|
|
||||||
Icon(Icons.check, size: 18, color: colors.primary),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 保存对话框 ─────────────────────────────────
|
|
||||||
|
|
||||||
void _showSaveDialog(NotePlusProvider provider) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
|
||||||
title: Text('未保存的更改',
|
|
||||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600,
|
|
||||||
color: colors.onSurface)),
|
|
||||||
content: Text('是否保存当前文档?',
|
|
||||||
style: TextStyle(fontSize: 14,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.6))),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () { Navigator.pop(ctx); Navigator.pop(context); },
|
|
||||||
child: Text('不保存', style: TextStyle(color: colors.error)),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: Text('取消',
|
|
||||||
style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () { Navigator.pop(ctx); _save(provider); Navigator.pop(context); },
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colors.primary, foregroundColor: colors.onPrimary,
|
|
||||||
elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
||||||
),
|
|
||||||
child: const Text('保存'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,657 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:expandable/expandable.dart';
|
|
||||||
import '../../providers/note_plus_provider.dart';
|
|
||||||
import '../../models/note_plus_models.dart';
|
|
||||||
|
|
||||||
/// Note Plus 树形文件列表页
|
|
||||||
class NotePlusTabPage extends StatefulWidget {
|
|
||||||
const NotePlusTabPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<NotePlusTabPage> createState() => _NotePlusTabPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NotePlusTabPageState extends State<NotePlusTabPage> {
|
|
||||||
String? _selectedDocId;
|
|
||||||
String? _dragOverNodeId; // 拖到节点上(成为子节点)
|
|
||||||
_DropPosition? _dropPos; // 拖到节点之间(排序)
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
context.read<NotePlusProvider>().loadDocuments();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
_buildHeader(colors),
|
|
||||||
Expanded(child: _buildTree(colors)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHeader(ColorScheme colors) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
||||||
child: Row(children: [
|
|
||||||
Icon(Icons.edit_note, size: 18, color: colors.onSurface.withValues(alpha: 0.5)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text('Note Plus', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.7))),
|
|
||||||
const Spacer(),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () async {
|
|
||||||
final provider = context.read<NotePlusProvider>();
|
|
||||||
final doc = await provider.createDocument();
|
|
||||||
if (!mounted) return;
|
|
||||||
Navigator.pushNamed(context, '/note-plus-form', arguments: doc.id);
|
|
||||||
},
|
|
||||||
child: Icon(Icons.add, size: 20,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.45)),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => _showRecycleBin(context),
|
|
||||||
child: Icon(Icons.delete_outline, size: 18,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.35)),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTree(ColorScheme colors) {
|
|
||||||
return Consumer<NotePlusProvider>(
|
|
||||||
builder: (context, provider, _) {
|
|
||||||
if (provider.documents.isEmpty) return _buildEmptyState(colors);
|
|
||||||
|
|
||||||
final roots = provider.documents
|
|
||||||
.where((d) => d.parentId.isEmpty)
|
|
||||||
.toList()
|
|
||||||
..sort((a, b) => a.sortIndex.compareTo(b.sortIndex));
|
|
||||||
|
|
||||||
if (roots.isEmpty) return _buildEmptyState(colors);
|
|
||||||
|
|
||||||
return ExpandableTheme(
|
|
||||||
data: const ExpandableThemeData(
|
|
||||||
useInkWell: false,
|
|
||||||
animationDuration: Duration(milliseconds: 200),
|
|
||||||
headerAlignment: ExpandablePanelHeaderAlignment.center,
|
|
||||||
tapBodyToExpand: false, tapBodyToCollapse: false,
|
|
||||||
tapHeaderToExpand: false, iconPadding: EdgeInsets.zero, hasIcon: false,
|
|
||||||
),
|
|
||||||
child: RefreshIndicator(
|
|
||||||
onRefresh: () => provider.loadDocuments(),
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 40),
|
|
||||||
children: _buildChildrenList(roots, 0, colors),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 构建子节点列表,节点之间插入 drop zone(共 n+1 个间隙)
|
|
||||||
List<Widget> _buildChildrenList(List<NotePlusDocument> docs, int depth, ColorScheme colors) {
|
|
||||||
final widgets = <Widget>[];
|
|
||||||
final parentId = docs.isNotEmpty ? docs.first.parentId : '';
|
|
||||||
for (int i = 0; i < docs.length; i++) {
|
|
||||||
// 节点前的间隙
|
|
||||||
widgets.add(_buildReorderGap(parentId, i, depth, colors));
|
|
||||||
// 节点本身
|
|
||||||
widgets.add(_buildTreeNode(docs[i], depth, colors));
|
|
||||||
}
|
|
||||||
// 末尾间隙
|
|
||||||
if (docs.isNotEmpty) {
|
|
||||||
widgets.add(_buildReorderGap(parentId, docs.length, depth, colors));
|
|
||||||
}
|
|
||||||
return widgets;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 排序间隙 drop zone
|
|
||||||
Widget _buildReorderGap(String parentId, int insertIndex, int depth, ColorScheme colors) {
|
|
||||||
final indent = 28.0 + depth * 18.0;
|
|
||||||
final isTarget = _dropPos != null &&
|
|
||||||
_dropPos!.parentId == parentId &&
|
|
||||||
_dropPos!.index == insertIndex;
|
|
||||||
|
|
||||||
return DragTarget<String>(
|
|
||||||
onWillAcceptWithDetails: (details) {
|
|
||||||
final draggedId = details.data;
|
|
||||||
final provider = context.read<NotePlusProvider>();
|
|
||||||
final dragged = provider.documents.where((d) => d.id == draggedId).firstOrNull;
|
|
||||||
if (dragged == null) return false;
|
|
||||||
// 防止拖到自己的子孙上
|
|
||||||
if (parentId.isNotEmpty &&
|
|
||||||
_isDescendantOf(provider.documents, childId: parentId, ancestorId: draggedId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setState(() => _dropPos = _DropPosition(parentId, insertIndex));
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
onLeave: (_) {
|
|
||||||
if (_dropPos?.parentId == parentId && _dropPos?.index == insertIndex) {
|
|
||||||
setState(() => _dropPos = null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onAcceptWithDetails: (details) {
|
|
||||||
setState(() { _dropPos = null; _dragOverNodeId = null; });
|
|
||||||
_handleDrop(details.data, parentId, insertIndex);
|
|
||||||
},
|
|
||||||
builder: (_, __, ___) {
|
|
||||||
return AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
height: isTarget ? 36 : 6,
|
|
||||||
margin: EdgeInsets.only(left: indent, right: 16),
|
|
||||||
decoration: isTarget
|
|
||||||
? BoxDecoration(
|
|
||||||
color: colors.primary.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
border: Border.all(color: colors.primary.withValues(alpha: 0.4), width: 1),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
child: isTarget
|
|
||||||
? Center(
|
|
||||||
child: Text('放置到此处',
|
|
||||||
style: TextStyle(fontSize: 11,
|
|
||||||
color: colors.primary.withValues(alpha: 0.7))),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 递归构建单个树节点
|
|
||||||
Widget _buildTreeNode(NotePlusDocument doc, int depth, ColorScheme colors) {
|
|
||||||
final provider = context.watch<NotePlusProvider>();
|
|
||||||
final children = provider.documents
|
|
||||||
.where((d) => d.parentId == doc.id)
|
|
||||||
.toList()
|
|
||||||
..sort((a, b) => a.sortIndex.compareTo(b.sortIndex));
|
|
||||||
final hasChildren = children.isNotEmpty;
|
|
||||||
final isSelected = _selectedDocId == doc.id;
|
|
||||||
final isDropTarget = _dragOverNodeId == doc.id;
|
|
||||||
|
|
||||||
final nodeContent = _buildNodeContent(doc, depth,
|
|
||||||
hasChildren: hasChildren, isSelected: isSelected,
|
|
||||||
isDropTarget: isDropTarget, colors: colors);
|
|
||||||
|
|
||||||
// 可拖拽包装
|
|
||||||
final draggable = LongPressDraggable<String>(
|
|
||||||
data: doc.id,
|
|
||||||
delay: const Duration(milliseconds: 300),
|
|
||||||
feedback: Material(
|
|
||||||
elevation: 4, borderRadius: BorderRadius.circular(6),
|
|
||||||
child: Container(
|
|
||||||
width: MediaQuery.of(context).size.width - 48,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: Row(children: [
|
|
||||||
Icon(Icons.description_outlined, size: 14,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.5)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(child: Text(doc.title.isEmpty ? '无标题' : doc.title,
|
|
||||||
style: TextStyle(fontSize: 12, color: colors.onSurface),
|
|
||||||
maxLines: 1, overflow: TextOverflow.ellipsis)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
childWhenDragging: Opacity(opacity: 0.3, child: nodeContent),
|
|
||||||
child: DragTarget<String>(
|
|
||||||
onWillAcceptWithDetails: (details) {
|
|
||||||
final draggedId = details.data;
|
|
||||||
if (draggedId == doc.id) return false;
|
|
||||||
if (_isDescendantOf(provider.documents, childId: doc.id, ancestorId: draggedId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setState(() => _dragOverNodeId = doc.id);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
onLeave: (_) {
|
|
||||||
if (_dragOverNodeId == doc.id) setState(() => _dragOverNodeId = null);
|
|
||||||
},
|
|
||||||
onAcceptWithDetails: (details) {
|
|
||||||
setState(() { _dragOverNodeId = null; _dropPos = null; });
|
|
||||||
// 拖到节点上 → 成为该节点的子节点
|
|
||||||
provider.moveDocument(details.data, doc.id);
|
|
||||||
},
|
|
||||||
builder: (_, __, ___) => nodeContent,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 组装:可展开节点带子树,叶子节点直接返回
|
|
||||||
if (hasChildren) {
|
|
||||||
return ExpandableNotifier(
|
|
||||||
initialExpanded: true,
|
|
||||||
child: Builder(
|
|
||||||
builder: (innerCtx) {
|
|
||||||
// 给 _buildNodeContent 传入 expandContext 用于展开/折叠
|
|
||||||
final nodeWithExpand = _buildNodeContent(doc, depth,
|
|
||||||
hasChildren: true, isSelected: isSelected,
|
|
||||||
isDropTarget: isDropTarget, expandContext: innerCtx, colors: colors);
|
|
||||||
|
|
||||||
final draggableWithExpand = LongPressDraggable<String>(
|
|
||||||
data: doc.id, delay: const Duration(milliseconds: 300),
|
|
||||||
feedback: Material(
|
|
||||||
elevation: 4, borderRadius: BorderRadius.circular(6),
|
|
||||||
child: Container(
|
|
||||||
width: MediaQuery.of(context).size.width - 48,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.surfaceContainerHigh,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: Row(children: [
|
|
||||||
Icon(Icons.description_outlined, size: 14,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.5)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(child: Text(doc.title.isEmpty ? '无标题' : doc.title,
|
|
||||||
style: TextStyle(fontSize: 12, color: colors.onSurface),
|
|
||||||
maxLines: 1, overflow: TextOverflow.ellipsis)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
childWhenDragging: Opacity(opacity: 0.3, child: nodeWithExpand),
|
|
||||||
child: DragTarget<String>(
|
|
||||||
onWillAcceptWithDetails: (details) {
|
|
||||||
final draggedId = details.data;
|
|
||||||
if (draggedId == doc.id) return false;
|
|
||||||
if (_isDescendantOf(provider.documents, childId: doc.id, ancestorId: draggedId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setState(() => _dragOverNodeId = doc.id);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
onLeave: (_) {
|
|
||||||
if (_dragOverNodeId == doc.id) setState(() => _dragOverNodeId = null);
|
|
||||||
},
|
|
||||||
onAcceptWithDetails: (details) {
|
|
||||||
setState(() { _dragOverNodeId = null; _dropPos = null; });
|
|
||||||
provider.moveDocument(details.data, doc.id);
|
|
||||||
},
|
|
||||||
builder: (_, __, ___) => nodeWithExpand,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Column(children: [
|
|
||||||
draggableWithExpand,
|
|
||||||
Expandable(
|
|
||||||
collapsed: const SizedBox(),
|
|
||||||
expanded: Column(children: _buildChildrenList(children, depth + 1, colors)),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return draggable;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 节点内容(标题行)
|
|
||||||
Widget _buildNodeContent(NotePlusDocument doc, int depth,
|
|
||||||
{required bool hasChildren, required bool isSelected,
|
|
||||||
required bool isDropTarget, BuildContext? expandContext,
|
|
||||||
required ColorScheme colors}) {
|
|
||||||
final indent = 12.0 + depth * 18.0;
|
|
||||||
|
|
||||||
return Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
setState(() => _selectedDocId = doc.id);
|
|
||||||
Navigator.pushNamed(context, '/note-plus-form', arguments: doc.id);
|
|
||||||
},
|
|
||||||
onLongPress: () => _showDocActions(doc),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: Container(
|
|
||||||
height: 30,
|
|
||||||
padding: EdgeInsets.only(left: indent, right: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
|
||||||
? colors.primary.withValues(alpha: 0.08)
|
|
||||||
: isDropTarget
|
|
||||||
? colors.primary.withValues(alpha: 0.12)
|
|
||||||
: null,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
border: isDropTarget
|
|
||||||
? Border.all(color: colors.primary.withValues(alpha: 0.4), width: 1)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: Row(children: [
|
|
||||||
// 展开/折叠箭头
|
|
||||||
if (hasChildren)
|
|
||||||
SizedBox(
|
|
||||||
width: 20, height: 20,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () {
|
|
||||||
if (expandContext != null) {
|
|
||||||
ExpandableController.of(expandContext,
|
|
||||||
rebuildOnChange: false, required: true)
|
|
||||||
?.toggle();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: expandContext != null
|
|
||||||
? ExpandableIcon(
|
|
||||||
theme: ExpandableThemeData(
|
|
||||||
expandIcon: Icons.keyboard_arrow_right,
|
|
||||||
collapseIcon: Icons.keyboard_arrow_down,
|
|
||||||
iconColor: colors.onSurface.withValues(alpha: 0.4),
|
|
||||||
iconSize: 14, iconPadding: EdgeInsets.zero, hasIcon: false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: Icon(Icons.keyboard_arrow_down, size: 14,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.4)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const SizedBox(width: 20),
|
|
||||||
// 图标
|
|
||||||
Icon(_getDocIcon(doc), size: 14,
|
|
||||||
color: isSelected ? colors.primary : colors.onSurface.withValues(alpha: 0.4)),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
// 标题
|
|
||||||
Expanded(
|
|
||||||
child: Text(doc.title.isEmpty ? '无标题' : doc.title,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
|
|
||||||
color: isSelected ? colors.primary : colors.onSurface.withValues(alpha: 0.75),
|
|
||||||
), maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
||||||
),
|
|
||||||
// 添加子文档
|
|
||||||
SizedBox(
|
|
||||||
width: 20, height: 20,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => _createChildDoc(doc.id),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: Icon(Icons.add, size: 13,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.25)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
// 删除
|
|
||||||
SizedBox(
|
|
||||||
width: 20, height: 20,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => _showDeleteDialog(doc.id),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: Icon(Icons.delete_outline, size: 13,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.25)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 拖放处理 ─────────────────────────────────────
|
|
||||||
|
|
||||||
void _handleDrop(String draggedId, String targetParentId, int insertIndex) {
|
|
||||||
final provider = context.read<NotePlusProvider>();
|
|
||||||
provider.moveDocumentTo(draggedId, targetParentId, insertIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 工具 ─────────────────────────────────────
|
|
||||||
|
|
||||||
bool _isDescendantOf(List<NotePlusDocument> docs,
|
|
||||||
{required String childId, required String ancestorId}) {
|
|
||||||
if (childId.isEmpty) return false;
|
|
||||||
if (childId == ancestorId) return true; // 自身也算(防止拖到自己下面)
|
|
||||||
var current = docs.where((d) => d.id == childId).firstOrNull;
|
|
||||||
while (current != null) {
|
|
||||||
if (current.parentId == ancestorId) return true;
|
|
||||||
current = docs.where((d) => d.id == current!.parentId).firstOrNull;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getDocIcon(NotePlusDocument doc) {
|
|
||||||
if (doc.blocks.isEmpty) return Icons.description_outlined;
|
|
||||||
switch (doc.blocks.first.type) {
|
|
||||||
case NoteBlockType.heading1:
|
|
||||||
case NoteBlockType.heading2:
|
|
||||||
case NoteBlockType.heading3:
|
|
||||||
return Icons.title;
|
|
||||||
case NoteBlockType.checklist:
|
|
||||||
return Icons.check_box_outlined;
|
|
||||||
case NoteBlockType.codeBlock:
|
|
||||||
return Icons.code;
|
|
||||||
case NoteBlockType.quote:
|
|
||||||
return Icons.format_quote;
|
|
||||||
default:
|
|
||||||
return Icons.description_outlined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 操作 ─────────────────────────────────────
|
|
||||||
|
|
||||||
Future<void> _createChildDoc(String parentId) async {
|
|
||||||
final provider = context.read<NotePlusProvider>();
|
|
||||||
final doc = await provider.createDocument(parentId: parentId);
|
|
||||||
if (!mounted) return;
|
|
||||||
Navigator.pushNamed(context, '/note-plus-form', arguments: doc.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showDocActions(NotePlusDocument doc) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
),
|
|
||||||
builder: (ctx) => SafeArea(
|
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Container(width: 36, height: 4,
|
|
||||||
margin: const EdgeInsets.only(top: 10, bottom: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(Icons.subdirectory_arrow_right, size: 20,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.6)),
|
|
||||||
title: const Text('新建子文档', style: TextStyle(fontSize: 14)),
|
|
||||||
onTap: () { Navigator.pop(ctx); _createChildDoc(doc.id); },
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(Icons.edit_outlined, size: 20,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.6)),
|
|
||||||
title: const Text('重命名', style: TextStyle(fontSize: 14)),
|
|
||||||
onTap: () { Navigator.pop(ctx); _showRenameDialog(doc); },
|
|
||||||
),
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(Icons.delete_outline, size: 20, color: colors.error),
|
|
||||||
title: Text('删除', style: TextStyle(fontSize: 14, color: colors.error)),
|
|
||||||
subtitle: Text('子文档将提升到上一级',
|
|
||||||
style: TextStyle(fontSize: 11,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.4))),
|
|
||||||
onTap: () { Navigator.pop(ctx); _showDeleteDialog(doc.id); },
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showRenameDialog(NotePlusDocument doc) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
final controller = TextEditingController(text: doc.title);
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
title: Text('重命名', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600,
|
|
||||||
color: colors.onSurface)),
|
|
||||||
content: TextField(
|
|
||||||
controller: controller, autofocus: true,
|
|
||||||
decoration: InputDecoration(hintText: '输入标题',
|
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8))),
|
|
||||||
onSubmitted: (_) {
|
|
||||||
context.read<NotePlusProvider>().renameDocument(doc.id, controller.text);
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
context.read<NotePlusProvider>().renameDocument(doc.id, controller.text);
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colors.primary, foregroundColor: colors.onPrimary,
|
|
||||||
elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
||||||
),
|
|
||||||
child: const Text('确定'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showDeleteDialog(String id) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
title: Text('删除文档', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600,
|
|
||||||
color: colors.onSurface)),
|
|
||||||
content: Text('子文档将自动提升到上一级。可在回收站恢复。',
|
|
||||||
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
|
|
||||||
actions: [
|
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
context.read<NotePlusProvider>().deleteDocument(id);
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: colors.error, foregroundColor: colors.onError,
|
|
||||||
elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
||||||
),
|
|
||||||
child: const Text('删除'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showRecycleBin(BuildContext context) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
final provider = context.read<NotePlusProvider>();
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context, backgroundColor: colors.surface,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
),
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (ctx) => DraggableScrollableSheet(
|
|
||||||
initialChildSize: 0.5, minChildSize: 0.3, maxChildSize: 0.8, expand: false,
|
|
||||||
builder: (ctx, scrollCtrl) {
|
|
||||||
return FutureBuilder<List<NotePlusDocument>>(
|
|
||||||
future: provider.getDeletedDocuments(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
final deleted = snapshot.data ?? [];
|
|
||||||
return Column(children: [
|
|
||||||
Container(width: 36, height: 4,
|
|
||||||
margin: const EdgeInsets.only(top: 10, bottom: 14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Row(children: [
|
|
||||||
Text('回收站', style: TextStyle(fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w600, color: colors.onSurface)),
|
|
||||||
const Spacer(),
|
|
||||||
Text('${deleted.length} 个文档', style: TextStyle(fontSize: 12,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.4))),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Expanded(
|
|
||||||
child: deleted.isEmpty
|
|
||||||
? Center(child: Text('回收站为空',
|
|
||||||
style: TextStyle(fontSize: 13,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.35))))
|
|
||||||
: ListView.builder(
|
|
||||||
controller: scrollCtrl, itemCount: deleted.length,
|
|
||||||
itemBuilder: (context, i) {
|
|
||||||
final doc = deleted[i];
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
leading: Icon(Icons.description_outlined, size: 18,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.4)),
|
|
||||||
title: Text(doc.title.isEmpty ? '无标题' : doc.title,
|
|
||||||
style: const TextStyle(fontSize: 13)),
|
|
||||||
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.restore, size: 18, color: colors.primary),
|
|
||||||
onPressed: () { provider.restoreDocument(doc.id); Navigator.pop(ctx); },
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.delete_forever, size: 18, color: colors.error),
|
|
||||||
onPressed: () { provider.permanentDeleteDocument(doc.id); Navigator.pop(ctx); },
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmptyState(ColorScheme colors) {
|
|
||||||
return Center(
|
|
||||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
|
||||||
Container(width: 64, height: 64,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.edit_note, size: 32,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.2)),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text('暂无文档', style: TextStyle(fontSize: 14,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.4))),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text('点击右上角 + 创建', style: TextStyle(fontSize: 12,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.25))),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 拖放位置记录
|
|
||||||
class _DropPosition {
|
|
||||||
final String parentId;
|
|
||||||
final int index;
|
|
||||||
_DropPosition(this.parentId, this.index);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import 'package:image_picker/image_picker.dart';
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import '../main.dart' show routeObserver;
|
import '../main.dart' show routeObserver;
|
||||||
@@ -2321,7 +2320,6 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
bool _showMovieTab = true;
|
bool _showMovieTab = true;
|
||||||
bool _showBookTab = true;
|
bool _showBookTab = true;
|
||||||
bool _showNoteTab = true;
|
bool _showNoteTab = true;
|
||||||
bool _showNotePlusTab = false;
|
|
||||||
int _defaultTabIndex = 0;
|
int _defaultTabIndex = 0;
|
||||||
|
|
||||||
// 侧边栏
|
// 侧边栏
|
||||||
@@ -2346,7 +2344,6 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
_showMovieTab = _userPrefs.showMovieTab;
|
_showMovieTab = _userPrefs.showMovieTab;
|
||||||
_showBookTab = _userPrefs.showBookTab;
|
_showBookTab = _userPrefs.showBookTab;
|
||||||
_showNoteTab = _userPrefs.showNoteTab;
|
_showNoteTab = _userPrefs.showNoteTab;
|
||||||
_showNotePlusTab = _userPrefs.showNotePlusTab;
|
|
||||||
_defaultTabIndex = _userPrefs.defaultMainTabIndex;
|
_defaultTabIndex = _userPrefs.defaultMainTabIndex;
|
||||||
_showHeatmap = _userPrefs.showSidebarHeatmap;
|
_showHeatmap = _userPrefs.showSidebarHeatmap;
|
||||||
_showRecent = _userPrefs.showSidebarRecent;
|
_showRecent = _userPrefs.showSidebarRecent;
|
||||||
@@ -2428,10 +2425,6 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleNotePlusTab(bool value) async {
|
|
||||||
await _userPrefs.setShowNotePlusTab(value);
|
|
||||||
setState(() => _showNotePlusTab = value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -2473,9 +2466,6 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
|
|||||||
indent: 24,
|
indent: 24,
|
||||||
endIndent: 24,
|
endIndent: 24,
|
||||||
color: colors.outlineVariant),
|
color: colors.outlineVariant),
|
||||||
_buildSwitchItem(Icons.edit_note, 'Note Plus', '块编辑器,支持富文本文档',
|
|
||||||
_showNotePlusTab, _toggleNotePlusTab),
|
|
||||||
|
|
||||||
// ── 侧边栏:信息模块 ──
|
// ── 侧边栏:信息模块 ──
|
||||||
_buildSectionHeader('侧边栏 · 信息模块'),
|
_buildSectionHeader('侧边栏 · 信息模块'),
|
||||||
_buildSwitchItem(
|
_buildSwitchItem(
|
||||||
@@ -3001,50 +2991,3 @@ class _LayoutSettingsPageState extends State<LayoutSettingsPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WebView 页面
|
|
||||||
class WebViewPage extends StatefulWidget {
|
|
||||||
final String url;
|
|
||||||
const WebViewPage({super.key, required this.url});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<WebViewPage> createState() => _WebViewPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WebViewPageState extends State<WebViewPage> {
|
|
||||||
late final WebViewController _controller;
|
|
||||||
bool _isLoading = true;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = WebViewController()
|
|
||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
|
||||||
..setNavigationDelegate(NavigationDelegate(
|
|
||||||
onPageStarted: (_) => setState(() => _isLoading = true),
|
|
||||||
onPageFinished: (_) => setState(() => _isLoading = false),
|
|
||||||
onWebResourceError: (_) => setState(() => _isLoading = false),
|
|
||||||
))
|
|
||||||
..loadRequest(Uri.parse(widget.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
appBar: AppBar(title: const Text(''), actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
onPressed: () => _controller.reload())
|
|
||||||
]),
|
|
||||||
body: Stack(children: [
|
|
||||||
WebViewWidget(controller: _controller),
|
|
||||||
if (_isLoading)
|
|
||||||
Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.4)))
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../models/data_models.dart';
|
import '../models/data_models.dart';
|
||||||
import '../models/note_plus_models.dart';
|
|
||||||
import '../utils/toast_util.dart';
|
import '../utils/toast_util.dart';
|
||||||
|
|
||||||
/// 回收站页面
|
/// 回收站页面
|
||||||
@@ -13,7 +12,7 @@ class RecycleBinPage extends StatefulWidget {
|
|||||||
State<RecycleBinPage> createState() => _RecycleBinPageState();
|
State<RecycleBinPage> createState() => _RecycleBinPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _ItemType { movie, book, note, movieReview, bookReview, bookExcerpt, notePlus }
|
enum _ItemType { movie, book, note, movieReview, bookReview, bookExcerpt }
|
||||||
|
|
||||||
class _DeletedItem {
|
class _DeletedItem {
|
||||||
final _ItemType type;
|
final _ItemType type;
|
||||||
@@ -71,13 +70,6 @@ class _DeletedItem {
|
|||||||
icon = Icons.format_quote_outlined,
|
icon = Icons.format_quote_outlined,
|
||||||
typeLabel = '书摘';
|
typeLabel = '书摘';
|
||||||
|
|
||||||
_DeletedItem.notePlus(NotePlusDocument d)
|
|
||||||
: type = _ItemType.notePlus,
|
|
||||||
id = d.id,
|
|
||||||
title = d.title.isNotEmpty ? d.title : '未命名文档',
|
|
||||||
subtitle = '删除于 ${d.updatedAt.year}.${d.updatedAt.month.toString().padLeft(2, '0')}.${d.updatedAt.day.toString().padLeft(2, '0')}',
|
|
||||||
icon = Icons.edit_note_outlined,
|
|
||||||
typeLabel = '高级笔记';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RecycleBinPageState extends State<RecycleBinPage> {
|
class _RecycleBinPageState extends State<RecycleBinPage> {
|
||||||
@@ -103,7 +95,6 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
final movieReviews = await provider.getDeletedMovieReviews();
|
final movieReviews = await provider.getDeletedMovieReviews();
|
||||||
final bookReviews = await provider.getDeletedBookReviews();
|
final bookReviews = await provider.getDeletedBookReviews();
|
||||||
final bookExcerpts = await provider.getDeletedBookExcerpts();
|
final bookExcerpts = await provider.getDeletedBookExcerpts();
|
||||||
final notePlusDocs = await provider.getDeletedNotePlusDocs();
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_allItems = [
|
_allItems = [
|
||||||
@@ -113,7 +104,6 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
for (final r in movieReviews) _DeletedItem.movieReview(r),
|
for (final r in movieReviews) _DeletedItem.movieReview(r),
|
||||||
for (final r in bookReviews) _DeletedItem.bookReview(r),
|
for (final r in bookReviews) _DeletedItem.bookReview(r),
|
||||||
for (final e in bookExcerpts) _DeletedItem.bookExcerpt(e),
|
for (final e in bookExcerpts) _DeletedItem.bookExcerpt(e),
|
||||||
for (final d in notePlusDocs) _DeletedItem.notePlus(d),
|
|
||||||
];
|
];
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
@@ -188,7 +178,6 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
_filterChip('影评', _ItemType.movieReview),
|
_filterChip('影评', _ItemType.movieReview),
|
||||||
_filterChip('书评', _ItemType.bookReview),
|
_filterChip('书评', _ItemType.bookReview),
|
||||||
_filterChip('书摘', _ItemType.bookExcerpt),
|
_filterChip('书摘', _ItemType.bookExcerpt),
|
||||||
_filterChip('高级笔记', _ItemType.notePlus),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -411,9 +400,6 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
case _ItemType.bookExcerpt:
|
case _ItemType.bookExcerpt:
|
||||||
await provider.restoreBookExcerpt(item.id);
|
await provider.restoreBookExcerpt(item.id);
|
||||||
if (mounted) ToastUtil.show(context, '书摘已恢复');
|
if (mounted) ToastUtil.show(context, '书摘已恢复');
|
||||||
case _ItemType.notePlus:
|
|
||||||
await provider.restoreNotePlusDoc(item.id);
|
|
||||||
if (mounted) ToastUtil.show(context, '高级笔记已恢复');
|
|
||||||
}
|
}
|
||||||
_loadDeletedItems();
|
_loadDeletedItems();
|
||||||
}
|
}
|
||||||
@@ -435,8 +421,6 @@ class _RecycleBinPageState extends State<RecycleBinPage> {
|
|||||||
await provider.permanentDeleteBookReview(item.id);
|
await provider.permanentDeleteBookReview(item.id);
|
||||||
case _ItemType.bookExcerpt:
|
case _ItemType.bookExcerpt:
|
||||||
await provider.permanentDeleteBookExcerpt(item.id);
|
await provider.permanentDeleteBookExcerpt(item.id);
|
||||||
case _ItemType.notePlus:
|
|
||||||
await provider.permanentDeleteNotePlusDoc(item.id);
|
|
||||||
}
|
}
|
||||||
_loadDeletedItems();
|
_loadDeletedItems();
|
||||||
if (mounted) ToastUtil.show(context, '已彻底删除');
|
if (mounted) ToastUtil.show(context, '已彻底删除');
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/data_models.dart';
|
import '../models/data_models.dart';
|
||||||
import '../models/note_plus_models.dart';
|
|
||||||
import '../utils/movie/movie_dao.dart';
|
import '../utils/movie/movie_dao.dart';
|
||||||
import '../utils/book/book_dao.dart';
|
import '../utils/book/book_dao.dart';
|
||||||
import '../utils/note/note_dao.dart';
|
import '../utils/note/note_dao.dart';
|
||||||
@@ -9,7 +8,6 @@ import '../utils/movie/movie_review_dao.dart';
|
|||||||
import '../utils/movie/movie_poster_dao.dart';
|
import '../utils/movie/movie_poster_dao.dart';
|
||||||
import '../utils/book/book_review_dao.dart';
|
import '../utils/book/book_review_dao.dart';
|
||||||
import '../utils/book/book_excerpt_dao.dart';
|
import '../utils/book/book_excerpt_dao.dart';
|
||||||
import '../utils/note_plus/note_plus_dao.dart';
|
|
||||||
import '../utils/tag/tag_dao.dart';
|
import '../utils/tag/tag_dao.dart';
|
||||||
import '../utils/database_helper.dart';
|
import '../utils/database_helper.dart';
|
||||||
import '../utils/image_path_helper.dart';
|
import '../utils/image_path_helper.dart';
|
||||||
@@ -27,7 +25,6 @@ class AppProvider extends ChangeNotifier {
|
|||||||
final MoviePosterDao _posterDao = MoviePosterDao();
|
final MoviePosterDao _posterDao = MoviePosterDao();
|
||||||
final BookReviewDao _bookReviewDao = BookReviewDao();
|
final BookReviewDao _bookReviewDao = BookReviewDao();
|
||||||
final BookExcerptDao _bookExcerptDao = BookExcerptDao();
|
final BookExcerptDao _bookExcerptDao = BookExcerptDao();
|
||||||
final NotePlusDao _notePlusDao = NotePlusDao();
|
|
||||||
final TagDao _tagDao = TagDao();
|
final TagDao _tagDao = TagDao();
|
||||||
// 数据列表
|
// 数据列表
|
||||||
List<Movie> _movies = [];
|
List<Movie> _movies = [];
|
||||||
@@ -560,7 +557,6 @@ class AppProvider extends ChangeNotifier {
|
|||||||
final deletedMovieReviews = await getDeletedMovieReviews();
|
final deletedMovieReviews = await getDeletedMovieReviews();
|
||||||
final deletedBookReviews = await getDeletedBookReviews();
|
final deletedBookReviews = await getDeletedBookReviews();
|
||||||
final deletedBookExcerpts = await getDeletedBookExcerpts();
|
final deletedBookExcerpts = await getDeletedBookExcerpts();
|
||||||
final deletedNotePlusDocs = await _notePlusDao.getDeleted();
|
|
||||||
|
|
||||||
for (final movie in deletedMovies) {
|
for (final movie in deletedMovies) {
|
||||||
await permanentDeleteMovie(movie.id);
|
await permanentDeleteMovie(movie.id);
|
||||||
@@ -580,9 +576,6 @@ class AppProvider extends ChangeNotifier {
|
|||||||
for (final excerpt in deletedBookExcerpts) {
|
for (final excerpt in deletedBookExcerpts) {
|
||||||
await _bookExcerptDao.permanentDeleteExcerpt(excerpt.id);
|
await _bookExcerptDao.permanentDeleteExcerpt(excerpt.id);
|
||||||
}
|
}
|
||||||
for (final doc in deletedNotePlusDocs) {
|
|
||||||
await _notePlusDao.permanentDelete(doc.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadMovies();
|
await loadMovies();
|
||||||
await loadBooks();
|
await loadBooks();
|
||||||
@@ -629,19 +622,6 @@ class AppProvider extends ChangeNotifier {
|
|||||||
await _bookExcerptDao.permanentDeleteExcerpt(id);
|
await _bookExcerptDao.permanentDeleteExcerpt(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Note Plus 回收站 ==========
|
|
||||||
|
|
||||||
Future<List<NotePlusDocument>> getDeletedNotePlusDocs() async {
|
|
||||||
return await _notePlusDao.getDeleted();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> restoreNotePlusDoc(String id) async {
|
|
||||||
await _notePlusDao.restore(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> permanentDeleteNotePlusDoc(String id) async {
|
|
||||||
await _notePlusDao.permanentDelete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 标签管理方法 ==========
|
// ========== 标签管理方法 ==========
|
||||||
|
|
||||||
|
|||||||
@@ -1,459 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import '../models/note_plus_models.dart';
|
|
||||||
import '../utils/note_plus/note_plus_dao.dart';
|
|
||||||
|
|
||||||
/// Note Plus 块编辑器状态管理
|
|
||||||
class NotePlusProvider extends ChangeNotifier {
|
|
||||||
final NotePlusDao _dao = NotePlusDao();
|
|
||||||
|
|
||||||
List<NotePlusDocument> _documents = [];
|
|
||||||
NotePlusDocument? _currentDocument;
|
|
||||||
List<NoteBlock> _blocks = [];
|
|
||||||
int _focusedBlockIndex = 0;
|
|
||||||
bool _isDirty = false;
|
|
||||||
|
|
||||||
// Undo/Redo
|
|
||||||
final List<List<NoteBlock>> _undoStack = [];
|
|
||||||
final List<List<NoteBlock>> _redoStack = [];
|
|
||||||
static const int _maxUndoSize = 50;
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
List<NotePlusDocument> get documents => _documents;
|
|
||||||
NotePlusDocument? get currentDocument => _currentDocument;
|
|
||||||
List<NoteBlock> get blocks => _blocks;
|
|
||||||
int get focusedBlockIndex => _focusedBlockIndex;
|
|
||||||
bool get isDirty => _isDirty;
|
|
||||||
bool get canUndo => _undoStack.isNotEmpty;
|
|
||||||
bool get canRedo => _redoStack.isNotEmpty;
|
|
||||||
|
|
||||||
// ========== 文档 CRUD ==========
|
|
||||||
|
|
||||||
Future<void> loadDocuments() async {
|
|
||||||
_documents = await _dao.getAll();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> loadDocumentById(String id) async {
|
|
||||||
_currentDocument = await _dao.getById(id);
|
|
||||||
if (_currentDocument != null) {
|
|
||||||
_blocks = _currentDocument!.blocks.map((b) => b.deepCopy()).toList();
|
|
||||||
_focusedBlockIndex = 0;
|
|
||||||
_isDirty = false;
|
|
||||||
_undoStack.clear();
|
|
||||||
_redoStack.clear();
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> saveDocument({String? deltaJson}) async {
|
|
||||||
if (_currentDocument == null) return;
|
|
||||||
final now = DateTime.now();
|
|
||||||
// 直接构造,避免 copyWith 的 null 歧义
|
|
||||||
_currentDocument = NotePlusDocument(
|
|
||||||
id: _currentDocument!.id,
|
|
||||||
title: _currentDocument!.title,
|
|
||||||
parentId: _currentDocument!.parentId,
|
|
||||||
sortIndex: _currentDocument!.sortIndex,
|
|
||||||
blocks: deltaJson == null ? List.from(_blocks) : _currentDocument!.blocks,
|
|
||||||
blocksJson: deltaJson,
|
|
||||||
tags: _currentDocument!.tags,
|
|
||||||
images: _currentDocument!.images,
|
|
||||||
createdAt: _currentDocument!.createdAt,
|
|
||||||
updatedAt: now,
|
|
||||||
isDeleted: _currentDocument!.isDeleted,
|
|
||||||
);
|
|
||||||
await _dao.update(_currentDocument!);
|
|
||||||
final idx = _documents.indexWhere((d) => d.id == _currentDocument!.id);
|
|
||||||
if (idx >= 0) _documents[idx] = _currentDocument!;
|
|
||||||
_isDirty = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 保存 Quill Delta JSON 作为文档内容(flutter_quill 模式)
|
|
||||||
void saveDeltaJson(String deltaJson) {
|
|
||||||
if (_currentDocument == null) return;
|
|
||||||
_currentDocument = NotePlusDocument(
|
|
||||||
id: _currentDocument!.id,
|
|
||||||
title: _currentDocument!.title,
|
|
||||||
parentId: _currentDocument!.parentId,
|
|
||||||
sortIndex: _currentDocument!.sortIndex,
|
|
||||||
blocksJson: deltaJson,
|
|
||||||
tags: _currentDocument!.tags,
|
|
||||||
images: _currentDocument!.images,
|
|
||||||
createdAt: _currentDocument!.createdAt,
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
isDeleted: _currentDocument!.isDeleted,
|
|
||||||
);
|
|
||||||
_isDirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<NotePlusDocument> createDocument({String title = '', String parentId = ''}) async {
|
|
||||||
final doc = NotePlusDocument(title: title, parentId: parentId);
|
|
||||||
await _dao.insert(doc);
|
|
||||||
await loadDocuments();
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 移动文档到指定父级的指定位置(原子操作,同时处理重排序)
|
|
||||||
Future<void> moveDocumentTo(String docId, String newParentId, int insertIndex) async {
|
|
||||||
final idx = _documents.indexWhere((d) => d.id == docId);
|
|
||||||
if (idx < 0) return;
|
|
||||||
|
|
||||||
final doc = _documents[idx];
|
|
||||||
final oldParentId = doc.parentId;
|
|
||||||
|
|
||||||
// 获取目标父级下的同级文档(不含自身)
|
|
||||||
final siblings = _documents
|
|
||||||
.where((d) => d.parentId == newParentId && d.id != docId)
|
|
||||||
.toList()
|
|
||||||
..sort((a, b) => a.sortIndex.compareTo(b.sortIndex));
|
|
||||||
|
|
||||||
// 钳制插入位置
|
|
||||||
var pos = insertIndex.clamp(0, siblings.length);
|
|
||||||
|
|
||||||
// 如果是同级移动,且拖到自己的后面,需要减 1(因为移除自己后索引会偏移)
|
|
||||||
if (oldParentId == newParentId) {
|
|
||||||
final oldIndex = siblings.indexWhere((d) => d.sortIndex > doc.sortIndex);
|
|
||||||
if (oldIndex >= 0 && pos > oldIndex) {
|
|
||||||
pos = (pos - 1).clamp(0, siblings.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 插入到指定位置
|
|
||||||
siblings.insert(pos, doc);
|
|
||||||
|
|
||||||
// 批量更新 sortIndex 和 parentId
|
|
||||||
for (int i = 0; i < siblings.length; i++) {
|
|
||||||
final s = siblings[i];
|
|
||||||
final needsUpdate = s.sortIndex != i || s.id == docId && doc.parentId != newParentId;
|
|
||||||
if (needsUpdate) {
|
|
||||||
final updated = s.copyWith(
|
|
||||||
parentId: newParentId,
|
|
||||||
sortIndex: i,
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
await _dao.update(updated);
|
|
||||||
final uIdx = _documents.indexWhere((d) => d.id == updated.id);
|
|
||||||
if (uIdx >= 0) _documents[uIdx] = updated;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 旧接口兼容:仅移动到新父级(放到末尾)
|
|
||||||
Future<void> moveDocument(String docId, String newParentId) async {
|
|
||||||
final siblings = _documents.where((d) => d.parentId == newParentId && d.id != docId).toList();
|
|
||||||
await moveDocumentTo(docId, newParentId, siblings.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除文档时,将其子文档提升到祖父节点
|
|
||||||
Future<void> deleteDocument(String id) async {
|
|
||||||
final idx = _documents.indexWhere((d) => d.id == id);
|
|
||||||
if (idx >= 0) {
|
|
||||||
final parentId = _documents[idx].parentId;
|
|
||||||
// 将子文档的 parent_id 改为被删文档的 parent_id
|
|
||||||
final children = _documents.where((d) => d.parentId == id).toList();
|
|
||||||
for (final child in children) {
|
|
||||||
final updated = child.copyWith(parentId: parentId, updatedAt: DateTime.now());
|
|
||||||
await _dao.update(updated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await _dao.delete(id);
|
|
||||||
await loadDocuments();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> restoreDocument(String id) async {
|
|
||||||
await _dao.restore(id);
|
|
||||||
await loadDocuments();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> permanentDeleteDocument(String id) async {
|
|
||||||
await _dao.permanentDelete(id);
|
|
||||||
await loadDocuments();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<NotePlusDocument>> getDeletedDocuments() async {
|
|
||||||
return await _dao.getDeleted();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> searchDocuments(String query) async {
|
|
||||||
_documents = await _dao.search(query);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 文档属性 ==========
|
|
||||||
|
|
||||||
void setTitle(String title) {
|
|
||||||
if (_currentDocument == null) return;
|
|
||||||
_currentDocument = _currentDocument!.copyWith(title: title);
|
|
||||||
_isDirty = true;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重命名文档(在文档列表中调用)
|
|
||||||
Future<void> renameDocument(String id, String newTitle) async {
|
|
||||||
final idx = _documents.indexWhere((d) => d.id == id);
|
|
||||||
if (idx < 0) return;
|
|
||||||
final doc = _documents[idx].copyWith(title: newTitle, updatedAt: DateTime.now());
|
|
||||||
await _dao.update(doc);
|
|
||||||
_documents[idx] = doc;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Block 操作 ==========
|
|
||||||
|
|
||||||
void setFocusedBlock(int index) {
|
|
||||||
if (index < 0 || index >= _blocks.length) return;
|
|
||||||
_focusedBlockIndex = index;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加 block(在指定位置之后)
|
|
||||||
void addBlock(int afterIndex, NoteBlock block) {
|
|
||||||
_pushUndo();
|
|
||||||
final insertAt = afterIndex + 1;
|
|
||||||
if (insertAt >= _blocks.length) {
|
|
||||||
_blocks.add(block);
|
|
||||||
} else {
|
|
||||||
_blocks.insert(insertAt, block);
|
|
||||||
}
|
|
||||||
_focusedBlockIndex = insertAt;
|
|
||||||
_isDirty = true;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 移除 block
|
|
||||||
void removeBlock(int index) {
|
|
||||||
if (_blocks.length <= 1 || index < 0 || index >= _blocks.length) return;
|
|
||||||
_pushUndo();
|
|
||||||
_blocks.removeAt(index);
|
|
||||||
if (_focusedBlockIndex >= _blocks.length) {
|
|
||||||
_focusedBlockIndex = _blocks.length - 1;
|
|
||||||
}
|
|
||||||
_isDirty = true;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新 block 内容(不触发 notify,编辑时高频调用)
|
|
||||||
void updateBlockSilent(int index, NoteBlock block) {
|
|
||||||
if (index < 0 || index >= _blocks.length) return;
|
|
||||||
_blocks[index] = block;
|
|
||||||
_isDirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 更新 block 并通知
|
|
||||||
void updateBlock(int index, NoteBlock block) {
|
|
||||||
updateBlockSilent(index, block);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 拖拽移动 block
|
|
||||||
void moveBlock(int oldIndex, int newIndex) {
|
|
||||||
if (oldIndex == newIndex) return;
|
|
||||||
_pushUndo();
|
|
||||||
final block = _blocks.removeAt(oldIndex);
|
|
||||||
final insertAt = newIndex > oldIndex ? newIndex - 1 : newIndex;
|
|
||||||
_blocks.insert(insertAt, block);
|
|
||||||
_focusedBlockIndex = insertAt;
|
|
||||||
_isDirty = true;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 转换 block 类型
|
|
||||||
void convertBlockType(int index, NoteBlockType newType) {
|
|
||||||
if (index < 0 || index >= _blocks.length) return;
|
|
||||||
_pushUndo();
|
|
||||||
_blocks[index] = _blocks[index].copyWith(type: newType);
|
|
||||||
if (newType == NoteBlockType.divider) {
|
|
||||||
_blocks[index] = _blocks[index].copyWith(text: '');
|
|
||||||
}
|
|
||||||
_isDirty = true;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enter 分割 block
|
|
||||||
/// 返回新 block 的索引
|
|
||||||
int splitBlock(int index, int splitPosition) {
|
|
||||||
if (index < 0 || index >= _blocks.length) return index;
|
|
||||||
_pushUndo();
|
|
||||||
|
|
||||||
final current = _blocks[index];
|
|
||||||
final textBefore = current.text.substring(0, splitPosition);
|
|
||||||
final textAfter = current.text.substring(splitPosition);
|
|
||||||
|
|
||||||
// 裁剪当前 block 的格式
|
|
||||||
final beforeFormatting = _clipFormatting(current.formatting, 0, splitPosition);
|
|
||||||
|
|
||||||
// 新 block 继承类型(heading 降为 paragraph)
|
|
||||||
var newType = current.type;
|
|
||||||
if (newType == NoteBlockType.heading1 ||
|
|
||||||
newType == NoteBlockType.heading2 ||
|
|
||||||
newType == NoteBlockType.heading3) {
|
|
||||||
newType = NoteBlockType.paragraph;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 平移新 block 的格式
|
|
||||||
final afterFormatting = _shiftFormatting(current.formatting, splitPosition, -splitPosition);
|
|
||||||
|
|
||||||
// 更新当前 block
|
|
||||||
_blocks[index] = current.copyWith(
|
|
||||||
text: textBefore,
|
|
||||||
formatting: beforeFormatting,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 插入新 block
|
|
||||||
final newBlock = NoteBlock(
|
|
||||||
type: newType,
|
|
||||||
text: textAfter,
|
|
||||||
formatting: afterFormatting,
|
|
||||||
);
|
|
||||||
final insertAt = index + 1;
|
|
||||||
_blocks.insert(insertAt, newBlock);
|
|
||||||
_focusedBlockIndex = insertAt;
|
|
||||||
_isDirty = true;
|
|
||||||
notifyListeners();
|
|
||||||
return insertAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backspace 合并到前一个 block
|
|
||||||
/// 返回合并后的光标位置
|
|
||||||
int mergeWithPrevious(int index) {
|
|
||||||
if (index <= 0 || index >= _blocks.length) return 0;
|
|
||||||
_pushUndo();
|
|
||||||
|
|
||||||
final prev = _blocks[index - 1];
|
|
||||||
final current = _blocks[index];
|
|
||||||
final cursorPos = prev.text.length;
|
|
||||||
|
|
||||||
// 合并文本
|
|
||||||
final mergedText = prev.text + current.text;
|
|
||||||
|
|
||||||
// 合并格式:平移当前 block 的格式
|
|
||||||
final shiftedFormatting = _shiftFormatting(current.formatting, 0, cursorPos);
|
|
||||||
final mergedFormatting = [...prev.formatting, ...shiftedFormatting];
|
|
||||||
|
|
||||||
// 更新前一个 block
|
|
||||||
_blocks[index - 1] = prev.copyWith(
|
|
||||||
text: mergedText,
|
|
||||||
formatting: mergedFormatting,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 移除当前 block
|
|
||||||
_blocks.removeAt(index);
|
|
||||||
_focusedBlockIndex = index - 1;
|
|
||||||
_isDirty = true;
|
|
||||||
notifyListeners();
|
|
||||||
return cursorPos;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换待办状态
|
|
||||||
void toggleChecklist(int index) {
|
|
||||||
if (index < 0 || index >= _blocks.length) return;
|
|
||||||
final block = _blocks[index];
|
|
||||||
if (block.type != NoteBlockType.checklist) return;
|
|
||||||
_pushUndo();
|
|
||||||
|
|
||||||
final checked = block.metadata['checked'] == true;
|
|
||||||
_blocks[index] = block.copyWith(
|
|
||||||
metadata: {...block.metadata, 'checked': !checked},
|
|
||||||
);
|
|
||||||
_isDirty = true;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 应用内联格式
|
|
||||||
void applyFormat(int blockIndex, InlineFormatType format, int start, int end) {
|
|
||||||
if (blockIndex < 0 || blockIndex >= _blocks.length) return;
|
|
||||||
if (start >= end) return;
|
|
||||||
|
|
||||||
final block = _blocks[blockIndex];
|
|
||||||
final formatting = List<InlineFormatSpan>.from(block.formatting);
|
|
||||||
|
|
||||||
// 查找是否已有重叠的同格式 span
|
|
||||||
bool found = false;
|
|
||||||
for (int i = 0; i < formatting.length; i++) {
|
|
||||||
final span = formatting[i];
|
|
||||||
if (span.start <= start && span.end >= end && span.formats.contains(format)) {
|
|
||||||
// 移除格式
|
|
||||||
final newFormats = Set<InlineFormatType>.from(span.formats)..remove(format);
|
|
||||||
if (newFormats.isEmpty) {
|
|
||||||
formatting.removeAt(i);
|
|
||||||
} else {
|
|
||||||
formatting[i] = span.copyWith(formats: newFormats);
|
|
||||||
}
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!found) {
|
|
||||||
formatting.add(InlineFormatSpan(start: start, end: end, formats: {format}));
|
|
||||||
}
|
|
||||||
|
|
||||||
_blocks[blockIndex] = block.copyWith(formatting: formatting);
|
|
||||||
_isDirty = true;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Undo/Redo ==========
|
|
||||||
|
|
||||||
void _pushUndo() {
|
|
||||||
_undoStack.add(_blocks.map((b) => b.deepCopy()).toList());
|
|
||||||
if (_undoStack.length > _maxUndoSize) {
|
|
||||||
_undoStack.removeAt(0);
|
|
||||||
}
|
|
||||||
_redoStack.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
void undo() {
|
|
||||||
if (_undoStack.isEmpty) return;
|
|
||||||
_redoStack.add(_blocks.map((b) => b.deepCopy()).toList());
|
|
||||||
_blocks = _undoStack.removeLast();
|
|
||||||
if (_focusedBlockIndex >= _blocks.length) {
|
|
||||||
_focusedBlockIndex = _blocks.length - 1;
|
|
||||||
}
|
|
||||||
_isDirty = true;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void redo() {
|
|
||||||
if (_redoStack.isEmpty) return;
|
|
||||||
_undoStack.add(_blocks.map((b) => b.deepCopy()).toList());
|
|
||||||
_blocks = _redoStack.removeLast();
|
|
||||||
if (_focusedBlockIndex >= _blocks.length) {
|
|
||||||
_focusedBlockIndex = _blocks.length - 1;
|
|
||||||
}
|
|
||||||
_isDirty = true;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 格式辅助 ==========
|
|
||||||
|
|
||||||
/// 裁剪格式区间到 [clipStart, clipEnd)
|
|
||||||
List<InlineFormatSpan> _clipFormatting(
|
|
||||||
List<InlineFormatSpan> spans, int clipStart, int clipEnd) {
|
|
||||||
return spans
|
|
||||||
.where((s) => s.end > clipStart && s.start < clipEnd)
|
|
||||||
.map((s) => InlineFormatSpan(
|
|
||||||
start: s.start < clipStart ? clipStart : s.start,
|
|
||||||
end: s.end > clipEnd ? clipEnd : s.end,
|
|
||||||
formats: Set.from(s.formats),
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 平移格式区间(从 fromPos 之后的 span 偏移 offset)
|
|
||||||
List<InlineFormatSpan> _shiftFormatting(
|
|
||||||
List<InlineFormatSpan> spans, int fromPos, int offset) {
|
|
||||||
return spans
|
|
||||||
.where((s) => s.end > fromPos)
|
|
||||||
.map((s) => InlineFormatSpan(
|
|
||||||
start: (s.start < fromPos ? fromPos : s.start) + offset,
|
|
||||||
end: s.end + offset,
|
|
||||||
formats: Set.from(s.formats),
|
|
||||||
))
|
|
||||||
.where((s) => s.start >= 0 && s.end > s.start)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,6 @@ import '../pages/movies/movie_detail_page.dart';
|
|||||||
import '../pages/book/book_detail_page.dart';
|
import '../pages/book/book_detail_page.dart';
|
||||||
import '../pages/note/note_detail_page.dart';
|
import '../pages/note/note_detail_page.dart';
|
||||||
import '../pages/movies/douban_webview_page.dart';
|
import '../pages/movies/douban_webview_page.dart';
|
||||||
import '../pages/note_plus/note_plus_form_page.dart';
|
|
||||||
import '../pages/note_plus/note_plus_detail_page.dart';
|
|
||||||
|
|
||||||
/// 路由生成器
|
/// 路由生成器
|
||||||
class AppRouter {
|
class AppRouter {
|
||||||
@@ -73,20 +71,6 @@ class AppRouter {
|
|||||||
}
|
}
|
||||||
return SlideUpPageRoute(page: DoubanWebViewPage(url: url));
|
return SlideUpPageRoute(page: DoubanWebViewPage(url: url));
|
||||||
|
|
||||||
case '/note-plus-form':
|
|
||||||
final id = settings.arguments is String ? settings.arguments as String : null;
|
|
||||||
if (id == null) {
|
|
||||||
return _buildUnknownRoute(settings.name);
|
|
||||||
}
|
|
||||||
return SlideUpPageRoute(page: NotePlusFormPage(documentId: id));
|
|
||||||
|
|
||||||
case '/note-plus-detail':
|
|
||||||
final id = settings.arguments is String ? settings.arguments as String : null;
|
|
||||||
if (id == null) {
|
|
||||||
return _buildUnknownRoute(settings.name);
|
|
||||||
}
|
|
||||||
return SlideUpPageRoute(page: NotePlusDetailPage(documentId: id));
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return _buildUnknownRoute(settings.name);
|
return _buildUnknownRoute(settings.name);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class DatabaseHelper {
|
|||||||
|
|
||||||
return await openDatabase(
|
return await openDatabase(
|
||||||
path,
|
path,
|
||||||
version: 27,
|
version: 28,
|
||||||
onCreate: _createDB,
|
onCreate: _createDB,
|
||||||
onUpgrade: _onUpgrade,
|
onUpgrade: _onUpgrade,
|
||||||
);
|
);
|
||||||
@@ -174,10 +174,10 @@ class DatabaseHelper {
|
|||||||
await db.execute('ALTER TABLE tags ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0');
|
await db.execute('ALTER TABLE tags ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0');
|
||||||
}
|
}
|
||||||
if (oldVersion < 18) {
|
if (oldVersion < 18) {
|
||||||
await _createNotePlusTable(db);
|
// note_plus table creation removed (feature dropped in v28)
|
||||||
}
|
}
|
||||||
if (oldVersion < 19) {
|
if (oldVersion < 19) {
|
||||||
await _createNotePlusTable(db);
|
// note_plus table creation removed (feature dropped in v28)
|
||||||
}
|
}
|
||||||
if (oldVersion < 20) {
|
if (oldVersion < 20) {
|
||||||
// 确保 note_plus 表有 parent_id 列(从旧版 folder 迁移)
|
// 确保 note_plus 表有 parent_id 列(从旧版 folder 迁移)
|
||||||
@@ -251,6 +251,10 @@ class DatabaseHelper {
|
|||||||
if (oldVersion < 27) {
|
if (oldVersion < 27) {
|
||||||
await _upgradeBooksTableV27(db);
|
await _upgradeBooksTableV27(db);
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 28) {
|
||||||
|
// 移除 Note Plus 功能,删除 note_plus 表
|
||||||
|
await db.execute('DROP TABLE IF EXISTS note_plus');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 升级books表到V27(添加阅读始末日期字段)
|
/// 升级books表到V27(添加阅读始末日期字段)
|
||||||
@@ -765,9 +769,6 @@ class DatabaseHelper {
|
|||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
// Note Plus 块编辑器文档表
|
|
||||||
await _createNotePlusTable(db);
|
|
||||||
|
|
||||||
// EPUB 阅读器书籍表
|
// EPUB 阅读器书籍表
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS reader_books (
|
CREATE TABLE IF NOT EXISTS reader_books (
|
||||||
@@ -804,24 +805,6 @@ class DatabaseHelper {
|
|||||||
''');
|
''');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建 Note Plus 文档表
|
|
||||||
Future<void> _createNotePlusTable(Database db) async {
|
|
||||||
await db.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS note_plus (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
title TEXT DEFAULT '',
|
|
||||||
parent_id TEXT DEFAULT '',
|
|
||||||
sort_index INTEGER DEFAULT 0,
|
|
||||||
blocks_json TEXT NOT NULL,
|
|
||||||
tags TEXT,
|
|
||||||
images TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
is_deleted INTEGER DEFAULT 0
|
|
||||||
)
|
|
||||||
''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭数据库
|
// 关闭数据库
|
||||||
Future close() async {
|
Future close() async {
|
||||||
if (_database != null) {
|
if (_database != null) {
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import '../../models/note_plus_models.dart';
|
|
||||||
import '../database_helper.dart';
|
|
||||||
|
|
||||||
/// Note Plus 块文档数据访问对象
|
|
||||||
class NotePlusDao {
|
|
||||||
final DatabaseHelper _dbHelper = DatabaseHelper.instance;
|
|
||||||
|
|
||||||
Future<T> _wrap<T>(String op, Future<T> Function() fn) async {
|
|
||||||
try {
|
|
||||||
return await fn();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[NotePlusDao] $op error: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取所有未删除的文档
|
|
||||||
Future<List<NotePlusDocument>> getAll() => _wrap('getAll', () async {
|
|
||||||
final db = await _dbHelper.database;
|
|
||||||
final List<Map<String, dynamic>> maps = await db.query(
|
|
||||||
'note_plus',
|
|
||||||
where: 'is_deleted = ?',
|
|
||||||
whereArgs: [0],
|
|
||||||
orderBy: 'sort_index ASC, updated_at DESC',
|
|
||||||
);
|
|
||||||
return List.generate(maps.length, (i) => NotePlusDocument.fromJson(maps[i]));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 分页查询
|
|
||||||
Future<List<NotePlusDocument>> getPaged({int limit = 20, int offset = 0}) =>
|
|
||||||
_wrap('getPaged', () async {
|
|
||||||
final db = await _dbHelper.database;
|
|
||||||
final maps = await db.query('note_plus', where: 'is_deleted = 0',
|
|
||||||
orderBy: 'sort_index ASC, updated_at DESC', limit: limit, offset: offset);
|
|
||||||
return List.generate(maps.length, (i) => NotePlusDocument.fromJson(maps[i]));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 根据ID获取
|
|
||||||
Future<NotePlusDocument?> getById(String id) => _wrap('getById', () async {
|
|
||||||
final db = await _dbHelper.database;
|
|
||||||
final List<Map<String, dynamic>> maps = await db.query(
|
|
||||||
'note_plus',
|
|
||||||
where: 'id = ? AND is_deleted = ?',
|
|
||||||
whereArgs: [id, 0],
|
|
||||||
);
|
|
||||||
if (maps.isEmpty) return null;
|
|
||||||
return NotePlusDocument.fromJson(maps.first);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 插入
|
|
||||||
Future<int> insert(NotePlusDocument doc) => _wrap('insert', () async {
|
|
||||||
final db = await _dbHelper.database;
|
|
||||||
return await db.insert('note_plus', doc.toJson());
|
|
||||||
});
|
|
||||||
|
|
||||||
// 更新
|
|
||||||
Future<int> update(NotePlusDocument doc) => _wrap('update', () async {
|
|
||||||
final db = await _dbHelper.database;
|
|
||||||
return await db.update(
|
|
||||||
'note_plus',
|
|
||||||
doc.toJson(),
|
|
||||||
where: 'id = ?',
|
|
||||||
whereArgs: [doc.id],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 软删除
|
|
||||||
Future<int> delete(String id) => _wrap('delete', () async {
|
|
||||||
final db = await _dbHelper.database;
|
|
||||||
return await db.update(
|
|
||||||
'note_plus',
|
|
||||||
{'is_deleted': 1, 'updated_at': DateTime.now().toIso8601String()},
|
|
||||||
where: 'id = ?',
|
|
||||||
whereArgs: [id],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== 回收站 ==========
|
|
||||||
|
|
||||||
Future<List<NotePlusDocument>> getDeleted() => _wrap('getDeleted', () async {
|
|
||||||
final db = await _dbHelper.database;
|
|
||||||
final List<Map<String, dynamic>> maps = await db.query(
|
|
||||||
'note_plus',
|
|
||||||
where: 'is_deleted = ?',
|
|
||||||
whereArgs: [1],
|
|
||||||
orderBy: 'sort_index ASC, updated_at DESC',
|
|
||||||
);
|
|
||||||
return List.generate(maps.length, (i) => NotePlusDocument.fromJson(maps[i]));
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<int> restore(String id) => _wrap('restore', () async {
|
|
||||||
final db = await _dbHelper.database;
|
|
||||||
return await db.update(
|
|
||||||
'note_plus',
|
|
||||||
{'is_deleted': 0, 'updated_at': DateTime.now().toIso8601String()},
|
|
||||||
where: 'id = ?',
|
|
||||||
whereArgs: [id],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<int> permanentDelete(String id) => _wrap('permanentDelete', () async {
|
|
||||||
final db = await _dbHelper.database;
|
|
||||||
return await db.delete(
|
|
||||||
'note_plus',
|
|
||||||
where: 'id = ?',
|
|
||||||
whereArgs: [id],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 搜索
|
|
||||||
Future<List<NotePlusDocument>> search(String query) => _wrap('search', () async {
|
|
||||||
final db = await _dbHelper.database;
|
|
||||||
final List<Map<String, dynamic>> maps = await db.query(
|
|
||||||
'note_plus',
|
|
||||||
where: '(title LIKE ? OR blocks_json LIKE ? OR tags LIKE ?) AND is_deleted = ?',
|
|
||||||
whereArgs: ['%$query%', '%$query%', '%$query%', 0],
|
|
||||||
orderBy: 'sort_index ASC, updated_at DESC',
|
|
||||||
);
|
|
||||||
return List.generate(maps.length, (i) => NotePlusDocument.fromJson(maps[i]));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 根据标签筛选
|
|
||||||
Future<List<NotePlusDocument>> getByTag(String tag) => _wrap('getByTag', () async {
|
|
||||||
final db = await _dbHelper.database;
|
|
||||||
final List<Map<String, dynamic>> maps = await db.query(
|
|
||||||
'note_plus',
|
|
||||||
where: 'tags LIKE ? AND is_deleted = ?',
|
|
||||||
whereArgs: ['%$tag%', 0],
|
|
||||||
orderBy: 'sort_index ASC, updated_at DESC',
|
|
||||||
);
|
|
||||||
return List.generate(maps.length, (i) => NotePlusDocument.fromJson(maps[i]));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -33,7 +33,6 @@ class BackupService {
|
|||||||
final tags = await db.query('tags');
|
final tags = await db.query('tags');
|
||||||
final readerBooks = await db.query('reader_books');
|
final readerBooks = await db.query('reader_books');
|
||||||
final bookAnnotations = await db.query('book_annotations');
|
final bookAnnotations = await db.query('book_annotations');
|
||||||
final notePlus = await db.query('note_plus');
|
|
||||||
|
|
||||||
// 收集图片路径
|
// 收集图片路径
|
||||||
final imagePaths = <String>{};
|
final imagePaths = <String>{};
|
||||||
@@ -92,7 +91,6 @@ class BackupService {
|
|||||||
'tags': tags,
|
'tags': tags,
|
||||||
'reader_books': readerBooks,
|
'reader_books': readerBooks,
|
||||||
'book_annotations': bookAnnotations,
|
'book_annotations': bookAnnotations,
|
||||||
'note_plus': notePlus,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -317,7 +315,6 @@ class BackupService {
|
|||||||
final tagsCols = await _getTableColumns(db, 'tags');
|
final tagsCols = await _getTableColumns(db, 'tags');
|
||||||
final readerBooksCols = await _getTableColumns(db, 'reader_books');
|
final readerBooksCols = await _getTableColumns(db, 'reader_books');
|
||||||
final bookAnnotationsCols = await _getTableColumns(db, 'book_annotations');
|
final bookAnnotationsCols = await _getTableColumns(db, 'book_annotations');
|
||||||
final notePlusCols = await _getTableColumns(db, 'note_plus');
|
|
||||||
|
|
||||||
await db.transaction((txn) async {
|
await db.transaction((txn) async {
|
||||||
await txn.delete('movie_reviews');
|
await txn.delete('movie_reviews');
|
||||||
@@ -329,7 +326,6 @@ class BackupService {
|
|||||||
await txn.delete('books');
|
await txn.delete('books');
|
||||||
await txn.delete('notes');
|
await txn.delete('notes');
|
||||||
await txn.delete('reader_books');
|
await txn.delete('reader_books');
|
||||||
await txn.delete('note_plus');
|
|
||||||
await txn.delete('tags');
|
await txn.delete('tags');
|
||||||
|
|
||||||
if (data.containsKey('movies')) {
|
if (data.containsKey('movies')) {
|
||||||
@@ -379,11 +375,6 @@ class BackupService {
|
|||||||
await txn.insert('book_annotations', _convertToDbMapSafe(a, bookAnnotationsCols));
|
await txn.insert('book_annotations', _convertToDbMapSafe(a, bookAnnotationsCols));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data.containsKey('note_plus')) {
|
|
||||||
for (final np in data['note_plus'] as List) {
|
|
||||||
await txn.insert('note_plus', _convertToDbMapSafe(np, notePlusCols));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.containsKey('tags')) {
|
if (data.containsKey('tags')) {
|
||||||
for (final t in data['tags'] as List) {
|
for (final t in data['tags'] as List) {
|
||||||
final map = _convertToDbMapSafe(t, tagsCols);
|
final map = _convertToDbMapSafe(t, tagsCols);
|
||||||
@@ -459,7 +450,6 @@ class BackupService {
|
|||||||
final tagsCols = await _getTableColumns(db, 'tags');
|
final tagsCols = await _getTableColumns(db, 'tags');
|
||||||
final readerBooksCols = await _getTableColumns(db, 'reader_books');
|
final readerBooksCols = await _getTableColumns(db, 'reader_books');
|
||||||
final bookAnnotationsCols = await _getTableColumns(db, 'book_annotations');
|
final bookAnnotationsCols = await _getTableColumns(db, 'book_annotations');
|
||||||
final notePlusCols = await _getTableColumns(db, 'note_plus');
|
|
||||||
|
|
||||||
await db.transaction((txn) async {
|
await db.transaction((txn) async {
|
||||||
await txn.delete('movie_reviews');
|
await txn.delete('movie_reviews');
|
||||||
@@ -471,7 +461,6 @@ class BackupService {
|
|||||||
await txn.delete('books');
|
await txn.delete('books');
|
||||||
await txn.delete('notes');
|
await txn.delete('notes');
|
||||||
await txn.delete('reader_books');
|
await txn.delete('reader_books');
|
||||||
await txn.delete('note_plus');
|
|
||||||
await txn.delete('tags'); // 修复: 之前漏删 tags 表
|
await txn.delete('tags'); // 修复: 之前漏删 tags 表
|
||||||
|
|
||||||
if (data.containsKey('movies')) {
|
if (data.containsKey('movies')) {
|
||||||
@@ -521,11 +510,6 @@ class BackupService {
|
|||||||
await txn.insert('book_annotations', _convertToDbMapSafe(a, bookAnnotationsCols));
|
await txn.insert('book_annotations', _convertToDbMapSafe(a, bookAnnotationsCols));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data.containsKey('note_plus')) {
|
|
||||||
for (final np in data['note_plus'] as List) {
|
|
||||||
await txn.insert('note_plus', _convertToDbMapSafe(np, notePlusCols));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.containsKey('tags')) {
|
if (data.containsKey('tags')) {
|
||||||
for (final t in data['tags'] as List) {
|
for (final t in data['tags'] as List) {
|
||||||
final map = _convertToDbMapSafe(t, tagsCols);
|
final map = _convertToDbMapSafe(t, tagsCols);
|
||||||
@@ -621,7 +605,6 @@ class BackupService {
|
|||||||
if (data.containsKey('tags')) stats['标签'] = (data['tags'] as List).length;
|
if (data.containsKey('tags')) stats['标签'] = (data['tags'] as List).length;
|
||||||
if (data.containsKey('reader_books')) stats['阅读'] = (data['reader_books'] as List).length;
|
if (data.containsKey('reader_books')) stats['阅读'] = (data['reader_books'] as List).length;
|
||||||
if (data.containsKey('book_annotations')) stats['批注'] = (data['book_annotations'] as List).length;
|
if (data.containsKey('book_annotations')) stats['批注'] = (data['book_annotations'] as List).length;
|
||||||
if (data.containsKey('note_plus')) stats['高级笔记'] = (data['note_plus'] as List).length;
|
|
||||||
if (imageCount > 0) stats['图片'] = imageCount;
|
if (imageCount > 0) stats['图片'] = imageCount;
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,10 +104,6 @@ class UserPrefs {
|
|||||||
bool get showNoteTab => prefs.getBool('showNoteTab') ?? true;
|
bool get showNoteTab => prefs.getBool('showNoteTab') ?? true;
|
||||||
Future<bool> setShowNoteTab(bool value) => prefs.setBool('showNoteTab', value);
|
Future<bool> setShowNoteTab(bool value) => prefs.setBool('showNoteTab', value);
|
||||||
|
|
||||||
/// 是否显示 Note Plus 标签(默认关闭)
|
|
||||||
bool get showNotePlusTab => prefs.getBool('showNotePlusTab') ?? false;
|
|
||||||
Future<bool> setShowNotePlusTab(bool value) => prefs.setBool('showNotePlusTab', value);
|
|
||||||
|
|
||||||
/// 默认启动标签 (0: 影视, 1: 阅读, 2: 笔记)
|
/// 默认启动标签 (0: 影视, 1: 阅读, 2: 笔记)
|
||||||
int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0;
|
int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0;
|
||||||
Future<bool> setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value);
|
Future<bool> setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value);
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../models/note_plus_models.dart';
|
|
||||||
|
|
||||||
/// AppFlowy 风格的格式化工具栏
|
|
||||||
///
|
|
||||||
/// 样式参考 AppFlowy tool_bar.dart:
|
|
||||||
/// - 高度 36px(iconSize 18 * 2)
|
|
||||||
/// - 背景 #f2f2f2
|
|
||||||
/// - 按钮 18px 图标,~32px 宽
|
|
||||||
/// - 切换态:#00bcf0 背景 + 白色图标
|
|
||||||
class BlockToolbar extends StatelessWidget {
|
|
||||||
final NoteBlockType currentBlockType;
|
|
||||||
final Set<InlineFormatType> activeFormats;
|
|
||||||
final void Function(InlineFormatType) onFormatToggle;
|
|
||||||
final void Function(NoteBlockType) onBlockTypeChange;
|
|
||||||
final VoidCallback onUndo;
|
|
||||||
final VoidCallback onRedo;
|
|
||||||
|
|
||||||
const BlockToolbar({
|
|
||||||
super.key,
|
|
||||||
required this.currentBlockType,
|
|
||||||
required this.activeFormats,
|
|
||||||
required this.onFormatToggle,
|
|
||||||
required this.onBlockTypeChange,
|
|
||||||
required this.onUndo,
|
|
||||||
required this.onRedo,
|
|
||||||
});
|
|
||||||
|
|
||||||
static const double _iconSize = 18;
|
|
||||||
static const double _buttonWidth = 32;
|
|
||||||
static const Color _bgColor = Color(0xFFF2F2F2);
|
|
||||||
static const Color _toggledColor = Color(0xFF00BCF0);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
height: 36,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
|
||||||
decoration: const BoxDecoration(color: _bgColor),
|
|
||||||
child: ListView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
children: [
|
|
||||||
// 撤销/重做
|
|
||||||
_iconButton(Icons.undo_outlined, onUndo, false),
|
|
||||||
_iconButton(Icons.redo_outlined, onRedo, false),
|
|
||||||
_divider(),
|
|
||||||
// 内联格式
|
|
||||||
_iconButton(Icons.format_bold, () => onFormatToggle(InlineFormatType.bold),
|
|
||||||
activeFormats.contains(InlineFormatType.bold)),
|
|
||||||
_iconButton(Icons.format_italic, () => onFormatToggle(InlineFormatType.italic),
|
|
||||||
activeFormats.contains(InlineFormatType.italic)),
|
|
||||||
_iconButton(Icons.format_underlined, () => onFormatToggle(InlineFormatType.underline),
|
|
||||||
activeFormats.contains(InlineFormatType.underline)),
|
|
||||||
_iconButton(Icons.format_strikethrough, () => onFormatToggle(InlineFormatType.strikethrough),
|
|
||||||
activeFormats.contains(InlineFormatType.strikethrough)),
|
|
||||||
_divider(),
|
|
||||||
// 标题
|
|
||||||
_headingButton('H1', NoteBlockType.heading1),
|
|
||||||
_headingButton('H2', NoteBlockType.heading2),
|
|
||||||
_headingButton('H3', NoteBlockType.heading3),
|
|
||||||
_divider(),
|
|
||||||
// 列表
|
|
||||||
_iconButton(Icons.format_list_numbered, () => onBlockTypeChange(NoteBlockType.numberedList),
|
|
||||||
currentBlockType == NoteBlockType.numberedList),
|
|
||||||
_iconButton(Icons.format_list_bulleted, () => onBlockTypeChange(NoteBlockType.bulletList),
|
|
||||||
currentBlockType == NoteBlockType.bulletList),
|
|
||||||
_iconButton(Icons.check_box_outlined, () => onBlockTypeChange(NoteBlockType.checklist),
|
|
||||||
currentBlockType == NoteBlockType.checklist),
|
|
||||||
_divider(),
|
|
||||||
// 代码/引用
|
|
||||||
_iconButton(Icons.code, () => onFormatToggle(InlineFormatType.inlineCode),
|
|
||||||
activeFormats.contains(InlineFormatType.inlineCode)),
|
|
||||||
_iconButton(Icons.format_quote, () => onBlockTypeChange(NoteBlockType.quote),
|
|
||||||
currentBlockType == NoteBlockType.quote),
|
|
||||||
// 块类型选择
|
|
||||||
_divider(),
|
|
||||||
_blockTypeSelector(context, colors),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _iconButton(IconData icon, VoidCallback onTap, bool isToggled) {
|
|
||||||
return SizedBox(
|
|
||||||
width: _buttonWidth,
|
|
||||||
child: Material(
|
|
||||||
color: isToggled ? _toggledColor : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
hoverColor: isToggled ? _toggledColor : const Color(0xFFE0E0E0),
|
|
||||||
splashColor: Colors.transparent,
|
|
||||||
highlightColor: Colors.transparent,
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
|
|
||||||
child: Icon(icon,
|
|
||||||
size: _iconSize,
|
|
||||||
color: isToggled ? Colors.white : const Color(0xFF333333)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _headingButton(String label, NoteBlockType type) {
|
|
||||||
final isToggled = currentBlockType == type;
|
|
||||||
return SizedBox(
|
|
||||||
width: _buttonWidth,
|
|
||||||
child: Material(
|
|
||||||
color: isToggled ? _toggledColor : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => onBlockTypeChange(type),
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
hoverColor: isToggled ? _toggledColor : const Color(0xFFE0E0E0),
|
|
||||||
splashColor: Colors.transparent,
|
|
||||||
highlightColor: Colors.transparent,
|
|
||||||
child: Center(
|
|
||||||
child: Text(label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: isToggled ? Colors.white : const Color(0xFF333333),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _divider() {
|
|
||||||
return Container(
|
|
||||||
width: 1,
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
|
|
||||||
color: const Color(0xFFE0E0E0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _blockTypeSelector(BuildContext context, ColorScheme colors) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => _showPicker(context),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(currentBlockType.label,
|
|
||||||
style: const TextStyle(fontSize: 12, color: Color(0xFF4F4F4F))),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
const Icon(Icons.arrow_drop_down, size: 16, color: Color(0xFF828282)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showPicker(BuildContext context) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
),
|
|
||||||
builder: (ctx) {
|
|
||||||
return SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 36, height: 4,
|
|
||||||
margin: const EdgeInsets.only(top: 10, bottom: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...NoteBlockType.values.map((type) {
|
|
||||||
final isSelected = type == currentBlockType;
|
|
||||||
return ListTile(
|
|
||||||
dense: true,
|
|
||||||
leading: Icon(_getBlockIcon(type), size: 20,
|
|
||||||
color: isSelected ? _toggledColor : colors.onSurface.withValues(alpha: 0.5)),
|
|
||||||
title: Text(type.label,
|
|
||||||
style: TextStyle(fontSize: 14,
|
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
|
||||||
color: isSelected ? _toggledColor : colors.onSurface)),
|
|
||||||
trailing: isSelected
|
|
||||||
? const Icon(Icons.check, size: 18, color: _toggledColor)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
onBlockTypeChange(type);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getBlockIcon(NoteBlockType type) {
|
|
||||||
switch (type) {
|
|
||||||
case NoteBlockType.paragraph:
|
|
||||||
return Icons.notes;
|
|
||||||
case NoteBlockType.heading1:
|
|
||||||
case NoteBlockType.heading2:
|
|
||||||
case NoteBlockType.heading3:
|
|
||||||
return Icons.title;
|
|
||||||
case NoteBlockType.bulletList:
|
|
||||||
return Icons.format_list_bulleted;
|
|
||||||
case NoteBlockType.numberedList:
|
|
||||||
return Icons.format_list_numbered;
|
|
||||||
case NoteBlockType.checklist:
|
|
||||||
return Icons.check_box_outlined;
|
|
||||||
case NoteBlockType.quote:
|
|
||||||
return Icons.format_quote;
|
|
||||||
case NoteBlockType.codeBlock:
|
|
||||||
return Icons.code;
|
|
||||||
case NoteBlockType.divider:
|
|
||||||
return Icons.horizontal_rule;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../models/note_plus_models.dart';
|
|
||||||
|
|
||||||
/// 单个块的渲染组件
|
|
||||||
///
|
|
||||||
/// 负责根据 block type 渲染不同 UI。
|
|
||||||
/// 焦点 block 使用 TextEditingController 编辑,非焦点 block 渲染为静态 Text。
|
|
||||||
class NotePlusBlockWidget extends StatelessWidget {
|
|
||||||
final NoteBlock block;
|
|
||||||
final int index;
|
|
||||||
final int numberedIndex; // 有序列表序号
|
|
||||||
final bool isFocused;
|
|
||||||
final TextEditingController? controller;
|
|
||||||
final FocusNode? focusNode;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
final VoidCallback? onToggleChecklist;
|
|
||||||
final void Function(String)? onTextChanged;
|
|
||||||
final void Function(TextSelection)? onSelectionChanged;
|
|
||||||
|
|
||||||
const NotePlusBlockWidget({
|
|
||||||
super.key,
|
|
||||||
required this.block,
|
|
||||||
required this.index,
|
|
||||||
this.numberedIndex = 1,
|
|
||||||
required this.isFocused,
|
|
||||||
this.controller,
|
|
||||||
this.focusNode,
|
|
||||||
required this.onTap,
|
|
||||||
this.onToggleChecklist,
|
|
||||||
this.onTextChanged,
|
|
||||||
this.onSelectionChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
if (block.type == NoteBlockType.divider) {
|
|
||||||
return _buildDivider(colors);
|
|
||||||
}
|
|
||||||
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// 左侧标记区
|
|
||||||
_buildPrefix(colors),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
// 内容区
|
|
||||||
Expanded(child: _buildContent(context, colors)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 块类型前缀标记
|
|
||||||
Widget _buildPrefix(ColorScheme colors) {
|
|
||||||
switch (block.type) {
|
|
||||||
case NoteBlockType.bulletList:
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 12),
|
|
||||||
child: Container(
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.5),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case NoteBlockType.numberedList:
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 10),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 24,
|
|
||||||
child: Text(
|
|
||||||
'$numberedIndex.',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case NoteBlockType.checklist:
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: onToggleChecklist,
|
|
||||||
child: Icon(
|
|
||||||
block.metadata['checked'] == true
|
|
||||||
? Icons.check_box
|
|
||||||
: Icons.check_box_outline_blank,
|
|
||||||
size: 20,
|
|
||||||
color: block.metadata['checked'] == true
|
|
||||||
? colors.primary
|
|
||||||
: colors.onSurface.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
case NoteBlockType.quote:
|
|
||||||
// AppFlowy: 4px wide grey.shade300 left border
|
|
||||||
return Container(
|
|
||||||
width: 4,
|
|
||||||
margin: const EdgeInsets.only(top: 6, bottom: 6, right: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return const SizedBox(width: 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 块内容
|
|
||||||
Widget _buildContent(BuildContext context, ColorScheme colors) {
|
|
||||||
final style = _getTextStyle(colors);
|
|
||||||
|
|
||||||
Widget content;
|
|
||||||
if (!isFocused) {
|
|
||||||
content = Padding(
|
|
||||||
padding: _getContentPadding(),
|
|
||||||
child: block.text.isEmpty
|
|
||||||
? Text(_getPlaceholder(), style: style.copyWith(
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.25)))
|
|
||||||
: _buildRichText(block.text, style, colors),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
content = _buildEditable(style, colors);
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppFlowy code block: grey.shade50 background, 2px border radius
|
|
||||||
if (block.type == NoteBlockType.codeBlock) {
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
child: content,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEditable(TextStyle style, ColorScheme colors) {
|
|
||||||
return TextField(
|
|
||||||
controller: controller,
|
|
||||||
focusNode: focusNode,
|
|
||||||
style: style,
|
|
||||||
maxLines: null,
|
|
||||||
minLines: 1,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: _getPlaceholder(),
|
|
||||||
hintStyle: style.copyWith(
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.25)),
|
|
||||||
border: InputBorder.none,
|
|
||||||
contentPadding: _getContentPadding(),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
onChanged: onTextChanged,
|
|
||||||
// selection handler 通过 controller listener 处理
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildRichText(String text, TextStyle style, ColorScheme colors) {
|
|
||||||
if (block.formatting.isEmpty) {
|
|
||||||
return Text(text, style: style);
|
|
||||||
}
|
|
||||||
|
|
||||||
final spans = _buildTextSpans(text, style, colors);
|
|
||||||
return Text.rich(
|
|
||||||
TextSpan(children: spans),
|
|
||||||
maxLines: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<InlineSpan> _buildTextSpans(
|
|
||||||
String text, TextStyle baseStyle, ColorScheme colors) {
|
|
||||||
// 收集所有格式边界
|
|
||||||
final events = <_FormatEvent>[];
|
|
||||||
for (final span in block.formatting) {
|
|
||||||
if (span.start < text.length) {
|
|
||||||
events.add(_FormatEvent(span.start, true, span.formats));
|
|
||||||
events.add(_FormatEvent(
|
|
||||||
span.end > text.length ? text.length : span.end, false, span.formats));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
events.sort((a, b) {
|
|
||||||
if (a.pos != b.pos) return a.pos.compareTo(b.pos);
|
|
||||||
// 关闭优先于打开
|
|
||||||
if (a.isStart && !b.isStart) return 1;
|
|
||||||
if (!a.isStart && b.isStart) return -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (events.isEmpty) return [TextSpan(text: text, style: baseStyle)];
|
|
||||||
|
|
||||||
final spans = <InlineSpan>[];
|
|
||||||
int lastPos = 0;
|
|
||||||
final activeFormats = <InlineFormatType>{};
|
|
||||||
|
|
||||||
for (final event in events) {
|
|
||||||
if (event.pos > lastPos) {
|
|
||||||
spans.add(TextSpan(
|
|
||||||
text: text.substring(lastPos, event.pos),
|
|
||||||
style: _applyFormats(baseStyle, activeFormats, colors),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if (event.isStart) {
|
|
||||||
activeFormats.addAll(event.formats);
|
|
||||||
} else {
|
|
||||||
activeFormats.removeAll(event.formats);
|
|
||||||
}
|
|
||||||
lastPos = event.pos;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastPos < text.length) {
|
|
||||||
spans.add(TextSpan(
|
|
||||||
text: text.substring(lastPos),
|
|
||||||
style: _applyFormats(baseStyle, activeFormats, colors),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return spans;
|
|
||||||
}
|
|
||||||
|
|
||||||
TextStyle _applyFormats(
|
|
||||||
TextStyle style, Set<InlineFormatType> formats, ColorScheme colors) {
|
|
||||||
if (formats.isEmpty) return style;
|
|
||||||
return style.copyWith(
|
|
||||||
fontWeight: formats.contains(InlineFormatType.bold)
|
|
||||||
? FontWeight.bold
|
|
||||||
: null,
|
|
||||||
fontStyle: formats.contains(InlineFormatType.italic)
|
|
||||||
? FontStyle.italic
|
|
||||||
: null,
|
|
||||||
decoration: _getTextDecoration(formats),
|
|
||||||
fontFamily: formats.contains(InlineFormatType.inlineCode)
|
|
||||||
? 'monospace'
|
|
||||||
: null,
|
|
||||||
fontSize: formats.contains(InlineFormatType.inlineCode) ? 13 : null,
|
|
||||||
color: formats.contains(InlineFormatType.inlineCode)
|
|
||||||
? Colors.blue.shade900.withValues(alpha: 0.9)
|
|
||||||
: null,
|
|
||||||
backgroundColor: formats.contains(InlineFormatType.inlineCode)
|
|
||||||
? Colors.grey.shade50
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
TextDecoration? _getTextDecoration(Set<InlineFormatType> formats) {
|
|
||||||
final decorations = <TextDecoration>[];
|
|
||||||
if (formats.contains(InlineFormatType.underline)) {
|
|
||||||
decorations.add(TextDecoration.underline);
|
|
||||||
}
|
|
||||||
if (formats.contains(InlineFormatType.strikethrough)) {
|
|
||||||
decorations.add(TextDecoration.lineThrough);
|
|
||||||
}
|
|
||||||
if (decorations.isEmpty) return null;
|
|
||||||
return TextDecoration.combine(decorations);
|
|
||||||
}
|
|
||||||
|
|
||||||
TextStyle _getTextStyle(ColorScheme colors) {
|
|
||||||
// AppFlowy: 18px, w300, height 1.3, letter-spacing 0.6
|
|
||||||
final base = TextStyle(
|
|
||||||
color: colors.onSurface,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w300,
|
|
||||||
height: 1.3,
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
);
|
|
||||||
switch (block.type) {
|
|
||||||
case NoteBlockType.heading1:
|
|
||||||
// AppFlowy H1: 34px, w300, height 1.15, 70% opacity
|
|
||||||
return base.copyWith(
|
|
||||||
fontSize: 34, fontWeight: FontWeight.w300, height: 1.15,
|
|
||||||
letterSpacing: 0,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.7));
|
|
||||||
case NoteBlockType.heading2:
|
|
||||||
// AppFlowy H2: 24px, w400, height 1.15, 70% opacity
|
|
||||||
return base.copyWith(
|
|
||||||
fontSize: 24, fontWeight: FontWeight.w400, height: 1.15,
|
|
||||||
letterSpacing: 0,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.7));
|
|
||||||
case NoteBlockType.heading3:
|
|
||||||
// AppFlowy H3: 20px, w500, height 1.25, 70% opacity
|
|
||||||
return base.copyWith(
|
|
||||||
fontSize: 20, fontWeight: FontWeight.w500, height: 1.25,
|
|
||||||
letterSpacing: 0,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.7));
|
|
||||||
case NoteBlockType.quote:
|
|
||||||
// AppFlowy quote: 60% opacity text
|
|
||||||
return base.copyWith(
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.6));
|
|
||||||
case NoteBlockType.codeBlock:
|
|
||||||
// AppFlowy code: 13px, blue.shade900 at 90%, height 1.15, monospace
|
|
||||||
return base.copyWith(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
height: 1.15,
|
|
||||||
letterSpacing: 0,
|
|
||||||
color: Colors.blue.shade900.withValues(alpha: 0.9),
|
|
||||||
);
|
|
||||||
case NoteBlockType.checklist:
|
|
||||||
return base.copyWith(
|
|
||||||
decoration: block.metadata['checked'] == true
|
|
||||||
? TextDecoration.lineThrough
|
|
||||||
: null,
|
|
||||||
color: block.metadata['checked'] == true
|
|
||||||
? colors.onSurface.withValues(alpha: 0.4)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
EdgeInsets _getContentPadding() {
|
|
||||||
switch (block.type) {
|
|
||||||
case NoteBlockType.heading1:
|
|
||||||
return const EdgeInsets.only(top: 16);
|
|
||||||
case NoteBlockType.heading2:
|
|
||||||
return const EdgeInsets.only(top: 8);
|
|
||||||
case NoteBlockType.heading3:
|
|
||||||
return const EdgeInsets.only(top: 8);
|
|
||||||
case NoteBlockType.codeBlock:
|
|
||||||
// AppFlowy code: grey.shade50 background, no inner padding
|
|
||||||
return const EdgeInsets.symmetric(vertical: 8, horizontal: 12);
|
|
||||||
case NoteBlockType.quote:
|
|
||||||
// AppFlowy quote: 6px top, 2px bottom inner padding
|
|
||||||
return const EdgeInsets.only(top: 6, bottom: 2);
|
|
||||||
default:
|
|
||||||
// AppFlowy paragraph: 10px top spacing
|
|
||||||
return const EdgeInsets.only(top: 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getPlaceholder() {
|
|
||||||
switch (block.type) {
|
|
||||||
case NoteBlockType.heading1:
|
|
||||||
return '标题1';
|
|
||||||
case NoteBlockType.heading2:
|
|
||||||
return '标题2';
|
|
||||||
case NoteBlockType.heading3:
|
|
||||||
return '标题3';
|
|
||||||
case NoteBlockType.bulletList:
|
|
||||||
return '列表项';
|
|
||||||
case NoteBlockType.numberedList:
|
|
||||||
return '列表项';
|
|
||||||
case NoteBlockType.checklist:
|
|
||||||
return '待办事项';
|
|
||||||
case NoteBlockType.quote:
|
|
||||||
return '引用';
|
|
||||||
case NoteBlockType.codeBlock:
|
|
||||||
return '代码';
|
|
||||||
default:
|
|
||||||
return '输入文字,或输入 / 打开菜单';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDivider(ColorScheme colors) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
height: 1,
|
|
||||||
color: colors.outlineVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FormatEvent {
|
|
||||||
final int pos;
|
|
||||||
final bool isStart;
|
|
||||||
final Set<InlineFormatType> formats;
|
|
||||||
_FormatEvent(this.pos, this.isStart, this.formats);
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../../models/note_plus_models.dart';
|
|
||||||
import '../../providers/note_plus_provider.dart';
|
|
||||||
import 'note_plus_block_widget.dart';
|
|
||||||
import 'slash_command_menu.dart';
|
|
||||||
|
|
||||||
/// Note Plus 块编辑器
|
|
||||||
///
|
|
||||||
/// 管理 block 列表、焦点切换、键盘事件处理。
|
|
||||||
/// 使用 ReorderableListView 支持拖拽排序。
|
|
||||||
class NotePlusEditor extends StatefulWidget {
|
|
||||||
const NotePlusEditor({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<NotePlusEditor> createState() => _NotePlusEditorState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NotePlusEditorState extends State<NotePlusEditor> {
|
|
||||||
final ScrollController _scrollController = ScrollController();
|
|
||||||
final Map<String, TextEditingController> _controllers = {};
|
|
||||||
final Map<String, FocusNode> _focusNodes = {};
|
|
||||||
OverlayEntry? _slashMenuOverlay;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_scrollController.dispose();
|
|
||||||
for (final c in _controllers.values) {
|
|
||||||
c.dispose();
|
|
||||||
}
|
|
||||||
for (final fn in _focusNodes.values) {
|
|
||||||
fn.dispose();
|
|
||||||
}
|
|
||||||
_removeSlashMenu();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
TextEditingController _getController(NoteBlock block) {
|
|
||||||
return _controllers.putIfAbsent(block.id, () {
|
|
||||||
final c = TextEditingController(text: block.text);
|
|
||||||
c.addListener(() => _onControllerChanged(block));
|
|
||||||
return c;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
FocusNode _getFocusNode(NoteBlock block, int index) {
|
|
||||||
return _focusNodes.putIfAbsent(block.id, () {
|
|
||||||
final fn = FocusNode(onKeyEvent: (node, event) => _handleKeyEvent(index, event));
|
|
||||||
fn.addListener(() {
|
|
||||||
if (fn.hasFocus) {
|
|
||||||
context.read<NotePlusProvider>().setFocusedBlock(index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return fn;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清理已删除 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);
|
|
||||||
if (idx < 0) return;
|
|
||||||
|
|
||||||
final text = _controllers[block.id]!.text;
|
|
||||||
|
|
||||||
// 更新 block 文本(静默,不 rebuild)
|
|
||||||
provider.updateBlockSilent(
|
|
||||||
idx,
|
|
||||||
provider.blocks[idx].copyWith(text: text),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 检测斜杠命令
|
|
||||||
if (text == '/' || (text.startsWith('/') && text.length > 1)) {
|
|
||||||
_showSlashMenu(idx, text);
|
|
||||||
} else {
|
|
||||||
_removeSlashMenu();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onBlockTap(int index) {
|
|
||||||
final provider = context.read<NotePlusProvider>();
|
|
||||||
provider.setFocusedBlock(index);
|
|
||||||
|
|
||||||
final block = provider.blocks[index];
|
|
||||||
if (block.type == NoteBlockType.divider) return;
|
|
||||||
|
|
||||||
final node = _getFocusNode(block, index);
|
|
||||||
if (!node.hasFocus) {
|
|
||||||
node.requestFocus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onToggleChecklist(int index) {
|
|
||||||
context.read<NotePlusProvider>().toggleChecklist(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理键盘事件
|
|
||||||
KeyEventResult _handleKeyEvent(int index, KeyEvent event) {
|
|
||||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
|
||||||
|
|
||||||
final provider = context.read<NotePlusProvider>();
|
|
||||||
final block = provider.blocks[index];
|
|
||||||
final controller = _controllers[block.id];
|
|
||||||
if (controller == null) return KeyEventResult.ignored;
|
|
||||||
|
|
||||||
final sel = controller.selection;
|
|
||||||
|
|
||||||
// Enter → 分割 block
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.enter &&
|
|
||||||
!HardwareKeyboard.instance.isShiftPressed) {
|
|
||||||
final pos = sel.isValid ? sel.baseOffset : controller.text.length;
|
|
||||||
provider.splitBlock(index, pos);
|
|
||||||
|
|
||||||
// 聚焦新 block
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_focusBlockAtIndex(index + 1, atStart: true);
|
|
||||||
});
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backspace 在位置0 → 合并到前一个
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.backspace &&
|
|
||||||
sel.isValid &&
|
|
||||||
sel.isCollapsed &&
|
|
||||||
sel.baseOffset == 0 &&
|
|
||||||
index > 0) {
|
|
||||||
final cursorPos = provider.mergeWithPrevious(index);
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_focusBlockAtIndex(index - 1, atCursor: cursorPos);
|
|
||||||
});
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ↑ 在位置0 → 聚焦上一个
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.arrowUp &&
|
|
||||||
sel.isValid &&
|
|
||||||
sel.isCollapsed &&
|
|
||||||
sel.baseOffset == 0 &&
|
|
||||||
index > 0) {
|
|
||||||
_focusBlockAtIndex(index - 1, atEnd: true);
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ↓ 在末位 → 聚焦下一个
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.arrowDown &&
|
|
||||||
sel.isValid &&
|
|
||||||
sel.isCollapsed &&
|
|
||||||
sel.baseOffset == controller.text.length &&
|
|
||||||
index < provider.blocks.length - 1) {
|
|
||||||
_focusBlockAtIndex(index + 1, atStart: true);
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _focusBlockAtIndex(int index, {bool atStart = false, bool atEnd = false, int? atCursor}) {
|
|
||||||
final provider = context.read<NotePlusProvider>();
|
|
||||||
if (index < 0 || index >= provider.blocks.length) return;
|
|
||||||
|
|
||||||
final block = provider.blocks[index];
|
|
||||||
if (block.type == NoteBlockType.divider) return;
|
|
||||||
|
|
||||||
provider.setFocusedBlock(index);
|
|
||||||
final node = _getFocusNode(block, index);
|
|
||||||
final controller = _getController(block);
|
|
||||||
|
|
||||||
// 等 controller 同步后设置光标
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!node.hasFocus) node.requestFocus();
|
|
||||||
int cursorPos;
|
|
||||||
if (atCursor != null) {
|
|
||||||
cursorPos = atCursor;
|
|
||||||
} else if (atEnd) {
|
|
||||||
cursorPos = controller.text.length;
|
|
||||||
} else {
|
|
||||||
cursorPos = 0;
|
|
||||||
}
|
|
||||||
controller.selection = TextSelection.collapsed(offset: cursorPos);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Slash Menu ==========
|
|
||||||
|
|
||||||
void _showSlashMenu(int blockIndex, String text) {
|
|
||||||
_removeSlashMenu();
|
|
||||||
|
|
||||||
final query = text.length > 1 ? text.substring(1) : '';
|
|
||||||
|
|
||||||
_slashMenuOverlay = OverlayEntry(
|
|
||||||
builder: (context) => Positioned(
|
|
||||||
bottom: MediaQuery.of(context).viewInsets.bottom + 60,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
child: Material(
|
|
||||||
elevation: 8,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: SlashCommandMenu(
|
|
||||||
query: query,
|
|
||||||
onSelect: (type) {
|
|
||||||
_removeSlashMenu();
|
|
||||||
final provider = context.read<NotePlusProvider>();
|
|
||||||
// 清除 / 文本
|
|
||||||
provider.updateBlockSilent(
|
|
||||||
blockIndex,
|
|
||||||
provider.blocks[blockIndex].copyWith(text: ''),
|
|
||||||
);
|
|
||||||
_controllers[provider.blocks[blockIndex].id]?.text = '';
|
|
||||||
// 转换类型
|
|
||||||
provider.convertBlockType(blockIndex, type);
|
|
||||||
},
|
|
||||||
onDismiss: _removeSlashMenu,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Overlay.of(context).insert(_slashMenuOverlay!);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _removeSlashMenu() {
|
|
||||||
_slashMenuOverlay?.remove();
|
|
||||||
_slashMenuOverlay = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Build ==========
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Consumer<NotePlusProvider>(
|
|
||||||
builder: (context, provider, _) {
|
|
||||||
final blocks = provider.blocks;
|
|
||||||
|
|
||||||
// 清理已删除 block 对应的控制器和焦点节点,防止内存泄漏
|
|
||||||
_cleanupStaleEntries(blocks);
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
controller: _scrollController,
|
|
||||||
itemCount: blocks.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final block = blocks[index];
|
|
||||||
final isFocused = provider.focusedBlockIndex == index;
|
|
||||||
|
|
||||||
// 计算有序列表序号
|
|
||||||
int numberedIndex = 1;
|
|
||||||
if (block.type == NoteBlockType.numberedList) {
|
|
||||||
for (int i = index - 1; i >= 0; i--) {
|
|
||||||
if (blocks[i].type == NoteBlockType.numberedList) {
|
|
||||||
numberedIndex++;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 同步 controller 文本
|
|
||||||
final controller = isFocused ? _getController(block) : null;
|
|
||||||
if (controller != null && controller.text != block.text) {
|
|
||||||
// 用 addPostFrameCallback 避免 build 期间修改
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (controller.text != block.text) {
|
|
||||||
controller.text = block.text;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
key: ValueKey(block.id),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Block 内容
|
|
||||||
Expanded(
|
|
||||||
child: NotePlusBlockWidget(
|
|
||||||
block: block,
|
|
||||||
index: index,
|
|
||||||
numberedIndex: numberedIndex,
|
|
||||||
isFocused: isFocused,
|
|
||||||
controller: controller,
|
|
||||||
focusNode: isFocused ? _getFocusNode(block, index) : null,
|
|
||||||
onTap: () => _onBlockTap(index),
|
|
||||||
onToggleChecklist: block.type == NoteBlockType.checklist
|
|
||||||
? () => _onToggleChecklist(index)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../models/note_plus_models.dart';
|
|
||||||
|
|
||||||
/// 斜杠命令菜单
|
|
||||||
///
|
|
||||||
/// 输入 `/` 后弹出,显示可插入的 block 类型列表。
|
|
||||||
/// 支持按中文关键词过滤。
|
|
||||||
class SlashCommandMenu extends StatefulWidget {
|
|
||||||
final String query;
|
|
||||||
final void Function(NoteBlockType) onSelect;
|
|
||||||
final VoidCallback onDismiss;
|
|
||||||
|
|
||||||
const SlashCommandMenu({
|
|
||||||
super.key,
|
|
||||||
required this.query,
|
|
||||||
required this.onSelect,
|
|
||||||
required this.onDismiss,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<SlashCommandMenu> createState() => _SlashCommandMenuState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SlashCommandMenuState extends State<SlashCommandMenu> {
|
|
||||||
int _selectedIndex = 0;
|
|
||||||
|
|
||||||
static const _menuItems = [
|
|
||||||
_MenuItem(NoteBlockType.paragraph, Icons.notes, '正文', '段落'),
|
|
||||||
_MenuItem(NoteBlockType.heading1, Icons.title, '标题1', '大标题'),
|
|
||||||
_MenuItem(NoteBlockType.heading2, Icons.title, '标题2', '中标题'),
|
|
||||||
_MenuItem(NoteBlockType.heading3, Icons.title, '标题3', '小标题'),
|
|
||||||
_MenuItem(NoteBlockType.bulletList, Icons.format_list_bulleted, '无序列表', '圆点列表'),
|
|
||||||
_MenuItem(NoteBlockType.numberedList, Icons.format_list_numbered, '有序列表', '数字列表'),
|
|
||||||
_MenuItem(NoteBlockType.checklist, Icons.check_box_outlined, '待办', '清单'),
|
|
||||||
_MenuItem(NoteBlockType.quote, Icons.format_quote, '引用', '引述'),
|
|
||||||
_MenuItem(NoteBlockType.codeBlock, Icons.code, '代码块', '源码'),
|
|
||||||
_MenuItem(NoteBlockType.divider, Icons.horizontal_rule, '分割线', '分隔'),
|
|
||||||
];
|
|
||||||
|
|
||||||
List<_MenuItem> get _filtered {
|
|
||||||
if (widget.query.isEmpty) return _menuItems;
|
|
||||||
final q = widget.query.toLowerCase();
|
|
||||||
return _menuItems.where((item) {
|
|
||||||
return item.label.toLowerCase().contains(q) ||
|
|
||||||
item.keywords.toLowerCase().contains(q);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(SlashCommandMenu oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.query != widget.query) {
|
|
||||||
final items = _filtered;
|
|
||||||
if (_selectedIndex >= items.length) {
|
|
||||||
_selectedIndex = items.isEmpty ? 0 : items.length - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
final items = _filtered;
|
|
||||||
|
|
||||||
if (items.isEmpty) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
|
||||||
child: Text('无匹配项',
|
|
||||||
style: TextStyle(fontSize: 13, color: colors.onSurface.withValues(alpha: 0.4))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxHeight: 320),
|
|
||||||
child: ListView.builder(
|
|
||||||
shrinkWrap: true,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
itemCount: items.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = items[index];
|
|
||||||
final isSelected = index == _selectedIndex;
|
|
||||||
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => widget.onSelect(item.type),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
||||||
color: isSelected
|
|
||||||
? colors.primary.withValues(alpha: 0.08)
|
|
||||||
: null,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(item.icon, size: 20,
|
|
||||||
color: isSelected
|
|
||||||
? colors.primary
|
|
||||||
: colors.onSurface.withValues(alpha: 0.5)),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(item.label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
|
|
||||||
color: isSelected ? colors.primary : colors.onSurface,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
Text(item.keywords,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.3),
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MenuItem {
|
|
||||||
final NoteBlockType type;
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final String keywords;
|
|
||||||
|
|
||||||
const _MenuItem(this.type, this.icon, this.label, this.keywords);
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../models/note_plus_models.dart';
|
|
||||||
|
|
||||||
/// AppFlowy 风格的文件树列表项
|
|
||||||
///
|
|
||||||
/// 匹配 AppFlowy ViewSectionItem 的样式:
|
|
||||||
/// - 固定高度 26px
|
|
||||||
/// - 16px 图标 + 文字
|
|
||||||
/// - 12px 字号
|
|
||||||
/// - hover 时显示操作按钮
|
|
||||||
class NotePlusTreeItem extends StatefulWidget {
|
|
||||||
final NotePlusDocument doc;
|
|
||||||
final bool isSelected;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
final VoidCallback? onRename;
|
|
||||||
final VoidCallback? onDelete;
|
|
||||||
|
|
||||||
const NotePlusTreeItem({
|
|
||||||
super.key,
|
|
||||||
required this.doc,
|
|
||||||
required this.isSelected,
|
|
||||||
required this.onTap,
|
|
||||||
this.onRename,
|
|
||||||
this.onDelete,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<NotePlusTreeItem> createState() => _NotePlusTreeItemState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NotePlusTreeItemState extends State<NotePlusTreeItem> {
|
|
||||||
bool _isHovering = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colors = Theme.of(context).colorScheme;
|
|
||||||
final isSelected = widget.isSelected;
|
|
||||||
final showActions = _isHovering || isSelected;
|
|
||||||
|
|
||||||
return MouseRegion(
|
|
||||||
onEnter: (_) => setState(() => _isHovering = true),
|
|
||||||
onExit: (_) => setState(() => _isHovering = false),
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: widget.onTap,
|
|
||||||
child: Container(
|
|
||||||
height: 26,
|
|
||||||
padding: const EdgeInsets.only(left: 22, right: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
|
||||||
? colors.primary.withValues(alpha: 0.08)
|
|
||||||
: _isHovering
|
|
||||||
? colors.onSurface.withValues(alpha: 0.04)
|
|
||||||
: null,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// 文档图标
|
|
||||||
SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: Icon(
|
|
||||||
_getBlockIcon(widget.doc),
|
|
||||||
size: 14,
|
|
||||||
color: isSelected
|
|
||||||
? colors.primary
|
|
||||||
: colors.onSurface.withValues(alpha: 0.4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
// 文档标题
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
widget.doc.title.isEmpty ? '无标题' : widget.doc.title,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
|
|
||||||
color: isSelected
|
|
||||||
? colors.primary
|
|
||||||
: colors.onSurface.withValues(alpha: 0.75),
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.clip,
|
|
||||||
maxLines: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// hover 操作按钮
|
|
||||||
if (showActions)
|
|
||||||
_buildActions(colors),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActions(ColorScheme colors) {
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (widget.onRename != null)
|
|
||||||
_ActionButton(
|
|
||||||
icon: Icons.edit_outlined,
|
|
||||||
onTap: widget.onRename!,
|
|
||||||
colors: colors,
|
|
||||||
),
|
|
||||||
if (widget.onDelete != null)
|
|
||||||
_ActionButton(
|
|
||||||
icon: Icons.delete_outline,
|
|
||||||
onTap: widget.onDelete!,
|
|
||||||
colors: colors,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData _getBlockIcon(NotePlusDocument doc) {
|
|
||||||
// 根据第一个块的类型显示图标
|
|
||||||
if (doc.blocks.isEmpty) return Icons.description_outlined;
|
|
||||||
final firstType = doc.blocks.first.type;
|
|
||||||
switch (firstType) {
|
|
||||||
case NoteBlockType.heading1:
|
|
||||||
case NoteBlockType.heading2:
|
|
||||||
case NoteBlockType.heading3:
|
|
||||||
return Icons.title;
|
|
||||||
case NoteBlockType.checklist:
|
|
||||||
return Icons.check_box_outlined;
|
|
||||||
case NoteBlockType.codeBlock:
|
|
||||||
return Icons.code;
|
|
||||||
case NoteBlockType.quote:
|
|
||||||
return Icons.format_quote;
|
|
||||||
default:
|
|
||||||
return Icons.description_outlined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ActionButton extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
final ColorScheme colors;
|
|
||||||
|
|
||||||
const _ActionButton({
|
|
||||||
required this.icon,
|
|
||||||
required this.onTap,
|
|
||||||
required this.colors,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: Icon(icon, size: 14,
|
|
||||||
color: colors.onSurface.withValues(alpha: 0.4)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,12 +10,10 @@ import file_picker
|
|||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import flutter_inappwebview_macos
|
import flutter_inappwebview_macos
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import quill_native_bridge_macos
|
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
import webview_flutter_wkwebview
|
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
|
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
|
||||||
@@ -23,10 +21,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin"))
|
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
228
pubspec.lock
228
pubspec.lock
@@ -41,14 +41,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
version: "1.4.1"
|
||||||
charcode:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: charcode
|
|
||||||
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.4.0"
|
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -105,38 +97,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
csslib:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: csslib
|
|
||||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.2"
|
|
||||||
cupertino_icons:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: cupertino_icons
|
|
||||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.9"
|
|
||||||
dart_quill_delta:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: dart_quill_delta
|
|
||||||
sha256: bddb0b2948bd5b5a328f1651764486d162c59a8ccffd4c63e8b2c5e44be1dac4
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "10.8.3"
|
|
||||||
diff_match_patch:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: diff_match_patch
|
|
||||||
sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.4.1"
|
|
||||||
dynamic_color:
|
dynamic_color:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -161,22 +121,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.1"
|
version: "5.0.1"
|
||||||
extended_text_field:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: extended_text_field
|
|
||||||
sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "16.0.2"
|
|
||||||
extended_text_library:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: extended_text_library
|
|
||||||
sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "12.0.1"
|
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -262,14 +206,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_colorpicker:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_colorpicker
|
|
||||||
sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.0"
|
|
||||||
flutter_inappwebview:
|
flutter_inappwebview:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -334,46 +270,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
version: "0.6.0"
|
||||||
flutter_keyboard_visibility_linux:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_keyboard_visibility_linux
|
|
||||||
sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.0"
|
|
||||||
flutter_keyboard_visibility_macos:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_keyboard_visibility_macos
|
|
||||||
sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.0"
|
|
||||||
flutter_keyboard_visibility_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_keyboard_visibility_platform_interface
|
|
||||||
sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.0"
|
|
||||||
flutter_keyboard_visibility_temp_fork:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_keyboard_visibility_temp_fork
|
|
||||||
sha256: e3d02900640fbc1129245540db16944a0898b8be81694f4bf04b6c985bed9048
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.1.5"
|
|
||||||
flutter_keyboard_visibility_windows:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_keyboard_visibility_windows
|
|
||||||
sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.0"
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -411,22 +307,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.35"
|
version: "2.0.35"
|
||||||
flutter_quill:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_quill
|
|
||||||
sha256: b96bb8525afdeaaea52f5d02f525e05cc34acd176467ab6d6f35d434cf14fde2
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "11.5.0"
|
|
||||||
flutter_quill_delta_from_html:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: flutter_quill_delta_from_html
|
|
||||||
sha256: "0eb801ea8dd498cadc057507af5da794d4c9599ce58b2569cb3d4bb53ba8bed2"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.5.3"
|
|
||||||
flutter_staggered_grid_view:
|
flutter_staggered_grid_view:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -461,14 +341,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.2"
|
||||||
html:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: html
|
|
||||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.15.6"
|
|
||||||
http:
|
http:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -861,70 +733,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
quill_native_bridge:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: quill_native_bridge
|
|
||||||
sha256: "76a16512e398e84216f3f659f7cb18a89ec1e141ea908e954652b4ce6cf15b18"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "11.1.0"
|
|
||||||
quill_native_bridge_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: quill_native_bridge_android
|
|
||||||
sha256: b75c7e6ede362a7007f545118e756b1f19053994144ec9eda932ce5e54a57569
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.0.1+2"
|
|
||||||
quill_native_bridge_ios:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: quill_native_bridge_ios
|
|
||||||
sha256: d23de3cd7724d482fe2b514617f8eedc8f296e120fb297368917ac3b59d8099f
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.0.1"
|
|
||||||
quill_native_bridge_macos:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: quill_native_bridge_macos
|
|
||||||
sha256: "1c0631bd1e2eee765a8b06017c5286a4e829778f4585736e048eb67c97af8a77"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.0.1"
|
|
||||||
quill_native_bridge_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: quill_native_bridge_platform_interface
|
|
||||||
sha256: "8264a2bdb8a294c31377a27b46c0f8717fa9f968cf113f7dc52d332ed9c84526"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.0.2+1"
|
|
||||||
quill_native_bridge_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: quill_native_bridge_web
|
|
||||||
sha256: "7c723f6824b0250d7f33e8b6c23f2f8eb0103fe48ee7ebf47ab6786b64d5c05d"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.0.2"
|
|
||||||
quill_native_bridge_windows:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: quill_native_bridge_windows
|
|
||||||
sha256: "3f96ced19e3206ddf4f6f7dde3eb16bdd05e10294964009ea3a806d995aa7caa"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.0.2"
|
|
||||||
quiver:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: quiver
|
|
||||||
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.2.2"
|
|
||||||
record_use:
|
record_use:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1070,10 +878,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqlite3
|
name: sqlite3
|
||||||
sha256: "37356bcb56ce0d9404d602c41e4bdb7765e7e9732a3e47adb3d98c556a6abdad"
|
sha256: "752d9d746052359a2022f588bb979f2e7c4e0f9e4b6a1c3121f7626a1574974b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.3"
|
version: "3.3.4"
|
||||||
stack_trace:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1226,38 +1034,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
webview_flutter:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: webview_flutter
|
|
||||||
sha256: e4d3474277c5043f5f3100ed27d94ad693abfd5c602b0221c719a4497e4ba1e4
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.14.0"
|
|
||||||
webview_flutter_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: webview_flutter_android
|
|
||||||
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.12.0"
|
|
||||||
webview_flutter_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: webview_flutter_platform_interface
|
|
||||||
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.15.1"
|
|
||||||
webview_flutter_wkwebview:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: webview_flutter_wkwebview
|
|
||||||
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.25.1"
|
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
15
pubspec.yaml
15
pubspec.yaml
@@ -11,11 +11,9 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
cupertino_icons: ^1.0.8
|
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
fl_chart: ^0.69.0
|
fl_chart: ^0.69.0
|
||||||
sqflite: ^2.3.0
|
sqflite: ^2.3.0
|
||||||
sqflite_common_ffi: ^2.3.0
|
|
||||||
path: ^1.8.3
|
path: ^1.8.3
|
||||||
image_picker: ^1.0.4
|
image_picker: ^1.0.4
|
||||||
path_provider: ^2.1.1
|
path_provider: ^2.1.1
|
||||||
@@ -30,14 +28,12 @@ dependencies:
|
|||||||
flutter_staggered_grid_view: ^0.7.0
|
flutter_staggered_grid_view: ^0.7.0
|
||||||
http: ^1.2.0
|
http: ^1.2.0
|
||||||
url_launcher: ^6.2.5
|
url_launcher: ^6.2.5
|
||||||
webview_flutter: ^4.8.0
|
|
||||||
flutter_inappwebview: ^6.1.5
|
flutter_inappwebview: ^6.1.5
|
||||||
dynamic_color: ^1.8.1
|
dynamic_color: ^1.8.1
|
||||||
package_info_plus: ^8.0.0
|
package_info_plus: ^8.0.0
|
||||||
extended_text_field: ^16.0.2
|
|
||||||
uuid: ^4.5.0
|
uuid: ^4.5.0
|
||||||
expandable: ^5.0.1
|
expandable: ^5.0.1
|
||||||
flutter_quill: ^11.5.0
|
sqflite_common_ffi: ^2.3.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@@ -48,11 +44,16 @@ dev_dependencies:
|
|||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: "ic_launcher"
|
android: "ic_launcher"
|
||||||
ios: false
|
ios: false
|
||||||
image_path: "assets/icon/app_icon.png"
|
image_path: "assets/icon/app_icon.webp"
|
||||||
adaptive_icon_background: "#FFFFFF"
|
adaptive_icon_background: "#FFFFFF"
|
||||||
adaptive_icon_foreground: "assets/icon/app_icon.png"
|
adaptive_icon_foreground: "assets/icon/app_icon.webp"
|
||||||
min_sdk_android: 21
|
min_sdk_android: 21
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
user_defines:
|
||||||
|
sqlite3:
|
||||||
|
source: system
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user