WIP: markdown reader, data migration, 编辑器增强等

This commit is contained in:
DelLevin-Home
2026-05-22 03:24:17 +08:00
parent edf35c5844
commit 8d5e1642d6
19 changed files with 2371 additions and 1177 deletions

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_provider.dart';
import '../utils/user_prefs.dart';
import '../widgets/custom_drawer.dart';
import '../widgets/bottom_nav_bar.dart';
import 'main_content_page.dart';
@@ -15,47 +16,132 @@ class HomePage extends StatefulWidget {
}
class _HomePageState extends State<HomePage> {
/// 当前正在滑动的页面索引用于PageView
final PageController _pageController = PageController();
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
// 左侧弹出菜单(仅在主页显示)
drawer: context.watch<AppProvider>().bottomNavIndex == 0
? CustomDrawer()
drawer: context.watch<AppProvider>().bottomNavIndex == 0
? CustomDrawer()
: null,
// 主体内容 - 使用 Stack 让 dock 栏悬浮在内容上方
body: Stack(
children: [
// 底层:主体内容
_buildBody(),
// 顶层:悬浮 dock 栏
const Positioned(
left: 0,
right: 0,
bottom: 0,
child: CustomBottomNavBar(),
),
],
// 主体内容
body: Consumer<AppProvider>(
builder: (context, provider, child) {
// 同步底部导航栏和 PageView 的页面
final currentPage = provider.bottomNavIndex == 0 ? 0 : 1;
if (_pageController.hasClients && _pageController.page?.round() != currentPage) {
_pageController.jumpToPage(currentPage);
}
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollUpdateNotification) {
final delta = notification.scrollDelta;
if (delta != null && delta.abs() > 2) {
// 根据用户设置决定是否启用滚动隐藏
final userPrefs = UserPrefs();
if (!userPrefs.hideBottomNavOnScroll) return false;
if (delta < 0) {
// 下拉(内容向下滚动)- 显示导航栏
provider.setBottomNavVisible(true);
} else {
// 上滑(内容向上滚动)- 隐藏导航栏
provider.setBottomNavVisible(false);
}
}
}
return false;
},
child: Stack(
children: [
// 底层:主体内容(支持左右滑动切换)
_buildPageView(provider),
// 底部导航栏(带动画)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: AnimatedSlide(
offset: provider.bottomNavVisible ? Offset.zero : const Offset(-1, 0),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: const CustomBottomNavBar(),
),
),
// 导航栏隐藏时的展开按钮
if (!provider.bottomNavVisible)
Positioned(
left: 0,
bottom: MediaQuery.of(context).padding.bottom + 20,
child: GestureDetector(
onTap: () => provider.setBottomNavVisible(true),
onHorizontalDragEnd: (details) {
if (details.primaryVelocity != null &&
details.primaryVelocity! > 0) {
provider.setBottomNavVisible(true);
}
},
child: Container(
width: 44,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 20,
offset: const Offset(0, 4),
spreadRadius: 0,
),
],
),
child: const Center(
child: Icon(
Icons.chevron_right,
color: Color(0xFF999999),
size: 24,
),
),
),
),
),
],
),
);
},
),
);
}
/// 构建主体内容
Widget _buildBody() {
return Consumer<AppProvider>(
builder: (context, provider, child) {
switch (provider.bottomNavIndex) {
case 0:
// 主页 - 观影/阅读/笔记
return const MainContentPage();
case 2:
// 我的页面
return const ProfilePage();
default:
return const MainContentPage();
/// 构建主体内容(使用 PageView 支持左右滑动切换)
Widget _buildPageView(AppProvider provider) {
return PageView(
controller: _pageController,
physics: const BouncingScrollPhysics(),
onPageChanged: (index) {
if (index == 0) {
provider.setBottomNavIndex(0);
} else if (index == 1) {
provider.setBottomNavIndex(2);
}
},
children: [
const MainContentPage(),
const ProfilePage(),
],
);
}
}

View File

@@ -0,0 +1,353 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import 'md_viewer_page.dart';
/// Markdown 阅读器 Tab 页 - 文件浏览器
class MdReaderTabPage extends StatefulWidget {
const MdReaderTabPage({super.key});
@override
State<MdReaderTabPage> createState() => _MdReaderTabPageState();
}
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? _error;
@override
void initState() {
super.initState();
_loadDirectory();
}
/// 加载当前目录内容
Future<void> _loadDirectory() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final dir = Directory(_currentPath);
if (!await dir.exists()) {
setState(() {
_error = '目录不存在\n请将 Markdown 文件放到:\n$_basePath';
_items = [];
_isLoading = false;
});
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;
});
}
}
/// 进入子目录
void _enterDirectory(String path) {
setState(() {
_currentPath = path;
});
_loadDirectory();
}
/// 返回上级目录
void _goBack() {
final parent = Directory(_currentPath).parent.path;
if (parent == _currentPath) return;
_enterDirectory(parent);
}
/// 打开 Markdown 文件
void _openFile(String path) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MdViewerPage(filePath: path),
),
);
}
/// 判断是否可以返回上级
bool get _canGoBack => _currentPath != _basePath;
/// 获取当前显示路径(相对路径)
String get _displayPath {
if (_currentPath == _basePath) return 'markdown';
return _currentPath.substring(_basePath.length + 1);
}
@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),
),
),
child: Row(
children: [
// 返回按钮
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),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator(color: Color(0xFF1A1A1A)));
}
if (_error != null) {
return _buildErrorState();
}
if (_items.isEmpty) {
return _buildEmptyState();
}
return RefreshIndicator(
onRefresh: _loadDirectory,
color: const Color(0xFF1A1A1A),
backgroundColor: Colors.white,
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: _items.length,
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();
}
return _buildListItem(item, isDirectory, name);
},
),
);
}
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(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isDirectory ? const Color(0xFFF0F7FF) : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
isDirectory ? Icons.folder_outlined : Icons.description_outlined,
color: isDirectory ? const Color(0xFF4A90D9) : const Color(0xFF666666),
size: 18,
),
),
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() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Color(0xFFCCCCCC),
),
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 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),
),
child: const Text('重试'),
),
],
),
);
}
}

View File

@@ -0,0 +1,196 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
/// Markdown 文件查看页面
class MdViewerPage extends StatefulWidget {
final String filePath;
const MdViewerPage({super.key, required this.filePath});
@override
State<MdViewerPage> createState() => _MdViewerPageState();
}
class _MdViewerPageState extends State<MdViewerPage> {
String _content = '';
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadFile();
}
/// 加载 Markdown 文件内容
Future<void> _loadFile() async {
try {
final file = File(widget.filePath);
if (!await file.exists()) {
setState(() {
_error = '文件不存在';
_isLoading = false;
});
return;
}
final content = await file.readAsString();
setState(() {
_content = content;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = '读取文件失败: $e';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
final fileName = widget.filePath.split('/').last;
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
elevation: 0,
title: Text(
fileName,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator(color: Color(0xFF1A1A1A)));
}
if (_error != null) {
return _buildErrorState();
}
return Markdown(
data: _content,
padding: const EdgeInsets.all(20),
styleSheet: MarkdownStyleSheet(
h1: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.4,
),
h2: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.4,
),
h3: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.4,
),
p: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.8,
),
code: const TextStyle(
fontSize: 13,
color: Color(0xFF1A1A1A),
backgroundColor: Color(0xFFF5F5F5),
),
codeblockDecoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
borderRadius: BorderRadius.circular(6),
),
codeblockPadding: const EdgeInsets.all(12),
blockquote: const TextStyle(
fontSize: 15,
color: Color(0xFF666666),
fontStyle: FontStyle.italic,
),
blockquoteDecoration: const BoxDecoration(
border: Border(left: BorderSide(color: Color(0xFF999999), width: 4)),
),
blockquotePadding: const EdgeInsets.only(left: 12),
listBullet: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
),
listIndent: 24,
a: const TextStyle(
fontSize: 15,
color: Color(0xFF4A90D9),
decoration: TextDecoration.underline,
),
),
sizedImageBuilder: (config) => _buildImage(config.uri.toString(), config.alt),
);
}
/// 构建图片显示
Widget _buildImage(String uri, String? alt) {
if (uri.isEmpty) return const SizedBox.shrink();
// 处理相对路径:基于 md 文件所在目录
String imagePath = uri;
if (!uri.startsWith('/')) {
final baseDir = File(widget.filePath).parent.path;
imagePath = '$baseDir/$uri';
}
final file = File(imagePath);
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
file,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.broken_image_outlined, size: 20, color: Color(0xFF999999)),
const SizedBox(width: 8),
Expanded(
child: Text(
alt ?? '图片加载失败',
style: const TextStyle(fontSize: 13, color: Color(0xFF999999)),
),
),
],
),
);
},
),
);
}
Widget _buildErrorState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
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)),
),
],
),
);
}
}

View File

@@ -29,46 +29,11 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
backgroundColor: Colors.white,
appBar: AppBar(
title: Text(
_getTitle(note.content),
note.title.isNotEmpty ? note.title : '无标题',
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
actions: [
// 格式指示器 - 纯文本标记
if (note.contentType == 'markdown')
Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(2),
),
child: const Text(
'MD',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: Color(0xFF666666),
),
),
)
else
Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(2),
),
child: const Text(
'TXT',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: Color(0xFF666666),
),
),
),
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _navigateToEdit(context),
@@ -114,141 +79,9 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
),
),
// 内容区域
// 内容区域 - Markdown 渲染
Expanded(
child: note.contentType == 'markdown'
? _buildMarkdownContent(note)
: _buildPlainTextContent(note),
),
// 图片区域(仅在纯文本模式下显示)
if (note.contentType == 'plain_text' && note.images.isNotEmpty)
Container(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 图片标题
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: const Color(0xFFFAFAFA),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: const Icon(
Icons.image_outlined,
size: 18,
color: Color(0xFF666666),
),
),
const SizedBox(width: 12),
Text(
'图片 (${note.images.length})',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
],
),
const SizedBox(height: 16),
// 图片列表
SizedBox(
height: 110,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: note.images.length,
itemBuilder: (context, index) {
return GestureDetector(
onTap: () => _showImagePreview(context, note.images, index),
child: Container(
width: 110,
height: 110,
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: Image.file(
File(note.images[index]),
fit: BoxFit.cover,
),
),
);
},
),
),
],
),
),
// 底部操作栏
Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(color: Color(0xFFE8E8E8), width: 0.5),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
children: [
// 时间信息
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'创建 ${_formatDateTime(note.createdAt)}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
const SizedBox(height: 4),
Text(
'更新 ${_formatDateTime(note.updatedAt)}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
],
),
),
// 操作按钮
Row(
children: [
_buildActionButton(
icon: Icons.edit_outlined,
color: const Color(0xFF666666),
onTap: () => _navigateToEdit(context),
),
const SizedBox(width: 12),
_buildActionButton(
icon: Icons.delete_outline,
color: Colors.red,
onTap: () => _showDeleteDialog(context),
),
],
),
],
),
),
),
child: _buildMarkdownContent(note),
),
],
),
@@ -259,79 +92,176 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
Widget _buildMarkdownContent(Note note) {
return Markdown(
data: note.content,
styleSheet: MarkdownStyleSheet(
h1: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.4,
),
h2: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.4,
),
h3: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.4,
),
p: const TextStyle(
fontSize: 16,
color: Color(0xFF1A1A1A),
height: 1.8,
),
code: const TextStyle(
fontSize: 14,
color: Color(0xFF1A1A1A),
backgroundColor: Color(0xFFF5F5F5),
),
codeblockDecoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
border: Border.all(color: const Color(0xFFE5E5E5)),
),
blockquote: const TextStyle(
fontSize: 16,
color: Color(0xFF666666),
fontStyle: FontStyle.italic,
),
blockquoteDecoration: BoxDecoration(
border: Border(
left: BorderSide(color: const Color(0xFF999999), width: 4),
),
),
listBullet: const TextStyle(
fontSize: 16,
color: Color(0xFF1A1A1A),
),
a: const TextStyle(
fontSize: 16,
color: Color(0xFF1A1A1A),
decoration: TextDecoration.underline,
),
),
styleSheet: _buildMarkdownStyleSheet(),
padding: const EdgeInsets.all(16),
// TODO: migrate to sizedImageBuilder when flutter_markdown is updated
// ignore: deprecated_member_use
imageBuilder: (uri, title, alt) => _buildMarkdownImage(uri),
);
}
/// 构建纯文本内容
Widget _buildPlainTextContent(Note note) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: SizedBox(
width: double.infinity,
child: SelectableText(
note.content,
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
height: 1.9,
letterSpacing: 0.2,
),
/// 构建 Markdown 样式表
MarkdownStyleSheet _buildMarkdownStyleSheet() {
return MarkdownStyleSheet(
h1: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.4,
),
h2: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.4,
),
h3: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.4,
),
h4: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.4,
),
p: const TextStyle(
fontSize: 15,
color: Color(0xFF333333),
height: 1.8,
),
code: const TextStyle(
fontSize: 14,
color: Color(0xFF1A1A1A),
backgroundColor: Color(0xFFF5F5F5),
fontFamily: 'monospace',
),
codeblockDecoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
border: Border.all(color: const Color(0xFFE5E5E5)),
borderRadius: BorderRadius.circular(6),
),
codeblockPadding: const EdgeInsets.all(12),
blockquote: const TextStyle(
fontSize: 15,
color: Color(0xFF666666),
fontStyle: FontStyle.italic,
height: 1.8,
),
blockquoteDecoration: const BoxDecoration(
border: Border(left: BorderSide(color: Color(0xFF999999), width: 4)),
),
blockquotePadding: const EdgeInsets.only(left: 12, top: 4, bottom: 4),
listBullet: const TextStyle(
fontSize: 15,
color: Color(0xFF1A1A1A),
),
listIndent: 24,
a: const TextStyle(
fontSize: 15,
color: Color(0xFF4A90D9),
decoration: TextDecoration.underline,
),
tableHead: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
tableBody: const TextStyle(
fontSize: 14,
color: Color(0xFF333333),
),
tableBorder: TableBorder.all(
color: const Color(0xFFE5E5E5),
width: 0.5,
),
tableColumnWidth: const FlexColumnWidth(),
tableCellsDecoration: const BoxDecoration(
color: Colors.white,
),
tablePadding: const EdgeInsets.all(8),
strong: const TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
em: const TextStyle(
fontStyle: FontStyle.italic,
color: Color(0xFF333333),
),
del: const TextStyle(
decoration: TextDecoration.lineThrough,
color: Color(0xFF999999),
),
);
}
/// 构建 Markdown 中的图片
Widget _buildMarkdownImage(Uri uri) {
// 检查是否是本地图片路径
final path = uri.toString();
if (path.isEmpty) return const SizedBox.shrink();
// 尝试从笔记图片列表中查找
final noteImages = widget.note.images;
String? matchedPath;
for (final imgPath in noteImages) {
if (imgPath.contains(path) || path.contains(imgPath)) {
matchedPath = imgPath;
break;
}
}
if (matchedPath != null && File(matchedPath).existsSync()) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(matchedPath),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return _buildImageErrorWidget();
},
),
);
}
// 如果是网络图片
if (path.startsWith('http')) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
path,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return _buildImageErrorWidget();
},
),
);
}
return _buildImageErrorWidget();
}
/// 构建图片错误状态
Widget _buildImageErrorWidget() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.broken_image_outlined, size: 20, color: Color(0xFF999999)),
const SizedBox(width: 8),
const Expanded(
child: Text(
'图片加载失败',
style: TextStyle(fontSize: 13, color: Color(0xFF999999)),
),
),
],
),
);
}
@@ -341,15 +271,6 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
return '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
}
/// 获取标题(内容第一行,去除换行)
String _getTitle(String content) {
if (content.isEmpty) return '无标题';
// 移除换行符和多余空格
final trimmed = content.replaceAll('\n', ' ').trim();
if (trimmed.isEmpty) return '无标题';
return trimmed;
}
/// 跳转到编辑页面
void _navigateToEdit(BuildContext context) {
// 从 Provider 获取最新的笔记数据,确保图片等字段是最新的
@@ -371,7 +292,7 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
builder: (context) => GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
color: Colors.black.withOpacity(0.9),
color: Colors.black.withValues(alpha: 0.9),
child: Center(
child: InteractiveViewer(
panEnabled: true,

File diff suppressed because it is too large Load Diff

View File

@@ -257,6 +257,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
return [
Note(
id: '1',
title: '学习 Flutter 笔记',
content: '今天开始学习 Flutter 框架,感觉和 Vue 有很多相似之处,都是声明式 UI组件化开发。Widget 的概念很有趣,一切皆 Widget。',
tags: ['学习', 'Flutter', '编程'],
createdAt: now.subtract(const Duration(days: 2)),
@@ -264,6 +265,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
),
Note(
id: '2',
title: '《活着》读后感',
content: '余华的《活着》真的是一部让人深思的作品。福贵的一生经历了太多的苦难,但他依然坚强地活着。生命的意义或许就在于活着本身。',
tags: ['阅读', '感悟', '书籍'],
createdAt: now.subtract(const Duration(days: 5)),
@@ -271,6 +273,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
),
Note(
id: '3',
title: '诺兰电影观后感',
content: '诺兰的电影总是充满想象力。《星际穿越》将科幻与亲情完美结合,五维空间的呈现方式令人震撼。配乐也是一绝。',
tags: ['观影', '科幻', '电影'],
createdAt: now.subtract(const Duration(days: 10)),
@@ -278,6 +281,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
),
Note(
id: '4',
title: 'Pandas 学习笔记',
content: 'Pandas 库的 DataFrame 操作非常强大,可以方便地进行数据清洗和分析。需要多练习熟练掌握常用操作。',
tags: ['Python', '数据分析', '技术'],
createdAt: now.subtract(const Duration(days: 30)),
@@ -285,6 +289,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
),
Note(
id: '5',
title: '春日随笔',
content: '春天来了,天气渐暖。周末去公园散步,看到花开得很好。生活中的小确幸值得记录。',
tags: ['生活', '随笔'],
createdAt: now.subtract(const Duration(hours: 5)),

View File

@@ -673,9 +673,23 @@ class _ProfilePageState extends State<ProfilePage> {
}
/// 设置页面
class SettingsPage extends StatelessWidget {
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
final UserPrefs _userPrefs = UserPrefs();
bool _hideBottomNavOnScroll = true;
@override
void initState() {
super.initState();
_hideBottomNavOnScroll = _userPrefs.hideBottomNavOnScroll;
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -697,6 +711,14 @@ class SettingsPage extends StatelessWidget {
// 主界面功能显示入口
_buildSectionHeader('个性化设置'),
_buildSwitchItem(
icon: Icons.swipe_vertical_outlined,
title: '底部导航栏滚动隐藏',
subtitle: '下滑时自动隐藏底部导航栏',
value: _hideBottomNavOnScroll,
onChanged: _toggleHideBottomNavOnScroll,
),
const Divider(height: 0.5, indent: 24, endIndent: 24),
_buildNavigationItem(
icon: Icons.apps_outlined,
title: '应用图标',
@@ -751,6 +773,77 @@ class SettingsPage extends StatelessWidget {
);
}
/// 切换底部导航栏滚动隐藏
Future<void> _toggleHideBottomNavOnScroll(bool value) async {
await _userPrefs.setHideBottomNavOnScroll(value);
setState(() => _hideBottomNavOnScroll = value);
}
/// 构建开关项
Widget _buildSwitchItem({
required IconData icon,
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return InkWell(
onTap: () => onChanged(!value),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
color: const Color(0xFF666666),
size: 22,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF999999),
),
),
],
),
),
Switch(
value: value,
onChanged: onChanged,
activeColor: const Color(0xFF1A1A1A),
activeTrackColor: const Color(0xFF1A1A1A).withOpacity(0.3),
inactiveThumbColor: Colors.white,
inactiveTrackColor: const Color(0xFFE5E5E5),
),
],
),
),
);
}
/// 构建区块标题
Widget _buildSectionHeader(String title) {
return Padding(