Files
Aq-Accounting-Flutter/lib/pages/note/note_list_page.dart
T

600 lines
20 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: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';
import '../../widgets/cute_icon.dart';
/// 笔记列表页
///
/// 列表数据在后端是分页返回的,这里用滚动到底部自动加载下一页,
/// 配合下拉刷新与 350ms 防抖的搜索框。
class NoteListPage extends ConsumerStatefulWidget {
const NoteListPage({super.key});
@override
ConsumerState<NoteListPage> createState() => _NoteListPageState();
}
class _NoteListPageState extends ConsumerState<NoteListPage> {
final _scrollController = ScrollController();
final _searchController = TextEditingController();
bool _searching = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
// 进页面就重取一次。
//
// 因为这个 provider 是 keepAlive 的(为的是从详情页返回时保住搜索词和
// 标签筛选),再次进入页面**不会**自动重新请求 —— 于是从 Web 后台导入的
// 笔记永远看不到。这里主动对一次账。
//
// 放在 addPostFrameCallback 里:initState 阶段还没法安全地改 provider 状态。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) ref.read(noteListProvider.notifier).refreshOnEnter();
});
}
@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<void> _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<void> _pickSort() async {
final current = ref.read(noteListProvider).sort;
final picked = await showModalBottomSheet<String>(
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 ? 'search' : 'note',
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 CuteIcon('pin', size: 14),
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<void> _quickCapture() async {
final location = ref.read(noteListProvider).location;
final targetLabel =
location.scope == NoteScope.folder ? location.folderName : '未分类';
final content = await showModalBottomSheet<String>(
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, '已记录', icon: 'check');
} 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)),
),
),
),
),
],
),
),
);
}
}