import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../api/note_api.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_detail_page.dart'; /// 笔记列表页 /// /// 列表数据在后端是分页返回的,这里用滚动到底部自动加载下一页, /// 配合下拉刷新与 350ms 防抖的搜索框。 class NoteListPage extends ConsumerStatefulWidget { const NoteListPage({super.key}); @override ConsumerState createState() => _NoteListPageState(); } class _NoteListPageState extends ConsumerState { final _scrollController = ScrollController(); final _searchController = TextEditingController(); bool _searching = false; @override void initState() { super.initState(); _scrollController.addListener(_onScroll); } @override void dispose() { _scrollController.removeListener(_onScroll); _scrollController.dispose(); _searchController.dispose(); super.dispose(); } /// 距底部还有 300px 就开始加载,避免用户看到明显的"卡一下" void _onScroll() { if (!_scrollController.hasClients) return; final position = _scrollController.position; if (position.pixels >= position.maxScrollExtent - 300) { ref.read(noteListProvider.notifier).loadMore(); } } void _toggleSearch() { setState(() { _searching = !_searching; if (!_searching) { _searchController.clear(); ref.read(noteListProvider.notifier).setKeyword(''); } }); } @override Widget build(BuildContext context) { final state = ref.watch(noteListProvider); // Riverpod 3 的 AsyncValue 只有 value(可空),没有 valueOrNull final tree = ref.watch(noteFolderTreeProvider).value; return Scaffold( appBar: AppBar( title: _searching ? TextField( controller: _searchController, autofocus: true, decoration: const InputDecoration( hintText: '搜索标题和正文…', border: InputBorder.none, hintStyle: TextStyle(fontSize: 15, color: Color(0xFFBCAAA4)), ), style: const TextStyle(fontSize: 15, color: Color(0xFF5D4037)), onChanged: (v) => ref.read(noteListProvider.notifier).setKeyword(v), ) : const Text('我的笔记'), actions: [ IconButton( tooltip: '排序', icon: const Icon(Icons.sort), onPressed: _pickSort, ), IconButton( tooltip: _searching ? '关闭搜索' : '搜索', icon: Icon(_searching ? Icons.close : Icons.search), onPressed: _toggleSearch, ), ], ), body: Column( children: [ _folderBar(state, tree), _tagBar(), Expanded(child: _body(state)), ], ), floatingActionButton: FloatingActionButton.extended( onPressed: _quickCapture, backgroundColor: const Color(0xFFC8956E), foregroundColor: Colors.white, icon: const Icon(Icons.edit_outlined), label: const Text('快速记录'), ), ); } // ---------------- 当前目录条 ---------------- Widget _folderBar(NoteListState state, FolderTree? tree) { return Material( color: Colors.white, child: InkWell( onTap: _pickLocation, child: Padding( padding: const EdgeInsets.fromLTRB(16, 11, 12, 11), child: Row( children: [ const Icon(Icons.folder_outlined, size: 18, color: Color(0xFFC8956E)), const SizedBox(width: 8), Expanded( child: Text( _pathText(state.location, tree), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), ), Text( '${state.total} 篇', style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), ), const Icon(Icons.expand_more, size: 18, color: Color(0xFFBCAAA4)), ], ), ), ), ); } /// 拼出「全部 › 工作 › 服务器」这样的路径 String _pathText(NoteLocation location, FolderTree? tree) { if (location.scope == NoteScope.all) return '全部'; if (location.scope == NoteScope.uncategorized) return '全部 › 未分类'; final path = tree?.findPath(location.folderId!); if (path == null || path.isEmpty) return '全部 › ${location.folderName}'; return ['全部', ...path.map((e) => e.name)].join(' › '); } Future _pickLocation() async { FolderTree tree; try { tree = await ref.read(noteFolderTreeProvider.future); } catch (_) { tree = FolderTree.empty; } if (!mounted) return; final current = ref.read(noteListProvider).location; final picked = await FolderPickerSheet.show(context, tree: tree, current: current); if (picked == null) return; ref.read(noteListProvider.notifier).setLocation(picked); } // ---------------- 排序 ---------------- Future _pickSort() async { final current = ref.read(noteListProvider).sort; final picked = await showModalBottomSheet( context: context, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 16), const Text( '排序方式', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), const SizedBox(height: 6), _sortOption(sheetContext, 'updated', '最近更新', current), _sortOption(sheetContext, 'created', '创建时间', current), _sortOption(sheetContext, 'title', '标题', current), const SizedBox(height: 16), ], ), ), ); if (picked == null) return; ref.read(noteListProvider.notifier).setSort(picked); } Widget _sortOption(BuildContext sheetContext, String value, String label, String current) { final selected = value == current; return ListTile( dense: true, leading: Icon( selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, size: 20, color: selected ? const Color(0xFFC8956E) : const Color(0xFFBCAAA4), ), title: Text( label, style: TextStyle( fontSize: 14.5, fontWeight: selected ? FontWeight.w600 : FontWeight.normal, color: selected ? const Color(0xFFC8956E) : const Color(0xFF5D4037), ), ), onTap: () => Navigator.pop(sheetContext, value), ); } // ---------------- 标签筛选栏 ---------------- Widget _tagBar() { final tagsAsync = ref.watch(noteTagsProvider); final selected = ref.watch(noteListProvider).tag; return tagsAsync.maybeWhen( // 标签加载失败或为空时不占位,避免空出一条白带 data: (tags) { if (tags.isEmpty) return const SizedBox.shrink(); return Container( height: 46, color: Colors.white, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), children: [ _tagChip('全部', selected.isEmpty, () { ref.read(noteListProvider.notifier).setTag(''); }), ...tags.map((t) => _tagChip(t, selected == t, () { ref.read(noteListProvider.notifier).setTag(t); })), ], ), ); }, orElse: () => const SizedBox.shrink(), ); } Widget _tagChip(String label, bool active, VoidCallback onTap) { return Padding( padding: const EdgeInsets.only(right: 8), child: GestureDetector( onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( color: active ? const Color(0xFFC8956E) : const Color(0xFFFFF3E6), borderRadius: BorderRadius.circular(14), ), child: Text( label, style: TextStyle( fontSize: 13, color: active ? Colors.white : const Color(0xFF8D6E63), fontWeight: active ? FontWeight.w600 : FontWeight.normal, ), ), ), ), ); } // ---------------- 主体 ---------------- Widget _body(NoteListState state) { if (state.loading && state.items.isEmpty) { return const Center(child: LoadingView(tip: '笔记加载中...')); } if (state.error != null && state.items.isEmpty) { return Center( child: ErrorView( message: state.error!, onRetry: () => ref.read(noteListProvider.notifier).refresh(), ), ); } if (state.isEmpty) { return RefreshIndicator( color: const Color(0xFFC8956E), onRefresh: () => ref.read(noteListProvider.notifier).refresh(), child: ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ SizedBox(height: MediaQuery.of(context).size.height * 0.18), EmptyView( icon: state.hasFilter ? '🔍' : '📝', title: state.hasFilter ? '没有匹配的笔记' : '还没有笔记', tip: state.hasFilter ? '换个关键词或标签试试' : '点右下角「快速记录」,\n或在电脑上打开后台管理批量导入', ), ], ), ); } return RefreshIndicator( color: const Color(0xFFC8956E), onRefresh: () => ref.read(noteListProvider.notifier).refresh(), child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(12, 12, 12, 90), itemCount: state.items.length + 1, itemBuilder: (context, index) { if (index == state.items.length) return _footer(state); return _noteCard(state.items[index]); }, ), ); } Widget _footer(NoteListState state) { if (state.loadingMore) { return const Padding( padding: EdgeInsets.symmetric(vertical: 18), child: Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2.2, color: Color(0xFFC8956E)), ), ), ); } if (!state.hasMore) { return Padding( padding: const EdgeInsets.symmetric(vertical: 18), child: Center( child: Text( '共 ${state.total} 篇', style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), ), ), ); } return const SizedBox(height: 18); } Widget _noteCard(NoteListItem note) { return GestureDetector( onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => NoteDetailPage(noteId: note.id)), ), child: Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: note.pinned ? Border.all(color: const Color(0xFFC8956E), width: 1.2) : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ if (note.pinned) ...[ const Text('📌', style: TextStyle(fontSize: 13)), const SizedBox(width: 4), ], Expanded( child: Text( note.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 15.5, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), ), ], ), if (note.summary.isNotEmpty) ...[ const SizedBox(height: 6), Text( note.summary, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, height: 1.5, color: Color(0xFF8D6E63)), ), ], const SizedBox(height: 10), Row( children: [ ...note.tags.take(3).map((t) => _miniTag(t)), if (note.tags.length > 3) Text( '+${note.tags.length - 3}', style: const TextStyle(fontSize: 11, color: Color(0xFFBCAAA4)), ), const Spacer(), Text( note.updateTime, style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)), ), ], ), ], ), ), ); } Widget _miniTag(String tag) { return Container( margin: const EdgeInsets.only(right: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: const Color(0xFFFFF3E6), borderRadius: BorderRadius.circular(6), ), child: Text(tag, style: const TextStyle(fontSize: 11, color: Color(0xFFC8956E))), ); } // ---------------- 快速记录 ---------------- Future _quickCapture() async { final location = ref.read(noteListProvider).location; final targetLabel = location.scope == NoteScope.folder ? location.folderName : '未分类'; final content = await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (_) => _QuickCaptureSheet(targetLabel: targetLabel), ); if (content == null || content.trim().isEmpty) return; try { final api = ref.read(noteApiProvider); // 标题传空串,由后端取正文首行 —— 手机上多填一个标题会打断记录节奏 final created = await api.create(title: '', content: content, tags: ''); // 记到当前所在目录里;停在「全部」或「未分类」时保持未分类 final location = ref.read(noteListProvider).location; if (location.scope == NoteScope.folder && location.folderId != null) { await api.assignFolder(created.id, location.folderId); } await ref.read(noteListProvider.notifier).refresh(); ref.invalidate(noteTagsProvider); ref.invalidate(noteFolderTreeProvider); // 目录上的笔记数变了 if (mounted) showToast(context, '✅ 已记录'); } on ApiException catch (e) { if (mounted) showToast(context, e.message, error: true); } catch (_) { if (mounted) showToast(context, '保存失败,请重试', error: true); } } } /// 快速记录面板:全屏高度的纯文本输入,不填标题 class _QuickCaptureSheet extends StatefulWidget { const _QuickCaptureSheet({required this.targetLabel}); /// 保存到哪个目录,仅用于提示文案 final String targetLabel; @override State<_QuickCaptureSheet> createState() => _QuickCaptureSheetState(); } class _QuickCaptureSheetState extends State<_QuickCaptureSheet> { final _controller = TextEditingController(); @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; return Padding( padding: EdgeInsets.only(bottom: bottomInset), child: Container( height: MediaQuery.of(context).size.height * 0.75, decoration: const BoxDecoration( color: Colors.white, borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( children: [ const SizedBox(height: 10), Container( width: 40, height: 4, decoration: BoxDecoration( color: const Color(0xFFE0D6CC), borderRadius: BorderRadius.circular(2), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 12, 12, 4), child: Row( children: [ const Text( '快速记录', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), const Spacer(), TextButton( onPressed: () => Navigator.pop(context), child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), ), FilledButton( onPressed: () => Navigator.pop(context, _controller.text), style: FilledButton.styleFrom( backgroundColor: const Color(0xFFC8956E), padding: const EdgeInsets.symmetric(horizontal: 20), ), child: const Text('保存'), ), ], ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Align( alignment: Alignment.centerLeft, child: Text( '第一行会作为标题,支持 Markdown,保存到「${widget.targetLabel}」', style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)), ), ), ), Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(20, 10, 20, 16), child: TextField( controller: _controller, autofocus: true, maxLines: null, expands: true, textAlignVertical: TextAlignVertical.top, keyboardType: TextInputType.multiline, style: const TextStyle(fontSize: 15, height: 1.6, color: Color(0xFF3E2723)), decoration: const InputDecoration( hintText: '想到什么就写下来…', border: InputBorder.none, hintStyle: TextStyle(color: Color(0xFFD7CCC8)), ), ), ), ), ], ), ), ); } }