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(