Initial commit: Flutter 无书应用项目
This commit is contained in:
620
lib/views/footprint/local_jilu.dart
Normal file
620
lib/views/footprint/local_jilu.dart
Normal file
@@ -0,0 +1,620 @@
|
||||
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')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user