generated from dellevin/template
md阅读器+
This commit is contained in:
@@ -119,6 +119,7 @@ flutter run
|
||||
**5. 图片文件存储路径:**
|
||||
|
||||
- 数据库:`/mooknote/mooknote.db`
|
||||
-
|
||||
- 图片:`/mooknote/images/类别(影视/图书/笔记)/类别下的条目id/图片文件名`
|
||||
|
||||
**注意:**
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<!-- 存储权限 -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||
<!-- Android 13+ 使用新的权限 -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
|
||||
@@ -36,13 +36,16 @@ class _HomePageState extends State<HomePage> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<MdReaderTabPage> {
|
||||
static const String _basePath = '/storage/emulated/0/Documents/mooknote/markdown';
|
||||
String _currentPath = _basePath;
|
||||
List<FileSystemEntity> _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<void> _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<void> _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<bool> _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<void> _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<void> _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('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
@@ -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<MdViewerPage> {
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
sizedImageBuilder: (config) => _buildImage(config.uri.toString(), config.alt),
|
||||
// ignore: deprecated_member_use
|
||||
imageBuilder: (uri, title, alt) => _buildImage(uri.toString(), alt),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -542,26 +542,30 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
||||
/// 显示添加标签对话框
|
||||
void _showAddTagDialog() {
|
||||
final controller = TextEditingController();
|
||||
|
||||
|
||||
// 获取所有已有标签(从所有笔记中收集)
|
||||
final provider = context.read<AppProvider>();
|
||||
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<NoteFormPage> {
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
363
lib/pages/stroll_page.dart
Normal file
363
lib/pages/stroll_page.dart
Normal file
@@ -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<StrollPage> createState() => _StrollPageState();
|
||||
}
|
||||
|
||||
class _StrollPageState extends State<StrollPage> {
|
||||
final _random = Random();
|
||||
_StrollItem? _currentItem;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
}
|
||||
|
||||
void _refresh() {
|
||||
final provider = context.read<AppProvider>();
|
||||
|
||||
// 按类别分组
|
||||
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 = <String, List<dynamic>>{};
|
||||
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 = <String>[];
|
||||
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 = <String>[];
|
||||
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<AppProvider>(
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -66,6 +66,20 @@ class UserPrefs {
|
||||
|
||||
// ========== 应用图标设置 ==========
|
||||
|
||||
/// Markdown 阅读器最近选择的目录
|
||||
String? get lastMdFolder => prefs.getString('lastMdFolder');
|
||||
Future<bool> setLastMdFolder(String value) => prefs.setString('lastMdFolder', value);
|
||||
|
||||
/// 是否显示空目录(无 Markdown 文件的目录)
|
||||
bool get showEmptyDirs => prefs.getBool('showEmptyDirs') ?? true;
|
||||
Future<bool> setShowEmptyDirs(bool value) => prefs.setBool('showEmptyDirs', value);
|
||||
|
||||
/// 是否显示纯图片目录(只有图片、无 Markdown 文件的目录)
|
||||
bool get showImageOnlyDirs => prefs.getBool('showImageOnlyDirs') ?? true;
|
||||
Future<bool> setShowImageOnlyDirs(bool value) => prefs.setBool('showImageOnlyDirs', value);
|
||||
|
||||
// ========== 应用图标设置 ==========
|
||||
|
||||
/// 当前选中的应用图标名称(对应 assets/icon/ 下的文件名,不含扩展名)
|
||||
String get appIconName => prefs.getString('appIconName') ?? 'app_icon';
|
||||
Future<bool> setAppIconName(String value) => prefs.setString('appIconName', value);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user