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,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'dart:async'; import 'dart:async';
import 'pages/home_page.dart'; import 'pages/home_page.dart';
@@ -65,6 +66,15 @@ class MyApp extends StatelessWidget {
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme, darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
localizationsDelegates: [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('zh', 'CN'),
Locale('en', 'US'),
],
home: const HomePage(), home: const HomePage(),
onGenerateRoute: AppRouter.generateRoute, onGenerateRoute: AppRouter.generateRoute,
builder: (context, child) { builder: (context, child) {

View File

@@ -294,6 +294,7 @@ class Book {
/// 笔记模型 /// 笔记模型
class Note { class Note {
final String id; final String id;
final String title;
final String content; final String content;
final String contentType; // markdown / plain_text final String contentType; // markdown / plain_text
final List<String> tags; final List<String> tags;
@@ -304,6 +305,7 @@ class Note {
Note({ Note({
required this.id, required this.id,
required this.title,
required this.content, required this.content,
this.contentType = 'markdown', this.contentType = 'markdown',
this.tags = const [], this.tags = const [],
@@ -316,6 +318,7 @@ class Note {
factory Note.fromJson(Map<String, dynamic> json) { factory Note.fromJson(Map<String, dynamic> json) {
return Note( return Note(
id: json['id']?.toString() ?? '', id: json['id']?.toString() ?? '',
title: json['title'] ?? '',
content: json['content'] ?? '', content: json['content'] ?? '',
contentType: json['content_type'] ?? 'markdown', contentType: json['content_type'] ?? 'markdown',
tags: Movie.parseStringList(json['tags']), tags: Movie.parseStringList(json['tags']),
@@ -333,6 +336,7 @@ class Note {
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'id': id, 'id': id,
'title': title,
'content': content, 'content': content,
'content_type': contentType, 'content_type': contentType,
'tags': jsonEncode(tags), 'tags': jsonEncode(tags),
@@ -346,6 +350,7 @@ class Note {
/// 复制并修改 /// 复制并修改
Note copyWith({ Note copyWith({
String? id, String? id,
String? title,
String? content, String? content,
String? contentType, String? contentType,
List<String>? tags, List<String>? tags,
@@ -356,6 +361,7 @@ class Note {
}) { }) {
return Note( return Note(
id: id ?? this.id, id: id ?? this.id,
title: title ?? this.title,
content: content ?? this.content, content: content ?? this.content,
contentType: contentType ?? this.contentType, contentType: contentType ?? this.contentType,
tags: tags ?? this.tags, tags: tags ?? this.tags,

View File

@@ -84,6 +84,7 @@ extension NoteExtension on Note {
/// 创建副本并允许修改部分属性 /// 创建副本并允许修改部分属性
Note copyWith({ Note copyWith({
String? id, String? id,
String? title,
String? content, String? content,
String? contentType, String? contentType,
List<String>? tags, List<String>? tags,
@@ -94,6 +95,7 @@ extension NoteExtension on Note {
}) { }) {
return Note( return Note(
id: id ?? this.id, id: id ?? this.id,
title: title ?? this.title,
content: content ?? this.content, content: content ?? this.content,
contentType: contentType ?? this.contentType, contentType: contentType ?? this.contentType,
tags: tags ?? this.tags, tags: tags ?? this.tags,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../utils/user_prefs.dart';
import '../widgets/custom_drawer.dart'; import '../widgets/custom_drawer.dart';
import '../widgets/bottom_nav_bar.dart'; import '../widgets/bottom_nav_bar.dart';
import 'main_content_page.dart'; import 'main_content_page.dart';
@@ -15,47 +16,132 @@ class HomePage extends StatefulWidget {
} }
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
/// 当前正在滑动的页面索引用于PageView
final PageController _pageController = PageController();
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
// 左侧弹出菜单(仅在主页显示) // 左侧弹出菜单(仅在主页显示)
drawer: context.watch<AppProvider>().bottomNavIndex == 0 drawer: context.watch<AppProvider>().bottomNavIndex == 0
? CustomDrawer() ? CustomDrawer()
: null, : null,
// 主体内容 - 使用 Stack 让 dock 栏悬浮在内容上方 // 主体内容
body: Stack( body: Consumer<AppProvider>(
children: [ builder: (context, provider, child) {
// 底层:主体内容 // 同步底部导航栏和 PageView 的页面
_buildBody(), final currentPage = provider.bottomNavIndex == 0 ? 0 : 1;
if (_pageController.hasClients && _pageController.page?.round() != currentPage) {
// 顶层:悬浮 dock 栏 _pageController.jumpToPage(currentPage);
const Positioned( }
left: 0,
right: 0, return NotificationListener<ScrollNotification>(
bottom: 0, onNotification: (notification) {
child: CustomBottomNavBar(), 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,
),
),
),
),
),
],
),
);
},
), ),
); );
} }
/// 构建主体内容 /// 构建主体内容(使用 PageView 支持左右滑动切换)
Widget _buildBody() { Widget _buildPageView(AppProvider provider) {
return Consumer<AppProvider>( return PageView(
builder: (context, provider, child) { controller: _pageController,
switch (provider.bottomNavIndex) { physics: const BouncingScrollPhysics(),
case 0: onPageChanged: (index) {
// 主页 - 观影/阅读/笔记 if (index == 0) {
return const MainContentPage(); provider.setBottomNavIndex(0);
case 2: } else if (index == 1) {
// 我的页面 provider.setBottomNavIndex(2);
return const ProfilePage();
default:
return const MainContentPage();
} }
}, },
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, backgroundColor: Colors.white,
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
_getTitle(note.content), note.title.isNotEmpty ? note.title : '无标题',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
maxLines: 1, maxLines: 1,
), ),
actions: [ 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( IconButton(
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit_outlined),
onPressed: () => _navigateToEdit(context), onPressed: () => _navigateToEdit(context),
@@ -114,141 +79,9 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
), ),
), ),
// 内容区域 // 内容区域 - Markdown 渲染
Expanded( Expanded(
child: note.contentType == 'markdown' child: _buildMarkdownContent(note),
? _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),
),
],
),
],
),
),
),
), ),
], ],
), ),
@@ -259,79 +92,176 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
Widget _buildMarkdownContent(Note note) { Widget _buildMarkdownContent(Note note) {
return Markdown( return Markdown(
data: note.content, data: note.content,
styleSheet: MarkdownStyleSheet( styleSheet: _buildMarkdownStyleSheet(),
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,
),
),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
// TODO: migrate to sizedImageBuilder when flutter_markdown is updated
// ignore: deprecated_member_use
imageBuilder: (uri, title, alt) => _buildMarkdownImage(uri),
); );
} }
/// 构建纯文本内容 /// 构建 Markdown 样式表
Widget _buildPlainTextContent(Note note) { MarkdownStyleSheet _buildMarkdownStyleSheet() {
return SingleChildScrollView( return MarkdownStyleSheet(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), h1: const TextStyle(
child: SizedBox( fontSize: 24,
width: double.infinity, fontWeight: FontWeight.w600,
child: SelectableText( color: Color(0xFF1A1A1A),
note.content, height: 1.4,
textAlign: TextAlign.left, ),
style: const TextStyle( h2: const TextStyle(
fontSize: 15, fontSize: 20,
color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600,
height: 1.9, color: Color(0xFF1A1A1A),
letterSpacing: 0.2, 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')}'; 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) { void _navigateToEdit(BuildContext context) {
// 从 Provider 获取最新的笔记数据,确保图片等字段是最新的 // 从 Provider 获取最新的笔记数据,确保图片等字段是最新的
@@ -371,7 +292,7 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
builder: (context) => GestureDetector( builder: (context) => GestureDetector(
onTap: () => Navigator.pop(context), onTap: () => Navigator.pop(context),
child: Container( child: Container(
color: Colors.black.withOpacity(0.9), color: Colors.black.withValues(alpha: 0.9),
child: Center( child: Center(
child: InteractiveViewer( child: InteractiveViewer(
panEnabled: true, panEnabled: true,

File diff suppressed because it is too large Load Diff

View File

@@ -257,6 +257,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
return [ return [
Note( Note(
id: '1', id: '1',
title: '学习 Flutter 笔记',
content: '今天开始学习 Flutter 框架,感觉和 Vue 有很多相似之处,都是声明式 UI组件化开发。Widget 的概念很有趣,一切皆 Widget。', content: '今天开始学习 Flutter 框架,感觉和 Vue 有很多相似之处,都是声明式 UI组件化开发。Widget 的概念很有趣,一切皆 Widget。',
tags: ['学习', 'Flutter', '编程'], tags: ['学习', 'Flutter', '编程'],
createdAt: now.subtract(const Duration(days: 2)), createdAt: now.subtract(const Duration(days: 2)),
@@ -264,6 +265,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
), ),
Note( Note(
id: '2', id: '2',
title: '《活着》读后感',
content: '余华的《活着》真的是一部让人深思的作品。福贵的一生经历了太多的苦难,但他依然坚强地活着。生命的意义或许就在于活着本身。', content: '余华的《活着》真的是一部让人深思的作品。福贵的一生经历了太多的苦难,但他依然坚强地活着。生命的意义或许就在于活着本身。',
tags: ['阅读', '感悟', '书籍'], tags: ['阅读', '感悟', '书籍'],
createdAt: now.subtract(const Duration(days: 5)), createdAt: now.subtract(const Duration(days: 5)),
@@ -271,6 +273,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
), ),
Note( Note(
id: '3', id: '3',
title: '诺兰电影观后感',
content: '诺兰的电影总是充满想象力。《星际穿越》将科幻与亲情完美结合,五维空间的呈现方式令人震撼。配乐也是一绝。', content: '诺兰的电影总是充满想象力。《星际穿越》将科幻与亲情完美结合,五维空间的呈现方式令人震撼。配乐也是一绝。',
tags: ['观影', '科幻', '电影'], tags: ['观影', '科幻', '电影'],
createdAt: now.subtract(const Duration(days: 10)), createdAt: now.subtract(const Duration(days: 10)),
@@ -278,6 +281,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
), ),
Note( Note(
id: '4', id: '4',
title: 'Pandas 学习笔记',
content: 'Pandas 库的 DataFrame 操作非常强大,可以方便地进行数据清洗和分析。需要多练习熟练掌握常用操作。', content: 'Pandas 库的 DataFrame 操作非常强大,可以方便地进行数据清洗和分析。需要多练习熟练掌握常用操作。',
tags: ['Python', '数据分析', '技术'], tags: ['Python', '数据分析', '技术'],
createdAt: now.subtract(const Duration(days: 30)), createdAt: now.subtract(const Duration(days: 30)),
@@ -285,6 +289,7 @@ class _NoteTabPageState extends State<NoteTabPage> {
), ),
Note( Note(
id: '5', id: '5',
title: '春日随笔',
content: '春天来了,天气渐暖。周末去公园散步,看到花开得很好。生活中的小确幸值得记录。', content: '春天来了,天气渐暖。周末去公园散步,看到花开得很好。生活中的小确幸值得记录。',
tags: ['生活', '随笔'], tags: ['生活', '随笔'],
createdAt: now.subtract(const Duration(hours: 5)), 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}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -697,6 +711,14 @@ class SettingsPage extends StatelessWidget {
// 主界面功能显示入口 // 主界面功能显示入口
_buildSectionHeader('个性化设置'), _buildSectionHeader('个性化设置'),
_buildSwitchItem(
icon: Icons.swipe_vertical_outlined,
title: '底部导航栏滚动隐藏',
subtitle: '下滑时自动隐藏底部导航栏',
value: _hideBottomNavOnScroll,
onChanged: _toggleHideBottomNavOnScroll,
),
const Divider(height: 0.5, indent: 24, endIndent: 24),
_buildNavigationItem( _buildNavigationItem(
icon: Icons.apps_outlined, icon: Icons.apps_outlined,
title: '应用图标', 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) { Widget _buildSectionHeader(String title) {
return Padding( return Padding(

View File

@@ -30,6 +30,9 @@ class AppProvider extends ChangeNotifier {
// 当前底部导航选中的索引 (0: 主页1: 新增2: 我的) // 当前底部导航选中的索引 (0: 主页1: 新增2: 我的)
int _bottomNavIndex = 0; int _bottomNavIndex = 0;
// 底部导航栏是否可见
bool _bottomNavVisible = true;
// 观影选中的状态 (0: 已看1: 想看2: 在看) // 观影选中的状态 (0: 已看1: 想看2: 在看)
int _movieStatusIndex = 0; int _movieStatusIndex = 0;
@@ -71,6 +74,7 @@ class AppProvider extends ChangeNotifier {
int get movieStatusIndex => _movieStatusIndex; int get movieStatusIndex => _movieStatusIndex;
int get bookStatusIndex => _bookStatusIndex; int get bookStatusIndex => _bookStatusIndex;
bool get drawerOpen => _drawerOpen; bool get drawerOpen => _drawerOpen;
bool get bottomNavVisible => _bottomNavVisible;
List<Movie> get movies => _movies; List<Movie> get movies => _movies;
List<Book> get books => _books; List<Book> get books => _books;
List<Note> get notes => _notes; List<Note> get notes => _notes;
@@ -93,9 +97,17 @@ class AppProvider extends ChangeNotifier {
void setBottomNavIndex(int index) { void setBottomNavIndex(int index) {
_bottomNavIndex = index; _bottomNavIndex = index;
_bottomNavVisible = true; // 切换页面时自动显示导航栏
notifyListeners(); notifyListeners();
} }
void setBottomNavVisible(bool visible) {
if (_bottomNavVisible != visible) {
_bottomNavVisible = visible;
notifyListeners();
}
}
void setMovieStatusIndex(int index) { void setMovieStatusIndex(int index) {
_movieStatusIndex = index; _movieStatusIndex = index;
notifyListeners(); notifyListeners();

View File

@@ -0,0 +1,372 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import '../models/data_models.dart';
import 'database_helper.dart';
import 'storage_helper.dart';
/// 数据迁移帮助类:将旧版文件系统数据迁移到 SQLite 数据库
class DataMigration {
final StorageHelper _storage = StorageHelper.instance;
final DatabaseHelper _db = DatabaseHelper.instance;
static bool _hasMigrated = false;
/// 执行数据迁移(幂等,只会执行一次)
Future<void> migrateIfNeeded() async {
if (_hasMigrated) return;
_hasMigrated = true;
try {
await _migrateMovies();
await _migrateBooks();
await _migrateNotes();
debugPrint('数据迁移完成');
} catch (e, stack) {
debugPrint('数据迁移失败: $e');
debugPrint('堆栈: $stack');
}
}
/// 迁移影视数据
Future<void> _migrateMovies() async {
final moviesDirPath = await _storage.moviesDir;
final movieDirs = await _listSubdirNames(moviesDirPath);
if (movieDirs.isEmpty) return;
debugPrint('发现 ${movieDirs.length} 个影视目录,开始迁移...');
final db = await _db.database;
for (final dirName in movieDirs) {
try {
final dirPath = p.join(moviesDirPath, dirName);
final dataPath = '$dirPath/data.json';
final data = await _readJsonFile(dataPath);
if (data == null) continue;
final movie = Movie.fromJson(data);
await db.insert(
'movies',
_movieToMap(movie),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
// 迁移影评
await _migrateMovieReviews(dirPath, movie.id);
// 迁移海报
await _migrateMoviePosters(dirPath, movie.id);
} catch (e) {
debugPrint('迁移影视 $dirName 失败: $e');
}
}
}
/// 迁移影评
Future<void> _migrateMovieReviews(String movieDirPath, String movieId) async {
final reviewsDir = p.join(movieDirPath, 'reviews');
if (!await Directory(reviewsDir).exists()) return;
final files = await _listJsonFiles(reviewsDir);
final db = await _db.database;
for (final data in files) {
try {
final review = MovieReview.fromJson(data);
await db.insert(
'movie_reviews',
_movieReviewToMap(review),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
} catch (e) {
debugPrint('迁移影评失败: $e');
}
}
}
/// 迁移海报
Future<void> _migrateMoviePosters(String movieDirPath, String movieId) async {
final postersDir = p.join(movieDirPath, 'posters');
if (!await Directory(postersDir).exists()) return;
final files = await _listJsonFiles(postersDir);
final db = await _db.database;
for (final data in files) {
try {
final poster = MoviePoster.fromJson(data);
await db.insert(
'movie_posters',
_moviePosterToMap(poster),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
} catch (e) {
debugPrint('迁移海报失败: $e');
}
}
}
/// 迁移书籍数据
Future<void> _migrateBooks() async {
final booksDirPath = await _storage.booksDir;
final bookDirs = await _listSubdirNames(booksDirPath);
if (bookDirs.isEmpty) return;
debugPrint('发现 ${bookDirs.length} 个书籍目录,开始迁移...');
final db = await _db.database;
for (final dirName in bookDirs) {
try {
final dirPath = p.join(booksDirPath, dirName);
final dataPath = '$dirPath/data.json';
final data = await _readJsonFile(dataPath);
if (data == null) continue;
final book = Book.fromJson(data);
await db.insert(
'books',
_bookToMap(book),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
// 迁移书评
await _migrateBookReviews(dirPath, book.id);
// 迁移摘抄
await _migrateBookExcerpts(dirPath, book.id);
} catch (e) {
debugPrint('迁移书籍 $dirName 失败: $e');
}
}
}
/// 迁移书评
Future<void> _migrateBookReviews(String bookDirPath, String bookId) async {
final reviewsDir = p.join(bookDirPath, 'reviews');
if (!await Directory(reviewsDir).exists()) return;
final files = await _listJsonFiles(reviewsDir);
final db = await _db.database;
for (final data in files) {
try {
final review = BookReview.fromJson(data);
await db.insert(
'book_reviews',
_bookReviewToMap(review),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
} catch (e) {
debugPrint('迁移书评失败: $e');
}
}
}
/// 迁移摘抄
Future<void> _migrateBookExcerpts(String bookDirPath, String bookId) async {
final excerptsDir = p.join(bookDirPath, 'excerpts');
if (!await Directory(excerptsDir).exists()) return;
final files = await _listJsonFiles(excerptsDir);
final db = await _db.database;
for (final data in files) {
try {
final excerpt = BookExcerpt.fromJson(data);
await db.insert(
'book_excerpts',
_bookExcerptToMap(excerpt),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
} catch (e) {
debugPrint('迁移摘抄失败: $e');
}
}
}
/// 迁移笔记数据
Future<void> _migrateNotes() async {
final notesDirPath = await _storage.notesDir;
final noteDirs = await _listSubdirNames(notesDirPath);
if (noteDirs.isEmpty) return;
debugPrint('发现 ${noteDirs.length} 个笔记目录,开始迁移...');
final db = await _db.database;
for (final dirName in noteDirs) {
try {
final dirPath = p.join(notesDirPath, dirName);
final dataPath = '$dirPath/data.json';
final data = await _readJsonFile(dataPath);
if (data == null) continue;
final note = Note.fromJson(data);
await db.insert(
'notes',
_noteToMap(note),
conflictAlgorithm: ConflictAlgorithm.ignore,
);
} catch (e) {
debugPrint('迁移笔记 $dirName 失败: $e');
}
}
}
// ========== 转换方法 ==========
Map<String, dynamic> _movieToMap(Movie movie) {
return {
'id': movie.id,
'title': movie.title,
'poster_path': movie.posterPath,
'release_date': movie.releaseDate?.toIso8601String(),
'directors': jsonEncode(movie.directors),
'writers': jsonEncode(movie.writers),
'actors': jsonEncode(movie.actors),
'genres': jsonEncode(movie.genres),
'alternate_titles': jsonEncode(movie.alternateTitles),
'summary': movie.summary,
'rating': movie.rating,
'status': movie.status,
'watch_date': movie.watchDate?.toIso8601String(),
'created_at': movie.createdAt.toIso8601String(),
'updated_at': movie.updatedAt.toIso8601String(),
'is_deleted': movie.isDeleted ? 1 : 0,
};
}
Map<String, dynamic> _bookToMap(Book book) {
return {
'id': book.id,
'title': book.title,
'cover_path': book.coverPath,
'authors': jsonEncode(book.authors),
'alternate_titles': jsonEncode(book.alternateTitles),
'publisher': book.publisher,
'genres': jsonEncode(book.genres),
'summary': book.summary,
'rating': book.rating,
'status': book.status,
'isbn': book.isbn,
'publish_date': book.publishDate?.toIso8601String(),
'created_at': book.createdAt.toIso8601String(),
'updated_at': book.updatedAt.toIso8601String(),
'is_deleted': book.isDeleted ? 1 : 0,
};
}
Map<String, dynamic> _noteToMap(Note note) {
return {
'id': note.id,
'content': note.content,
'content_type': note.contentType,
'tags': jsonEncode(note.tags),
'images': jsonEncode(note.images),
'created_at': note.createdAt.toIso8601String(),
'updated_at': note.updatedAt.toIso8601String(),
'is_deleted': note.isDeleted ? 1 : 0,
};
}
Map<String, dynamic> _movieReviewToMap(MovieReview review) {
return {
'id': review.id,
'movie_id': review.movieId,
'content': review.content,
'reviewer': review.reviewer,
'source': review.source,
'review_type': review.reviewType,
'is_deleted': review.isDeleted ? 1 : 0,
'created_at': review.createdAt.toIso8601String(),
'updated_at': review.updatedAt.toIso8601String(),
};
}
Map<String, dynamic> _moviePosterToMap(MoviePoster poster) {
return {
'id': poster.id,
'movie_id': poster.movieId,
'poster_path': poster.posterPath,
'is_deleted': poster.isDeleted ? 1 : 0,
'created_at': poster.createdAt.toIso8601String(),
};
}
Map<String, dynamic> _bookReviewToMap(BookReview review) {
return {
'id': review.id,
'book_id': review.bookId,
'content': review.content,
'reviewer': review.reviewer,
'source': review.source,
'review_type': review.reviewType,
'is_deleted': review.isDeleted ? 1 : 0,
'created_at': review.createdAt.toIso8601String(),
'updated_at': review.updatedAt.toIso8601String(),
};
}
Map<String, dynamic> _bookExcerptToMap(BookExcerpt excerpt) {
return {
'id': excerpt.id,
'book_id': excerpt.bookId,
'chapter': excerpt.chapter,
'content': excerpt.content,
'comment': excerpt.comment,
'is_deleted': excerpt.isDeleted ? 1 : 0,
'created_at': excerpt.createdAt.toIso8601String(),
'updated_at': excerpt.updatedAt.toIso8601String(),
};
}
// ========== 辅助方法 ==========
/// 列出子目录名
Future<List<String>> _listSubdirNames(String dirPath) async {
try {
final dir = Directory(dirPath);
if (!await dir.exists()) return [];
final entities = await dir.list().toList();
return entities
.whereType<Directory>()
.map((e) => p.basename(e.path))
.toList();
} catch (e) {
return [];
}
}
/// 读取 JSON 文件
Future<Map<String, dynamic>?> _readJsonFile(String path) async {
try {
final file = File(path);
if (!await file.exists()) return null;
final content = await file.readAsString();
return jsonDecode(content) as Map<String, dynamic>;
} catch (e) {
return null;
}
}
/// 列出目录中的 JSON 文件并解析
Future<List<Map<String, dynamic>>> _listJsonFiles(String dirPath) async {
try {
final dir = Directory(dirPath);
if (!await dir.exists()) return [];
final files = await dir
.list()
.where((entity) => entity is File && entity.path.endsWith('.json'))
.toList();
final results = <Map<String, dynamic>>[];
for (final file in files) {
final data = await _readJsonFile(file.path);
if (data != null) results.add(data);
}
return results;
} catch (e) {
return [];
}
}
}

View File

@@ -31,7 +31,7 @@ class DatabaseHelper {
return await openDatabase( return await openDatabase(
path, path,
version: 11, version: 12,
onCreate: _createDB, onCreate: _createDB,
onUpgrade: _onUpgrade, onUpgrade: _onUpgrade,
); );
@@ -82,6 +82,10 @@ class DatabaseHelper {
// 为书籍表添加ISBN和出版时间字段 // 为书籍表添加ISBN和出版时间字段
await _upgradeBooksTableV11(db); await _upgradeBooksTableV11(db);
} }
if (oldVersion < 12) {
// 确保notes表有title列
await _upgradeNotesTableV12(db);
}
} }
/// 升级books表到V11添加ISBN和出版时间字段 /// 升级books表到V11添加ISBN和出版时间字段
@@ -110,6 +114,17 @@ class DatabaseHelper {
} }
} }
/// 升级notes表到V12确保title列存在
Future<void> _upgradeNotesTableV12(Database db) async {
// 检查是否存在 title 列
final columns = await db.rawQuery('PRAGMA table_info(notes)');
final hasTitle = columns.any((col) => col['name'] == 'title');
if (!hasTitle) {
await db.execute('ALTER TABLE notes ADD COLUMN title TEXT DEFAULT \'\'');
}
}
/// 升级notes表到V9添加图片字段 /// 升级notes表到V9添加图片字段
Future<void> _upgradeNotesTableV9(Database db) async { Future<void> _upgradeNotesTableV9(Database db) async {
// 检查是否存在 images 列 // 检查是否存在 images 列
@@ -211,6 +226,7 @@ class DatabaseHelper {
await db.execute(''' await db.execute('''
CREATE TABLE notes ( CREATE TABLE notes (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT DEFAULT '',
content TEXT NOT NULL, content TEXT NOT NULL,
content_type TEXT DEFAULT 'markdown', content_type TEXT DEFAULT 'markdown',
tags TEXT, tags TEXT,
@@ -219,19 +235,17 @@ class DatabaseHelper {
) )
'''); ''');
// 迁移旧数据将title合并到content中 // 迁移旧数据将title字段恢复
for (final row in oldData) { for (final row in oldData) {
try { try {
final now = DateTime.now().toIso8601String(); final now = DateTime.now().toIso8601String();
final title = row['title']?.toString() ?? ''; final title = row['title']?.toString() ?? '';
final content = row['content']?.toString() ?? ''; final content = row['content']?.toString() ?? '';
final combinedContent = title.isNotEmpty
? '# $title\n\n$content'
: content;
await db.insert('notes', { await db.insert('notes', {
'id': row['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(), 'id': row['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(),
'content': combinedContent, 'title': title,
'content': content,
'content_type': 'markdown', 'content_type': 'markdown',
'tags': row['tags'] ?? '', 'tags': row['tags'] ?? '',
'created_at': row['created_at']?.toString() ?? now, 'created_at': row['created_at']?.toString() ?? now,
@@ -404,6 +418,7 @@ class DatabaseHelper {
await db.execute(''' await db.execute('''
CREATE TABLE notes ( CREATE TABLE notes (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT DEFAULT '',
content TEXT NOT NULL, content TEXT NOT NULL,
content_type TEXT DEFAULT 'markdown', content_type TEXT DEFAULT 'markdown',
tags TEXT, tags TEXT,

View File

@@ -101,8 +101,8 @@ class NoteDao {
final db = await _dbHelper.database; final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query( final List<Map<String, dynamic>> maps = await db.query(
'notes', 'notes',
where: '(content LIKE ? OR tags LIKE ?) AND is_deleted = ?', where: '(title LIKE ? OR content LIKE ? OR tags LIKE ?) AND is_deleted = ?',
whereArgs: ['%$query%', '%$query%', 0], whereArgs: ['%$query%', '%$query%', '%$query%', 0],
orderBy: 'created_at DESC', orderBy: 'created_at DESC',
); );

View File

@@ -48,6 +48,10 @@ class UserPrefs {
// ========== 主界面显示设置 ========== // ========== 主界面显示设置 ==========
/// 是否启用底部导航栏滚动隐藏(默认开启)
bool get hideBottomNavOnScroll => prefs.getBool('hideBottomNavOnScroll') ?? true;
Future<bool> setHideBottomNavOnScroll(bool value) => prefs.setBool('hideBottomNavOnScroll', value);
/// 是否显示观影标签 /// 是否显示观影标签
bool get showMovieTab => prefs.getBool('showMovieTab') ?? true; bool get showMovieTab => prefs.getBool('showMovieTab') ?? true;
Future<bool> setShowMovieTab(bool value) => prefs.setBool('showMovieTab', value); Future<bool> setShowMovieTab(bool value) => prefs.setBool('showMovieTab', value);

View File

@@ -0,0 +1,373 @@
import 'package:flutter/material.dart';
/// Markdown 编辑器控制器
/// 实现 Typora 风格的所见即所得 Markdown 编辑体验
/// 输入 # 标题 时,# 变小变淡,标题文字变大加粗
/// 输入 **粗体** 时,文字自动加粗
class MarkdownEditingController extends TextEditingController {
MarkdownEditingController({String? text}) : super(text: text);
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
return _buildMarkdownSpan(text, style);
}
/// 构建 Markdown 样式的 TextSpan
TextSpan _buildMarkdownSpan(String text, TextStyle? baseStyle) {
if (text.isEmpty) {
return TextSpan(text: '', style: baseStyle);
}
final spans = <InlineSpan>[];
final lines = text.split('\n');
for (var i = 0; i < lines.length; i++) {
if (i > 0) {
spans.add(const TextSpan(text: '\n'));
}
spans.add(_parseLine(lines[i], baseStyle));
}
return TextSpan(children: spans);
}
/// 解析单行文本
InlineSpan _parseLine(String line, TextStyle? baseStyle) {
// 空行
if (line.isEmpty) {
return const TextSpan(text: '');
}
// 代码块分隔符 ```
if (line.startsWith('```')) {
return TextSpan(
text: line,
style: _codeBlockStyle(baseStyle),
);
}
// 标题 # ## ### 等
if (line.startsWith('#')) {
final headerMatch = RegExp(r'^(#{1,6})\s+(.*)$').firstMatch(line);
if (headerMatch != null) {
final level = headerMatch.group(1)!.length;
final content = headerMatch.group(2)!;
return _buildHeaderSpan(level, content, baseStyle);
}
}
// 引用 >
if (line.startsWith('>')) {
final quoteMatch = RegExp(r'^>\s?(.*)$').firstMatch(line);
if (quoteMatch != null) {
final content = quoteMatch.group(1)!;
return _buildQuoteSpan(content, baseStyle);
}
}
// 无序列表 - 或 *
final ulMatch = RegExp(r'^([\-\*])\s+(.*)$').firstMatch(line);
if (ulMatch != null) {
final content = ulMatch.group(2)!;
return _buildListSpan(content, baseStyle, isOrdered: false);
}
// 有序列表 1. 2. 等
final olMatch = RegExp(r'^(\d+)\.\s+(.*)$').firstMatch(line);
if (olMatch != null) {
final number = olMatch.group(1)!;
final content = olMatch.group(2)!;
return _buildListSpan(content, baseStyle, isOrdered: true, number: number);
}
// 分割线 --- *** ___
if (RegExp(r'^( {0,3}([-_*])\s*\2\s*\2[\s\2]*)$').hasMatch(line)) {
return _buildDividerSpan(line, baseStyle);
}
// 普通行 - 解析行内元素
return _parseInline(line, baseStyle);
}
// ==================== 标题 ====================
InlineSpan _buildHeaderSpan(int level, String content, TextStyle? baseStyle) {
// 标题只改变颜色和粗细,不改变字体大小,避免光标错位
final headerStyle = (baseStyle ?? const TextStyle()).copyWith(
fontWeight: FontWeight.w600,
color: const Color(0xFF1A1A1A),
);
return TextSpan(
children: [
TextSpan(
text: '${'#' * level} ',
style: const TextStyle(
color: Color(0xFFCCCCCC),
fontWeight: FontWeight.w400,
),
),
..._parseInlineSpans(content, headerStyle),
],
);
}
// ==================== 引用 ====================
InlineSpan _buildQuoteSpan(String content, TextStyle? baseStyle) {
final quoteStyle = (baseStyle ?? const TextStyle()).copyWith(
color: const Color(0xFF666666),
fontStyle: FontStyle.italic,
height: 1.8,
);
return TextSpan(
children: [
const TextSpan(
text: '> ',
style: TextStyle(
color: Color(0xFF999999),
fontWeight: FontWeight.bold,
),
),
..._parseInlineSpans(content, quoteStyle),
],
);
}
// ==================== 列表 ====================
InlineSpan _buildListSpan(String content, TextStyle? baseStyle,
{required bool isOrdered, String? number}) {
return TextSpan(
children: [
TextSpan(
text: isOrdered ? '$number. ' : '',
style: const TextStyle(
color: Color(0xFF333333),
fontWeight: FontWeight.w600,
),
),
..._parseInlineSpans(
content,
(baseStyle ?? const TextStyle()).copyWith(
color: const Color(0xFF333333),
),
),
],
);
}
// ==================== 分割线 ====================
InlineSpan _buildDividerSpan(String line, TextStyle? baseStyle) {
// 返回原始文本,但用灰色显示
return TextSpan(
text: line,
style: const TextStyle(
color: Color(0xFFCCCCCC),
),
);
}
// ==================== 行内元素解析 ====================
InlineSpan _parseInline(String text, TextStyle? baseStyle) {
return TextSpan(children: _parseInlineSpans(text, baseStyle));
}
/// 解析行内 Markdown 元素
/// 返回 InlineSpan 列表
List<InlineSpan> _parseInlineSpans(String text, TextStyle? baseStyle) {
if (text.isEmpty) {
return [const TextSpan(text: '')];
}
// 收集所有匹配的模式
final patterns = <_MatchPattern>[];
// 粗体 **text**
for (final match in RegExp(r'\*\*([^*]+)\*\*').allMatches(text)) {
if (match.group(1)!.isNotEmpty) {
patterns.add(_MatchPattern(
match.start,
match.end,
_InlineType.bold,
match.group(0)!,
match.group(1)!,
));
}
}
// 斜体 *text* (排除 **)
for (final match in RegExp(r'(?<!\*)\*([^*]+)\*(?!\*)').allMatches(text)) {
if (match.group(1)!.isNotEmpty) {
patterns.add(_MatchPattern(
match.start,
match.end,
_InlineType.italic,
match.group(0)!,
match.group(1)!,
));
}
}
// 删除线 ~~text~~
for (final match in RegExp(r'~~([^~]+)~~').allMatches(text)) {
if (match.group(1)!.isNotEmpty) {
patterns.add(_MatchPattern(
match.start,
match.end,
_InlineType.strikethrough,
match.group(0)!,
match.group(1)!,
));
}
}
// 行内代码 `code`
for (final match in RegExp(r'`([^`]+)`').allMatches(text)) {
if (match.group(1)!.isNotEmpty) {
patterns.add(_MatchPattern(
match.start,
match.end,
_InlineType.inlineCode,
match.group(0)!,
match.group(1)!,
));
}
}
// 链接 [text](url)
for (final match in RegExp(r'\[([^\]]+)\]\(([^)]+)\)').allMatches(text)) {
if (match.group(1)!.isNotEmpty) {
patterns.add(_MatchPattern(
match.start,
match.end,
_InlineType.link,
match.group(0)!,
match.group(1)!,
url: match.group(2),
));
}
}
// 如果没有匹配到任何模式,返回原始文本
if (patterns.isEmpty) {
return [TextSpan(text: text, style: baseStyle)];
}
// 按起始位置排序
patterns.sort((a, b) => a.start.compareTo(b.start));
// 过滤重叠的模式(选择第一个匹配的,跳过被包含的)
final filtered = <_MatchPattern>[];
_MatchPattern? last;
for (final pattern in patterns) {
if (last == null || pattern.start >= last.end) {
filtered.add(pattern);
last = pattern;
}
}
// 构建 InlineSpan 列表
final spans = <InlineSpan>[];
var currentPos = 0;
for (final pattern in filtered) {
// 添加匹配前的普通文本
if (pattern.start > currentPos) {
spans.add(TextSpan(
text: text.substring(currentPos, pattern.start),
style: baseStyle,
));
}
// 添加带样式的匹配内容
final style = _getInlineStyle(pattern.type, baseStyle);
spans.add(TextSpan(
text: pattern.content,
style: style,
));
currentPos = pattern.end;
}
// 添加剩余的普通文本
if (currentPos < text.length) {
spans.add(TextSpan(
text: text.substring(currentPos),
style: baseStyle,
));
}
return spans;
}
/// 获取行内元素的样式
TextStyle? _getInlineStyle(_InlineType type, TextStyle? base) {
final baseStyle = base ?? const TextStyle();
switch (type) {
case _InlineType.bold:
return baseStyle.copyWith(fontWeight: FontWeight.bold);
case _InlineType.italic:
return baseStyle.copyWith(fontStyle: FontStyle.italic);
case _InlineType.strikethrough:
return baseStyle.copyWith(
decoration: TextDecoration.lineThrough,
color: const Color(0xFF999999),
);
case _InlineType.inlineCode:
return baseStyle.copyWith(
fontFamily: 'monospace',
backgroundColor: const Color(0xFFF5F5F5),
color: const Color(0xFF1A1A1A),
);
case _InlineType.link:
return baseStyle.copyWith(
color: const Color(0xFF4A90D9),
decoration: TextDecoration.underline,
);
}
}
/// 代码块样式
TextStyle _codeBlockStyle(TextStyle? baseStyle) {
return (baseStyle ?? const TextStyle()).copyWith(
fontFamily: 'monospace',
color: const Color(0xFF999999),
fontSize: 14,
);
}
}
/// 行内元素类型
enum _InlineType {
bold,
italic,
strikethrough,
inlineCode,
link,
}
/// 匹配模式
class _MatchPattern {
final int start;
final int end;
final _InlineType type;
final String fullMatch;
final String content;
final String? url;
_MatchPattern(
this.start,
this.end,
this.type,
this.fullMatch,
this.content, {
this.url,
});
}

View File

@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
@@ -28,8 +27,6 @@ class _NoteListItemContent extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isPlainText = note.contentType == 'plain_text';
return InkWell( return InkWell(
onTap: () { onTap: () {
Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) async { Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) async {
@@ -50,28 +47,10 @@ class _NoteListItemContent extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// 顶部:格式标记 + 时间 + 图片数 // 顶部:时间 + MD标记
Row( Row(
children: [ children: [
// 格式标记 // 时间
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(3),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
child: Text(
isPlainText ? 'TXT' : 'MD',
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w600,
color: Color(0xFF999999),
),
),
),
const SizedBox(width: 8),
// 时间 - 使用缓存的格式化结果
Text( Text(
_formatDateCached(note.updatedAt), _formatDateCached(note.updatedAt),
style: const TextStyle( style: const TextStyle(
@@ -79,64 +58,60 @@ class _NoteListItemContent extends StatelessWidget {
color: Color(0xFF999999), color: Color(0xFF999999),
), ),
), ),
const Spacer(), const SizedBox(width: 8),
// 图片数量(如果有图片) // MD标记
if (note.images.isNotEmpty) ...[ Container(
const Icon( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
Icons.image_outlined, decoration: BoxDecoration(
size: 12, color: Colors.white,
color: Color(0xFF999999), borderRadius: BorderRadius.circular(3),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
), ),
const SizedBox(width: 3), child: const Text(
Text( 'MD',
'${note.images.length}', style: TextStyle(
style: const TextStyle( fontSize: 9,
fontSize: 11, fontWeight: FontWeight.w600,
color: Color(0xFF999999), color: Color(0xFF999999),
), ),
), ),
], ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 6),
// 内容摘要(去除首尾空格) // 标题
Text( if (note.title.isNotEmpty) ...[
note.summary.trim(), Text(
style: TextStyle( note.title,
fontSize: 14, style: const TextStyle(
color: const Color(0xFF1A1A1A), fontSize: 15,
height: isPlainText ? 1.5 : 1.45, fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
height: 1.4,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
maxLines: isPlainText ? 3 : 2, const SizedBox(height: 4),
],
// 内容摘要去除Markdown标记
Text(
_cleanMarkdown(note.content).trim(),
style: const TextStyle(
fontSize: 13,
color: Color(0xFF666666),
height: 1.5,
),
maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
// 图片预览区域显示前4张图片
if (note.images.isNotEmpty) ...[
const SizedBox(height: 8),
SizedBox(
height: 52,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: note.images.length > 4 ? 4 : note.images.length,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
return _NoteImage(
imagePath: note.images[index],
index: index,
totalCount: note.images.length,
showMore: index == 3 && note.images.length > 4,
);
},
),
),
],
// 底部标签 // 底部标签
if (note.tags.isNotEmpty) ...[ if (note.tags.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 6),
Wrap( Wrap(
spacing: 6, spacing: 6,
runSpacing: 4, runSpacing: 4,
@@ -165,6 +140,20 @@ class _NoteListItemContent extends StatelessWidget {
); );
} }
/// 清理 Markdown 标记,提取纯文本
String _cleanMarkdown(String text) {
return text
.replaceAll(RegExp(r'^#+\s+', multiLine: true), '') // 标题
.replaceAll(RegExp(r'\*\*(.+?)\*\*'), r'$1') // 粗体
.replaceAll(RegExp(r'\*(.+?)\*'), r'$1') // 斜体
.replaceAll(RegExp(r'`(.+?)`'), r'$1') // 行内代码
.replaceAll(RegExp(r'^\s*[-*+]\s', multiLine: true), '') // 列表
.replaceAll(RegExp(r'^\s*>\s', multiLine: true), '') // 引用
.replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'$1') // 链接
.replaceAll(RegExp(r'!\[([^\]]*)\]\([^)]+\)'), '') // 图片
.trim();
}
/// 显示删除确认对话框 /// 显示删除确认对话框
void _showDeleteDialog(BuildContext context) { void _showDeleteDialog(BuildContext context) {
showDialog( showDialog(
@@ -221,60 +210,6 @@ class _NoteListItemContent extends StatelessWidget {
} }
} }
/// 笔记图片组件 - 独立出来便于优化
class _NoteImage extends StatelessWidget {
final String imagePath;
final int index;
final int totalCount;
final bool showMore;
const _NoteImage({
required this.imagePath,
required this.index,
this.totalCount = 0,
this.showMore = false,
});
@override
Widget build(BuildContext context) {
return Container(
width: 52,
height: 52,
margin: EdgeInsets.only(right: index < 3 ? 6 : 0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5),
),
clipBehavior: Clip.antiAlias,
child: showMore
? Container(
color: const Color(0xFFF5F5F5),
child: Center(
child: Text(
'+${totalCount - 4}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF666666),
),
),
),
)
: Image.file(
File(imagePath),
fit: BoxFit.cover,
cacheWidth: 104,
cacheHeight: 104,
errorBuilder: (_, __, ___) => const Icon(
Icons.broken_image,
size: 20,
color: Color(0xFFCCCCCC),
),
),
);
}
}
// 日期格式化缓存 // 日期格式化缓存
final Map<DateTime, String> _dateFormatCache = {}; final Map<DateTime, String> _dateFormatCache = {};

View File

@@ -113,6 +113,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.8" version: "2.0.8"
extended_text_field:
dependency: "direct main"
description:
name: extended_text_field
sha256: "3996195c117c6beb734026a7bc0ba80d7e4e84e4edd4728caa544d8209ab4d7d"
url: "https://pub.dev"
source: hosted
version: "16.0.2"
extended_text_library:
dependency: transitive
description:
name: extended_text_library
sha256: "13d99f8a10ead472d5e2cf4770d3d047203fe5054b152e9eb5dc692a71befbba"
url: "https://pub.dev"
source: hosted
version: "12.0.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -214,6 +230,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_markdown: flutter_markdown:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -222,6 +243,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7+1" version: "0.7.7+1"
flutter_markdown_plus:
dependency: "direct main"
description:
name: flutter_markdown_plus
sha256: "039177906850278e8fb1cd364115ee0a46281135932fa8ecea8455522166d2de"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -352,6 +381,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.2" version: "0.2.2"
intl:
dependency: transitive
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:

View File

@@ -9,6 +9,8 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_localizations:
sdk: flutter
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
provider: ^6.1.2 provider: ^6.1.2
fl_chart: ^0.69.0 fl_chart: ^0.69.0
@@ -28,6 +30,8 @@ dependencies:
url_launcher: ^6.2.5 url_launcher: ^6.2.5
webview_flutter: ^4.8.0 webview_flutter: ^4.8.0
package_info_plus: ^8.0.0 package_info_plus: ^8.0.0
flutter_markdown_plus: ^1.0.7
extended_text_field: ^16.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: