import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../api/note_api.dart'; import '../../core/network/api_env.dart'; import '../../core/network/api_exception.dart'; import '../../models/note.dart'; import '../../providers/note_provider.dart'; import '../../utils/toast.dart'; import '../../widgets/state_views.dart'; import 'folder_picker_sheet.dart'; import 'note_edit_page.dart'; /// 笔记详情页(Markdown 渲染,可编辑 / 置顶 / 删除) class NoteDetailPage extends ConsumerWidget { const NoteDetailPage({super.key, required this.noteId}); final int noteId; @override Widget build(BuildContext context, WidgetRef ref) { final detailAsync = ref.watch(noteDetailProvider(noteId)); return Scaffold( appBar: AppBar( title: const Text('笔记'), actions: [ detailAsync.maybeWhen( data: (note) => IconButton( tooltip: '编辑', icon: const Icon(Icons.edit_outlined), onPressed: () => _openEditor(context, ref, note), ), orElse: () => const SizedBox.shrink(), ), detailAsync.maybeWhen( data: (note) => PopupMenuButton( icon: const Icon(Icons.more_vert), onSelected: (value) { if (value == 'pin') { _togglePin(context, ref, note); } else if (value == 'move') { _moveTo(context, ref, note); } else if (value == 'delete') { _confirmDelete(context, ref, note); } }, itemBuilder: (_) => [ PopupMenuItem( value: 'pin', child: Text(note.pinned ? '取消置顶' : '置顶'), ), const PopupMenuItem( value: 'move', child: Text('移动到…'), ), const PopupMenuItem( value: 'delete', child: Text('删除', style: TextStyle(color: Color(0xFFD32F2F))), ), ], ), orElse: () => const SizedBox.shrink(), ), ], ), body: detailAsync.when( loading: () => const Center(child: LoadingView(tip: '笔记加载中...')), error: (e, _) => Center( child: ErrorView( message: asApiException(e).message, onRetry: () => ref.invalidate(noteDetailProvider(noteId)), ), ), data: (note) => _content(context, note), ), ); } Widget _content(BuildContext context, NoteDetail note) { return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 18, 20, 40), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( note.title, style: const TextStyle( fontSize: 22, fontWeight: FontWeight.bold, height: 1.35, color: Color(0xFF3E2723), ), ), const SizedBox(height: 10), Row( children: [ Expanded( child: Text( '更新于 ${note.updateTime}', style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), ), ), ], ), if (note.tags.isNotEmpty) ...[ const SizedBox(height: 10), Wrap( spacing: 6, runSpacing: 6, children: note.tags.map((t) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), decoration: BoxDecoration( color: const Color(0xFFFFF3E6), borderRadius: BorderRadius.circular(8), ), child: Text( t, style: const TextStyle(fontSize: 12, color: Color(0xFFC8956E)), ), ); }).toList(), ), ], const SizedBox(height: 16), const Divider(height: 1, color: Color(0xFFF0E6D8)), const SizedBox(height: 16), if (note.content.trim().isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 30), child: Center( child: Text( '(空笔记)', style: TextStyle(fontSize: 14, color: Color(0xFFBCAAA4)), ), ), ) else MarkdownBody( data: note.content, // 允许长按选中复制 selectable: true, styleSheet: _styleSheet(context), onTapLink: (text, href, title) => _showLink(context, href), imageBuilder: (uri, title, alt) => _image(uri), ), ], ), ); } MarkdownStyleSheet _styleSheet(BuildContext context) { final base = MarkdownStyleSheet.fromTheme(Theme.of(context)); return base.copyWith( p: const TextStyle(fontSize: 15, height: 1.75, color: Color(0xFF3E2723)), h1: const TextStyle( fontSize: 21, fontWeight: FontWeight.bold, height: 1.5, color: Color(0xFF3E2723), ), h2: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, height: 1.5, color: Color(0xFF3E2723), ), h3: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, height: 1.5, color: Color(0xFF3E2723), ), a: const TextStyle( fontSize: 15, color: Color(0xFFC8956E), decoration: TextDecoration.underline, ), code: const TextStyle( fontSize: 13.5, fontFamily: 'monospace', backgroundColor: Color(0xFFF5EFE8), color: Color(0xFFB4654A), ), codeblockDecoration: BoxDecoration( color: const Color(0xFFF7F3EE), borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFEDE3D8)), ), blockquoteDecoration: const BoxDecoration( border: Border(left: BorderSide(color: Color(0xFFC8956E), width: 3)), ), blockquotePadding: const EdgeInsets.fromLTRB(12, 4, 0, 4), listBullet: const TextStyle(fontSize: 15, color: Color(0xFFC8956E)), tableBorder: TableBorder.all(color: const Color(0xFFEDE3D8), width: 1), tableHead: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF5D4037)), tableCellsPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), ); } /// 笔记里的图片是 `/uploads/xxx` 相对路径,要补上 host 才能加载 Widget _image(Uri uri) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: ClipRRect( borderRadius: BorderRadius.circular(10), child: Image.network( resolveAssetUrl(uri.toString()), fit: BoxFit.contain, loadingBuilder: (context, child, progress) { if (progress == null) return child; return const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFFC8956E)), ), ), ); }, errorBuilder: (_, _, _) => Container( padding: const EdgeInsets.symmetric(vertical: 18), alignment: Alignment.center, decoration: BoxDecoration( color: const Color(0xFFF7F3EE), borderRadius: BorderRadius.circular(10), ), child: const Text( '🖼 图片加载失败', style: TextStyle(fontSize: 13, color: Color(0xFFBCAAA4)), ), ), ), ), ); } /// 没有引入 url_launcher,这里给出地址让用户自己复制, /// 比静默无反应体验好,也不用为打开链接单独加一个依赖。 void _showLink(BuildContext context, String? href) { if (href == null || href.isEmpty) return; showModalBottomSheet( context: context, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(18)), ), builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 16), const Text('链接地址', style: TextStyle(fontSize: 15, color: Color(0xFF5D4037))), const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: SelectableText( resolveAssetUrl(href), textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63)), ), ), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ TextButton( onPressed: () => Navigator.pop(sheetContext), child: const Text('关闭', style: TextStyle(color: Color(0xFF8D6E63))), ), FilledButton( onPressed: () { Clipboard.setData(ClipboardData(text: resolveAssetUrl(href))); Navigator.pop(sheetContext); showToast(context, '✅ 已复制链接'); }, style: FilledButton.styleFrom(backgroundColor: const Color(0xFFC8956E)), child: const Text('复制'), ), ], ), const SizedBox(height: 12), ], ), ), ); } // ---------------- 操作 ---------------- Future _openEditor(BuildContext context, WidgetRef ref, NoteDetail note) async { final changed = await Navigator.of(context).push( MaterialPageRoute(builder: (_) => NoteEditPage(noteId: note.id)), ); if (changed == true) { ref.invalidate(noteDetailProvider(noteId)); await ref.read(noteListProvider.notifier).refresh(); ref.invalidate(noteTagsProvider); ref.invalidate(noteFolderTreeProvider); } } /// 把笔记移动到别的目录 Future _moveTo(BuildContext context, WidgetRef ref, NoteDetail note) async { FolderTree tree; try { tree = await ref.read(noteFolderTreeProvider.future); } catch (_) { tree = FolderTree.empty; } if (!context.mounted) return; // 当前所在位置(用于面板里高亮) final NoteLocation current; if (note.folderId == null) { current = NoteLocation.uncategorized; } else { final path = tree.findPath(note.folderId!); current = NoteLocation( scope: NoteScope.folder, folderId: note.folderId, folderName: (path != null && path.isNotEmpty) ? path.last.name : '目录', ); } final picked = await FolderPickerSheet.show( context, tree: tree, current: current, title: '移动到', // 「全部」不是能存放笔记的位置,给笔记选目录时不该出现 showAll: false, ); if (picked == null || !context.mounted) return; final targetId = picked.scope == NoteScope.folder ? picked.folderId : null; if (targetId == note.folderId) return; try { await ref.read(noteApiProvider).assignFolder(note.id, targetId); ref.invalidate(noteDetailProvider(noteId)); await ref.read(noteListProvider.notifier).refresh(); ref.invalidate(noteFolderTreeProvider); // 两个目录的笔记数都变了 if (context.mounted) { showToast(context, targetId == null ? '已移到未分类' : '已移动到「${picked.folderName}」'); } } on ApiException catch (e) { if (context.mounted) showToast(context, e.message, error: true); } } Future _togglePin(BuildContext context, WidgetRef ref, NoteDetail note) async { try { await ref.read(noteApiProvider).update(note.id, pinned: !note.pinned); ref.invalidate(noteDetailProvider(noteId)); await ref.read(noteListProvider.notifier).refresh(); if (context.mounted) showToast(context, note.pinned ? '已取消置顶' : '📌 已置顶'); } on ApiException catch (e) { if (context.mounted) showToast(context, e.message, error: true); } } Future _confirmDelete(BuildContext context, WidgetRef ref, NoteDetail note) async { final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('删除笔记'), content: Text('确定删除「${note.title}」?'), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext, false), child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), ), TextButton( onPressed: () => Navigator.pop(dialogContext, true), child: const Text('删除', style: TextStyle(color: Color(0xFFD32F2F))), ), ], ), ); if (confirmed != true) return; try { await ref.read(noteApiProvider).delete(note.id); await ref.read(noteListProvider.notifier).refresh(); ref.invalidate(noteTagsProvider); ref.invalidate(noteFolderTreeProvider); if (context.mounted) { showToast(context, '已删除'); Navigator.of(context).pop(); } } on ApiException catch (e) { if (context.mounted) showToast(context, e.message, error: true); } } }