import 'package:flutter/material.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_exception.dart'; import '../../utils/toast.dart'; import '../../widgets/state_views.dart'; /// 笔记编辑页 /// /// [noteId] 为 null 时是新建,否则是编辑已有笔记。 /// 保存成功会 pop(true),上一页据此刷新数据。 /// /// 这里用 StatefulWidget + TextEditingController 管理表单, /// 而不是像账单编辑页那样走 Notifier —— 后者需要额外的 revision /// 字段来同步受控输入框,对纯文本编辑没有必要。 class NoteEditPage extends ConsumerStatefulWidget { const NoteEditPage({super.key, this.noteId}); final int? noteId; bool get isCreate => noteId == null; @override ConsumerState createState() => _NoteEditPageState(); } class _NoteEditPageState extends ConsumerState { final _titleController = TextEditingController(); final _tagsController = TextEditingController(); final _contentController = TextEditingController(); bool _loading = false; bool _saving = false; bool _preview = false; String? _loadError; /// 是否已经成功保存过——用于「返回时提示未保存」的判断 bool _saved = false; @override void initState() { super.initState(); if (!widget.isCreate) _load(); } @override void dispose() { _titleController.dispose(); _tagsController.dispose(); _contentController.dispose(); super.dispose(); } Future _load() async { setState(() { _loading = true; _loadError = null; }); try { final note = await ref.read(noteApiProvider).detail(widget.noteId!); if (!mounted) return; _titleController.text = note.title; _tagsController.text = note.tags.join(','); _contentController.text = note.content; setState(() => _loading = false); } on ApiException catch (e) { if (!mounted) return; setState(() { _loading = false; _loadError = e.message; }); } catch (_) { if (!mounted) return; setState(() { _loading = false; _loadError = '笔记加载失败'; }); } } bool get _dirty => !_saved && (_titleController.text.isNotEmpty || _tagsController.text.isNotEmpty || _contentController.text.isNotEmpty); Future _save() async { final title = _titleController.text.trim(); final tags = _tagsController.text.trim(); final content = _contentController.text; if (title.isEmpty && content.trim().isEmpty) { showToast(context, '标题和内容不能同时为空', error: true); return; } setState(() => _saving = true); try { final api = ref.read(noteApiProvider); if (widget.isCreate) { await api.create(title: title, content: content, tags: tags); } else { await api.update(widget.noteId!, title: title, content: content, tags: tags); } if (!mounted) return; _saved = true; showToast(context, '✅ 已保存'); Navigator.of(context).pop(true); } on ApiException catch (e) { if (mounted) showToast(context, e.message, error: true); } catch (_) { if (mounted) showToast(context, '保存失败,请重试', error: true); } finally { if (mounted) setState(() => _saving = false); } } Future _confirmDiscard() async { final leave = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('放弃编辑?'), content: const Text('当前改动还没有保存,返回将丢失。'), 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 (leave == true && mounted) Navigator.of(context).pop(false); } @override Widget build(BuildContext context) { return PopScope( canPop: !_dirty, onPopInvokedWithResult: (didPop, _) { if (!didPop) _confirmDiscard(); }, child: Scaffold( appBar: AppBar( title: Text(widget.isCreate ? '新建笔记' : '编辑笔记'), actions: [ IconButton( tooltip: _preview ? '继续编辑' : '预览', icon: Icon(_preview ? Icons.edit_outlined : Icons.visibility_outlined), onPressed: _loading ? null : () => setState(() => _preview = !_preview), ), Padding( padding: const EdgeInsets.only(right: 8), child: TextButton( onPressed: (_saving || _loading) ? null : _save, child: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Color(0xFFC8956E), ), ) : const Text( '保存', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFC8956E), ), ), ), ), ], ), body: _body(), ), ); } Widget _body() { if (_loading) return const Center(child: LoadingView(tip: '笔记加载中...')); if (_loadError != null) { return Center( child: ErrorView(message: _loadError!, onRetry: _load), ); } if (_preview) return _previewBody(); return ListView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 40), children: [ TextField( controller: _titleController, style: const TextStyle( fontSize: 19, fontWeight: FontWeight.bold, color: Color(0xFF3E2723), ), decoration: const InputDecoration( hintText: '标题(留空则自动取正文首行)', hintStyle: TextStyle(fontSize: 17, color: Color(0xFFD7CCC8)), border: InputBorder.none, ), ), const Divider(height: 20, color: Color(0xFFF0E6D8)), TextField( controller: _tagsController, style: const TextStyle(fontSize: 14, color: Color(0xFF5D4037)), decoration: const InputDecoration( hintText: '标签,用逗号分隔,例如:工作,服务器', hintStyle: TextStyle(fontSize: 13.5, color: Color(0xFFD7CCC8)), prefixIcon: Icon(Icons.label_outline, size: 18, color: Color(0xFFBCAAA4)), prefixIconConstraints: BoxConstraints(minWidth: 34), border: InputBorder.none, ), ), const Divider(height: 20, color: Color(0xFFF0E6D8)), TextField( controller: _contentController, maxLines: null, minLines: 16, keyboardType: TextInputType.multiline, textAlignVertical: TextAlignVertical.top, style: const TextStyle(fontSize: 15, height: 1.7, color: Color(0xFF3E2723)), decoration: const InputDecoration( hintText: '正文,支持 Markdown…', hintStyle: TextStyle(fontSize: 15, color: Color(0xFFD7CCC8)), border: InputBorder.none, ), ), ], ); } Widget _previewBody() { final content = _contentController.text; if (content.trim().isEmpty) { return const Center( child: EmptyView(icon: '📄', title: '还没有内容', tip: '切回编辑模式写点什么吧'), ); } final base = MarkdownStyleSheet.fromTheme(Theme.of(context)); return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 18, 20, 40), child: MarkdownBody( data: content, selectable: true, styleSheet: base.copyWith( p: const TextStyle(fontSize: 15, height: 1.75, color: Color(0xFF3E2723)), h1: const TextStyle(fontSize: 21, fontWeight: FontWeight.bold, color: Color(0xFF3E2723)), h2: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF3E2723)), h3: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF3E2723)), 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)), ), ), ), ); } }