generated from dellevin/template
md阅读器+
This commit is contained in:
@@ -119,6 +119,7 @@ flutter run
|
|||||||
**5. 图片文件存储路径:**
|
**5. 图片文件存储路径:**
|
||||||
|
|
||||||
- 数据库:`/mooknote/mooknote.db`
|
- 数据库:`/mooknote/mooknote.db`
|
||||||
|
-
|
||||||
- 图片:`/mooknote/images/类别(影视/图书/笔记)/类别下的条目id/图片文件名`
|
- 图片:`/mooknote/images/类别(影视/图书/笔记)/类别下的条目id/图片文件名`
|
||||||
|
|
||||||
**注意:**
|
**注意:**
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
<!-- 存储权限 -->
|
<!-- 存储权限 -->
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||||
<!-- Android 13+ 使用新的权限 -->
|
<!-- Android 13+ 使用新的权限 -->
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ class _HomePageState extends State<HomePage> {
|
|||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
final currentPage = provider.bottomNavIndex == 0 ? 0 : 1;
|
final currentPage = provider.bottomNavIndex == 0 ? 0 : 1;
|
||||||
if (_pageController.hasClients && _pageController.page?.round() != currentPage) {
|
if (_pageController.hasClients && _pageController.page?.round() != currentPage) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
_isSwitchingPage = true;
|
_isSwitchingPage = true;
|
||||||
_pageController.jumpToPage(currentPage);
|
_pageController.jumpToPage(currentPage);
|
||||||
// 切换完成后重置标记,确保导航栏显示
|
|
||||||
Future.delayed(const Duration(milliseconds: 300), () {
|
Future.delayed(const Duration(milliseconds: 300), () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_isSwitchingPage = false;
|
_isSwitchingPage = false;
|
||||||
@@ -46,6 +47,8 @@ class _HomePageState extends State<HomePage> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return NotificationListener<ScrollNotification>(
|
return NotificationListener<ScrollNotification>(
|
||||||
onNotification: (notification) {
|
onNotification: (notification) {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import '../../utils/user_prefs.dart';
|
||||||
import 'md_viewer_page.dart';
|
import 'md_viewer_page.dart';
|
||||||
|
|
||||||
/// Markdown 阅读器 Tab 页 - 文件浏览器
|
/// Markdown 阅读器 - 文件浏览器
|
||||||
class MdReaderTabPage extends StatefulWidget {
|
class MdReaderTabPage extends StatefulWidget {
|
||||||
const MdReaderTabPage({super.key});
|
const MdReaderTabPage({super.key});
|
||||||
|
|
||||||
@@ -12,342 +15,543 @@ class MdReaderTabPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MdReaderTabPageState extends State<MdReaderTabPage> {
|
class _MdReaderTabPageState extends State<MdReaderTabPage> {
|
||||||
static const String _basePath = '/storage/emulated/0/Documents/mooknote/markdown';
|
String? _rootPath;
|
||||||
String _currentPath = _basePath;
|
String? _currentPath;
|
||||||
List<FileSystemEntity> _items = [];
|
List<_FileEntry> _entries = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
|
bool _showEmptyDirs = true;
|
||||||
|
bool _showImageOnlyDirs = true;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadDirectory();
|
_loadSettings();
|
||||||
|
_init();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 加载当前目录内容
|
void _loadSettings() {
|
||||||
Future<void> _loadDirectory() async {
|
final prefs = UserPrefs();
|
||||||
setState(() {
|
_showEmptyDirs = prefs.showEmptyDirs;
|
||||||
_isLoading = true;
|
_showImageOnlyDirs = prefs.showImageOnlyDirs;
|
||||||
_error = null;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
Future<void> _init() async {
|
||||||
final dir = Directory(_currentPath);
|
final saved = UserPrefs().lastMdFolder;
|
||||||
if (!await dir.exists()) {
|
if (saved != null && saved.isNotEmpty) {
|
||||||
setState(() {
|
final dir = Directory(saved);
|
||||||
_error = '目录不存在\n请将 Markdown 文件放到:\n$_basePath';
|
if (await dir.exists()) {
|
||||||
_items = [];
|
_rootPath = saved;
|
||||||
_isLoading = false;
|
_currentPath = saved;
|
||||||
});
|
await _loadDirectory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求存储权限(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDirectory() async {
|
||||||
|
final hasPermission = await _requestStoragePermission();
|
||||||
|
if (!hasPermission) {
|
||||||
|
if (mounted) {
|
||||||
|
_showPermissionDeniedDialog();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final entities = await dir.list().toList();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
// 排序:文件夹在前,文件在后,按名称排序
|
if (result == null) return;
|
||||||
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(() {
|
UserPrefs().setLastMdFolder(result);
|
||||||
_items = entities;
|
_rootPath = result;
|
||||||
_isLoading = false;
|
_currentPath = result;
|
||||||
});
|
_error = null;
|
||||||
} catch (e) {
|
await _loadDirectory();
|
||||||
setState(() {
|
|
||||||
_error = '读取目录失败: $e';
|
|
||||||
_items = [];
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 进入子目录
|
void _showPermissionDeniedDialog() {
|
||||||
void _enterDirectory(String path) {
|
showDialog(
|
||||||
setState(() {
|
context: context,
|
||||||
_currentPath = path;
|
builder: (ctx) => AlertDialog(
|
||||||
});
|
title: const Text('需要存储权限', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
_loadDirectory();
|
content: const Text(
|
||||||
}
|
'Android 11+ 需要在系统设置中授予"所有文件访问权限"才能读取目录中的 Markdown 文件。\n\n是否前往设置?',
|
||||||
|
style: TextStyle(fontSize: 14, color: Color(0xFF666666), height: 1.6),
|
||||||
/// 返回上级目录
|
),
|
||||||
void _goBack() {
|
actions: [
|
||||||
final parent = Directory(_currentPath).parent.path;
|
TextButton(
|
||||||
if (parent == _currentPath) return;
|
onPressed: () => Navigator.pop(ctx),
|
||||||
_enterDirectory(parent);
|
child: const Text('取消', style: TextStyle(color: Color(0xFF999999))),
|
||||||
}
|
),
|
||||||
|
TextButton(
|
||||||
/// 打开 Markdown 文件
|
onPressed: () {
|
||||||
void _openFile(String path) {
|
Navigator.pop(ctx);
|
||||||
Navigator.push(
|
openAppSettings();
|
||||||
context,
|
},
|
||||||
MaterialPageRoute(
|
child: const Text('前往设置', style: TextStyle(color: Color(0xFF1A1A1A))),
|
||||||
builder: (context) => MdViewerPage(filePath: path),
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 判断是否可以返回上级
|
/// 递归检查目录(含子目录)是否包含 Markdown 文件
|
||||||
bool get _canGoBack => _currentPath != _basePath;
|
bool _hasMarkdownFiles(String dirPath) {
|
||||||
|
try {
|
||||||
/// 获取当前显示路径(相对路径)
|
final dir = Directory(dirPath);
|
||||||
String get _displayPath {
|
if (!dir.existsSync()) return false;
|
||||||
if (_currentPath == _basePath) return 'markdown';
|
for (final entity in dir.listSync(recursive: true, followLinks: false)) {
|
||||||
return _currentPath.substring(_basePath.length + 1);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
body: Column(
|
appBar: AppBar(
|
||||||
children: [
|
backgroundColor: Colors.white,
|
||||||
// 紧凑顶部栏
|
elevation: 0,
|
||||||
_buildHeader(),
|
title: Text(
|
||||||
// 内容区域
|
_currentPath != null ? p.basename(_currentPath!) : 'Markdown 阅读',
|
||||||
Expanded(
|
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)),
|
||||||
child: _buildBody(),
|
|
||||||
),
|
),
|
||||||
],
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Color(0xFF1A1A1A)),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
);
|
actions: [
|
||||||
}
|
|
||||||
|
|
||||||
/// 构建紧凑顶部栏
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// 返回按钮
|
|
||||||
if (_canGoBack)
|
if (_canGoBack)
|
||||||
|
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))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.arrow_back, size: 20),
|
icon: const Icon(Icons.tune, size: 20, color: Color(0xFF888888)),
|
||||||
padding: EdgeInsets.zero,
|
onPressed: _showSettingsSheet,
|
||||||
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),
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
body: _buildBody(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
|
if (_currentPath == null) {
|
||||||
|
return _buildWelcome();
|
||||||
|
}
|
||||||
|
|
||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator(color: Color(0xFF1A1A1A)));
|
return const Center(child: CircularProgressIndicator(color: Color(0xFF1A1A1A)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return _buildErrorState();
|
return _buildError();
|
||||||
}
|
|
||||||
|
|
||||||
if (_items.isEmpty) {
|
|
||||||
return _buildEmptyState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: _loadDirectory,
|
onRefresh: _loadDirectory,
|
||||||
color: const Color(0xFF1A1A1A),
|
color: const Color(0xFF1A1A1A),
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
child: ListView.builder(
|
child: _entries.isEmpty ? _buildEmpty() : ListView.separated(
|
||||||
padding: EdgeInsets.zero,
|
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) {
|
itemBuilder: (context, index) {
|
||||||
final item = _items[index];
|
if (_canGoBack && index == 0) {
|
||||||
final isDirectory = item is Directory;
|
return ListTile(
|
||||||
final name = p.basename(item.path);
|
leading: Container(
|
||||||
final isMdFile = !isDirectory && name.toLowerCase().endsWith('.md');
|
width: 36, height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
// 跳过非 md 文件和非目录项
|
color: const Color(0xFFF5F5F5),
|
||||||
if (!isDirectory && !isMdFile) {
|
borderRadius: BorderRadius.circular(8),
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
return _buildListItem(item, isDirectory, name);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
child: const Icon(Icons.arrow_upward, size: 18, color: Color(0xFF888888)),
|
||||||
|
),
|
||||||
|
title: const Text('..', style: TextStyle(fontSize: 14, color: Color(0xFF888888))),
|
||||||
|
onTap: _goBack,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
final entry = _entries[_canGoBack ? index - 1 : index];
|
||||||
Widget _buildListItem(FileSystemEntity item, bool isDirectory, String name) {
|
return ListTile(
|
||||||
return InkWell(
|
leading: Container(
|
||||||
onTap: () {
|
width: 36, height: 36,
|
||||||
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(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isDirectory ? const Color(0xFFF0F7FF) : const Color(0xFFF5F5F5),
|
color: entry.isDir ? const Color(0xFFF0F7FF) : const Color(0xFFF5F5F5),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
isDirectory ? Icons.folder_outlined : Icons.description_outlined,
|
entry.isDir ? Icons.folder_outlined : Icons.description_outlined,
|
||||||
color: isDirectory ? const Color(0xFF4A90D9) : const Color(0xFF666666),
|
|
||||||
size: 18,
|
size: 18,
|
||||||
|
color: entry.isDir ? const Color(0xFF4A90D9) : const Color(0xFF666666),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
title: Text(entry.name, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
Expanded(
|
subtitle: entry.isDir ? null : Text(_formatSize(entry.size), style: const TextStyle(fontSize: 11, color: Color(0xFF999999))),
|
||||||
child: Column(
|
trailing: Icon(entry.isDir ? Icons.chevron_right : Icons.open_in_new_outlined, size: 16, color: const Color(0xFFCCCCCC)),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onTap: () => entry.isDir ? _enterDirectory(entry.path) : _openFile(entry),
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 格式化文件大小
|
Widget _buildWelcome() {
|
||||||
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() {
|
|
||||||
return Center(
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(40),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Container(
|
||||||
Icons.error_outline,
|
width: 80, height: 80,
|
||||||
size: 48,
|
decoration: BoxDecoration(
|
||||||
color: Color(0xFFCCCCCC),
|
color: const Color(0xFFF5F5F5),
|
||||||
),
|
borderRadius: BorderRadius.circular(20),
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
|
child: const Icon(Icons.folder_open_outlined, size: 40, color: Color(0xFFCCCCCC)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ElevatedButton(
|
const Text('Markdown 阅读', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||||
onPressed: _loadDirectory,
|
const SizedBox(height: 8),
|
||||||
style: ElevatedButton.styleFrom(
|
const Text('选择一个包含 .md 文件的文件夹', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||||||
backgroundColor: const Color(0xFF1A1A1A),
|
const SizedBox(height: 32),
|
||||||
foregroundColor: Colors.white,
|
GestureDetector(
|
||||||
elevation: 0,
|
onTap: _pickDirectory,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmpty() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.folder_open_outlined, size: 64, color: Color(0xFFE0E0E0)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
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),
|
||||||
|
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 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||||
|
|
||||||
/// Markdown 文件查看页面
|
/// Markdown 文件查看页面
|
||||||
class MdViewerPage extends StatefulWidget {
|
class MdViewerPage extends StatefulWidget {
|
||||||
@@ -132,7 +132,8 @@ class _MdViewerPageState extends State<MdViewerPage> {
|
|||||||
decoration: TextDecoration.underline,
|
decoration: TextDecoration.underline,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
sizedImageBuilder: (config) => _buildImage(config.uri.toString(), config.alt),
|
// ignore: deprecated_member_use
|
||||||
|
imageBuilder: (uri, title, alt) => _buildImage(uri.toString(), alt),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -551,17 +551,21 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
|||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (ctx) => StatefulBuilder(
|
||||||
|
builder: (ctx, setDialogState) => AlertDialog(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
elevation: 0,
|
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(
|
title: const Text(
|
||||||
'添加标签',
|
'添加标签',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF1A1A1A),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
||||||
content: SizedBox(
|
content: SizedBox(
|
||||||
width: double.maxFinite,
|
width: double.maxFinite,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -572,31 +576,38 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
|||||||
TextField(
|
TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
|
||||||
|
cursorColor: const Color(0xFF1A1A1A),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: '输入新标签名称',
|
hintText: '输入新标签名称',
|
||||||
hintStyle: const TextStyle(
|
hintStyle: const TextStyle(fontSize: 14, color: Color(0xFFBBBBBB)),
|
||||||
fontSize: 14,
|
|
||||||
color: Color(0xFF999999),
|
|
||||||
),
|
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFFFAFAFA),
|
fillColor: const Color(0xFFF8F8F8),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(10),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(10),
|
||||||
borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(10),
|
||||||
borderSide: const BorderSide(color: Color(0xFF1A1A1A), width: 1),
|
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) {
|
onSubmitted: (value) {
|
||||||
_addTag(value);
|
_addTag(value);
|
||||||
Navigator.pop(context);
|
controller.clear();
|
||||||
|
setDialogState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -604,29 +615,37 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
|||||||
if (availableTags.isNotEmpty) ...[
|
if (availableTags.isNotEmpty) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
const Text(
|
const Text(
|
||||||
'或选择已有标签:',
|
'或选择已有标签',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
color: Color(0xFF999999),
|
color: Color(0xFFAAAAAA),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SizedBox(
|
ConstrainedBox(
|
||||||
height: 200,
|
constraints: const BoxConstraints(maxHeight: 180),
|
||||||
child: ListView(
|
child: SingleChildScrollView(
|
||||||
shrinkWrap: true,
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
children: availableTags.map((tag) {
|
children: availableTags.map((tag) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_addTag(tag);
|
_addTag(tag);
|
||||||
Navigator.pop(context);
|
Navigator.pop(ctx);
|
||||||
},
|
},
|
||||||
child: Padding(
|
borderRadius: BorderRadius.circular(16),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
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(
|
child: Text(
|
||||||
tag,
|
tag,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
color: Color(0xFF555555),
|
color: Color(0xFF555555),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -635,37 +654,48 @@ class _NoteFormPageState extends State<NoteFormPage> {
|
|||||||
}).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: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(ctx),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: const Color(0xFF666666),
|
foregroundColor: const Color(0xFF999999),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
),
|
),
|
||||||
child: const Text('取消'),
|
child: const Text('取消', style: TextStyle(fontSize: 14)),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_addTag(controller.text);
|
_addTag(controller.text);
|
||||||
Navigator.pop(context);
|
Navigator.pop(ctx);
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF1A1A1A),
|
backgroundColor: const Color(0xFF1A1A1A),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
borderRadius: BorderRadius.circular(8),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
child: const Text('添加', style: TextStyle(fontSize: 14)),
|
||||||
),
|
|
||||||
child: const Text('添加'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
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/ 下的文件名,不含扩展名)
|
/// 当前选中的应用图标名称(对应 assets/icon/ 下的文件名,不含扩展名)
|
||||||
String get appIconName => prefs.getString('appIconName') ?? 'app_icon';
|
String get appIconName => prefs.getString('appIconName') ?? 'app_icon';
|
||||||
Future<bool> setAppIconName(String value) => prefs.setString('appIconName', value);
|
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标记),内容为空则不显示
|
// 内容摘要(去除Markdown标记),内容为空则不显示
|
||||||
if (_cleanMarkdown(note.content).trim().isNotEmpty)
|
if (_collapseBlankLines(_cleanMarkdown(note.content).trim()).isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
_cleanMarkdown(note.content).trim(),
|
_collapseBlankLines(_cleanMarkdown(note.content).trim()),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Color(0xFF666666),
|
color: Color(0xFF666666),
|
||||||
@@ -205,6 +205,11 @@ class _NoteListItemContent extends StatelessWidget {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 合并连续空行为单行
|
||||||
|
String _collapseBlankLines(String text) {
|
||||||
|
return text.replaceAll(RegExp(r'\n\s*\n+'), '\n');
|
||||||
|
}
|
||||||
|
|
||||||
/// 显示删除确认对话框
|
/// 显示删除确认对话框
|
||||||
void _showDeleteDialog(BuildContext context) {
|
void _showDeleteDialog(BuildContext context) {
|
||||||
showDialog(
|
showDialog(
|
||||||
|
|||||||
Reference in New Issue
Block a user