diff --git a/README.md b/README.md
index fb2cc49..1da6bf7 100644
--- a/README.md
+++ b/README.md
@@ -119,6 +119,7 @@ flutter run
**5. 图片文件存储路径:**
- 数据库:`/mooknote/mooknote.db`
+-
- 图片:`/mooknote/images/类别(影视/图书/笔记)/类别下的条目id/图片文件名`
**注意:**
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 5cfe280..7c14e19 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -57,6 +57,7 @@
+
diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart
index 9c251fc..ae4b60c 100644
--- a/lib/pages/home_page.dart
+++ b/lib/pages/home_page.dart
@@ -36,13 +36,16 @@ class _HomePageState extends State {
builder: (context, provider, child) {
final currentPage = provider.bottomNavIndex == 0 ? 0 : 1;
if (_pageController.hasClients && _pageController.page?.round() != currentPage) {
- _isSwitchingPage = true;
- _pageController.jumpToPage(currentPage);
- // 切换完成后重置标记,确保导航栏显示
- Future.delayed(const Duration(milliseconds: 300), () {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
- _isSwitchingPage = false;
- provider.setBottomNavVisible(true);
+ _isSwitchingPage = true;
+ _pageController.jumpToPage(currentPage);
+ Future.delayed(const Duration(milliseconds: 300), () {
+ if (mounted) {
+ _isSwitchingPage = false;
+ provider.setBottomNavVisible(true);
+ }
+ });
}
});
}
diff --git a/lib/pages/markdown_reader/md_reader_tab_page.dart b/lib/pages/markdown_reader/md_reader_tab_page.dart
index 6866127..91f967a 100644
--- a/lib/pages/markdown_reader/md_reader_tab_page.dart
+++ b/lib/pages/markdown_reader/md_reader_tab_page.dart
@@ -1,9 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart';
+import 'package:file_picker/file_picker.dart';
import 'package:path/path.dart' as p;
+import 'package:permission_handler/permission_handler.dart';
+import '../../utils/user_prefs.dart';
import 'md_viewer_page.dart';
-/// Markdown 阅读器 Tab 页 - 文件浏览器
+/// Markdown 阅读器 - 文件浏览器
class MdReaderTabPage extends StatefulWidget {
const MdReaderTabPage({super.key});
@@ -12,342 +15,543 @@ class MdReaderTabPage extends StatefulWidget {
}
class _MdReaderTabPageState extends State {
- static const String _basePath = '/storage/emulated/0/Documents/mooknote/markdown';
- String _currentPath = _basePath;
- List _items = [];
- bool _isLoading = true;
+ String? _rootPath;
+ String? _currentPath;
+ List<_FileEntry> _entries = [];
+ bool _isLoading = false;
String? _error;
+ bool _showEmptyDirs = true;
+ bool _showImageOnlyDirs = true;
+
@override
void initState() {
super.initState();
- _loadDirectory();
+ _loadSettings();
+ _init();
}
- /// 加载当前目录内容
- Future _loadDirectory() async {
- setState(() {
- _isLoading = true;
- _error = null;
- });
+ void _loadSettings() {
+ final prefs = UserPrefs();
+ _showEmptyDirs = prefs.showEmptyDirs;
+ _showImageOnlyDirs = prefs.showImageOnlyDirs;
+ }
- try {
- final dir = Directory(_currentPath);
- if (!await dir.exists()) {
- setState(() {
- _error = '目录不存在\n请将 Markdown 文件放到:\n$_basePath';
- _items = [];
- _isLoading = false;
- });
+ Future _init() async {
+ final saved = UserPrefs().lastMdFolder;
+ if (saved != null && saved.isNotEmpty) {
+ final dir = Directory(saved);
+ if (await dir.exists()) {
+ _rootPath = saved;
+ _currentPath = saved;
+ await _loadDirectory();
return;
}
-
- final entities = await dir.list().toList();
- // 排序:文件夹在前,文件在后,按名称排序
- entities.sort((a, b) {
- final aIsDir = a is Directory;
- final bIsDir = b is Directory;
- if (aIsDir != bIsDir) {
- return aIsDir ? -1 : 1;
- }
- return p.basename(a.path).toLowerCase().compareTo(
- p.basename(b.path).toLowerCase());
- });
-
- setState(() {
- _items = entities;
- _isLoading = false;
- });
- } catch (e) {
- setState(() {
- _error = '读取目录失败: $e';
- _items = [];
- _isLoading = false;
- });
}
+ if (mounted) setState(() {});
}
- /// 进入子目录
- void _enterDirectory(String path) {
- setState(() {
- _currentPath = path;
- });
- _loadDirectory();
+ /// 请求存储权限(Android 11+ 需要 MANAGE_EXTERNAL_STORAGE)
+ Future _requestStoragePermission() async {
+ if (!Platform.isAndroid) return true;
+
+ var status = await Permission.manageExternalStorage.status;
+ if (status.isGranted) return true;
+
+ status = await Permission.manageExternalStorage.request();
+ if (status.isGranted) return true;
+
+ status = await Permission.storage.status;
+ if (status.isGranted) return true;
+
+ status = await Permission.storage.request();
+ return status.isGranted;
}
- /// 返回上级目录
- void _goBack() {
- final parent = Directory(_currentPath).parent.path;
- if (parent == _currentPath) return;
- _enterDirectory(parent);
+ Future _pickDirectory() async {
+ final hasPermission = await _requestStoragePermission();
+ if (!hasPermission) {
+ if (mounted) {
+ _showPermissionDeniedDialog();
+ }
+ return;
+ }
+
+ final result = await FilePicker.platform.getDirectoryPath();
+ if (result == null) return;
+
+ UserPrefs().setLastMdFolder(result);
+ _rootPath = result;
+ _currentPath = result;
+ _error = null;
+ await _loadDirectory();
}
- /// 打开 Markdown 文件
- void _openFile(String path) {
- Navigator.push(
- context,
- MaterialPageRoute(
- builder: (context) => MdViewerPage(filePath: path),
+ void _showPermissionDeniedDialog() {
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: const Text('需要存储权限', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
+ content: const Text(
+ 'Android 11+ 需要在系统设置中授予"所有文件访问权限"才能读取目录中的 Markdown 文件。\n\n是否前往设置?',
+ style: TextStyle(fontSize: 14, color: Color(0xFF666666), height: 1.6),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('取消', style: TextStyle(color: Color(0xFF999999))),
+ ),
+ TextButton(
+ onPressed: () {
+ Navigator.pop(ctx);
+ openAppSettings();
+ },
+ child: const Text('前往设置', style: TextStyle(color: Color(0xFF1A1A1A))),
+ ),
+ ],
),
);
}
- /// 判断是否可以返回上级
- bool get _canGoBack => _currentPath != _basePath;
-
- /// 获取当前显示路径(相对路径)
- String get _displayPath {
- if (_currentPath == _basePath) return 'markdown';
- return _currentPath.substring(_basePath.length + 1);
+ /// 递归检查目录(含子目录)是否包含 Markdown 文件
+ bool _hasMarkdownFiles(String dirPath) {
+ try {
+ final dir = Directory(dirPath);
+ if (!dir.existsSync()) return false;
+ for (final entity in dir.listSync(recursive: true, followLinks: false)) {
+ if (entity is File) {
+ final lower = p.basename(entity.path).toLowerCase();
+ if (lower.endsWith('.md') || lower.endsWith('.markdown') || lower.endsWith('.mdown') || lower.endsWith('.txt')) {
+ return true;
+ }
+ }
+ }
+ } catch (_) {}
+ return false;
}
+ /// 递归检查目录是否只有图片文件(无 markdown、无非图片文件)
+ bool _isImageOnlyDir(String dirPath) {
+ try {
+ final dir = Directory(dirPath);
+ if (!dir.existsSync()) return false;
+ const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico', '.tiff', '.heic', '.webm'];
+ bool hasAnyFile = false;
+ for (final entity in dir.listSync(recursive: true, followLinks: false)) {
+ if (entity is File) {
+ hasAnyFile = true;
+ final lower = p.basename(entity.path).toLowerCase();
+ if (lower.endsWith('.md') || lower.endsWith('.markdown') || lower.endsWith('.mdown') || lower.endsWith('.txt')) {
+ return false;
+ }
+ if (!imageExts.any((ext) => lower.endsWith(ext))) {
+ return false;
+ }
+ }
+ }
+ return hasAnyFile;
+ } catch (_) {}
+ return false;
+ }
+
+ /// 判断目录是否应该被过滤掉
+ bool _shouldFilterDir(String dirPath) {
+ if (!_showEmptyDirs && !_hasMarkdownFiles(dirPath)) return true;
+ if (!_showImageOnlyDirs && _isImageOnlyDir(dirPath)) return true;
+ return false;
+ }
+
+ Future _loadDirectory() async {
+ if (_currentPath == null) return;
+ setState(() {
+ _isLoading = true;
+ _error = null;
+ _entries = [];
+ });
+
+ try {
+ final dir = Directory(_currentPath!);
+ final exists = await dir.exists();
+ if (!exists) {
+ if (mounted) {
+ setState(() {
+ _error = '目录不存在: $_currentPath';
+ _isLoading = false;
+ });
+ }
+ return;
+ }
+
+ final list = dir.listSync(recursive: false, followLinks: false);
+
+ final entries = <_FileEntry>[];
+ for (final entity in list) {
+ final name = p.basename(entity.path);
+ if (entity is Directory) {
+ if (!_shouldFilterDir(entity.path)) {
+ entries.add(_FileEntry(name: name, path: entity.path, isDir: true));
+ }
+ } else if (entity is File) {
+ final lower = name.toLowerCase();
+ if (lower.endsWith('.md') || lower.endsWith('.markdown') || lower.endsWith('.mdown') || lower.endsWith('.txt')) {
+ final stat = entity.statSync();
+ entries.add(_FileEntry(name: name, path: entity.path, isDir: false, size: stat.size));
+ }
+ }
+ }
+
+ entries.sort((a, b) {
+ if (a.isDir != b.isDir) return a.isDir ? -1 : 1;
+ return a.name.toLowerCase().compareTo(b.name.toLowerCase());
+ });
+
+ if (mounted) {
+ setState(() {
+ _entries = entries;
+ _isLoading = false;
+ });
+ }
+ } catch (e) {
+ if (mounted) {
+ setState(() {
+ _error = '读取失败: $e';
+ _isLoading = false;
+ });
+ }
+ }
+ }
+
+ void _enterDirectory(String path) {
+ _currentPath = path;
+ _loadDirectory();
+ }
+
+ void _goBack() {
+ if (_currentPath == null || _rootPath == null || _currentPath == _rootPath) return;
+ final parent = Directory(_currentPath!).parent.path;
+ if (parent == _currentPath!) return;
+ if (!parent.startsWith(_rootPath!)) return;
+ _currentPath = parent;
+ _loadDirectory();
+ }
+
+ void _openFile(_FileEntry entry) {
+ Navigator.push(context, MaterialPageRoute(
+ builder: (context) => MdViewerPage(filePath: entry.path),
+ ));
+ }
+
+ void _showSettingsSheet() {
+ showModalBottomSheet(
+ context: context,
+ backgroundColor: Colors.white,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
+ ),
+ builder: (ctx) {
+ return StatefulBuilder(
+ builder: (ctx, setLocalState) => Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 36, height: 4,
+ decoration: BoxDecoration(
+ color: const Color(0xFFDDDDDD),
+ borderRadius: BorderRadius.circular(2),
+ ),
+ ),
+ const SizedBox(height: 20),
+ const Align(
+ alignment: Alignment.centerLeft,
+ child: Text('目录显示设置', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
+ ),
+ const SizedBox(height: 16),
+ SwitchListTile(
+ contentPadding: EdgeInsets.zero,
+ title: const Text('显示空目录', style: TextStyle(fontSize: 14, color: Color(0xFF333333))),
+ subtitle: const Text('关闭后隐藏无 Markdown 文件的目录', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
+ value: _showEmptyDirs,
+ activeColor: const Color(0xFF1A1A1A),
+ onChanged: (val) {
+ setLocalState(() => _showEmptyDirs = val);
+ UserPrefs().setShowEmptyDirs(val);
+ setState(() {});
+ _loadDirectory();
+ },
+ ),
+ const Divider(height: 0.5, color: Color(0xFFF0F0F0)),
+ SwitchListTile(
+ contentPadding: EdgeInsets.zero,
+ title: const Text('显示纯图片目录', style: TextStyle(fontSize: 14, color: Color(0xFF333333))),
+ subtitle: const Text('关闭后隐藏只含图片、无 Markdown 的目录', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
+ value: _showImageOnlyDirs,
+ activeColor: const Color(0xFF1A1A1A),
+ onChanged: (val) {
+ setLocalState(() => _showImageOnlyDirs = val);
+ UserPrefs().setShowImageOnlyDirs(val);
+ setState(() {});
+ _loadDirectory();
+ },
+ ),
+ const SizedBox(height: 20),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ bool get _canGoBack => _currentPath != null && _rootPath != null && _currentPath != _rootPath;
+
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
- body: Column(
- children: [
- // 紧凑顶部栏
- _buildHeader(),
- // 内容区域
- Expanded(
- child: _buildBody(),
- ),
- ],
- ),
- );
- }
-
- /// 构建紧凑顶部栏
- Widget _buildHeader() {
- final topPadding = MediaQuery.of(context).padding.top;
- return Container(
- padding: EdgeInsets.only(top: topPadding, left: 12, right: 12, bottom: 8),
- decoration: const BoxDecoration(
- color: Colors.white,
- border: Border(
- bottom: BorderSide(color: Color(0xFFF0F0F0), width: 0.5),
+ appBar: AppBar(
+ backgroundColor: Colors.white,
+ elevation: 0,
+ title: Text(
+ _currentPath != null ? p.basename(_currentPath!) : 'Markdown 阅读',
+ style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)),
),
- ),
- child: Row(
- children: [
- // 返回按钮
+ leading: IconButton(
+ icon: const Icon(Icons.arrow_back, color: Color(0xFF1A1A1A)),
+ onPressed: () => Navigator.pop(context),
+ ),
+ actions: [
if (_canGoBack)
- IconButton(
- icon: const Icon(Icons.arrow_back, size: 20),
- padding: EdgeInsets.zero,
- constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
- onPressed: _goBack,
- )
- else
- const SizedBox(width: 8),
- // 路径标题
- Expanded(
- child: Text(
- _canGoBack ? _displayPath : '文件列表',
- style: const TextStyle(
- fontSize: 15,
- fontWeight: FontWeight.w500,
- color: Color(0xFF1A1A1A),
+ Padding(
+ padding: const EdgeInsets.only(right: 8),
+ child: GestureDetector(
+ onTap: _goBack,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
+ decoration: BoxDecoration(
+ color: const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
+ ),
+ child: const Text('返回上级', style: TextStyle(fontSize: 12, color: Color(0xFF666666))),
+ ),
),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
),
+ if (_currentPath != null)
+ Padding(
+ padding: const EdgeInsets.only(right: 4),
+ child: GestureDetector(
+ onTap: _pickDirectory,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
+ decoration: BoxDecoration(
+ color: const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
+ ),
+ child: const Text('更换目录', style: TextStyle(fontSize: 12, color: Color(0xFF888888))),
+ ),
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.tune, size: 20, color: Color(0xFF888888)),
+ onPressed: _showSettingsSheet,
),
+ const SizedBox(width: 4),
],
),
+ body: _buildBody(),
);
}
Widget _buildBody() {
+ if (_currentPath == null) {
+ return _buildWelcome();
+ }
+
if (_isLoading) {
return const Center(child: CircularProgressIndicator(color: Color(0xFF1A1A1A)));
}
if (_error != null) {
- return _buildErrorState();
- }
-
- if (_items.isEmpty) {
- return _buildEmptyState();
+ return _buildError();
}
return RefreshIndicator(
onRefresh: _loadDirectory,
color: const Color(0xFF1A1A1A),
backgroundColor: Colors.white,
- child: ListView.builder(
+ child: _entries.isEmpty ? _buildEmpty() : ListView.separated(
padding: EdgeInsets.zero,
- itemCount: _items.length,
+ itemCount: (_canGoBack ? 1 : 0) + _entries.length,
+ separatorBuilder: (_, __) => const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFF0F0F0)),
itemBuilder: (context, index) {
- final item = _items[index];
- final isDirectory = item is Directory;
- final name = p.basename(item.path);
- final isMdFile = !isDirectory && name.toLowerCase().endsWith('.md');
-
- // 跳过非 md 文件和非目录项
- if (!isDirectory && !isMdFile) {
- return const SizedBox.shrink();
+ if (_canGoBack && index == 0) {
+ return ListTile(
+ leading: Container(
+ width: 36, height: 36,
+ decoration: BoxDecoration(
+ color: const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: const Icon(Icons.arrow_upward, size: 18, color: Color(0xFF888888)),
+ ),
+ title: const Text('..', style: TextStyle(fontSize: 14, color: Color(0xFF888888))),
+ onTap: _goBack,
+ );
}
-
- return _buildListItem(item, isDirectory, name);
+ final entry = _entries[_canGoBack ? index - 1 : index];
+ return ListTile(
+ leading: Container(
+ width: 36, height: 36,
+ decoration: BoxDecoration(
+ color: entry.isDir ? const Color(0xFFF0F7FF) : const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Icon(
+ entry.isDir ? Icons.folder_outlined : Icons.description_outlined,
+ size: 18,
+ color: entry.isDir ? const Color(0xFF4A90D9) : const Color(0xFF666666),
+ ),
+ ),
+ title: Text(entry.name, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), maxLines: 1, overflow: TextOverflow.ellipsis),
+ subtitle: entry.isDir ? null : Text(_formatSize(entry.size), style: const TextStyle(fontSize: 11, color: Color(0xFF999999))),
+ trailing: Icon(entry.isDir ? Icons.chevron_right : Icons.open_in_new_outlined, size: 16, color: const Color(0xFFCCCCCC)),
+ onTap: () => entry.isDir ? _enterDirectory(entry.path) : _openFile(entry),
+ );
},
),
);
}
- Widget _buildListItem(FileSystemEntity item, bool isDirectory, String name) {
- return InkWell(
- onTap: () {
- if (isDirectory) {
- _enterDirectory(item.path);
- } else {
- _openFile(item.path);
- }
- },
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
- decoration: const BoxDecoration(
- border: Border(
- bottom: BorderSide(color: Color(0xFFF0F0F0), width: 0.5),
- ),
- ),
- child: Row(
+ Widget _buildWelcome() {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(40),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
children: [
Container(
- width: 36,
- height: 36,
+ width: 80, height: 80,
decoration: BoxDecoration(
- color: isDirectory ? const Color(0xFFF0F7FF) : const Color(0xFFF5F5F5),
- borderRadius: BorderRadius.circular(8),
+ color: const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(20),
),
- child: Icon(
- isDirectory ? Icons.folder_outlined : Icons.description_outlined,
- color: isDirectory ? const Color(0xFF4A90D9) : const Color(0xFF666666),
- size: 18,
+ child: const Icon(Icons.folder_open_outlined, size: 40, color: Color(0xFFCCCCCC)),
+ ),
+ const SizedBox(height: 24),
+ const Text('Markdown 阅读', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
+ const SizedBox(height: 8),
+ const Text('选择一个包含 .md 文件的文件夹', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
+ const SizedBox(height: 32),
+ GestureDetector(
+ onTap: _pickDirectory,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
+ decoration: BoxDecoration(
+ color: const Color(0xFF1A1A1A),
+ borderRadius: BorderRadius.circular(24),
+ ),
+ child: const Text('选择目录', style: TextStyle(fontSize: 15, color: Colors.white, fontWeight: FontWeight.w500)),
),
),
- const SizedBox(width: 10),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- name,
- style: const TextStyle(
- fontSize: 14,
- color: Color(0xFF1A1A1A),
- ),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- ),
- if (!isDirectory)
- Text(
- _formatFileSize(item),
- style: const TextStyle(
- fontSize: 11,
- color: Color(0xFF999999),
- ),
- ),
- ],
- ),
- ),
- if (isDirectory)
- const Icon(Icons.chevron_right, color: Color(0xFFCCCCCC), size: 18)
- else
- const Icon(Icons.open_in_new_outlined, color: Color(0xFFCCCCCC), size: 16),
],
),
),
);
}
- /// 格式化文件大小
- String _formatFileSize(FileSystemEntity entity) {
- try {
- if (entity is File) {
- final stat = entity.statSync();
- final size = stat.size;
- if (size < 1024) return '$size B';
- if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)} KB';
- return '${(size / (1024 * 1024)).toStringAsFixed(1)} MB';
- }
- } catch (_) {}
- return '';
- }
-
- Widget _buildEmptyState() {
- return const Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(
- Icons.folder_open_outlined,
- size: 64,
- color: Color(0xFFE0E0E0),
- ),
- SizedBox(height: 20),
- Text(
- '暂无 Markdown 文件',
- style: TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.w500,
- color: Color(0xFF999999),
- ),
- ),
- SizedBox(height: 8),
- Padding(
- padding: EdgeInsets.symmetric(horizontal: 40),
- child: Text(
- '请在 /Documents/mooknote/markdown 目录下放置 .md 文件',
- style: TextStyle(
- fontSize: 13,
- color: Color(0xFFCCCCCC),
- ),
- textAlign: TextAlign.center,
- ),
- ),
- ],
- ),
- );
- }
-
- Widget _buildErrorState() {
+ Widget _buildEmpty() {
return Center(
child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
children: [
- const Icon(
- Icons.error_outline,
- size: 48,
- color: Color(0xFFCCCCCC),
- ),
+ const Icon(Icons.folder_open_outlined, size: 64, color: Color(0xFFE0E0E0)),
const SizedBox(height: 16),
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: 40),
- child: Text(
- _error!,
- style: const TextStyle(
- fontSize: 14,
- color: Color(0xFF999999),
- ),
- textAlign: TextAlign.center,
- ),
- ),
+ const Text('此目录下没有 Markdown 文件', style: TextStyle(fontSize: 15, color: Color(0xFF999999))),
+ const SizedBox(height: 4),
+ Text(_currentPath ?? '', style: const TextStyle(fontSize: 12, color: Color(0xFFCCCCCC))),
const SizedBox(height: 24),
- ElevatedButton(
- onPressed: _loadDirectory,
- style: ElevatedButton.styleFrom(
- backgroundColor: const Color(0xFF1A1A1A),
- foregroundColor: Colors.white,
- elevation: 0,
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
- padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
+ GestureDetector(
+ onTap: _canGoBack ? _goBack : () => _pickDirectory(),
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
+ decoration: BoxDecoration(
+ border: Border.all(color: const Color(0xFFDDDDDD)),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Text(
+ _canGoBack ? '返回上级目录' : '换一个目录',
+ style: const TextStyle(fontSize: 13, color: Color(0xFF888888)),
+ ),
),
- child: const Text('重试'),
),
],
),
);
}
-}
\ No newline at end of file
+
+ Widget _buildError() {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(40),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.error_outline, size: 48, color: Color(0xFFCCCCCC)),
+ const SizedBox(height: 16),
+ Text(_error!, style: const TextStyle(fontSize: 14, color: Color(0xFF999999)), textAlign: TextAlign.center),
+ const SizedBox(height: 8),
+ Text('路径: ${_currentPath ?? ""}', style: const TextStyle(fontSize: 12, color: Color(0xFFCCCCCC))),
+ const SizedBox(height: 24),
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ GestureDetector(
+ onTap: _loadDirectory,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
+ decoration: BoxDecoration(
+ color: const Color(0xFF1A1A1A),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: const Text('重试', style: TextStyle(fontSize: 13, color: Colors.white)),
+ ),
+ ),
+ const SizedBox(width: 12),
+ GestureDetector(
+ onTap: _pickDirectory,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
+ decoration: BoxDecoration(
+ border: Border.all(color: const Color(0xFFDDDDDD)),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: const Text('更换目录', style: TextStyle(fontSize: 13, color: Color(0xFF888888))),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ String _formatSize(int? bytes) {
+ if (bytes == null) return '';
+ if (bytes < 1024) return '$bytes B';
+ if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
+ return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
+ }
+}
+
+class _FileEntry {
+ final String name;
+ final String path;
+ final bool isDir;
+ final int? size;
+
+ _FileEntry({required this.name, required this.path, required this.isDir, this.size});
+}
diff --git a/lib/pages/markdown_reader/md_viewer_page.dart b/lib/pages/markdown_reader/md_viewer_page.dart
index 205a179..0a26e04 100644
--- a/lib/pages/markdown_reader/md_viewer_page.dart
+++ b/lib/pages/markdown_reader/md_viewer_page.dart
@@ -1,6 +1,6 @@
import 'dart:io';
import 'package:flutter/material.dart';
-import 'package:flutter_markdown/flutter_markdown.dart';
+import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
/// Markdown 文件查看页面
class MdViewerPage extends StatefulWidget {
@@ -132,7 +132,8 @@ class _MdViewerPageState extends State {
decoration: TextDecoration.underline,
),
),
- sizedImageBuilder: (config) => _buildImage(config.uri.toString(), config.alt),
+ // ignore: deprecated_member_use
+ imageBuilder: (uri, title, alt) => _buildImage(uri.toString(), alt),
);
}
diff --git a/lib/pages/note/note_form_page.dart b/lib/pages/note/note_form_page.dart
index 6c783bc..bc40f48 100644
--- a/lib/pages/note/note_form_page.dart
+++ b/lib/pages/note/note_form_page.dart
@@ -542,26 +542,30 @@ class _NoteFormPageState extends State {
/// 显示添加标签对话框
void _showAddTagDialog() {
final controller = TextEditingController();
-
+
// 获取所有已有标签(从所有笔记中收集)
final provider = context.read();
final allTags = _getAllExistingTags(provider);
// 过滤掉已添加的标签
final availableTags = allTags.where((tag) => !_tags.contains(tag)).toList();
-
+
showDialog(
context: context,
- builder: (context) => AlertDialog(
+ builder: (ctx) => StatefulBuilder(
+ builder: (ctx, setDialogState) => AlertDialog(
backgroundColor: Colors.white,
elevation: 0,
- shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
+ titlePadding: const EdgeInsets.fromLTRB(24, 24, 24, 0),
title: const Text(
'添加标签',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
+ color: Color(0xFF1A1A1A),
),
),
+ contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
content: SizedBox(
width: double.maxFinite,
child: Column(
@@ -572,100 +576,126 @@ class _NoteFormPageState extends State {
TextField(
controller: controller,
autofocus: true,
+ style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
+ cursorColor: const Color(0xFF1A1A1A),
decoration: InputDecoration(
hintText: '输入新标签名称',
- hintStyle: const TextStyle(
- fontSize: 14,
- color: Color(0xFF999999),
- ),
+ hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFBBBBBB)),
filled: true,
- fillColor: const Color(0xFFFAFAFA),
+ fillColor: const Color(0xFFF8F8F8),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(8),
- borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
+ borderRadius: BorderRadius.circular(10),
+ borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
- borderRadius: BorderRadius.circular(8),
- borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
+ borderRadius: BorderRadius.circular(10),
+ borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
- borderRadius: BorderRadius.circular(8),
+ borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFF1A1A1A), width: 1),
),
- contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+ suffixIcon: controller.text.isNotEmpty
+ ? IconButton(
+ icon: const Icon(Icons.clear, size: 16, color: Color(0xFFAAAAAA)),
+ onPressed: () => controller.clear(),
+ )
+ : null,
),
+ onChanged: (_) => setDialogState(() {}),
onSubmitted: (value) {
_addTag(value);
- Navigator.pop(context);
+ controller.clear();
+ setDialogState(() {});
},
),
-
+
// 已有标签列表
if (availableTags.isNotEmpty) ...[
const SizedBox(height: 20),
const Text(
- '或选择已有标签:',
+ '或选择已有标签',
style: TextStyle(
- fontSize: 13,
- color: Color(0xFF999999),
+ fontSize: 12,
+ color: Color(0xFFAAAAAA),
),
),
const SizedBox(height: 12),
- SizedBox(
- height: 200,
- child: ListView(
- shrinkWrap: true,
- children: availableTags.map((tag) {
- return InkWell(
- onTap: () {
- _addTag(tag);
- Navigator.pop(context);
- },
- child: Padding(
- padding: const EdgeInsets.symmetric(vertical: 6),
- child: Text(
- tag,
- style: const TextStyle(
- fontSize: 14,
- color: Color(0xFF555555),
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxHeight: 180),
+ child: SingleChildScrollView(
+ child: Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: availableTags.map((tag) {
+ return InkWell(
+ onTap: () {
+ _addTag(tag);
+ Navigator.pop(ctx);
+ },
+ borderRadius: BorderRadius.circular(16),
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ color: const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
+ ),
+ child: Text(
+ tag,
+ style: const TextStyle(
+ fontSize: 13,
+ color: Color(0xFF555555),
+ ),
),
),
- ),
- );
- }).toList(),
+ );
+ }).toList(),
+ ),
),
),
],
+
+ if (availableTags.isEmpty)
+ Padding(
+ padding: const EdgeInsets.only(top: 16, bottom: 8),
+ child: Center(
+ child: Text(
+ '暂无已有标签',
+ style: TextStyle(fontSize: 13, color: Colors.grey[400]),
+ ),
+ ),
+ ),
],
),
),
actions: [
TextButton(
- onPressed: () => Navigator.pop(context),
+ onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(
- foregroundColor: const Color(0xFF666666),
+ foregroundColor: const Color(0xFF999999),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
- child: const Text('取消'),
+ child: const Text('取消', style: TextStyle(fontSize: 14)),
),
ElevatedButton(
onPressed: () {
_addTag(controller.text);
- Navigator.pop(context);
+ Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A1A1A),
foregroundColor: Colors.white,
elevation: 0,
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(8),
- ),
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
),
- child: const Text('添加'),
+ child: const Text('添加', style: TextStyle(fontSize: 14)),
),
],
- actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
+ ),
),
);
}
diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart
index 61c936e..0ca45bf 100644
--- a/lib/pages/search_page.dart
+++ b/lib/pages/search_page.dart
@@ -8,7 +8,7 @@ import 'movies/movie_detail_page.dart';
import 'book/book_detail_page.dart';
import 'note/note_detail_page.dart';
-/// 搜索页面
+/// 搜索页面 - 统一搜索影视/书籍/笔记,标签区分
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@@ -18,180 +18,98 @@ class SearchPage extends StatefulWidget {
class _SearchPageState extends State {
final _searchController = TextEditingController();
- int _selectedType = 0; // 0: 影视, 1: 书籍, 2: 笔记
- List _results = [];
- bool _isSearching = false;
- String? _selectedTag; // 选中的标签
+ final _focusNode = FocusNode();
- final List _typeLabels = ['影视', '书籍', '笔记'];
+ // 类型筛选:默认全部选中
+ bool _showMovies = true;
+ bool _showBooks = true;
+ bool _showNotes = true;
+
+ String? _selectedTag;
+
+ List<_SearchResult> _results = [];
+ bool _isSearching = false;
+
+ @override
+ void initState() {
+ super.initState();
+ // 自动聚焦,弹出键盘
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ _focusNode.requestFocus();
+ });
+ }
@override
void dispose() {
_searchController.dispose();
+ _focusNode.dispose();
super.dispose();
}
- Future _performSearch() async {
+ void _performSearch() {
final keyword = _searchController.text.trim();
- // 笔记搜索允许空关键词(用于标签筛选)
- if (keyword.isEmpty && _selectedType != 2 && _selectedTag == null) return;
+ if (keyword.isEmpty && _selectedTag == null) {
+ setState(() => _results = []);
+ return;
+ }
setState(() => _isSearching = true);
- try {
- List results;
- final provider = context.read();
+ final provider = context.read();
+ final lowerKeyword = keyword.toLowerCase();
+ final results = <_SearchResult>[];
- switch (_selectedType) {
- case 0: // 影视
- results = provider.movies.where((movie) {
- return _matchMovie(movie, keyword);
- }).toList();
- break;
- case 1: // 书籍
- results = provider.books.where((book) {
- return _matchBook(book, keyword);
- }).toList();
- break;
- case 2: // 笔记
- results = provider.notes.where((note) {
- return _matchNote(note, keyword);
- }).toList();
- break;
- default:
- results = [];
+ // 影视
+ if (_showMovies) {
+ for (final movie in provider.movies.where((m) => !m.isDeleted)) {
+ if (_matchMovie(movie, lowerKeyword)) {
+ results.add(_SearchResult(type: 'movie', data: movie));
+ }
}
-
- setState(() {
- _results = results;
- _isSearching = false;
- });
- } catch (e) {
- setState(() => _isSearching = false);
- ToastUtil.show(context, '搜索失败: $e');
}
+
+ // 书籍
+ if (_showBooks) {
+ for (final book in provider.books.where((b) => !b.isDeleted)) {
+ if (_matchBook(book, lowerKeyword)) {
+ results.add(_SearchResult(type: 'book', data: book));
+ }
+ }
+ }
+
+ // 笔记
+ if (_showNotes) {
+ for (final note in provider.notes.where((n) => !n.isDeleted)) {
+ if (_matchNote(note, lowerKeyword)) {
+ results.add(_SearchResult(type: 'note', data: note));
+ }
+ }
+ }
+
+ setState(() {
+ _results = results;
+ _isSearching = false;
+ });
}
- bool _matchMovie(Movie movie, String keyword) {
- final lowerKeyword = keyword.toLowerCase();
+ bool _matchMovie(Movie movie, String lowerKeyword) {
+ if (lowerKeyword.isEmpty) return true;
return movie.title.toLowerCase().contains(lowerKeyword) ||
movie.alternateTitles.any((t) => t.toLowerCase().contains(lowerKeyword)) ||
(movie.summary?.toLowerCase().contains(lowerKeyword) ?? false);
}
- bool _matchBook(Book book, String keyword) {
- final lowerKeyword = keyword.toLowerCase();
+ bool _matchBook(Book book, String lowerKeyword) {
+ if (lowerKeyword.isEmpty) return true;
return book.title.toLowerCase().contains(lowerKeyword) ||
book.alternateTitles.any((t) => t.toLowerCase().contains(lowerKeyword)) ||
(book.summary?.toLowerCase().contains(lowerKeyword) ?? false);
}
- bool _matchNote(Note note, String keyword) {
- // 搜索框为空且不选标签时,不显示任何笔记
- if (keyword.isEmpty && _selectedTag == null) {
- return false;
- }
- // 如果有选中的标签,先按标签筛选
- if (_selectedTag != null) {
- if (!note.tags.contains(_selectedTag)) {
- return false;
- }
- // 有标签但关键词为空时,只按标签筛选
- if (keyword.isEmpty) {
- return true;
- }
- }
- // 按内容搜索
- return note.content.toLowerCase().contains(keyword.toLowerCase());
- }
-
- /// 构建标签筛选区域
- Widget _buildTagFilter() {
- return Consumer(
- builder: (context, provider, child) {
- final allTags = {};
- for (final note in provider.notes.where((n) => !n.isDeleted)) {
- allTags.addAll(note.tags);
- }
-
- if (allTags.isEmpty) {
- return const SizedBox.shrink();
- }
-
- final tags = allTags.toList()..sort();
-
- return Container(
- padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const Padding(
- padding: EdgeInsets.only(bottom: 12),
- child: Row(
- children: [
- Icon(
- Icons.label_outline,
- size: 16,
- color: Color(0xFF999999),
- ),
- SizedBox(width: 6),
- Text(
- '按标签筛选',
- style: TextStyle(
- fontSize: 13,
- color: Color(0xFF999999),
- ),
- ),
- ],
- ),
- ),
- Wrap(
- spacing: 10,
- runSpacing: 10,
- alignment: WrapAlignment.start,
- children: tags.map((tag) => GestureDetector(
- onTap: () {
- setState(() {
- if (_selectedTag == tag) {
- _selectedTag = null;
- } else {
- _selectedTag = tag;
- }
- _performSearch();
- });
- },
- child: Container(
- padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
- decoration: BoxDecoration(
- color: _selectedTag == tag
- ? const Color(0xFF1A1A1A)
- : const Color(0xFFFAFAFA),
- borderRadius: BorderRadius.circular(8),
- border: Border.all(
- color: _selectedTag == tag
- ? const Color(0xFF1A1A1A)
- : const Color(0xFFE8E8E8),
- width: 0.5,
- ),
- ),
- child: Text(
- tag,
- style: TextStyle(
- fontSize: 13,
- fontWeight: _selectedTag == tag ? FontWeight.w600 : FontWeight.w500,
- color: _selectedTag == tag
- ? Colors.white
- : const Color(0xFF666666),
- ),
- ),
- ),
- )).toList(),
- ),
- ],
- ),
- );
- },
- );
+ bool _matchNote(Note note, String lowerKeyword) {
+ if (_selectedTag != null && !note.tags.contains(_selectedTag)) return false;
+ if (lowerKeyword.isEmpty) return _selectedTag != null;
+ return note.content.toLowerCase().contains(lowerKeyword);
}
@override
@@ -200,208 +118,242 @@ class _SearchPageState extends State {
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('搜索'),
- actions: [
- IconButton(
- icon: const Icon(Icons.search),
- onPressed: _performSearch,
- ),
- const SizedBox(width: 8),
- ],
+ elevation: 0,
),
body: Column(
children: [
- // 搜索类型选择
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
- decoration: const BoxDecoration(
- border: Border(
- bottom: BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
- ),
- ),
- child: Row(
- children: List.generate(_typeLabels.length, (index) {
- final isSelected = _selectedType == index;
- return GestureDetector(
- onTap: () {
- setState(() {
- _selectedType = index;
- _results = [];
- _selectedTag = null;
- });
- _searchController.clear();
- },
- child: Container(
- margin: const EdgeInsets.only(right: 12),
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
- decoration: BoxDecoration(
- color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFFAFAFA),
- borderRadius: BorderRadius.circular(8),
- border: Border.all(
- color: isSelected ? const Color(0xFF1A1A1A) : const Color(0xFFE8E8E8),
- width: 0.5,
- ),
- ),
- child: Text(
- _typeLabels[index],
- style: TextStyle(
- fontSize: 14,
- fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
- color: isSelected ? Colors.white : const Color(0xFF666666),
- ),
- ),
- ),
- );
- }),
- ),
- ),
-
// 搜索输入框
- Container(
- padding: const EdgeInsets.all(20),
- child: TextField(
- controller: _searchController,
- autofocus: true,
- decoration: InputDecoration(
- hintText: _getSearchHint(),
- hintStyle: const TextStyle(
- color: Color(0xFF999999),
- fontSize: 14,
- ),
- prefixIcon: Container(
- margin: const EdgeInsets.all(12),
- decoration: BoxDecoration(
- color: const Color(0xFFFAFAFA),
- borderRadius: BorderRadius.circular(8),
- ),
- child: const Icon(Icons.search, color: Color(0xFF666666), size: 20),
- ),
- prefixIconConstraints: const BoxConstraints(
- minWidth: 48,
- minHeight: 48,
- ),
- suffixIcon: _searchController.text.isNotEmpty
- ? IconButton(
- icon: const Icon(Icons.clear, color: Color(0xFF999999)),
- onPressed: () {
- _searchController.clear();
- setState(() => _results = []);
- },
- )
- : null,
- filled: true,
- fillColor: const Color(0xFFFAFAFA),
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(12),
- borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
- ),
- enabledBorder: OutlineInputBorder(
- borderRadius: BorderRadius.circular(12),
- borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
- ),
- focusedBorder: OutlineInputBorder(
- borderRadius: BorderRadius.circular(12),
- borderSide: const BorderSide(color: Color(0xFF1A1A1A), width: 1),
- ),
- contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
- ),
- onSubmitted: (_) => _performSearch(),
- onChanged: (_) => setState(() {}),
- ),
- ),
+ _buildSearchBar(),
- // 笔记标签筛选(仅在笔记搜索时显示)
- if (_selectedType == 2) _buildTagFilter(),
+ // 类型筛选 & 标签筛选
+ _buildFilterRow(),
- // 搜索结果
+ // 结果
Expanded(
child: _isSearching
- ? const Center(child: CircularProgressIndicator())
- : _results.isEmpty
- ? _buildEmptyState()
- : _buildResultList(),
+ ? const Center(child: CircularProgressIndicator(color: Color(0xFF1A1A1A)))
+ : _results.isEmpty && _searchController.text.isEmpty && _selectedTag == null
+ ? _buildInitialState()
+ : _results.isEmpty
+ ? _buildEmptyState()
+ : _buildResultList(),
),
],
),
);
}
- String _getSearchHint() {
- switch (_selectedType) {
- case 0:
- return '搜索影视名称、别名、简介...';
- case 1:
- return '搜索书籍名称、别名、简介...';
- case 2:
- return '搜索笔记内容...';
- default:
- return '请输入搜索关键词';
- }
+ Widget _buildSearchBar() {
+ return Container(
+ padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
+ child: TextField(
+ controller: _searchController,
+ focusNode: _focusNode,
+ decoration: InputDecoration(
+ hintText: '搜索影视、书籍、笔记...',
+ hintStyle: const TextStyle(color: Color(0xFF999999), fontSize: 14),
+ prefixIcon: const Icon(Icons.search, color: Color(0xFF666666), size: 20),
+ suffixIcon: _searchController.text.isNotEmpty
+ ? IconButton(
+ icon: const Icon(Icons.clear, color: Color(0xFF999999), size: 20),
+ onPressed: () {
+ _searchController.clear();
+ _performSearch();
+ },
+ )
+ : null,
+ filled: true,
+ fillColor: const Color(0xFFFAFAFA),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(color: Color(0xFF1A1A1A), width: 1),
+ ),
+ contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
+ ),
+ onSubmitted: (_) => _performSearch(),
+ onChanged: (_) {
+ _performSearch();
+ },
+ ),
+ );
}
- Widget _buildEmptyState() {
- if (_searchController.text.isEmpty && _selectedTag == null) {
- return Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
+ Widget _buildFilterRow() {
+ return Consumer(
+ builder: (context, provider, child) {
+ // 收集所有笔记标签
+ final allTags = {};
+ for (final note in provider.notes.where((n) => !n.isDeleted)) {
+ allTags.addAll(note.tags);
+ }
+ final tags = allTags.toList()..sort();
+
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // 类型筛选行
+ Row(
+ children: [
+ const Text('类型', style: TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))),
+ const SizedBox(width: 10),
+ _buildTypeChip('影视', _showMovies, (v) {
+ setState(() { _showMovies = v; _performSearch(); });
+ }),
+ const SizedBox(width: 8),
+ _buildTypeChip('书籍', _showBooks, (v) {
+ setState(() { _showBooks = v; _performSearch(); });
+ }),
+ const SizedBox(width: 8),
+ _buildTypeChip('笔记', _showNotes, (v) {
+ setState(() { _showNotes = v; _performSearch(); });
+ }),
+ ],
+ ),
+
+ // 笔记标签筛选
+ if (tags.isNotEmpty && _showNotes) ...[
+ const SizedBox(height: 10),
+ SizedBox(
+ height: 32,
+ child: ListView.separated(
+ scrollDirection: Axis.horizontal,
+ itemCount: tags.length + (_selectedTag != null ? 1 : 0),
+ separatorBuilder: (_, __) => const SizedBox(width: 8),
+ itemBuilder: (context, index) {
+ // 第一个始终是"全部标签"清除按钮
+ if (_selectedTag != null && index == 0) {
+ return _buildTagChip('全部标签', true, () {
+ setState(() { _selectedTag = null; _performSearch(); });
+ });
+ }
+ final tagIndex = _selectedTag != null ? index - 1 : index;
+ final tag = tags[tagIndex];
+ final isSelected = _selectedTag == tag;
+ return _buildTagChip(tag, isSelected, () {
+ setState(() {
+ _selectedTag = isSelected ? null : tag;
+ _performSearch();
+ });
+ });
+ },
+ ),
+ ),
+ ],
+ ],
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _buildTypeChip(String label, bool selected, ValueChanged onChanged) {
+ return GestureDetector(
+ onTap: () => onChanged(!selected),
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
+ decoration: BoxDecoration(
+ color: selected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(
+ color: selected ? const Color(0xFF1A1A1A) : const Color(0xFFE8E8E8),
+ width: 0.5,
+ ),
+ ),
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
+ color: selected ? Colors.white : const Color(0xFF888888),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildTagChip(String label, bool selected, VoidCallback onTap) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+ decoration: BoxDecoration(
+ color: selected ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(
+ color: selected ? const Color(0xFF1A1A1A) : const Color(0xFFE8E8E8),
+ width: 0.5,
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
children: [
- Container(
- width: 80,
- height: 80,
- decoration: BoxDecoration(
- color: const Color(0xFFF5F5F5),
- borderRadius: BorderRadius.circular(20),
- ),
- child: const Icon(
- Icons.search,
- size: 40,
- color: Color(0xFFCCCCCC),
- ),
- ),
- const SizedBox(height: 20),
- const Text(
- '输入关键词开始搜索',
+ if (selected)
+ const Icon(Icons.close, size: 12, color: Colors.white70),
+ if (selected) const SizedBox(width: 4),
+ Text(
+ label,
style: TextStyle(
- fontSize: 15,
- color: Color(0xFF999999),
+ fontSize: 12,
+ fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
+ color: selected ? Colors.white : const Color(0xFF888888),
),
),
],
),
- );
- }
+ ),
+ );
+ }
+
+ Widget _buildInitialState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
- width: 80,
- height: 80,
+ width: 80, height: 80,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(20),
),
- child: const Icon(
- Icons.search_off,
- size: 40,
- color: Color(0xFFCCCCCC),
- ),
+ child: const Icon(Icons.search, size: 40, color: Color(0xFFCCCCCC)),
),
const SizedBox(height: 20),
- const Text(
- '未找到相关内容',
- style: TextStyle(
- fontSize: 15,
- color: Color(0xFF999999),
+ const Text('输入关键词搜索影视、书籍、笔记', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
+ const SizedBox(height: 4),
+ const Text('可同时筛选多个类型', style: TextStyle(fontSize: 12, color: Color(0xFFCCCCCC))),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildEmptyState() {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Container(
+ width: 80, height: 80,
+ decoration: BoxDecoration(
+ color: const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(20),
),
+ child: const Icon(Icons.search_off, size: 40, color: Color(0xFFCCCCCC)),
),
+ const SizedBox(height: 20),
+ const Text('未找到相关内容', style: TextStyle(fontSize: 15, color: Color(0xFF999999))),
const SizedBox(height: 8),
- Text(
- '尝试更换关键词或标签',
- style: TextStyle(
- fontSize: 13,
- color: const Color(0xFFBBBBBB),
- ),
- ),
+ const Text('尝试更换关键词或筛选条件', style: TextStyle(fontSize: 13, color: Color(0xFFCCCCCC))),
],
),
);
@@ -409,187 +361,36 @@ class _SearchPageState extends State {
Widget _buildResultList() {
return ListView.builder(
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
+ padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
itemCount: _results.length,
itemBuilder: (context, index) {
final item = _results[index];
- if (item is Movie) {
- return _buildMovieItem(item);
- } else if (item is Book) {
- return _buildBookItem(item);
- } else if (item is Note) {
- return _buildNoteItem(item);
+ switch (item.type) {
+ case 'movie':
+ return _buildMovieItem(item.data as Movie);
+ case 'book':
+ return _buildBookItem(item.data as Book);
+ case 'note':
+ return _buildNoteItem(item.data as Note);
+ default:
+ return const SizedBox.shrink();
}
- return const SizedBox.shrink();
},
);
}
- Widget _buildMovieItem(Movie movie) {
+ Widget _buildItemWrapper({
+ required Widget child,
+ required String typeLabel,
+ required IconData typeIcon,
+ required Color typeColor,
+ required VoidCallback onTap,
+ }) {
return GestureDetector(
- onTap: () {
- Navigator.push(
- context,
- MaterialPageRoute(
- builder: (context) => MovieDetailPage(movie: movie),
- ),
- );
- },
+ onTap: onTap,
child: Container(
- margin: const EdgeInsets.only(bottom: 12),
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: const Color(0xFFFAFAFA),
- borderRadius: BorderRadius.circular(12),
- border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
- ),
- child: Row(
- children: [
- // 海报
- Container(
- width: 60,
- height: 80,
- decoration: BoxDecoration(
- color: const Color(0xFFF5F5F5),
- borderRadius: BorderRadius.circular(8),
- ),
- clipBehavior: Clip.antiAlias,
- child: movie.posterPath != null
- ? Image.file(
- File(movie.posterPath!),
- fit: BoxFit.cover,
- errorBuilder: (_, __, ___) => const Icon(Icons.movie, color: Color(0xFFCCCCCC)),
- )
- : const Icon(Icons.movie, color: Color(0xFFCCCCCC)),
- ),
- const SizedBox(width: 16),
- // 信息
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- movie.title,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.w600,
- color: Color(0xFF1A1A1A),
- ),
- ),
- if (movie.alternateTitles.isNotEmpty) ...[
- const SizedBox(height: 6),
- Text(
- movie.alternateTitles.join(' / '),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(
- fontSize: 13,
- color: Color(0xFF999999),
- ),
- ),
- ],
- const SizedBox(height: 10),
- _buildStatusTag(movie.status),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
-
- Widget _buildBookItem(Book book) {
- return GestureDetector(
- onTap: () {
- Navigator.push(
- context,
- MaterialPageRoute(
- builder: (context) => BookDetailPage(book: book),
- ),
- );
- },
- child: Container(
- margin: const EdgeInsets.only(bottom: 12),
- padding: const EdgeInsets.all(16),
- decoration: BoxDecoration(
- color: const Color(0xFFFAFAFA),
- borderRadius: BorderRadius.circular(12),
- border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
- ),
- child: Row(
- children: [
- // 封面
- Container(
- width: 60,
- height: 80,
- decoration: BoxDecoration(
- color: const Color(0xFFF5F5F5),
- borderRadius: BorderRadius.circular(8),
- ),
- clipBehavior: Clip.antiAlias,
- child: book.coverPath != null
- ? Image.file(
- File(book.coverPath!),
- fit: BoxFit.cover,
- errorBuilder: (_, __, ___) => const Icon(Icons.book, color: Color(0xFFCCCCCC)),
- )
- : const Icon(Icons.book, color: Color(0xFFCCCCCC)),
- ),
- const SizedBox(width: 16),
- // 信息
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- book.title,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(
- fontSize: 16,
- fontWeight: FontWeight.w600,
- color: Color(0xFF1A1A1A),
- ),
- ),
- if (book.alternateTitles.isNotEmpty) ...[
- const SizedBox(height: 6),
- Text(
- book.alternateTitles.join(' / '),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(
- fontSize: 13,
- color: Color(0xFF999999),
- ),
- ),
- ],
- const SizedBox(height: 10),
- _buildBookStatusTag(book.status),
- ],
- ),
- ),
- ],
- ),
- ),
- );
- }
-
- Widget _buildNoteItem(Note note) {
- return GestureDetector(
- onTap: () {
- Navigator.push(
- context,
- MaterialPageRoute(
- builder: (context) => NoteDetailPage(note: note),
- ),
- );
- },
- child: Container(
- margin: const EdgeInsets.only(bottom: 12),
- padding: const EdgeInsets.all(16),
+ margin: const EdgeInsets.only(bottom: 10),
+ padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(12),
@@ -598,178 +399,186 @@ class _SearchPageState extends State {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- // 顶部:格式标记 + 时间
+ // 类型标签
Row(
children: [
Container(
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+ padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
- color: Colors.white,
+ color: typeColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
- border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
- child: Text(
- 'MD',
- style: const TextStyle(
- fontSize: 10,
- fontWeight: FontWeight.w600,
- color: Color(0xFF999999),
- ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(typeIcon, size: 10, color: typeColor),
+ const SizedBox(width: 3),
+ Text(typeLabel, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: typeColor)),
+ ],
),
),
- const SizedBox(width: 10),
- Text(
- '${note.createdAt.year}.${note.createdAt.month.toString().padLeft(2, '0')}.${note.createdAt.day.toString().padLeft(2, '0')}',
- style: const TextStyle(
- fontSize: 12,
- color: Color(0xFF999999),
- ),
- ),
- const Spacer(),
- // 图片数量(如果有图片)
- if (note.images.isNotEmpty) ...[
- const Icon(
- Icons.image_outlined,
- size: 14,
- color: Color(0xFF999999),
- ),
- const SizedBox(width: 4),
- Text(
- '${note.images.length}',
- style: const TextStyle(
- fontSize: 12,
- color: Color(0xFF999999),
- ),
- ),
- ],
],
),
- const SizedBox(height: 12),
- // 内容
- Text(
- note.summary.trim(),
- maxLines: 3,
- overflow: TextOverflow.ellipsis,
- style: const TextStyle(
- fontSize: 15,
- color: Color(0xFF1A1A1A),
- height: 1.6,
- ),
- ),
- // 标签
- if (note.tags.isNotEmpty) ...[
- const SizedBox(height: 12),
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: note.tags.take(3).map((tag) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
- decoration: BoxDecoration(
- color: Colors.white,
- borderRadius: BorderRadius.circular(6),
- border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
- ),
- child: Text(
- tag,
- style: const TextStyle(
- fontSize: 11,
- color: Color(0xFF666666),
- ),
- ),
- );
- }).toList(),
- ),
- ],
+ const SizedBox(height: 10),
+ child,
],
),
),
);
}
- Widget _buildStatusTag(String status) {
- String label;
- Color bgColor;
- Color textColor;
- switch (status) {
- case 'watched':
- label = '已看';
- bgColor = const Color(0xFF1A1A1A);
- textColor = Colors.white;
- break;
- case 'watching':
- label = '在看';
- bgColor = const Color(0xFFF0F0F0);
- textColor = const Color(0xFF666666);
- break;
- case 'want_to_watch':
- label = '想看';
- bgColor = const Color(0xFFF5F5F5);
- textColor = const Color(0xFF999999);
- break;
- default:
- label = '未知';
- bgColor = const Color(0xFFEEEEEE);
- textColor = const Color(0xFFCCCCCC);
- }
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- decoration: BoxDecoration(
- color: bgColor,
- borderRadius: BorderRadius.circular(6),
- ),
- child: Text(
- label,
- style: TextStyle(
- fontSize: 11,
- fontWeight: FontWeight.w600,
- color: textColor,
- ),
+ Widget _buildMovieItem(Movie movie) {
+ return _buildItemWrapper(
+ typeLabel: '影视',
+ typeIcon: Icons.movie_outlined,
+ typeColor: const Color(0xFF4A90D9),
+ onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => MovieDetailPage(movie: movie))),
+ child: Row(
+ children: [
+ Container(
+ width: 52, height: 70,
+ decoration: BoxDecoration(
+ color: const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: movie.posterPath != null && movie.posterPath!.isNotEmpty
+ ? Image.file(File(movie.posterPath!), fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => const Icon(Icons.movie, size: 22, color: Color(0xFFCCCCCC)))
+ : const Icon(Icons.movie, size: 22, color: Color(0xFFCCCCCC)),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(movie.title, maxLines: 1, overflow: TextOverflow.ellipsis,
+ style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
+ if (movie.alternateTitles.isNotEmpty) ...[
+ const SizedBox(height: 4),
+ Text(movie.alternateTitles.take(2).join(' / '), maxLines: 1, overflow: TextOverflow.ellipsis,
+ style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
+ ],
+ const SizedBox(height: 6),
+ _statusTag(movie.status),
+ ],
+ ),
+ ),
+ ],
),
);
}
- Widget _buildBookStatusTag(String status) {
- String label;
- Color bgColor;
- Color textColor;
- switch (status) {
- case 'read':
- label = '已读';
- bgColor = const Color(0xFF1A1A1A);
- textColor = Colors.white;
- break;
- case 'reading':
- label = '在读';
- bgColor = const Color(0xFFF0F0F0);
- textColor = const Color(0xFF666666);
- break;
- case 'want_to_read':
- label = '想读';
- bgColor = const Color(0xFFF5F5F5);
- textColor = const Color(0xFF999999);
- break;
- default:
- label = '未知';
- bgColor = const Color(0xFFEEEEEE);
- textColor = const Color(0xFFCCCCCC);
- }
+ Widget _buildBookItem(Book book) {
+ return _buildItemWrapper(
+ typeLabel: '书籍',
+ typeIcon: Icons.menu_book_outlined,
+ typeColor: const Color(0xFF7E57C2),
+ onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => BookDetailPage(book: book))),
+ child: Row(
+ children: [
+ Container(
+ width: 52, height: 70,
+ decoration: BoxDecoration(
+ color: const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(6),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: book.coverPath != null && book.coverPath!.isNotEmpty
+ ? Image.file(File(book.coverPath!), fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => const Icon(Icons.book, size: 22, color: Color(0xFFCCCCCC)))
+ : const Icon(Icons.book, size: 22, color: Color(0xFFCCCCCC)),
+ ),
+ const SizedBox(width: 14),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(book.title, maxLines: 1, overflow: TextOverflow.ellipsis,
+ style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
+ if (book.authors.isNotEmpty) ...[
+ const SizedBox(height: 4),
+ Text(book.authors.take(2).join(' / '), maxLines: 1, overflow: TextOverflow.ellipsis,
+ style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
+ ],
+ const SizedBox(height: 6),
+ _bookStatusTag(book.status),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildNoteItem(Note note) {
+ return _buildItemWrapper(
+ typeLabel: '笔记',
+ typeIcon: Icons.note_outlined,
+ typeColor: const Color(0xFF66BB6A),
+ onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => NoteDetailPage(note: note))),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ note.summary.trim(),
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(fontSize: 14, color: Color(0xFF333333), height: 1.7),
+ ),
+ if (note.tags.isNotEmpty) ...[
+ const SizedBox(height: 10),
+ Wrap(
+ spacing: 6,
+ runSpacing: 6,
+ children: note.tags.map((tag) => Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(4),
+ border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
+ ),
+ child: Text(tag, style: const TextStyle(fontSize: 10, color: Color(0xFF999999))),
+ )).toList(),
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+
+ Widget _statusTag(String status) {
+ final (label, bg, fg) = switch (status) {
+ 'watched' => ('已看', const Color(0xFF1A1A1A), Colors.white),
+ 'watching' => ('在看', const Color(0xFFF0F0F0), const Color(0xFF666666)),
+ 'want_to_watch' => ('想看', const Color(0xFFF5F5F5), const Color(0xFF999999)),
+ _ => ('未标记', const Color(0xFFF5F5F5), const Color(0xFFBBBBBB)),
+ };
return Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- decoration: BoxDecoration(
- color: bgColor,
- borderRadius: BorderRadius.circular(6),
- ),
- child: Text(
- label,
- style: TextStyle(
- fontSize: 11,
- fontWeight: FontWeight.w600,
- color: textColor,
- ),
- ),
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)),
+ child: Text(label, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: fg)),
+ );
+ }
+
+ Widget _bookStatusTag(String status) {
+ final (label, bg, fg) = switch (status) {
+ 'read' => ('已读', const Color(0xFF1A1A1A), Colors.white),
+ 'reading' => ('在读', const Color(0xFFF0F0F0), const Color(0xFF666666)),
+ 'want_to_read' => ('想读', const Color(0xFFF5F5F5), const Color(0xFF999999)),
+ _ => ('未标记', const Color(0xFFF5F5F5), const Color(0xFFBBBBBB)),
+ };
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+ decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)),
+ child: Text(label, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: fg)),
);
}
}
+class _SearchResult {
+ final String type; // movie, book, note
+ final dynamic data;
+ _SearchResult({required this.type, required this.data});
+}
diff --git a/lib/pages/stroll_page.dart b/lib/pages/stroll_page.dart
new file mode 100644
index 0000000..35a1e05
--- /dev/null
+++ b/lib/pages/stroll_page.dart
@@ -0,0 +1,363 @@
+import 'dart:io';
+import 'dart:math';
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+import '../providers/app_provider.dart';
+import '../models/data_models.dart';
+
+/// 漫步页面 - 随机发现一条内容
+class StrollPage extends StatefulWidget {
+ const StrollPage({super.key});
+
+ @override
+ State createState() => _StrollPageState();
+}
+
+class _StrollPageState extends State {
+ final _random = Random();
+ _StrollItem? _currentItem;
+
+ @override
+ void initState() {
+ super.initState();
+ _refresh();
+ }
+
+ void _refresh() {
+ final provider = context.read();
+
+ // 按类别分组
+ final movies = provider.movies.where((m) => !m.isDeleted).toList();
+ final books = provider.books.where((b) => !b.isDeleted).toList();
+ final notes = provider.notes.where((n) => !n.isDeleted).toList();
+
+ // 收集非空类别
+ final categories = >{};
+ if (movies.isNotEmpty) categories['movie'] = movies;
+ if (books.isNotEmpty) categories['book'] = books;
+ if (notes.isNotEmpty) categories['note'] = notes;
+
+ if (categories.isEmpty) {
+ setState(() => _currentItem = null);
+ return;
+ }
+
+ // 先等概率选类别,再从该类中随机选一条
+ final categoryKeys = categories.keys.toList();
+ final pickedCategory = categoryKeys[_random.nextInt(categoryKeys.length)];
+
+ _StrollItem item;
+ switch (pickedCategory) {
+ case 'movie':
+ final m = movies[_random.nextInt(movies.length)];
+ item = _StrollItem(
+ type: 'movie',
+ title: m.title,
+ subtitle: m.alternateTitles.isNotEmpty ? m.alternateTitles.first : '',
+ detail: _buildMovieDetail(m),
+ imagePath: m.posterPath,
+ icon: Icons.movie_outlined,
+ label: '影视',
+ );
+ break;
+ case 'book':
+ final b = books[_random.nextInt(books.length)];
+ item = _StrollItem(
+ type: 'book',
+ title: b.title,
+ subtitle: b.authors.isNotEmpty ? b.authors.first : '',
+ detail: _buildBookDetail(b),
+ imagePath: b.coverPath,
+ icon: Icons.menu_book_outlined,
+ label: '书籍',
+ );
+ break;
+ case 'note':
+ final n = notes[_random.nextInt(notes.length)];
+ item = _StrollItem(
+ type: 'note',
+ title: n.title.isNotEmpty ? n.title : '无标题',
+ subtitle: '${n.content.length} 字 · ${n.tags.isNotEmpty ? n.tags.take(2).join(' / ') : '无标签'}',
+ detail: n.content,
+ imagePath: n.images.isNotEmpty ? n.images.first : null,
+ icon: Icons.note_outlined,
+ label: '笔记',
+ );
+ break;
+ default:
+ setState(() => _currentItem = null);
+ return;
+ }
+
+ setState(() => _currentItem = item);
+ }
+
+ String _buildMovieDetail(Movie m) {
+ final parts = [];
+ if (m.rating != null && m.rating! > 0) parts.add('评分 ${m.rating!.toStringAsFixed(1)}');
+ if (m.genres.isNotEmpty) parts.add(m.genres.take(3).join(' / '));
+ if (m.status == 'watched') parts.add('已看');
+ if (m.status == 'watching') parts.add('在看');
+ return parts.join(' · ');
+ }
+
+ String _buildBookDetail(Book b) {
+ final parts = [];
+ if (b.rating != null && b.rating! > 0) parts.add('评分 ${b.rating!.toStringAsFixed(1)}');
+ if (b.publisher != null && b.publisher!.isNotEmpty) parts.add(b.publisher!);
+ if (b.status == 'read') parts.add('已读');
+ if (b.status == 'reading') parts.add('在读');
+ return parts.join(' · ');
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: Colors.white,
+ appBar: AppBar(
+ title: const Text('漫步'),
+ actions: [
+ Padding(
+ padding: const EdgeInsets.only(right: 12),
+ child: GestureDetector(
+ onTap: _refresh,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
+ decoration: BoxDecoration(
+ color: const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.refresh, size: 14, color: Color(0xFF666666)),
+ const SizedBox(width: 4),
+ const Text(
+ '换一个',
+ style: TextStyle(fontSize: 12, color: Color(0xFF666666)),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ body: _currentItem == null
+ ? const Center(
+ child: Text(
+ '还没有任何内容\n去添加一些吧',
+ textAlign: TextAlign.center,
+ style: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB), height: 1.6),
+ ),
+ )
+ : Consumer(
+ builder: (context, provider, _) {
+ final item = _currentItem!;
+
+ // 笔记:独立卡片样式
+ if (item.type == 'note') {
+ return _buildNoteCard(item);
+ }
+
+ // 影视/书籍:海报+信息样式
+ final hasImage = item.imagePath != null &&
+ item.imagePath!.isNotEmpty &&
+ File(item.imagePath!).existsSync();
+
+ return Center(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(32),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // 类型标签
+ _buildTypeChip(item),
+
+ const SizedBox(height: 28),
+
+ // 海报/封面/图标
+ if (hasImage)
+ ClipRRect(
+ borderRadius: BorderRadius.circular(16),
+ child: Image.file(
+ File(item.imagePath!),
+ width: 200,
+ height: 260,
+ fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => _buildPlaceholder(item),
+ ),
+ )
+ else
+ _buildPlaceholder(item),
+
+ const SizedBox(height: 28),
+
+ // 标题
+ Text(
+ item.title,
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ fontSize: 22,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF1A1A1A),
+ height: 1.3,
+ ),
+ ),
+
+ // 副标题
+ if (item.subtitle.isNotEmpty) ...[
+ const SizedBox(height: 8),
+ Text(
+ item.subtitle,
+ textAlign: TextAlign.center,
+ style: const TextStyle(fontSize: 14, color: Color(0xFF999999)),
+ ),
+ ],
+
+ // 详情
+ if (item.detail.isNotEmpty) ...[
+ const SizedBox(height: 6),
+ Text(
+ item.detail,
+ textAlign: TextAlign.center,
+ style: const TextStyle(fontSize: 13, color: Color(0xFFAAAAAA)),
+ ),
+ ],
+
+ const SizedBox(height: 40),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _buildTypeChip(_StrollItem item) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
+ decoration: BoxDecoration(
+ color: const Color(0xFFF5F5F5),
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(item.icon, size: 14, color: const Color(0xFF999999)),
+ const SizedBox(width: 6),
+ Text(item.label, style: const TextStyle(fontSize: 12, color: Color(0xFF888888))),
+ ],
+ ),
+ );
+ }
+
+ /// 笔记卡片样式
+ Widget _buildNoteCard(_StrollItem item) {
+ return Center(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _buildTypeChip(item),
+ const SizedBox(height: 24),
+
+ // 笔记卡片 - 类似分享海报
+ Container(
+ width: double.infinity,
+ constraints: const BoxConstraints(maxWidth: 340),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(16),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.08),
+ blurRadius: 20,
+ offset: const Offset(0, 8),
+ ),
+ ],
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 内容
+ Padding(
+ padding: const EdgeInsets.all(24),
+ child: Text(
+ item.detail,
+ style: const TextStyle(
+ fontSize: 15,
+ color: Color(0xFF333333),
+ height: 1.8,
+ ),
+ ),
+ ),
+
+ // 底部分隔
+ Container(height: 0.5, color: const Color(0xFFEEEEEE)),
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ children: [
+ const Icon(Icons.note_outlined, size: 13, color: Color(0xFFCCCCCC)),
+ const SizedBox(width: 6),
+ Text(
+ '${item.detail.length} 字',
+ style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB)),
+ ),
+ const Spacer(),
+ const Text(
+ 'Mooknote',
+ style: TextStyle(fontSize: 11, color: Color(0xFFDDDDDD)),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+
+ const SizedBox(height: 32),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildPlaceholder(_StrollItem item) {
+ return Container(
+ width: 120,
+ height: 160,
+ decoration: BoxDecoration(
+ color: const Color(0xFFFAFAFA),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(color: const Color(0xFFEEEEEE), width: 0.5),
+ ),
+ child: Icon(item.icon, size: 40, color: const Color(0xFFDDDDDD)),
+ );
+ }
+}
+
+class _StrollItem {
+ final String type;
+ final String title;
+ final String subtitle;
+ final String detail;
+ final String? imagePath;
+ final IconData icon;
+ final String label;
+
+ _StrollItem({
+ required this.type,
+ required this.title,
+ required this.subtitle,
+ required this.detail,
+ this.imagePath,
+ required this.icon,
+ required this.label,
+ });
+}
diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart
index 80113e5..b2ab9c1 100644
--- a/lib/utils/user_prefs.dart
+++ b/lib/utils/user_prefs.dart
@@ -66,6 +66,20 @@ class UserPrefs {
// ========== 应用图标设置 ==========
+ /// Markdown 阅读器最近选择的目录
+ String? get lastMdFolder => prefs.getString('lastMdFolder');
+ Future setLastMdFolder(String value) => prefs.setString('lastMdFolder', value);
+
+ /// 是否显示空目录(无 Markdown 文件的目录)
+ bool get showEmptyDirs => prefs.getBool('showEmptyDirs') ?? true;
+ Future setShowEmptyDirs(bool value) => prefs.setBool('showEmptyDirs', value);
+
+ /// 是否显示纯图片目录(只有图片、无 Markdown 文件的目录)
+ bool get showImageOnlyDirs => prefs.getBool('showImageOnlyDirs') ?? true;
+ Future setShowImageOnlyDirs(bool value) => prefs.setBool('showImageOnlyDirs', value);
+
+ // ========== 应用图标设置 ==========
+
/// 当前选中的应用图标名称(对应 assets/icon/ 下的文件名,不含扩展名)
String get appIconName => prefs.getString('appIconName') ?? 'app_icon';
Future setAppIconName(String value) => prefs.setString('appIconName', value);
diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart
index 35f190e..913541e 100644
--- a/lib/widgets/custom_drawer.dart
+++ b/lib/widgets/custom_drawer.dart
@@ -5,8 +5,9 @@ import 'package:provider/provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../providers/app_provider.dart';
import '../utils/user_prefs.dart';
-import '../utils/toast_util.dart';
import '../models/data_models.dart';
+import '../pages/stroll_page.dart';
+import '../pages/markdown_reader/md_reader_tab_page.dart';
/// 自定义左侧弹出菜单 - 极简主义设计
class CustomDrawer extends StatefulWidget {
@@ -25,7 +26,6 @@ class _CustomDrawerState extends State {
_loadVersionInfo();
}
- /// 加载版本信息
Future _loadVersionInfo() async {
final packageInfo = await PackageInfo.fromPlatform();
setState(() {
@@ -38,45 +38,266 @@ class _CustomDrawerState extends State {
return Drawer(
backgroundColor: Colors.white,
child: SafeArea(
- child: Column(
- children: [
- // 顶部用户信息区域
- _buildHeader(context),
-
- const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
-
- // 可滚动区域 - 包含回顾、日历和菜单
- Expanded(
- child: SingleChildScrollView(
- physics: const AlwaysScrollableScrollPhysics(),
+ child: SingleChildScrollView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // 1. 顶部用户信息(头像左,名称/座右铭右)
+ _buildHeader(context),
+
+ const Divider(
+ height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
+
+ // 2. 统计数据
+ _buildStatsSection(context),
+
+ const Divider(
+ height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
+
+ // 3. 热力图
+ _buildCalendarSection(context),
+
+ const Divider(
+ height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
+
+ // 4. 回顾信息
+ _buildMemorySection(context),
+
+ const Divider(
+ height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
+
+ // 5. 功能模块:漫步 / Markdown 阅读
+ _buildToolsSection(context),
+
+ // 底部版本号
+ Padding(
+ padding: const EdgeInsets.all(20),
+ child: Text(
+ 'MookNote v$_version',
+ style:
+ const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB)),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ /// 1. 构建头部 - 头像左侧,名称/座右铭右侧
+ Widget _buildHeader(BuildContext context) {
+ return Consumer(
+ builder: (context, provider, child) {
+ final userPrefs = UserPrefs();
+ final nickname = userPrefs.nickname;
+ final motto = userPrefs.motto;
+ final avatarPath = userPrefs.avatarPath;
+
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(24, 40, 24, 24),
+ child: Row(
+ children: [
+ // 左侧:头像
+ Container(
+ width: 56,
+ height: 56,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: const Color(0xFFF5F5F5),
+ border:
+ Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: avatarPath != null && avatarPath.isNotEmpty
+ ? Image.file(
+ File(avatarPath),
+ fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => _buildAvatarPlaceholder(),
+ )
+ : _buildAvatarPlaceholder(),
+ ),
+
+ const SizedBox(width: 16),
+
+ // 右侧:名称 + 座右铭
+ Expanded(
child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
- // 回顾功能区域
- _buildMemorySection(context),
-
- const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
-
- // 日历热力图区域
- _buildCalendarSection(context),
-
- const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE5E5E5)),
-
- // 底部留白,避免内容被遮挡
- const SizedBox(height: 16),
+ Text(
+ nickname,
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w600,
+ color: Color(0xFF1A1A1A),
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ motto,
+ style: const TextStyle(
+ fontSize: 13,
+ color: Color(0xFF999999),
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _buildAvatarPlaceholder() {
+ return const Center(
+ child: Icon(Icons.person_outline, size: 28, color: Color(0xFFAAAAAA)),
+ );
+ }
+
+ /// 2. 统计数据:观影xxx 阅读xxx 笔记xxx
+ Widget _buildStatsSection(BuildContext context) {
+ return Consumer(
+ builder: (context, provider, child) {
+ final movieCount = provider.movies.where((m) => !m.isDeleted).length;
+ final bookCount = provider.books.length;
+ final noteCount = provider.notes.length;
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
+ child: Row(
+ children: [
+ _buildStatItem('观影', movieCount),
+ const SizedBox(width: 32),
+ _buildStatItem('阅读', bookCount),
+ const SizedBox(width: 32),
+ _buildStatItem('笔记', noteCount),
+ ],
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _buildStatItem(String label, int count) {
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '$count',
+ style: const TextStyle(
+ fontSize: 22,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF1A1A1A),
+ ),
+ ),
+ const SizedBox(height: 2),
+ Text(
+ label,
+ style: const TextStyle(fontSize: 12, color: Color(0xFF999999)),
+ ),
+ ],
+ );
+ }
+
+ /// 5. 功能模块:漫步 / Markdown 阅读
+ Widget _buildToolsSection(BuildContext context) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
+ child: Container(
+ width: double.infinity,
+ decoration: BoxDecoration(
+ color: const Color(0xFFFAFAFA),
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: const Color(0xFFEEEEEE), width: 0.5),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // 漫步
+ InkWell(
+ onTap: () {
+ Navigator.pop(context);
+ Navigator.push(
+ context,
+ MaterialPageRoute(builder: (_) => const StrollPage()),
+ );
+ },
+ borderRadius:
+ const BorderRadius.vertical(top: Radius.circular(10)),
+ child: const Padding(
+ padding: EdgeInsets.symmetric(vertical: 13, horizontal: 16),
+ child: Row(
+ children: [
+ Icon(Icons.explore_outlined,
+ size: 18, color: Color(0xFF666666)),
+ SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ '漫步',
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: Color(0xFF1A1A1A)),
+ ),
+ ),
+ Text(
+ '随机发现一条内容',
+ style: TextStyle(fontSize: 11, color: Color(0xFFBBBBBB)),
+ ),
+ SizedBox(width: 6),
+ Icon(Icons.chevron_right,
+ size: 16, color: Color(0xFFCCCCCC)),
],
),
),
),
-
- // 底部版本信息 - 固定在底部
- Container(
- padding: const EdgeInsets.all(16),
- child: Text(
- 'MookNote v$_version',
- style: const TextStyle(
- fontSize: 12,
- color: Color(0xFF999999),
+
+ // 分隔线
+ const Divider(
+ height: 0.5, thickness: 0.5, color: Color(0xFFEEEEEE)),
+
+ // Markdown 阅读
+ InkWell(
+ onTap: () {
+ Navigator.pop(context);
+ Navigator.push(
+ context,
+ MaterialPageRoute(builder: (_) => const MdReaderTabPage()),
+ );
+ },
+ borderRadius:
+ const BorderRadius.vertical(bottom: Radius.circular(10)),
+ child: const Padding(
+ padding: EdgeInsets.symmetric(vertical: 13, horizontal: 16),
+ child: Row(
+ children: [
+ Icon(Icons.description_outlined,
+ size: 18, color: Color(0xFF666666)),
+ SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ 'MD阅读',
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: Color(0xFF1A1A1A)),
+ ),
+ ),
+ Text(
+ '浏览本地 md 文件',
+ style: TextStyle(fontSize: 11, color: Color(0xFFBBBBBB)),
+ ),
+ SizedBox(width: 6),
+ Icon(Icons.chevron_right,
+ size: 16, color: Color(0xFFCCCCCC)),
+ ],
),
),
),
@@ -86,19 +307,208 @@ class _CustomDrawerState extends State {
);
}
- /// 构建回顾功能区域
+ Widget _buildCalendarSection(BuildContext context) {
+ return Consumer(
+ builder: (context, provider, child) {
+ final Map dailyCounts = {};
+
+ for (final movie in provider.movies.where((m) => !m.isDeleted)) {
+ final date = DateTime(
+ movie.createdAt.year, movie.createdAt.month, movie.createdAt.day);
+ dailyCounts[date] = (dailyCounts[date] ?? 0) + 1;
+ }
+ for (final book in provider.books.where((b) => !b.isDeleted)) {
+ final date = DateTime(
+ book.createdAt.year, book.createdAt.month, book.createdAt.day);
+ dailyCounts[date] = (dailyCounts[date] ?? 0) + 1;
+ }
+ for (final note in provider.notes.where((n) => !n.isDeleted)) {
+ final date = DateTime(
+ note.createdAt.year, note.createdAt.month, note.createdAt.day);
+ dailyCounts[date] = (dailyCounts[date] ?? 0) + 1;
+ }
+
+ // 计算最大计数用于颜色映射
+ int maxCount = 0;
+ for (final c in dailyCounts.values) {
+ if (c > maxCount) maxCount = c;
+ }
+ if (maxCount == 0) maxCount = 1;
+
+ // 从上周日开始,往前推 N 周
+ final now = DateTime.now();
+ final today = DateTime(now.year, now.month, now.day);
+ final daysSinceSunday = today.weekday % 7; // Sunday=0
+ final lastSunday = today.subtract(Duration(days: daysSinceSunday));
+
+ const totalWeeks = 20;
+ const weekDays = 7;
+ const cellSize = 13.0;
+ const cellGap = 3.0;
+
+ // 构建网格:行=星期几(0=日..6=六),列=周(0=最旧..19=最新)
+ final cells =
+ List.generate(weekDays, (_) => List.generate(totalWeeks, (_) => 0));
+
+ for (int week = 0; week < totalWeeks; week++) {
+ for (int day = 0; day < weekDays; day++) {
+ final date = lastSunday.subtract(
+ Duration(days: (totalWeeks - 1 - week) * 7 + (6 - day)));
+ cells[day][week] = dailyCounts[date] ?? 0;
+ }
+ }
+
+ // 月份标签:找出每个月第一天所在的列
+ final monthLabels = {};
+ for (int week = 0; week < totalWeeks; week++) {
+ final date =
+ lastSunday.subtract(Duration(days: (totalWeeks - 1 - week) * 7));
+ final key = date.month;
+ if (!monthLabels.containsKey(key) || date.day <= 7) {
+ monthLabels[week] = '${date.month}月';
+ }
+ }
+
+ // 只保留首、中间、尾三个月份标签
+ final sortedWeeks = monthLabels.keys.toList()..sort();
+ final keepWeeks = {
+ sortedWeeks.first,
+ sortedWeeks[sortedWeeks.length ~/ 2],
+ sortedWeeks.last,
+ };
+
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const Row(
+ children: [
+ Icon(Icons.calendar_today,
+ size: 14, color: Color(0xFF999999)),
+ SizedBox(width: 8),
+ Text(
+ '热力图',
+ style: TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w500,
+ color: Color(0xFF666666)),
+ ),
+ ],
+ ),
+ const SizedBox(height: 16),
+
+ // 热力图主体
+ SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // 月份标签行
+ SizedBox(
+ height: 16,
+ child: Row(
+ children: List.generate(totalWeeks, (week) {
+ final label = keepWeeks.contains(week)
+ ? monthLabels[week]
+ : null;
+ return SizedBox(
+ width: cellSize + cellGap,
+ child: label != null
+ ? Text(label,
+ style: const TextStyle(
+ fontSize: 9, color: Color(0xFFBBBBBB)))
+ : null,
+ );
+ }),
+ ),
+ ),
+ const SizedBox(height: 2),
+
+ // 日期网格行
+ ...List.generate(weekDays, (day) {
+ return Row(
+ children: List.generate(totalWeeks, (week) {
+ final count = cells[day][week];
+ final color = _heatmapColor(count, maxCount);
+ return Container(
+ width: cellSize,
+ height: cellSize,
+ margin: EdgeInsets.only(
+ right: week < totalWeeks - 1 ? cellGap : 0,
+ bottom: day < weekDays - 1 ? cellGap : 0,
+ ),
+ decoration: BoxDecoration(
+ color: color,
+ borderRadius: BorderRadius.circular(2),
+ ),
+ );
+ }),
+ );
+ }),
+ ],
+ ),
+ ),
+
+ // 图例
+ const SizedBox(height: 10),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ const Text('少',
+ style: TextStyle(fontSize: 9, color: Color(0xFFBBBBBB))),
+ const SizedBox(width: 3),
+ _legendCell(const Color(0xFFF0F0F0)),
+ _legendCell(const Color(0xFFC8E6C9)),
+ _legendCell(const Color(0xFF66BB6A)),
+ _legendCell(const Color(0xFF2E7D32)),
+ _legendCell(const Color(0xFF1B5E20)),
+ const SizedBox(width: 3),
+ const Text('多',
+ style: TextStyle(fontSize: 9, color: Color(0xFFBBBBBB))),
+ ],
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+
+ Color _heatmapColor(int count, int maxCount) {
+ if (count == 0) return const Color(0xFFF0F0F0);
+ final ratio = count / maxCount;
+ if (ratio <= 0.25) return const Color(0xFFC8E6C9);
+ if (ratio <= 0.50) return const Color(0xFF66BB6A);
+ if (ratio <= 0.75) return const Color(0xFF2E7D32);
+ return const Color(0xFF1B5E20);
+ }
+
+ Widget _legendCell(Color color) {
+ return Container(
+ width: 10,
+ height: 10,
+ margin: const EdgeInsets.symmetric(horizontal: 1),
+ decoration:
+ BoxDecoration(color: color, borderRadius: BorderRadius.circular(2)),
+ );
+ }
+
+ /// 4. 回顾信息
Widget _buildMemorySection(BuildContext context) {
return Consumer(
builder: (context, provider, child) {
final memoryItem = _getRandomMemoryItem(provider);
-
+
if (memoryItem == null) {
return const SizedBox.shrink();
}
-
+
final memoryText = _buildMemoryText(memoryItem);
final timeAgo = _getTimeAgoText(memoryItem.date);
-
+
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
@@ -108,33 +518,29 @@ class _CustomDrawerState extends State {
// 标题
Row(
children: [
- const Icon(
- Icons.history,
- size: 16,
- color: Color(0xFF666666),
- ),
+ const Icon(Icons.history, size: 14, color: Color(0xFF999999)),
const SizedBox(width: 8),
const Text(
'回顾',
style: TextStyle(
- fontSize: 13,
- fontWeight: FontWeight.w500,
- color: Color(0xFF666666),
- ),
+ fontSize: 13,
+ fontWeight: FontWeight.w500,
+ color: Color(0xFF666666)),
),
],
),
const SizedBox(height: 12),
-
- // 内容卡片(带头图)
+
+ // 内容卡片
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- // 头图(影视/书籍显示,笔记不显示)
- if (memoryItem.imagePath != null && memoryItem.imagePath!.isNotEmpty)
+ // 头图
+ if (memoryItem.imagePath != null &&
+ memoryItem.imagePath!.isNotEmpty)
Container(
- width: 60,
- height: 80,
+ width: 56,
+ height: 72,
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
@@ -145,37 +551,37 @@ class _CustomDrawerState extends State {
child: Image.file(
File(memoryItem.imagePath!),
fit: BoxFit.cover,
- errorBuilder: (_, __, ___) => const Icon(
- Icons.image,
- color: Color(0xFFCCCCCC),
- ),
+ errorBuilder: (_, __, ___) =>
+ const Icon(Icons.image, color: Color(0xFFCCCCCC)),
),
),
)
else if (memoryItem.type != 'note')
Container(
- width: 60,
- height: 80,
+ width: 56,
+ height: 72,
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(4),
),
child: Icon(
- memoryItem.type == 'movie' ? Icons.movie : Icons.menu_book,
+ memoryItem.type == 'movie'
+ ? Icons.movie
+ : Icons.menu_book,
color: const Color(0xFFCCCCCC),
- size: 24,
+ size: 22,
),
),
-
- // 文字内容
+
+ // 文字
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- // 时间标签
Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ padding: const EdgeInsets.symmetric(
+ horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(4),
@@ -183,21 +589,16 @@ class _CustomDrawerState extends State {
child: Text(
timeAgo,
style: const TextStyle(
- fontSize: 11,
- color: Color(0xFF999999),
- ),
+ fontSize: 10, color: Color(0xFF999999)),
),
),
const SizedBox(height: 8),
-
- // 内容文字
Text(
memoryText,
style: const TextStyle(
- fontSize: 14,
- color: Color(0xFF1A1A1A),
- height: 1.5,
- ),
+ fontSize: 13,
+ color: Color(0xFF1A1A1A),
+ height: 1.5),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
@@ -213,12 +614,10 @@ class _CustomDrawerState extends State {
);
}
- /// 获取随机回顾项
_MemoryItem? _getRandomMemoryItem(AppProvider provider) {
final now = DateTime.now();
final candidates = <_MemoryItem>[];
-
- // 收集所有未删除的影视、书籍(去掉笔记)
+
for (final movie in provider.movies.where((m) => !m.isDeleted)) {
candidates.add(_MemoryItem(
type: 'movie',
@@ -227,7 +626,7 @@ class _CustomDrawerState extends State {
imagePath: movie.posterPath,
));
}
-
+
for (final book in provider.books.where((b) => !b.isDeleted)) {
candidates.add(_MemoryItem(
type: 'book',
@@ -236,416 +635,60 @@ class _CustomDrawerState extends State {
imagePath: book.coverPath,
));
}
-
+
if (candidates.isEmpty) return null;
-
- // 优先选择1个月、3个月、6个月、1年前的数据
+
final oneMonthAgo = now.subtract(const Duration(days: 30));
final threeMonthsAgo = now.subtract(const Duration(days: 90));
final sixMonthsAgo = now.subtract(const Duration(days: 180));
final oneYearAgo = now.subtract(const Duration(days: 365));
-
+
final memoryCandidates = candidates.where((item) {
- return _isInTimeRange(item.date, oneMonthAgo, threeMonthsAgo, sixMonthsAgo, oneYearAgo);
+ return _isInTimeRange(
+ item.date, oneMonthAgo, threeMonthsAgo, sixMonthsAgo, oneYearAgo);
}).toList();
-
- // 如果有符合时间范围的,从中随机选择;否则从所有数据中随机选择
+
final random = Random();
- final selectedList = memoryCandidates.isNotEmpty ? memoryCandidates : candidates;
+ final selectedList =
+ memoryCandidates.isNotEmpty ? memoryCandidates : candidates;
return selectedList[random.nextInt(selectedList.length)];
}
- /// 检查时间是否在范围内(1月、3月、6月、1年前)
- bool _isInTimeRange(DateTime date, DateTime oneMonth, DateTime threeMonths,
+ bool _isInTimeRange(DateTime date, DateTime oneMonth, DateTime threeMonths,
DateTime sixMonths, DateTime oneYear) {
- // 检查是否在1个月前左右(±7天)
if (_isCloseTo(date, oneMonth)) return true;
- // 检查是否在3个月前左右(±14天)
if (_isCloseTo(date, threeMonths, days: 14)) return true;
- // 检查是否在6个月前左右(±30天)
if (_isCloseTo(date, sixMonths, days: 30)) return true;
- // 检查是否在1年前左右(±30天)
if (_isCloseTo(date, oneYear, days: 30)) return true;
return false;
}
- /// 检查两个日期是否接近
bool _isCloseTo(DateTime date, DateTime target, {int days = 7}) {
final diff = date.difference(target).inDays.abs();
return diff <= days;
}
- /// 构建回顾文本
String _buildMemoryText(_MemoryItem item) {
- // 只显示标题,不添加描述前缀
return '《${item.title}》';
}
- /// 获取时间描述文本
String _getTimeAgoText(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
-
- if (diff.inDays >= 365) {
- return '1年前';
- } else if (diff.inDays >= 180) {
- return '6个月前';
- } else if (diff.inDays >= 90) {
- return '3个月前';
- } else if (diff.inDays >= 30) {
- return '1个月前';
- } else {
- return '${diff.inDays}天前';
- }
+ if (diff.inDays >= 365) return '1年前';
+ if (diff.inDays >= 180) return '6个月前';
+ if (diff.inDays >= 90) return '3个月前';
+ if (diff.inDays >= 30) return '1个月前';
+ return '${diff.inDays}天前';
}
-
- /// 构建头部
- Widget _buildHeader(BuildContext context) {
- return Consumer(
- builder: (context, provider, child) {
- // 统计数据
- final movieCount = provider.movies.where((m) => !m.isDeleted).length;
- final bookCount = provider.books.length;
- final noteCount = provider.notes.length;
-
- // 获取用户信息
- final userPrefs = UserPrefs();
- final nickname = userPrefs.nickname;
- final motto = userPrefs.motto;
- final avatarPath = userPrefs.avatarPath;
-
- return Container(
- width: double.infinity,
- padding: const EdgeInsets.fromLTRB(24, 48, 24, 24),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 用户头像
- Container(
- width: 64,
- height: 64,
- decoration: BoxDecoration(
- color: const Color(0xFFF5F5F5),
- shape: BoxShape.circle,
- border: Border.all(color: const Color(0xFFE5E5E5), width: 0.5),
- ),
- child: avatarPath != null && avatarPath.isNotEmpty
- ? ClipOval(
- child: Image.file(
- File(avatarPath),
- fit: BoxFit.cover,
- errorBuilder: (_, __, ___) => _buildAvatarPlaceholder(),
- ),
- )
- : _buildAvatarPlaceholder(),
- ),
-
- const SizedBox(height: 16),
-
- // 用户名称
- Text(
- nickname,
- style: const TextStyle(
- fontSize: 20,
- fontWeight: FontWeight.w600,
- color: Color(0xFF1A1A1A),
- ),
- ),
-
- const SizedBox(height: 4),
-
- // 座右铭
- Text(
- motto,
- style: const TextStyle(
- fontSize: 14,
- color: Color(0xFF666666),
- ),
- ),
-
- const SizedBox(height: 24),
-
- // 简化统计
- Row(
- children: [
- _buildStatItem('观影', movieCount),
- const SizedBox(width: 24),
- _buildStatItem('阅读', bookCount),
- const SizedBox(width: 24),
- _buildStatItem('笔记', noteCount),
- ],
- ),
- ],
- ),
- );
- },
- );
- }
-
- Widget _buildAvatarPlaceholder() {
- return const Center(
- child: Icon(
- Icons.person_outline,
- size: 32,
- color: Color(0xFF999999),
- ),
- );
- }
-
- /// 统计项
- Widget _buildStatItem(String label, int count) {
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- '$count',
- style: const TextStyle(
- fontSize: 20,
- fontWeight: FontWeight.w600,
- color: Color(0xFF1A1A1A),
- ),
- ),
- const SizedBox(height: 2),
- Text(
- label,
- style: const TextStyle(
- fontSize: 12,
- color: Color(0xFF999999),
- ),
- ),
- ],
- );
- }
-
- /// 菜单项
- Widget _buildMenuItem({
- required IconData icon,
- required String title,
- required VoidCallback onTap,
- }) {
- return ListTile(
- contentPadding: const EdgeInsets.symmetric(horizontal: 24),
- leading: Icon(icon, size: 22, color: const Color(0xFF666666)),
- title: Text(
- title,
- style: const TextStyle(
- fontSize: 15,
- color: Color(0xFF1A1A1A),
- ),
- ),
- trailing: const Icon(
- Icons.chevron_right,
- size: 20,
- color: Color(0xFFCCCCCC),
- ),
- onTap: onTap,
- );
- }
-
- /// 显示提示
- void _showToast(BuildContext context, String message) {
- ToastUtil.show(context, message);
- }
-
- /// 构建日历热力图区域
- Widget _buildCalendarSection(BuildContext context) {
- return StatefulBuilder(
- builder: (context, setState) {
- return Consumer(
- builder: (context, provider, child) {
- // 使用 StatefulBuilder 的状态来管理选中的月份
- final selectedMonth = _calendarSelectedMonth ?? DateTime.now();
-
- // 合并所有数据按日期
- final Map dailyData = {};
-
- for (final movie in provider.movies.where((m) => !m.isDeleted)) {
- final date = DateTime(movie.createdAt.year, movie.createdAt.month, movie.createdAt.day);
- dailyData.putIfAbsent(date, () => _DailyData()).movies++;
- }
-
- for (final book in provider.books.where((b) => !b.isDeleted)) {
- final date = DateTime(book.createdAt.year, book.createdAt.month, book.createdAt.day);
- dailyData.putIfAbsent(date, () => _DailyData()).books++;
- }
-
- for (final note in provider.notes.where((n) => !n.isDeleted)) {
- final date = DateTime(note.createdAt.year, note.createdAt.month, note.createdAt.day);
- dailyData.putIfAbsent(date, () => _DailyData()).notes++;
- }
-
- // 计算最大数量用于颜色强度
- int maxCount = 0;
- for (final data in dailyData.values) {
- final count = data.total;
- if (count > maxCount) maxCount = count;
- }
- if (maxCount == 0) maxCount = 1;
-
- final year = selectedMonth.year;
- final month = selectedMonth.month;
- final daysInMonth = DateTime(year, month + 1, 0).day;
- final firstWeekday = DateTime(year, month, 1).weekday % 7;
-
- return Container(
- width: double.infinity,
- padding: const EdgeInsets.all(20),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 标题和月份切换
- Row(
- children: [
- const Icon(
- Icons.calendar_today,
- size: 16,
- color: Color(0xFF666666),
- ),
- const SizedBox(width: 8),
- // 上个月按钮
- GestureDetector(
- onTap: () {
- setState(() {
- _calendarSelectedMonth = DateTime(year, month - 1);
- });
- },
- child: const Icon(
- Icons.chevron_left,
- size: 20,
- color: Color(0xFF666666),
- ),
- ),
- const SizedBox(width: 8),
- Text(
- '$year年$month月',
- style: const TextStyle(
- fontSize: 13,
- fontWeight: FontWeight.w500,
- color: Color(0xFF666666),
- ),
- ),
- const SizedBox(width: 8),
- // 下个月按钮
- GestureDetector(
- onTap: () {
- setState(() {
- _calendarSelectedMonth = DateTime(year, month + 1);
- });
- },
- child: const Icon(
- Icons.chevron_right,
- size: 20,
- color: Color(0xFF666666),
- ),
- ),
- ],
- ),
- const SizedBox(height: 12),
-
- // 星期标题 - 使用与日期相同的Wrap布局
- Wrap(
- spacing: 4,
- runSpacing: 4,
- children: const ['日', '一', '二', '三', '四', '五', '六']
- .map((d) => SizedBox(
- width: 32,
- height: 20,
- child: Text(
- d,
- textAlign: TextAlign.center,
- style: const TextStyle(fontSize: 10, color: Color(0xFF999999)),
- ),
- ))
- .toList(),
- ),
- const SizedBox(height: 4),
-
- // 日历网格
- Wrap(
- spacing: 4,
- runSpacing: 4,
- children: [
- // 空白填充
- ...List.generate(firstWeekday, (_) => const SizedBox(width: 32, height: 28)),
-
- // 日期
- ...List.generate(daysInMonth, (index) {
- final day = index + 1;
- final date = DateTime(year, month, day);
- final data = dailyData[date];
- final count = data?.total ?? 0;
-
- // 计算颜色强度
- double opacity = 0.1;
- if (count > 0) {
- opacity = 0.3 + (count / maxCount * 0.7);
- opacity = opacity.clamp(0.3, 1.0);
- }
-
- return Container(
- width: 32,
- height: 28,
- decoration: BoxDecoration(
- color: count > 0
- ? const Color(0xFF1A1A1A).withOpacity(opacity)
- : const Color(0xFFF5F5F5),
- borderRadius: BorderRadius.circular(4),
- ),
- child: Center(
- child: Text(
- day.toString(),
- style: TextStyle(
- fontSize: 10,
- color: count > 0 ? Colors.white : const Color(0xFF666666),
- ),
- ),
- ),
- );
- }),
- ],
- ),
-
- // 图例
- const SizedBox(height: 12),
- Row(
- mainAxisAlignment: MainAxisAlignment.end,
- children: [
- const Text('少', style: TextStyle(fontSize: 10, color: Color(0xFF999999))),
- const SizedBox(width: 4),
- ...List.generate(4, (index) {
- final opacity = 0.2 + (index * 0.2);
- return Container(
- width: 10,
- height: 10,
- margin: const EdgeInsets.symmetric(horizontal: 1),
- decoration: BoxDecoration(
- color: const Color(0xFF1A1A1A).withOpacity(opacity),
- borderRadius: BorderRadius.circular(2),
- ),
- );
- }),
- const SizedBox(width: 4),
- const Text('多', style: TextStyle(fontSize: 10, color: Color(0xFF999999))),
- ],
- ),
- ],
- ),
- );
- });
- });
- }
-
- // 日历选中的月份状态(用于 StatefulBuilder)
- static DateTime? _calendarSelectedMonth;
}
-/// 回顾项数据类
class _MemoryItem {
- final String type; // 'movie', 'book', 'note'
+ final String type;
final String title;
final DateTime date;
- final String? imagePath; // 头图路径(影视/书籍有,笔记无)
-
+ final String? imagePath;
+
_MemoryItem({
required this.type,
required this.title,
@@ -653,12 +696,3 @@ class _MemoryItem {
this.imagePath,
});
}
-
-/// 每日数据
-class _DailyData {
- int movies = 0;
- int books = 0;
- int notes = 0;
-
- int get total => movies + books + notes;
-}
diff --git a/lib/widgets/note_list_item.dart b/lib/widgets/note_list_item.dart
index 1cac643..5985dc3 100644
--- a/lib/widgets/note_list_item.dart
+++ b/lib/widgets/note_list_item.dart
@@ -99,9 +99,9 @@ class _NoteListItemContent extends StatelessWidget {
],
// 内容摘要(去除Markdown标记),内容为空则不显示
- if (_cleanMarkdown(note.content).trim().isNotEmpty)
+ if (_collapseBlankLines(_cleanMarkdown(note.content).trim()).isNotEmpty)
Text(
- _cleanMarkdown(note.content).trim(),
+ _collapseBlankLines(_cleanMarkdown(note.content).trim()),
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
@@ -205,6 +205,11 @@ class _NoteListItemContent extends StatelessWidget {
.trim();
}
+ /// 合并连续空行为单行
+ String _collapseBlankLines(String text) {
+ return text.replaceAll(RegExp(r'\n\s*\n+'), '\n');
+ }
+
/// 显示删除确认对话框
void _showDeleteDialog(BuildContext context) {
showDialog(