generated from dellevin/template
测试块编辑器笔记
This commit is contained in:
276
lib/pages/note_plus/note_plus_detail_page.dart
Normal file
276
lib/pages/note_plus/note_plus_detail_page.dart
Normal file
@@ -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<NotePlusDocument?>(
|
||||
future: context.read<NotePlusProvider>().getDeletedDocuments().then(
|
||||
(_) => context.read<NotePlusProvider>().currentDocument?.id == documentId
|
||||
? context.read<NotePlusProvider>().currentDocument
|
||||
: null),
|
||||
builder: (context, snapshot) {
|
||||
// 直接从 provider 取
|
||||
return Consumer<NotePlusProvider>(
|
||||
builder: (context, provider, _) {
|
||||
final doc = provider.currentDocument;
|
||||
if (doc == null || doc.id != documentId) {
|
||||
// 加载文档
|
||||
provider.loadDocumentById(documentId);
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
appBar: AppBar(
|
||||
title: Text(doc.title.isEmpty ? '无标题' : doc.title),
|
||||
backgroundColor: colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(context, '/note-plus-form',
|
||||
arguments: doc.id);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
|
||||
itemCount: doc.blocks.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildBlock(doc.blocks[index], index, doc.blocks, colors);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBlock(NoteBlock block, int index, List<NoteBlock> allBlocks,
|
||||
ColorScheme colors) {
|
||||
if (block.type == NoteBlockType.divider) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Container(height: 1, color: colors.outlineVariant),
|
||||
);
|
||||
}
|
||||
|
||||
final style = _getTextStyle(block, colors);
|
||||
final text = block.text.isEmpty ? '(空)' : block.text;
|
||||
|
||||
return Padding(
|
||||
padding: _getBlockPadding(block),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildPrefix(block, index, allBlocks, colors),
|
||||
Expanded(
|
||||
child: block.type == NoteBlockType.codeBlock
|
||||
? Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||
child: block.text.isEmpty
|
||||
? Text('(空)', style: style.copyWith(
|
||||
color: colors.onSurface.withValues(alpha: 0.2)))
|
||||
: _buildRichText(text, style, block, colors),
|
||||
)
|
||||
: block.text.isEmpty
|
||||
? Text(text, style: style.copyWith(
|
||||
color: colors.onSurface.withValues(alpha: 0.2)))
|
||||
: _buildRichText(text, style, block, colors),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPrefix(NoteBlock block, int index, List<NoteBlock> allBlocks,
|
||||
ColorScheme colors) {
|
||||
switch (block.type) {
|
||||
case NoteBlockType.bulletList:
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8, right: 8),
|
||||
child: Container(
|
||||
width: 6, height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.onSurface.withValues(alpha: 0.5),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
);
|
||||
case NoteBlockType.numberedList:
|
||||
int num = 1;
|
||||
for (int i = index - 1; i >= 0; i--) {
|
||||
if (allBlocks[i].type == NoteBlockType.numberedList) {
|
||||
num++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 6, right: 8),
|
||||
child: Text('$num.', style: TextStyle(
|
||||
fontSize: 14, color: colors.onSurface.withValues(alpha: 0.5))),
|
||||
);
|
||||
case NoteBlockType.checklist:
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4, right: 8),
|
||||
child: Icon(
|
||||
block.metadata['checked'] == true
|
||||
? Icons.check_box
|
||||
: Icons.check_box_outline_blank,
|
||||
size: 20,
|
||||
color: block.metadata['checked'] == true
|
||||
? colors.primary
|
||||
: colors.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
);
|
||||
case NoteBlockType.quote:
|
||||
return Container(
|
||||
width: 4,
|
||||
margin: const EdgeInsets.only(top: 6, bottom: 6, right: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
default:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildRichText(String text, TextStyle style, NoteBlock block,
|
||||
ColorScheme colors) {
|
||||
if (block.formatting.isEmpty) return Text(text, style: style);
|
||||
|
||||
final spans = <InlineSpan>[];
|
||||
final events = <_FmtEvent>[];
|
||||
for (final span in block.formatting) {
|
||||
if (span.start < text.length) {
|
||||
events.add(_FmtEvent(span.start, true, span.formats));
|
||||
events.add(_FmtEvent(
|
||||
span.end > text.length ? text.length : span.end, false, span.formats));
|
||||
}
|
||||
}
|
||||
events.sort((a, b) => a.pos.compareTo(b.pos));
|
||||
|
||||
int lastPos = 0;
|
||||
final active = <InlineFormatType>{};
|
||||
for (final e in events) {
|
||||
if (e.pos > lastPos) {
|
||||
spans.add(TextSpan(
|
||||
text: text.substring(lastPos, e.pos),
|
||||
style: _applyFormats(style, active, colors),
|
||||
));
|
||||
}
|
||||
if (e.isStart) {
|
||||
active.addAll(e.formats);
|
||||
} else {
|
||||
active.removeAll(e.formats);
|
||||
}
|
||||
lastPos = e.pos;
|
||||
}
|
||||
if (lastPos < text.length) {
|
||||
spans.add(TextSpan(
|
||||
text: text.substring(lastPos),
|
||||
style: _applyFormats(style, active, colors),
|
||||
));
|
||||
}
|
||||
|
||||
return Text.rich(TextSpan(children: spans));
|
||||
}
|
||||
|
||||
TextStyle _applyFormats(TextStyle style, Set<InlineFormatType> formats,
|
||||
ColorScheme colors) {
|
||||
if (formats.isEmpty) return style;
|
||||
return style.copyWith(
|
||||
fontWeight: formats.contains(InlineFormatType.bold) ? FontWeight.w700 : null,
|
||||
fontStyle: formats.contains(InlineFormatType.italic) ? FontStyle.italic : null,
|
||||
decoration: _getDecoration(formats),
|
||||
fontFamily: formats.contains(InlineFormatType.inlineCode) ? 'monospace' : null,
|
||||
backgroundColor: formats.contains(InlineFormatType.inlineCode)
|
||||
? colors.surfaceContainerHighest : null,
|
||||
);
|
||||
}
|
||||
|
||||
TextDecoration? _getDecoration(Set<InlineFormatType> formats) {
|
||||
final list = <TextDecoration>[];
|
||||
if (formats.contains(InlineFormatType.underline)) list.add(TextDecoration.underline);
|
||||
if (formats.contains(InlineFormatType.strikethrough)) list.add(TextDecoration.lineThrough);
|
||||
return list.isEmpty ? null : TextDecoration.combine(list);
|
||||
}
|
||||
|
||||
TextStyle _getTextStyle(NoteBlock block, ColorScheme colors) {
|
||||
// AppFlowy base: 18px, w300, height 1.3, letter-spacing 0.6
|
||||
final base = TextStyle(
|
||||
color: colors.onSurface,
|
||||
fontSize: 18, fontWeight: FontWeight.w300, height: 1.3, letterSpacing: 0.6,
|
||||
);
|
||||
switch (block.type) {
|
||||
case NoteBlockType.heading1:
|
||||
return base.copyWith(fontSize: 34, fontWeight: FontWeight.w300, height: 1.15,
|
||||
letterSpacing: 0, color: colors.onSurface.withValues(alpha: 0.7));
|
||||
case NoteBlockType.heading2:
|
||||
return base.copyWith(fontSize: 24, fontWeight: FontWeight.w400, height: 1.15,
|
||||
letterSpacing: 0, color: colors.onSurface.withValues(alpha: 0.7));
|
||||
case NoteBlockType.heading3:
|
||||
return base.copyWith(fontSize: 20, fontWeight: FontWeight.w500, height: 1.25,
|
||||
letterSpacing: 0, color: colors.onSurface.withValues(alpha: 0.7));
|
||||
case NoteBlockType.quote:
|
||||
return base.copyWith(color: colors.onSurface.withValues(alpha: 0.6));
|
||||
case NoteBlockType.codeBlock:
|
||||
return base.copyWith(fontSize: 13, fontWeight: FontWeight.w400, fontFamily: 'monospace',
|
||||
height: 1.15, letterSpacing: 0, color: Colors.blue.shade900.withValues(alpha: 0.9));
|
||||
case NoteBlockType.checklist:
|
||||
return base.copyWith(
|
||||
decoration: block.metadata['checked'] == true ? TextDecoration.lineThrough : null,
|
||||
color: block.metadata['checked'] == true ? colors.onSurface.withValues(alpha: 0.4) : null,
|
||||
);
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
EdgeInsets _getBlockPadding(NoteBlock block) {
|
||||
switch (block.type) {
|
||||
case NoteBlockType.heading1:
|
||||
return const EdgeInsets.only(top: 16);
|
||||
case NoteBlockType.heading2:
|
||||
return const EdgeInsets.only(top: 8);
|
||||
case NoteBlockType.heading3:
|
||||
return const EdgeInsets.only(top: 8);
|
||||
case NoteBlockType.codeBlock:
|
||||
return const EdgeInsets.symmetric(vertical: 6);
|
||||
case NoteBlockType.quote:
|
||||
return const EdgeInsets.only(top: 6, bottom: 2);
|
||||
default:
|
||||
return const EdgeInsets.only(top: 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FmtEvent {
|
||||
final int pos;
|
||||
final bool isStart;
|
||||
final Set<InlineFormatType> formats;
|
||||
_FmtEvent(this.pos, this.isStart, this.formats);
|
||||
}
|
||||
448
lib/pages/note_plus/note_plus_form_page.dart
Normal file
448
lib/pages/note_plus/note_plus_form_page.dart
Normal file
@@ -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<NotePlusFormPage> createState() => _NotePlusFormPageState();
|
||||
}
|
||||
|
||||
class _NotePlusFormPageState extends State<NotePlusFormPage> {
|
||||
final _titleController = TextEditingController();
|
||||
final _titleFocus = FocusNode();
|
||||
final _scrollController = ScrollController();
|
||||
final _editorFocus = FocusNode();
|
||||
quill.QuillController? _controller;
|
||||
bool _isInitialized = false;
|
||||
Timer? _autoSaveTimer;
|
||||
int _charCount = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoSaveTimer?.cancel();
|
||||
_titleController.dispose();
|
||||
_titleFocus.dispose();
|
||||
_scrollController.dispose();
|
||||
_editorFocus.dispose();
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ─── 初始化 ─────────────────────────────────────
|
||||
|
||||
void _initDocument(NotePlusProvider provider) {
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
|
||||
provider.loadDocumentById(widget.documentId).then((_) {
|
||||
if (!mounted || provider.currentDocument == null) return;
|
||||
|
||||
_titleController.text = provider.currentDocument!.title;
|
||||
|
||||
quill.Document doc;
|
||||
final raw = provider.currentDocument!.blocksJson;
|
||||
if (raw != null && raw.isNotEmpty && raw.startsWith('[')) {
|
||||
try {
|
||||
final parsed = jsonDecode(raw) as List;
|
||||
if (parsed.isNotEmpty &&
|
||||
parsed.first is Map &&
|
||||
(parsed.first as Map).containsKey('insert')) {
|
||||
doc = quill.Document.fromJson(parsed);
|
||||
} else {
|
||||
doc = quill.Document();
|
||||
}
|
||||
} catch (_) {
|
||||
doc = quill.Document();
|
||||
}
|
||||
} else {
|
||||
doc = quill.Document();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_controller = quill.QuillController(
|
||||
document: doc,
|
||||
selection: const TextSelection.collapsed(offset: 0),
|
||||
);
|
||||
_controller!.addListener(_onDocChanged);
|
||||
_charCount = _controller!.document.length - 1; // 去掉末尾 \n
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 文档变化监听 ─────────────────────────────────
|
||||
|
||||
void _onDocChanged() {
|
||||
if (_controller == null) return;
|
||||
final newCount = _controller!.document.length - 1;
|
||||
if (newCount != _charCount) {
|
||||
setState(() => _charCount = newCount);
|
||||
}
|
||||
// 自动保存(防抖 2 秒)
|
||||
_autoSaveTimer?.cancel();
|
||||
_autoSaveTimer = Timer(const Duration(seconds: 2), () {
|
||||
if (mounted) _autoSave();
|
||||
});
|
||||
}
|
||||
|
||||
void _autoSave() {
|
||||
final provider = context.read<NotePlusProvider>();
|
||||
if (_controller == null) return;
|
||||
provider.setTitle(_titleController.text);
|
||||
final deltaJson = jsonEncode(_controller!.document.toDelta().toJson());
|
||||
provider.saveDocument(deltaJson: deltaJson);
|
||||
}
|
||||
|
||||
void _save(NotePlusProvider provider) async {
|
||||
_autoSaveTimer?.cancel();
|
||||
if (_controller == null) return;
|
||||
provider.setTitle(_titleController.text);
|
||||
final deltaJson = jsonEncode(_controller!.document.toDelta().toJson());
|
||||
await provider.saveDocument(deltaJson: deltaJson);
|
||||
if (mounted) ToastUtil.show(context, '已保存');
|
||||
}
|
||||
|
||||
// ─── 构建 ─────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return Consumer<NotePlusProvider>(
|
||||
builder: (context, provider, _) {
|
||||
_initDocument(provider);
|
||||
|
||||
return PopScope(
|
||||
canPop: !provider.isDirty,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop && provider.isDirty) {
|
||||
_showSaveDialog(provider);
|
||||
} else {
|
||||
_autoSave(); // 退出时自动保存
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: colors.surface,
|
||||
appBar: _buildAppBar(provider, colors),
|
||||
body: _controller == null
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: GestureDetector(
|
||||
// 点击空白区域聚焦编辑器
|
||||
onTap: () => _editorFocus.requestFocus(),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Column(
|
||||
children: [
|
||||
// 标题区
|
||||
_buildTitleArea(colors),
|
||||
// 分隔线
|
||||
Divider(height: 1, color: colors.outlineVariant.withValues(alpha: 0.3)),
|
||||
// 编辑器
|
||||
Expanded(child: _buildEditor(colors)),
|
||||
// 工具栏
|
||||
_buildToolbar(colors),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AppBar ─────────────────────────────────────
|
||||
|
||||
PreferredSizeWidget _buildAppBar(NotePlusProvider provider, ColorScheme colors) {
|
||||
return AppBar(
|
||||
backgroundColor: colors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, size: 22),
|
||||
onPressed: () {
|
||||
_autoSave();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
'$_charCount 字',
|
||||
style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.35)),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
// 未保存指示
|
||||
if (provider.isDirty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 6, height: 6,
|
||||
decoration: BoxDecoration(color: colors.error, shape: BoxShape.circle),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.undo, size: 20),
|
||||
onPressed: _controller != null ? () => _controller!.undo() : null,
|
||||
tooltip: '撤销',
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.redo, size: 20),
|
||||
onPressed: _controller != null ? () => _controller!.redo() : null,
|
||||
tooltip: '重做',
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.check, size: 22, color: colors.primary),
|
||||
onPressed: () => _save(provider),
|
||||
tooltip: '保存',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 标题区 ─────────────────────────────────────
|
||||
|
||||
Widget _buildTitleArea(ColorScheme colors) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: TextField(
|
||||
controller: _titleController,
|
||||
focusNode: _titleFocus,
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface,
|
||||
height: 1.3,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入标题...',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface.withValues(alpha: 0.15),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (_) => context.read<NotePlusProvider>().setTitle(_titleController.text),
|
||||
textInputAction: TextInputAction.next,
|
||||
onSubmitted: (_) => _editorFocus.requestFocus(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 编辑器 ─────────────────────────────────────
|
||||
|
||||
Widget _buildEditor(ColorScheme colors) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: TextSelectionTheme(
|
||||
data: TextSelectionThemeData(
|
||||
selectionColor: colors.primary.withValues(alpha: 0.12),
|
||||
selectionHandleColor: colors.primary,
|
||||
cursorColor: colors.primary,
|
||||
),
|
||||
child: quill.QuillEditor.basic(
|
||||
controller: _controller!,
|
||||
focusNode: _editorFocus,
|
||||
scrollController: _scrollController,
|
||||
config: const quill.QuillEditorConfig(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
autoFocus: false,
|
||||
expands: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 工具栏 ─────────────────────────────────────
|
||||
|
||||
Widget _buildToolbar(ColorScheme colors) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 6),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: 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('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
657
lib/pages/note_plus/note_plus_tab_page.dart
Normal file
657
lib/pages/note_plus/note_plus_tab_page.dart
Normal file
@@ -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<NotePlusTabPage> createState() => _NotePlusTabPageState();
|
||||
}
|
||||
|
||||
class _NotePlusTabPageState extends State<NotePlusTabPage> {
|
||||
String? _selectedDocId;
|
||||
String? _dragOverNodeId; // 拖到节点上(成为子节点)
|
||||
_DropPosition? _dropPos; // 拖到节点之间(排序)
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<NotePlusProvider>().loadDocuments();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
children: [
|
||||
_buildHeader(colors),
|
||||
Expanded(child: _buildTree(colors)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(ColorScheme colors) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(children: [
|
||||
Icon(Icons.edit_note, size: 18, color: colors.onSurface.withValues(alpha: 0.5)),
|
||||
const SizedBox(width: 8),
|
||||
Text('Note Plus', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface.withValues(alpha: 0.7))),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final provider = context.read<NotePlusProvider>();
|
||||
final doc = await provider.createDocument();
|
||||
if (!mounted) return;
|
||||
Navigator.pushNamed(context, '/note-plus-form', arguments: doc.id);
|
||||
},
|
||||
child: Icon(Icons.add, size: 20,
|
||||
color: colors.onSurface.withValues(alpha: 0.45)),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
GestureDetector(
|
||||
onTap: () => _showRecycleBin(context),
|
||||
child: Icon(Icons.delete_outline, size: 18,
|
||||
color: colors.onSurface.withValues(alpha: 0.35)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTree(ColorScheme colors) {
|
||||
return Consumer<NotePlusProvider>(
|
||||
builder: (context, provider, _) {
|
||||
if (provider.documents.isEmpty) return _buildEmptyState(colors);
|
||||
|
||||
final roots = provider.documents
|
||||
.where((d) => d.parentId.isEmpty)
|
||||
.toList()
|
||||
..sort((a, b) => a.sortIndex.compareTo(b.sortIndex));
|
||||
|
||||
if (roots.isEmpty) return _buildEmptyState(colors);
|
||||
|
||||
return ExpandableTheme(
|
||||
data: const ExpandableThemeData(
|
||||
useInkWell: false,
|
||||
animationDuration: Duration(milliseconds: 200),
|
||||
headerAlignment: ExpandablePanelHeaderAlignment.center,
|
||||
tapBodyToExpand: false, tapBodyToCollapse: false,
|
||||
tapHeaderToExpand: false, iconPadding: EdgeInsets.zero, hasIcon: false,
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => provider.loadDocuments(),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 40),
|
||||
children: _buildChildrenList(roots, 0, colors),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建子节点列表,节点之间插入 drop zone(共 n+1 个间隙)
|
||||
List<Widget> _buildChildrenList(List<NotePlusDocument> docs, int depth, ColorScheme colors) {
|
||||
final widgets = <Widget>[];
|
||||
final parentId = docs.isNotEmpty ? docs.first.parentId : '';
|
||||
for (int i = 0; i < docs.length; i++) {
|
||||
// 节点前的间隙
|
||||
widgets.add(_buildReorderGap(parentId, i, depth, colors));
|
||||
// 节点本身
|
||||
widgets.add(_buildTreeNode(docs[i], depth, colors));
|
||||
}
|
||||
// 末尾间隙
|
||||
if (docs.isNotEmpty) {
|
||||
widgets.add(_buildReorderGap(parentId, docs.length, depth, colors));
|
||||
}
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/// 排序间隙 drop zone
|
||||
Widget _buildReorderGap(String parentId, int insertIndex, int depth, ColorScheme colors) {
|
||||
final indent = 28.0 + depth * 18.0;
|
||||
final isTarget = _dropPos != null &&
|
||||
_dropPos!.parentId == parentId &&
|
||||
_dropPos!.index == insertIndex;
|
||||
|
||||
return DragTarget<String>(
|
||||
onWillAcceptWithDetails: (details) {
|
||||
final draggedId = details.data;
|
||||
final provider = context.read<NotePlusProvider>();
|
||||
final dragged = provider.documents.where((d) => d.id == draggedId).firstOrNull;
|
||||
if (dragged == null) return false;
|
||||
// 防止拖到自己的子孙上
|
||||
if (parentId.isNotEmpty &&
|
||||
_isDescendantOf(provider.documents, childId: parentId, ancestorId: draggedId)) {
|
||||
return false;
|
||||
}
|
||||
setState(() => _dropPos = _DropPosition(parentId, insertIndex));
|
||||
return true;
|
||||
},
|
||||
onLeave: (_) {
|
||||
if (_dropPos?.parentId == parentId && _dropPos?.index == insertIndex) {
|
||||
setState(() => _dropPos = null);
|
||||
}
|
||||
},
|
||||
onAcceptWithDetails: (details) {
|
||||
setState(() { _dropPos = null; _dragOverNodeId = null; });
|
||||
_handleDrop(details.data, parentId, insertIndex);
|
||||
},
|
||||
builder: (_, __, ___) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: isTarget ? 36 : 6,
|
||||
margin: EdgeInsets.only(left: indent, right: 16),
|
||||
decoration: isTarget
|
||||
? BoxDecoration(
|
||||
color: colors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: colors.primary.withValues(alpha: 0.4), width: 1),
|
||||
)
|
||||
: null,
|
||||
child: isTarget
|
||||
? Center(
|
||||
child: Text('放置到此处',
|
||||
style: TextStyle(fontSize: 11,
|
||||
color: colors.primary.withValues(alpha: 0.7))),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 递归构建单个树节点
|
||||
Widget _buildTreeNode(NotePlusDocument doc, int depth, ColorScheme colors) {
|
||||
final provider = context.watch<NotePlusProvider>();
|
||||
final children = provider.documents
|
||||
.where((d) => d.parentId == doc.id)
|
||||
.toList()
|
||||
..sort((a, b) => a.sortIndex.compareTo(b.sortIndex));
|
||||
final hasChildren = children.isNotEmpty;
|
||||
final isSelected = _selectedDocId == doc.id;
|
||||
final isDropTarget = _dragOverNodeId == doc.id;
|
||||
|
||||
final nodeContent = _buildNodeContent(doc, depth,
|
||||
hasChildren: hasChildren, isSelected: isSelected,
|
||||
isDropTarget: isDropTarget, colors: colors);
|
||||
|
||||
// 可拖拽包装
|
||||
final draggable = LongPressDraggable<String>(
|
||||
data: doc.id,
|
||||
delay: const Duration(milliseconds: 300),
|
||||
feedback: Material(
|
||||
elevation: 4, borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width - 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(children: [
|
||||
Icon(Icons.description_outlined, size: 14,
|
||||
color: colors.onSurface.withValues(alpha: 0.5)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(doc.title.isEmpty ? '无标题' : doc.title,
|
||||
style: TextStyle(fontSize: 12, color: colors.onSurface),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
childWhenDragging: Opacity(opacity: 0.3, child: nodeContent),
|
||||
child: DragTarget<String>(
|
||||
onWillAcceptWithDetails: (details) {
|
||||
final draggedId = details.data;
|
||||
if (draggedId == doc.id) return false;
|
||||
if (_isDescendantOf(provider.documents, childId: doc.id, ancestorId: draggedId)) {
|
||||
return false;
|
||||
}
|
||||
setState(() => _dragOverNodeId = doc.id);
|
||||
return true;
|
||||
},
|
||||
onLeave: (_) {
|
||||
if (_dragOverNodeId == doc.id) setState(() => _dragOverNodeId = null);
|
||||
},
|
||||
onAcceptWithDetails: (details) {
|
||||
setState(() { _dragOverNodeId = null; _dropPos = null; });
|
||||
// 拖到节点上 → 成为该节点的子节点
|
||||
provider.moveDocument(details.data, doc.id);
|
||||
},
|
||||
builder: (_, __, ___) => nodeContent,
|
||||
),
|
||||
);
|
||||
|
||||
// 组装:可展开节点带子树,叶子节点直接返回
|
||||
if (hasChildren) {
|
||||
return ExpandableNotifier(
|
||||
initialExpanded: true,
|
||||
child: Builder(
|
||||
builder: (innerCtx) {
|
||||
// 给 _buildNodeContent 传入 expandContext 用于展开/折叠
|
||||
final nodeWithExpand = _buildNodeContent(doc, depth,
|
||||
hasChildren: true, isSelected: isSelected,
|
||||
isDropTarget: isDropTarget, expandContext: innerCtx, colors: colors);
|
||||
|
||||
final draggableWithExpand = LongPressDraggable<String>(
|
||||
data: doc.id, delay: const Duration(milliseconds: 300),
|
||||
feedback: Material(
|
||||
elevation: 4, borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width - 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(children: [
|
||||
Icon(Icons.description_outlined, size: 14,
|
||||
color: colors.onSurface.withValues(alpha: 0.5)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(doc.title.isEmpty ? '无标题' : doc.title,
|
||||
style: TextStyle(fontSize: 12, color: colors.onSurface),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
childWhenDragging: Opacity(opacity: 0.3, child: nodeWithExpand),
|
||||
child: DragTarget<String>(
|
||||
onWillAcceptWithDetails: (details) {
|
||||
final draggedId = details.data;
|
||||
if (draggedId == doc.id) return false;
|
||||
if (_isDescendantOf(provider.documents, childId: doc.id, ancestorId: draggedId)) {
|
||||
return false;
|
||||
}
|
||||
setState(() => _dragOverNodeId = doc.id);
|
||||
return true;
|
||||
},
|
||||
onLeave: (_) {
|
||||
if (_dragOverNodeId == doc.id) setState(() => _dragOverNodeId = null);
|
||||
},
|
||||
onAcceptWithDetails: (details) {
|
||||
setState(() { _dragOverNodeId = null; _dropPos = null; });
|
||||
provider.moveDocument(details.data, doc.id);
|
||||
},
|
||||
builder: (_, __, ___) => nodeWithExpand,
|
||||
),
|
||||
);
|
||||
|
||||
return Column(children: [
|
||||
draggableWithExpand,
|
||||
Expandable(
|
||||
collapsed: const SizedBox(),
|
||||
expanded: Column(children: _buildChildrenList(children, depth + 1, colors)),
|
||||
),
|
||||
]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return draggable;
|
||||
}
|
||||
|
||||
/// 节点内容(标题行)
|
||||
Widget _buildNodeContent(NotePlusDocument doc, int depth,
|
||||
{required bool hasChildren, required bool isSelected,
|
||||
required bool isDropTarget, BuildContext? expandContext,
|
||||
required ColorScheme colors}) {
|
||||
final indent = 12.0 + depth * 18.0;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() => _selectedDocId = doc.id);
|
||||
Navigator.pushNamed(context, '/note-plus-form', arguments: doc.id);
|
||||
},
|
||||
onLongPress: () => _showDocActions(doc),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Container(
|
||||
height: 30,
|
||||
padding: EdgeInsets.only(left: indent, right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colors.primary.withValues(alpha: 0.08)
|
||||
: isDropTarget
|
||||
? colors.primary.withValues(alpha: 0.12)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: isDropTarget
|
||||
? Border.all(color: colors.primary.withValues(alpha: 0.4), width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Row(children: [
|
||||
// 展开/折叠箭头
|
||||
if (hasChildren)
|
||||
SizedBox(
|
||||
width: 20, height: 20,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (expandContext != null) {
|
||||
ExpandableController.of(expandContext,
|
||||
rebuildOnChange: false, required: true)
|
||||
?.toggle();
|
||||
}
|
||||
},
|
||||
child: expandContext != null
|
||||
? ExpandableIcon(
|
||||
theme: ExpandableThemeData(
|
||||
expandIcon: Icons.keyboard_arrow_right,
|
||||
collapseIcon: Icons.keyboard_arrow_down,
|
||||
iconColor: colors.onSurface.withValues(alpha: 0.4),
|
||||
iconSize: 14, iconPadding: EdgeInsets.zero, hasIcon: false,
|
||||
),
|
||||
)
|
||||
: Icon(Icons.keyboard_arrow_down, size: 14,
|
||||
color: colors.onSurface.withValues(alpha: 0.4)),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 20),
|
||||
// 图标
|
||||
Icon(_getDocIcon(doc), size: 14,
|
||||
color: isSelected ? colors.primary : colors.onSurface.withValues(alpha: 0.4)),
|
||||
const SizedBox(width: 6),
|
||||
// 标题
|
||||
Expanded(
|
||||
child: Text(doc.title.isEmpty ? '无标题' : doc.title,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
|
||||
color: isSelected ? colors.primary : colors.onSurface.withValues(alpha: 0.75),
|
||||
), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
// 添加子文档
|
||||
SizedBox(
|
||||
width: 20, height: 20,
|
||||
child: InkWell(
|
||||
onTap: () => _createChildDoc(doc.id),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Icon(Icons.add, size: 13,
|
||||
color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
// 删除
|
||||
SizedBox(
|
||||
width: 20, height: 20,
|
||||
child: InkWell(
|
||||
onTap: () => _showDeleteDialog(doc.id),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Icon(Icons.delete_outline, size: 13,
|
||||
color: colors.onSurface.withValues(alpha: 0.25)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 拖放处理 ─────────────────────────────────────
|
||||
|
||||
void _handleDrop(String draggedId, String targetParentId, int insertIndex) {
|
||||
final provider = context.read<NotePlusProvider>();
|
||||
provider.moveDocumentTo(draggedId, targetParentId, insertIndex);
|
||||
}
|
||||
|
||||
// ─── 工具 ─────────────────────────────────────
|
||||
|
||||
bool _isDescendantOf(List<NotePlusDocument> docs,
|
||||
{required String childId, required String ancestorId}) {
|
||||
if (childId.isEmpty) return false;
|
||||
if (childId == ancestorId) return true; // 自身也算(防止拖到自己下面)
|
||||
var current = docs.where((d) => d.id == childId).firstOrNull;
|
||||
while (current != null) {
|
||||
if (current.parentId == ancestorId) return true;
|
||||
current = docs.where((d) => d.id == current!.parentId).firstOrNull;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
IconData _getDocIcon(NotePlusDocument doc) {
|
||||
if (doc.blocks.isEmpty) return Icons.description_outlined;
|
||||
switch (doc.blocks.first.type) {
|
||||
case NoteBlockType.heading1:
|
||||
case NoteBlockType.heading2:
|
||||
case NoteBlockType.heading3:
|
||||
return Icons.title;
|
||||
case NoteBlockType.checklist:
|
||||
return Icons.check_box_outlined;
|
||||
case NoteBlockType.codeBlock:
|
||||
return Icons.code;
|
||||
case NoteBlockType.quote:
|
||||
return Icons.format_quote;
|
||||
default:
|
||||
return Icons.description_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 操作 ─────────────────────────────────────
|
||||
|
||||
Future<void> _createChildDoc(String parentId) async {
|
||||
final provider = context.read<NotePlusProvider>();
|
||||
final doc = await provider.createDocument(parentId: parentId);
|
||||
if (!mounted) return;
|
||||
Navigator.pushNamed(context, '/note-plus-form', arguments: doc.id);
|
||||
}
|
||||
|
||||
void _showDocActions(NotePlusDocument doc) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(width: 36, height: 4,
|
||||
margin: const EdgeInsets.only(top: 10, bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.onSurface.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.subdirectory_arrow_right, size: 20,
|
||||
color: colors.onSurface.withValues(alpha: 0.6)),
|
||||
title: const Text('新建子文档', style: TextStyle(fontSize: 14)),
|
||||
onTap: () { Navigator.pop(ctx); _createChildDoc(doc.id); },
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.edit_outlined, size: 20,
|
||||
color: colors.onSurface.withValues(alpha: 0.6)),
|
||||
title: const Text('重命名', style: TextStyle(fontSize: 14)),
|
||||
onTap: () { Navigator.pop(ctx); _showRenameDialog(doc); },
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete_outline, size: 20, color: colors.error),
|
||||
title: Text('删除', style: TextStyle(fontSize: 14, color: colors.error)),
|
||||
subtitle: Text('子文档将提升到上一级',
|
||||
style: TextStyle(fontSize: 11,
|
||||
color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
onTap: () { Navigator.pop(ctx); _showDeleteDialog(doc.id); },
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showRenameDialog(NotePlusDocument doc) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final controller = TextEditingController(text: doc.title);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: colors.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text('重命名', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface)),
|
||||
content: TextField(
|
||||
controller: controller, autofocus: true,
|
||||
decoration: InputDecoration(hintText: '输入标题',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8))),
|
||||
onSubmitted: (_) {
|
||||
context.read<NotePlusProvider>().renameDocument(doc.id, controller.text);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
context.read<NotePlusProvider>().renameDocument(doc.id, controller.text);
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.primary, foregroundColor: colors.onPrimary,
|
||||
elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteDialog(String id) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: colors.surface,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
title: Text('删除文档', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface)),
|
||||
content: Text('子文档将自动提升到上一级。可在回收站恢复。',
|
||||
style: TextStyle(fontSize: 14, color: colors.onSurface.withValues(alpha: 0.6))),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx),
|
||||
child: Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6)))),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
context.read<NotePlusProvider>().deleteDocument(id);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colors.error, foregroundColor: colors.onError,
|
||||
elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showRecycleBin(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final provider = context.read<NotePlusProvider>();
|
||||
showModalBottomSheet(
|
||||
context: context, backgroundColor: colors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.5, minChildSize: 0.3, maxChildSize: 0.8, expand: false,
|
||||
builder: (ctx, scrollCtrl) {
|
||||
return FutureBuilder<List<NotePlusDocument>>(
|
||||
future: provider.getDeletedDocuments(),
|
||||
builder: (context, snapshot) {
|
||||
final deleted = snapshot.data ?? [];
|
||||
return Column(children: [
|
||||
Container(width: 36, height: 4,
|
||||
margin: const EdgeInsets.only(top: 10, bottom: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.onSurface.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(children: [
|
||||
Text('回收站', style: TextStyle(fontSize: 15,
|
||||
fontWeight: FontWeight.w600, color: colors.onSurface)),
|
||||
const Spacer(),
|
||||
Text('${deleted.length} 个文档', style: TextStyle(fontSize: 12,
|
||||
color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: deleted.isEmpty
|
||||
? Center(child: Text('回收站为空',
|
||||
style: TextStyle(fontSize: 13,
|
||||
color: colors.onSurface.withValues(alpha: 0.35))))
|
||||
: ListView.builder(
|
||||
controller: scrollCtrl, itemCount: deleted.length,
|
||||
itemBuilder: (context, i) {
|
||||
final doc = deleted[i];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
leading: Icon(Icons.description_outlined, size: 18,
|
||||
color: colors.onSurface.withValues(alpha: 0.4)),
|
||||
title: Text(doc.title.isEmpty ? '无标题' : doc.title,
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.restore, size: 18, color: colors.primary),
|
||||
onPressed: () { provider.restoreDocument(doc.id); Navigator.pop(ctx); },
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_forever, size: 18, color: colors.error),
|
||||
onPressed: () { provider.permanentDeleteDocument(doc.id); Navigator.pop(ctx); },
|
||||
),
|
||||
]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(ColorScheme colors) {
|
||||
return Center(
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Container(width: 64, height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(Icons.edit_note, size: 32,
|
||||
color: colors.onSurface.withValues(alpha: 0.2)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('暂无文档', style: TextStyle(fontSize: 14,
|
||||
color: colors.onSurface.withValues(alpha: 0.4))),
|
||||
const SizedBox(height: 6),
|
||||
Text('点击右上角 + 创建', style: TextStyle(fontSize: 12,
|
||||
color: colors.onSurface.withValues(alpha: 0.25))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 拖放位置记录
|
||||
class _DropPosition {
|
||||
final String parentId;
|
||||
final int index;
|
||||
_DropPosition(this.parentId, this.index);
|
||||
}
|
||||
Reference in New Issue
Block a user