diff --git a/lib/main.dart b/lib/main.dart index b1dafa5..b8a1c5b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:provider/provider.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:url_launcher/url_launcher.dart'; import 'pages/home_page.dart'; import 'utils/theme/app_theme.dart'; @@ -13,6 +14,7 @@ import 'utils/changelog_service.dart'; import 'utils/sync/auto_backup_service.dart'; import 'utils/usage_stats_service.dart'; import 'providers/app_provider.dart'; +import 'providers/note_plus_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -187,6 +189,7 @@ class _MyAppState extends State with WidgetsBindingObserver { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: widget.appProvider), + ChangeNotifierProvider(create: (_) => NotePlusProvider()), ], child: Consumer( builder: (context, provider, _) { @@ -200,6 +203,7 @@ class _MyAppState extends State with WidgetsBindingObserver { GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + quill.FlutterQuillLocalizations.delegate, ], supportedLocales: const [ Locale('zh', 'CN'), diff --git a/lib/models/note_plus_models.dart b/lib/models/note_plus_models.dart new file mode 100644 index 0000000..a5fa61e --- /dev/null +++ b/lib/models/note_plus_models.dart @@ -0,0 +1,311 @@ +import 'dart:convert'; +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 formats; + + const InlineFormatSpan({ + required this.start, + required this.end, + required this.formats, + }); + + Map toJson() => { + 's': start, + 'e': end, + 'f': formats.map((f) => f.toJson()).toList(), + }; + + factory InlineFormatSpan.fromJson(Map json) { + return InlineFormatSpan( + start: json['s'] as int? ?? 0, + end: json['e'] as int? ?? 0, + formats: (json['f'] as List?) + ?.map((f) => InlineFormatType.fromString(f as String)) + .toSet() ?? + {}, + ); + } + + InlineFormatSpan copyWith( + {int? start, int? end, Set? 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 metadata; + final List formatting; + + NoteBlock({ + String? id, + this.type = NoteBlockType.paragraph, + this.text = '', + Map? metadata, + List? formatting, + }) : id = id ?? const Uuid().v4(), + metadata = metadata ?? {}, + formatting = formatting ?? []; + + Map toJson() => { + 'id': id, + 'type': type.toJson(), + 'text': text, + 'metadata': metadata, + 'formatting': formatting.map((f) => f.toJson()).toList(), + }; + + factory NoteBlock.fromJson(Map 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?) ?? {}, + formatting: (json['formatting'] as List?) + ?.map( + (f) => InlineFormatSpan.fromJson(f as Map)) + .toList() ?? + [], + ); + } + + NoteBlock copyWith({ + NoteBlockType? type, + String? text, + Map? metadata, + List? 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 blocks; + final String? blocksJson; // 原始 Delta JSON(flutter_quill 模式优先使用) + final List tags; + final List images; + final DateTime createdAt; + final DateTime updatedAt; + final bool isDeleted; + + NotePlusDocument({ + String? id, + this.title = '', + this.parentId = '', + this.sortIndex = 0, + List? blocks, + this.blocksJson, + List? tags, + List? 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 json) { + final blocksJson = json['blocks_json'] as String? ?? '[]'; + final blocksList = (jsonDecode(blocksJson) as List) + .map((b) => NoteBlock.fromJson(b as Map)) + .toList(); + + 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) ?? DateTime.now() + : DateTime.now(), + updatedAt: json['updated_at'] != null + ? DateTime.tryParse(json['updated_at'] as String) ?? DateTime.now() + : DateTime.now(), + isDeleted: (json['is_deleted'] as int?) == 1, + ); + } + + /// 序列化为 DB 行 + Map 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? blocks, + String? blocksJson, + List? tags, + List? 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 _parseStringList(String? json) { + if (json == null || json.isEmpty) return []; + try { + final list = jsonDecode(json) as List; + return list.map((e) => e.toString()).toList(); + } catch (_) { + return []; + } + } +} diff --git a/lib/pages/main_content_page.dart b/lib/pages/main_content_page.dart index df3096d..947ccb0 100644 --- a/lib/pages/main_content_page.dart +++ b/lib/pages/main_content_page.dart @@ -7,6 +7,7 @@ import '../utils/toast_util.dart'; import 'movies/movie_tab_page.dart'; import 'book/book_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/online_search_page.dart'; import 'sync/webdav_sync_page.dart'; @@ -25,6 +26,7 @@ class _MainContentPageState extends State { bool _showMovieTab = true; bool _showBookTab = true; bool _showNoteTab = true; + bool _showNotePlusTab = false; late PageController _pageController; bool _isTabTap = false; @@ -48,6 +50,7 @@ class _MainContentPageState extends State { _showMovieTab = _userPrefs.showMovieTab; _showBookTab = _userPrefs.showBookTab; _showNoteTab = _userPrefs.showNoteTab; + _showNotePlusTab = _userPrefs.showNotePlusTab; }); } @@ -62,6 +65,7 @@ class _MainContentPageState extends State { if (_showMovieTab) tabs.add(_TabItem('影视', 0)); if (_showBookTab) tabs.add(_TabItem('阅读', 1)); if (_showNoteTab) tabs.add(_TabItem('笔记', 2)); + if (_showNotePlusTab) tabs.add(_TabItem('Plus', 3)); return tabs; } @@ -135,6 +139,7 @@ class _MainContentPageState extends State { case 0: return '影视'; case 1: return '阅读'; case 2: return '笔记'; + case 3: return 'Note Plus'; default: return 'MookNote'; } } @@ -368,6 +373,7 @@ class _MainContentPageState extends State { if (_showMovieTab) const MovieTabPage(), if (_showBookTab) const BookTabPage(), if (_showNoteTab) const NoteTabPage(), + if (_showNotePlusTab) const NotePlusTabPage(), ], ); }, @@ -379,6 +385,7 @@ class _MainContentPageState extends State { case '影视': return Icons.movie_outlined; case '阅读': return Icons.menu_book_outlined; case '笔记': return Icons.note_outlined; + case 'Plus': return Icons.edit_note; default: return Icons.circle; } } diff --git a/lib/pages/note_plus/note_plus_detail_page.dart b/lib/pages/note_plus/note_plus_detail_page.dart new file mode 100644 index 0000000..b57d497 --- /dev/null +++ b/lib/pages/note_plus/note_plus_detail_page.dart @@ -0,0 +1,276 @@ +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( + future: context.read().getDeletedDocuments().then( + (_) => context.read().currentDocument?.id == documentId + ? context.read().currentDocument + : null), + builder: (context, snapshot) { + // 直接从 provider 取 + return Consumer( + builder: (context, provider, _) { + final doc = provider.currentDocument; + if (doc == null || doc.id != documentId) { + // 加载文档 + 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 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 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 = []; + 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 = {}; + 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 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 formats) { + final list = []; + 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 formats; + _FmtEvent(this.pos, this.isStart, this.formats); +} diff --git a/lib/pages/note_plus/note_plus_form_page.dart b/lib/pages/note_plus/note_plus_form_page.dart new file mode 100644 index 0000000..70f8cf8 --- /dev/null +++ b/lib/pages/note_plus/note_plus_form_page.dart @@ -0,0 +1,448 @@ +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 createState() => _NotePlusFormPageState(); +} + +class _NotePlusFormPageState extends State { + 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(); + 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( + 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().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: const Color(0xFFF5F5F5), + 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('保存'), + ), + ], + ), + ); + } +} diff --git a/lib/pages/note_plus/note_plus_tab_page.dart b/lib/pages/note_plus/note_plus_tab_page.dart new file mode 100644 index 0000000..21abdeb --- /dev/null +++ b/lib/pages/note_plus/note_plus_tab_page.dart @@ -0,0 +1,657 @@ +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 createState() => _NotePlusTabPageState(); +} + +class _NotePlusTabPageState extends State { + String? _selectedDocId; + String? _dragOverNodeId; // 拖到节点上(成为子节点) + _DropPosition? _dropPos; // 拖到节点之间(排序) + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().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(); + 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( + 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 _buildChildrenList(List docs, int depth, ColorScheme colors) { + final widgets = []; + 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( + onWillAcceptWithDetails: (details) { + final draggedId = details.data; + final provider = context.read(); + 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(); + 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( + 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( + 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( + 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( + 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(); + provider.moveDocumentTo(draggedId, targetParentId, insertIndex); + } + + // ─── 工具 ───────────────────────────────────── + + bool _isDescendantOf(List 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 _createChildDoc(String parentId) async { + final provider = context.read(); + 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().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().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().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(); + 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>( + 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); +} diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index f87e769..cfb956b 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -1100,6 +1100,7 @@ class _MainContentSettingsPageState extends State { bool _showMovieTab = true; bool _showBookTab = true; bool _showNoteTab = true; + bool _showNotePlusTab = false; int _defaultTabIndex = 0; @override @@ -1113,6 +1114,7 @@ class _MainContentSettingsPageState extends State { _showMovieTab = _userPrefs.showMovieTab; _showBookTab = _userPrefs.showBookTab; _showNoteTab = _userPrefs.showNoteTab; + _showNotePlusTab = _userPrefs.showNotePlusTab; _defaultTabIndex = _userPrefs.defaultMainTabIndex; }); } @@ -1143,6 +1145,11 @@ class _MainContentSettingsPageState extends State { setState(() => _showNoteTab = value); } + Future _toggleNotePlusTab(bool value) async { + await _userPrefs.setShowNotePlusTab(value); + setState(() => _showNotePlusTab = value); + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; @@ -1161,6 +1168,8 @@ class _MainContentSettingsPageState extends State { Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), _buildSwitchItem(Icons.note_outlined, '笔记', '记录和管理笔记', _showNoteTab, _toggleNoteTab), Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), + _buildSwitchItem(Icons.edit_note, 'Note Plus', '块编辑器,支持富文本文档', _showNotePlusTab, _toggleNotePlusTab), + Divider(height: 0.5, indent: 24, endIndent: 24, color: colors.outlineVariant), Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10), child: Text('至少保留一个模块,关闭后对应标签页将不再显示。', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))), diff --git a/lib/providers/note_plus_provider.dart b/lib/providers/note_plus_provider.dart new file mode 100644 index 0000000..52d8a34 --- /dev/null +++ b/lib/providers/note_plus_provider.dart @@ -0,0 +1,469 @@ +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 _documents = []; + NotePlusDocument? _currentDocument; + List _blocks = []; + int _focusedBlockIndex = 0; + bool _isDirty = false; + + // Undo/Redo + final List> _undoStack = []; + final List> _redoStack = []; + static const int _maxUndoSize = 50; + + // Getters + List get documents => _documents; + NotePlusDocument? get currentDocument => _currentDocument; + List get blocks => _blocks; + int get focusedBlockIndex => _focusedBlockIndex; + bool get isDirty => _isDirty; + bool get canUndo => _undoStack.isNotEmpty; + bool get canRedo => _redoStack.isNotEmpty; + + // ========== 文档 CRUD ========== + + Future loadDocuments() async { + _documents = await _dao.getAll(); + notifyListeners(); + } + + Future 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 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 createDocument({String title = '', String parentId = ''}) async { + final doc = NotePlusDocument(title: title, parentId: parentId); + await _dao.insert(doc); + await loadDocuments(); + return doc; + } + + /// 移动文档到指定父级的指定位置(原子操作,同时处理重排序) + Future 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 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 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 restoreDocument(String id) async { + await _dao.restore(id); + await loadDocuments(); + } + + Future permanentDeleteDocument(String id) async { + await _dao.permanentDelete(id); + await loadDocuments(); + } + + Future> getDeletedDocuments() async { + return await _dao.getDeleted(); + } + + Future 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 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(); + } + + /// 更新文档所属文件夹 + Future updateDocumentFolder(String id, String folder) async { + final idx = _documents.indexWhere((d) => d.id == id); + if (idx < 0) return; + final doc = _documents[idx].copyWith(parentId: folder, 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.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.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 _clipFormatting( + List 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 _shiftFormatting( + List 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(); + } +} diff --git a/lib/utils/app_router.dart b/lib/utils/app_router.dart index d60b6c4..2dffefb 100644 --- a/lib/utils/app_router.dart +++ b/lib/utils/app_router.dart @@ -8,6 +8,8 @@ import '../pages/movies/movie_detail_page.dart'; import '../pages/book/book_detail_page.dart'; import '../pages/note/note_detail_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 { @@ -64,6 +66,20 @@ class AppRouter { } 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: return _buildUnknownRoute(settings.name); } diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index a0d62d0..0b09e7c 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -39,7 +39,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 17, + version: 21, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -125,6 +125,33 @@ class DatabaseHelper { if (oldVersion < 17) { await db.execute('ALTER TABLE tags ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0'); } + if (oldVersion < 18) { + await _createNotePlusTable(db); + } + if (oldVersion < 19) { + await _createNotePlusTable(db); + } + if (oldVersion < 20) { + // 确保 note_plus 表有 parent_id 列(从旧版 folder 迁移) + try { + final cols = await db.rawQuery('PRAGMA table_info(note_plus)'); + if (!cols.any((col) => col['name'] == 'parent_id')) { + if (cols.any((col) => col['name'] == 'folder')) { + await db.execute("ALTER TABLE note_plus RENAME COLUMN folder TO parent_id"); + } else { + await db.execute("ALTER TABLE note_plus ADD COLUMN parent_id TEXT DEFAULT ''"); + } + } + } catch (_) {} + } + if (oldVersion < 21) { + try { + final cols = await db.rawQuery('PRAGMA table_info(note_plus)'); + if (!cols.any((col) => col['name'] == 'sort_index')) { + await db.execute("ALTER TABLE note_plus ADD COLUMN sort_index INTEGER DEFAULT 0"); + } + } catch (_) {} + } } /// 升级books表到V11(添加ISBN和出版时间字段) @@ -639,6 +666,27 @@ class DatabaseHelper { is_deleted INTEGER DEFAULT 0 ) '''); + + // Note Plus 块编辑器文档表 + await _createNotePlusTable(db); + } + + /// 创建 Note Plus 文档表 + Future _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 + ) + '''); } // 关闭数据库 diff --git a/lib/utils/note_plus/note_plus_dao.dart b/lib/utils/note_plus/note_plus_dao.dart new file mode 100644 index 0000000..f636de7 --- /dev/null +++ b/lib/utils/note_plus/note_plus_dao.dart @@ -0,0 +1,134 @@ +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 _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[NotePlusDao] $op error: $e'); + rethrow; + } + } + + // 获取所有未删除的文档 + Future> getAll() => _wrap('getAll', () async { + final db = await _dbHelper.database; + final List> 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> 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 getById(String id) => _wrap('getById', () async { + final db = await _dbHelper.database; + final List> 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 insert(NotePlusDocument doc) => _wrap('insert', () async { + final db = await _dbHelper.database; + return await db.insert('note_plus', doc.toJson()); + }); + + // 更新 + Future 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 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> getDeleted() => _wrap('getDeleted', () async { + final db = await _dbHelper.database; + final List> 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 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 permanentDelete(String id) => _wrap('permanentDelete', () async { + final db = await _dbHelper.database; + return await db.delete( + 'note_plus', + where: 'id = ?', + whereArgs: [id], + ); + }); + + // 搜索 + Future> search(String query) => _wrap('search', () async { + final db = await _dbHelper.database; + final List> 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> getByTag(String tag) => _wrap('getByTag', () async { + final db = await _dbHelper.database; + final List> 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])); + }); +} diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 806b341..d32710f 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -89,6 +89,10 @@ class UserPrefs { bool get showNoteTab => prefs.getBool('showNoteTab') ?? true; Future setShowNoteTab(bool value) => prefs.setBool('showNoteTab', value); + /// 是否显示 Note Plus 标签(默认关闭) + bool get showNotePlusTab => prefs.getBool('showNotePlusTab') ?? false; + Future setShowNotePlusTab(bool value) => prefs.setBool('showNotePlusTab', value); + /// 默认启动标签 (0: 影视, 1: 阅读, 2: 笔记) int get defaultMainTabIndex => prefs.getInt('defaultMainTabIndex') ?? 0; Future setDefaultMainTabIndex(int value) => prefs.setInt('defaultMainTabIndex', value); diff --git a/lib/widgets/note_plus/block_toolbar.dart b/lib/widgets/note_plus/block_toolbar.dart new file mode 100644 index 0000000..e3e5020 --- /dev/null +++ b/lib/widgets/note_plus/block_toolbar.dart @@ -0,0 +1,230 @@ +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 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; + } + } +} diff --git a/lib/widgets/note_plus/note_plus_block_widget.dart b/lib/widgets/note_plus/note_plus_block_widget.dart new file mode 100644 index 0000000..7aa2132 --- /dev/null +++ b/lib/widgets/note_plus/note_plus_block_widget.dart @@ -0,0 +1,385 @@ +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 _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 = []; + int lastPos = 0; + final activeFormats = {}; + + 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 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 formats) { + final decorations = []; + 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 formats; + _FormatEvent(this.pos, this.isStart, this.formats); +} diff --git a/lib/widgets/note_plus/note_plus_editor.dart b/lib/widgets/note_plus/note_plus_editor.dart new file mode 100644 index 0000000..244f47c --- /dev/null +++ b/lib/widgets/note_plus/note_plus_editor.dart @@ -0,0 +1,292 @@ +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 createState() => _NotePlusEditorState(); +} + +class _NotePlusEditorState extends State { + final ScrollController _scrollController = ScrollController(); + final Map _controllers = {}; + final Map _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().setFocusedBlock(index); + } + }); + return fn; + }); + } + + void _onControllerChanged(NoteBlock block) { + final provider = context.read(); + 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(); + 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().toggleChecklist(index); + } + + /// 处理键盘事件 + KeyEventResult _handleKeyEvent(int index, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + final provider = context.read(); + 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(); + 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(); + // 清除 / 文本 + 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( + builder: (context, provider, _) { + final blocks = provider.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, + ), + ), + ], + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/widgets/note_plus/slash_command_menu.dart b/lib/widgets/note_plus/slash_command_menu.dart new file mode 100644 index 0000000..bd5d1bc --- /dev/null +++ b/lib/widgets/note_plus/slash_command_menu.dart @@ -0,0 +1,127 @@ +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 createState() => _SlashCommandMenuState(); +} + +class _SlashCommandMenuState extends State { + 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); +} diff --git a/lib/widgets/note_plus_list_item.dart b/lib/widgets/note_plus_list_item.dart new file mode 100644 index 0000000..94f045e --- /dev/null +++ b/lib/widgets/note_plus_list_item.dart @@ -0,0 +1,161 @@ +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 createState() => _NotePlusTreeItemState(); +} + +class _NotePlusTreeItemState extends State { + 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)), + ), + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index da5a4bb..609ed59 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import file_picker import file_selector_macos import flutter_inappwebview_macos import package_info_plus +import quill_native_bridge_macos import share_plus import shared_preferences_foundation import sqflite_darwin @@ -21,6 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + QuillNativeBridgePlugin.register(with: registry.registrar(forPlugin: "QuillNativeBridgePlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/pubspec.lock b/pubspec.lock index bb76c0e..91b678b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -113,6 +121,14 @@ packages: 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" dbus: dependency: transitive description: @@ -121,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.14" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" equatable: dependency: transitive description: @@ -129,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.8" + expandable: + dependency: "direct main" + description: + name: expandable + sha256: "9604d612d4d1146dafa96c6d8eec9c2ff0994658d6d09fed720ab788c7f5afc2" + url: "https://pub.dev" + source: hosted + version: "5.0.1" extended_text_field: dependency: "direct main" description: @@ -230,6 +262,14 @@ packages: description: flutter source: sdk 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: dependency: "direct main" description: @@ -294,6 +334,46 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: @@ -331,6 +411,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -789,6 +885,70 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b03eaab..2096ae3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,8 @@ dependencies: wakelock_plus: ^1.2.5 pointer_interceptor: ^0.10.1+2 gbk_codec: ^0.4.0 + expandable: ^5.0.1 + flutter_quill: ^11.5.0 dev_dependencies: flutter_test: