621 lines
21 KiB
Dart
621 lines
21 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import '../../constants/app_constants.dart';
|
|
import 'collect_notes.dart';
|
|
import '../../controllers/history_controller.dart';
|
|
import '../../services/network_listener_service.dart';
|
|
|
|
/// 时间: 2026-03-26
|
|
/// 功能: 本地笔记列表组件
|
|
/// 介绍: 展示用户笔记列表,支持置顶、锁定、删除等功能
|
|
/// 最新变化: 从 favorites_page.dart 独立出来,支持实时更新
|
|
|
|
class LocalNotesList extends StatefulWidget {
|
|
const LocalNotesList({super.key});
|
|
|
|
@override
|
|
State<LocalNotesList> createState() => _LocalNotesListState();
|
|
}
|
|
|
|
class _LocalNotesListState extends State<LocalNotesList> {
|
|
List<Map<String, dynamic>> _notes = [];
|
|
bool _isLoadingNotes = false;
|
|
StreamSubscription<NetworkEvent>? _networkSubscription;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadNotes();
|
|
_listenToNoteUpdates();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_networkSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _listenToNoteUpdates() {
|
|
_networkSubscription = NetworkListenerService().eventStream.listen((event) {
|
|
if (event.type == NetworkEventType.noteUpdate) {
|
|
_loadNotes();
|
|
}
|
|
});
|
|
}
|
|
|
|
// 加载笔记列表
|
|
Future<void> _loadNotes() async {
|
|
if (_isLoadingNotes) return;
|
|
|
|
setState(() {
|
|
_isLoadingNotes = true;
|
|
});
|
|
|
|
try {
|
|
final notes = await HistoryController.getNotes();
|
|
if (mounted) {
|
|
setState(() {
|
|
_notes = notes;
|
|
_isLoadingNotes = false;
|
|
});
|
|
}
|
|
} catch (e) {
|
|
print('加载笔记失败: $e');
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoadingNotes = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_isLoadingNotes && _notes.isEmpty) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (_notes.isEmpty) {
|
|
return _buildEmptyNotes();
|
|
}
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: _loadNotes,
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 80),
|
|
itemCount: _notes.length + 1,
|
|
itemBuilder: (context, index) {
|
|
if (index == _notes.length) {
|
|
return _buildBottomIndicator();
|
|
}
|
|
final note = _notes[index];
|
|
return _buildNoteCard(note);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBottomIndicator() {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Container(width: 40, height: 1, color: Colors.grey[300]),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Text(
|
|
'到底了',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
|
),
|
|
),
|
|
Container(width: 40, height: 1, color: Colors.grey[300]),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// 构建空笔记状态
|
|
Widget _buildEmptyNotes() {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.note_add_outlined, size: 64, color: Colors.grey[400]),
|
|
const SizedBox(height: 16),
|
|
Text('暂无笔记', style: TextStyle(fontSize: 16, color: Colors.grey[600])),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'点击右下角按钮创建新笔记',
|
|
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// 构建笔记卡片
|
|
Widget _buildNoteCard(Map<String, dynamic> note) {
|
|
final title = note['title'] as String? ?? '';
|
|
final content = note['content'] as String? ?? '';
|
|
final timeStr = note['time'] as String? ?? '';
|
|
final createTimeStr = note['createTime'] as String? ?? '';
|
|
final category = note['category'] as String? ?? '';
|
|
final isPinned = note['isPinned'] == true;
|
|
final isLocked = note['isLocked'] == true;
|
|
|
|
// 显示逻辑:有标题显示标题,无标题有分类显示分类,否则显示内容
|
|
String displayText;
|
|
bool hasTitle = title.isNotEmpty;
|
|
bool hasCategory = category.isNotEmpty && category != '未分类';
|
|
|
|
if (hasTitle) {
|
|
displayText = title;
|
|
} else if (hasCategory) {
|
|
displayText = '[$category]';
|
|
} else {
|
|
displayText = content;
|
|
}
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.08),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.04),
|
|
blurRadius: 6,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Stack(
|
|
children: [
|
|
// 原始内容
|
|
InkWell(
|
|
onTap: () => _handleNoteTap(note, isLocked),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 顶部:创建时间、保存时间和置顶/锁定按钮
|
|
Row(
|
|
children: [
|
|
// 创建时间
|
|
if (createTimeStr.isNotEmpty) ...[
|
|
Icon(
|
|
Icons.add_circle_outline,
|
|
size: 12,
|
|
color: Colors.grey[400],
|
|
),
|
|
const SizedBox(width: 2),
|
|
Text(
|
|
_formatDate(createTimeStr),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.grey[400],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
],
|
|
// 保存时间
|
|
Icon(
|
|
Icons.access_time,
|
|
size: 12,
|
|
color: Colors.grey[400],
|
|
),
|
|
const SizedBox(width: 2),
|
|
Text(
|
|
_formatDateTime(timeStr),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.grey[400],
|
|
),
|
|
),
|
|
const Spacer(),
|
|
// 分类标签
|
|
if (hasCategory)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 2,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppConstants.primaryColor.withValues(
|
|
alpha: 0.1,
|
|
),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
category,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: AppConstants.primaryColor,
|
|
),
|
|
),
|
|
),
|
|
if (hasCategory) const SizedBox(width: 8),
|
|
// 锁定图标
|
|
if (isLocked)
|
|
Container(
|
|
padding: const EdgeInsets.all(4),
|
|
child: Icon(
|
|
Icons.lock,
|
|
size: 16,
|
|
color: AppConstants.primaryColor,
|
|
),
|
|
),
|
|
// 置顶按钮
|
|
GestureDetector(
|
|
onTap: () => _togglePin(note['id'] as String?),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
child: Icon(
|
|
isPinned
|
|
? Icons.push_pin
|
|
: Icons.push_pin_outlined,
|
|
size: 16,
|
|
color: isPinned
|
|
? AppConstants.primaryColor
|
|
: Colors.grey[400],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
// 标题或内容
|
|
Text(
|
|
displayText,
|
|
style: TextStyle(
|
|
fontSize: hasTitle ? 16 : 14,
|
|
fontWeight: hasTitle
|
|
? FontWeight.w600
|
|
: FontWeight.normal,
|
|
color: Colors.black87,
|
|
height: 1.5,
|
|
),
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
// 底部:字数和删除按钮
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'${note['charCount'] ?? displayText.length} 字',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[400],
|
|
),
|
|
),
|
|
const Spacer(),
|
|
// 删除按钮
|
|
GestureDetector(
|
|
onTap: () =>
|
|
_showDeleteNoteDialog(note['id'] as String?),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
child: Icon(
|
|
Icons.delete_outline,
|
|
size: 18,
|
|
color: Colors.grey[400],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
// 锁定时的毛玻璃遮罩
|
|
if (isLocked)
|
|
Positioned.fill(
|
|
child: GestureDetector(
|
|
onTap: () => _handleNoteTap(note, isLocked),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
|
|
child: Container(
|
|
color: Colors.white.withValues(alpha: 0.3),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
// 顶部时间信息
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.access_time,
|
|
size: 12,
|
|
color: Colors.grey[600],
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
_formatDateTime(timeStr),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
const Spacer(),
|
|
// 删除按钮
|
|
GestureDetector(
|
|
onTap: () => _showDeleteNoteDialog(
|
|
note['id'] as String?,
|
|
),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
child: Icon(
|
|
Icons.delete_outline,
|
|
size: 16,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
// 中间锁定图标(左右结构)
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.lock,
|
|
size: 28,
|
|
color: AppConstants.primaryColor,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'已锁定',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: AppConstants.primaryColor,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'点击输入密码访问',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
// 底部字数
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'${note['charCount'] ?? displayText.length} 字',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 处理笔记点击
|
|
Future<void> _handleNoteTap(Map<String, dynamic> note, bool isLocked) async {
|
|
final noteId = note['id'] as String?;
|
|
if (noteId == null) return;
|
|
|
|
if (isLocked) {
|
|
// 显示密码输入对话框
|
|
final password = await _showPasswordInputDialog(noteId);
|
|
if (password == null) return;
|
|
|
|
// 验证密码
|
|
final isValid = await HistoryController.verifyNotePassword(
|
|
noteId,
|
|
password,
|
|
);
|
|
if (!isValid) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('密码错误')));
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 进入编辑页面
|
|
if (mounted) {
|
|
Navigator.of(context)
|
|
.push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => CollectNotesPage(noteId: noteId),
|
|
),
|
|
)
|
|
.then((_) {
|
|
_loadNotes();
|
|
});
|
|
}
|
|
}
|
|
|
|
// 显示密码输入对话框
|
|
Future<String?> _showPasswordInputDialog(String noteId) async {
|
|
final TextEditingController passwordController = TextEditingController();
|
|
|
|
return showDialog<String>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Row(
|
|
children: [
|
|
Icon(Icons.lock, size: 20),
|
|
SizedBox(width: 8),
|
|
Text('输入密码'),
|
|
],
|
|
),
|
|
content: TextField(
|
|
controller: passwordController,
|
|
obscureText: true,
|
|
autofocus: true,
|
|
decoration: const InputDecoration(
|
|
hintText: '请输入访问密码',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onSubmitted: (value) {
|
|
Navigator.of(context).pop(value);
|
|
},
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(null),
|
|
child: const Text('取消'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(passwordController.text),
|
|
child: const Text('确定'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// 切换置顶状态
|
|
Future<void> _togglePin(String? noteId) async {
|
|
if (noteId == null) return;
|
|
|
|
try {
|
|
await HistoryController.togglePinNote(noteId);
|
|
NetworkListenerService().sendSuccessEvent(
|
|
NetworkEventType.noteUpdate,
|
|
data: noteId,
|
|
);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('已更新置顶状态')));
|
|
}
|
|
} catch (e) {
|
|
print('切换置顶失败: $e');
|
|
}
|
|
}
|
|
|
|
// 格式化创建日期
|
|
String _formatDate(String timeStr) {
|
|
if (timeStr.isEmpty) return '';
|
|
try {
|
|
final dateTime = DateTime.parse(timeStr);
|
|
return '${dateTime.month}-${dateTime.day}';
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// 格式化日期时间
|
|
String _formatDateTime(String timeStr) {
|
|
if (timeStr.isEmpty) return '';
|
|
try {
|
|
final dateTime = DateTime.parse(timeStr);
|
|
final now = DateTime.now();
|
|
final difference = now.difference(dateTime);
|
|
|
|
if (difference.inMinutes < 1) {
|
|
return '刚刚';
|
|
} else if (difference.inMinutes < 60) {
|
|
return '${difference.inMinutes}分钟前';
|
|
} else if (difference.inHours < 24) {
|
|
return '${difference.inHours}小时前';
|
|
} else if (difference.inDays < 7) {
|
|
return '${difference.inDays}天前';
|
|
} else if (difference.inDays < 30) {
|
|
return '${(difference.inDays / 7).floor()}周前';
|
|
} else {
|
|
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}';
|
|
}
|
|
} catch (e) {
|
|
return timeStr;
|
|
}
|
|
}
|
|
|
|
// 显示删除笔记对话框
|
|
void _showDeleteNoteDialog(String? noteId) {
|
|
if (noteId == null) return;
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('确认删除'),
|
|
content: const Text('确定要删除这条笔记吗?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('取消'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.of(context).pop();
|
|
await _deleteNote(noteId);
|
|
},
|
|
child: const Text('删除', style: TextStyle(color: Colors.red)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// 删除笔记
|
|
Future<void> _deleteNote(String noteId) async {
|
|
try {
|
|
await HistoryController.deleteNote(noteId);
|
|
NetworkListenerService().sendSuccessEvent(
|
|
NetworkEventType.noteUpdate,
|
|
data: noteId,
|
|
);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('删除成功')));
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('删除失败: $e')));
|
|
}
|
|
}
|
|
}
|
|
}
|