Files
MookNote/lib/pages/note_plus/note_plus_tab_page.dart
2026-06-26 05:53:40 +08:00

658 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}