diff --git a/lib/main.dart b/lib/main.dart index 791e770..70ceafc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:provider/provider.dart'; import 'dart:async'; import 'pages/home_page.dart'; @@ -65,6 +66,15 @@ class MyApp extends StatelessWidget { theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: ThemeMode.system, + localizationsDelegates: [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('zh', 'CN'), + Locale('en', 'US'), + ], home: const HomePage(), onGenerateRoute: AppRouter.generateRoute, builder: (context, child) { diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index 67e81fa..be014ae 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -294,6 +294,7 @@ class Book { /// 笔记模型 class Note { final String id; + final String title; final String content; final String contentType; // markdown / plain_text final List tags; @@ -304,6 +305,7 @@ class Note { Note({ required this.id, + required this.title, required this.content, this.contentType = 'markdown', this.tags = const [], @@ -316,6 +318,7 @@ class Note { factory Note.fromJson(Map json) { return Note( id: json['id']?.toString() ?? '', + title: json['title'] ?? '', content: json['content'] ?? '', contentType: json['content_type'] ?? 'markdown', tags: Movie.parseStringList(json['tags']), @@ -333,6 +336,7 @@ class Note { Map toJson() { return { 'id': id, + 'title': title, 'content': content, 'content_type': contentType, 'tags': jsonEncode(tags), @@ -346,6 +350,7 @@ class Note { /// 复制并修改 Note copyWith({ String? id, + String? title, String? content, String? contentType, List? tags, @@ -356,6 +361,7 @@ class Note { }) { return Note( id: id ?? this.id, + title: title ?? this.title, content: content ?? this.content, contentType: contentType ?? this.contentType, tags: tags ?? this.tags, diff --git a/lib/models/data_models_extension.dart b/lib/models/data_models_extension.dart index ff08a95..bd1795c 100644 --- a/lib/models/data_models_extension.dart +++ b/lib/models/data_models_extension.dart @@ -84,6 +84,7 @@ extension NoteExtension on Note { /// 创建副本并允许修改部分属性 Note copyWith({ String? id, + String? title, String? content, String? contentType, List? tags, @@ -94,6 +95,7 @@ extension NoteExtension on Note { }) { return Note( id: id ?? this.id, + title: title ?? this.title, content: content ?? this.content, contentType: contentType ?? this.contentType, tags: tags ?? this.tags, diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index f56fd0f..2d6d39a 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -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 { + /// 当前正在滑动的页面索引(用于PageView) + final PageController _pageController = PageController(); + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( // 左侧弹出菜单(仅在主页显示) - drawer: context.watch().bottomNavIndex == 0 - ? CustomDrawer() + drawer: context.watch().bottomNavIndex == 0 + ? CustomDrawer() : null, - - // 主体内容 - 使用 Stack 让 dock 栏悬浮在内容上方 - body: Stack( - children: [ - // 底层:主体内容 - _buildBody(), - - // 顶层:悬浮 dock 栏 - const Positioned( - left: 0, - right: 0, - bottom: 0, - child: CustomBottomNavBar(), - ), - ], + + // 主体内容 + body: Consumer( + builder: (context, provider, child) { + // 同步底部导航栏和 PageView 的页面 + final currentPage = provider.bottomNavIndex == 0 ? 0 : 1; + if (_pageController.hasClients && _pageController.page?.round() != currentPage) { + _pageController.jumpToPage(currentPage); + } + + return NotificationListener( + 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( - 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(), + ], ); } } diff --git a/lib/pages/markdown_reader/md_reader_tab_page.dart b/lib/pages/markdown_reader/md_reader_tab_page.dart new file mode 100644 index 0000000..6866127 --- /dev/null +++ b/lib/pages/markdown_reader/md_reader_tab_page.dart @@ -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 createState() => _MdReaderTabPageState(); +} + +class _MdReaderTabPageState extends State { + static const String _basePath = '/storage/emulated/0/Documents/mooknote/markdown'; + String _currentPath = _basePath; + List _items = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadDirectory(); + } + + /// 加载当前目录内容 + Future _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('重试'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/markdown_reader/md_viewer_page.dart b/lib/pages/markdown_reader/md_viewer_page.dart new file mode 100644 index 0000000..205a179 --- /dev/null +++ b/lib/pages/markdown_reader/md_viewer_page.dart @@ -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 createState() => _MdViewerPageState(); +} + +class _MdViewerPageState extends State { + String _content = ''; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadFile(); + } + + /// 加载 Markdown 文件内容 + Future _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)), + ), + ], + ), + ); + } +} diff --git a/lib/pages/note/note_detail_page.dart b/lib/pages/note/note_detail_page.dart index 631f4e5..6303587 100644 --- a/lib/pages/note/note_detail_page.dart +++ b/lib/pages/note/note_detail_page.dart @@ -29,46 +29,11 @@ class _NoteDetailPageState extends State { 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 { ), ), - // 内容区域 + // 内容区域 - 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 { 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 { 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 { 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, diff --git a/lib/pages/note/note_form_page.dart b/lib/pages/note/note_form_page.dart index e56ca91..9121b3f 100644 --- a/lib/pages/note/note_form_page.dart +++ b/lib/pages/note/note_form_page.dart @@ -1,14 +1,17 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:provider/provider.dart'; import 'package:path/path.dart' as p; import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; +import '../../widgets/markdown_editing_controller.dart'; -/// 添加/编辑笔记页面 - 极简书写界面 +/// Typora 风格 Markdown 编辑器 +/// 所见即所得,输入 # 标题自动渲染,**粗体** 自动加粗等 class NoteFormPage extends StatefulWidget { final Note? note; @@ -19,30 +22,35 @@ class NoteFormPage extends StatefulWidget { } class _NoteFormPageState extends State { - late TextEditingController _contentController; + late MarkdownEditingController _contentController; + late TextEditingController _titleController; late DateTime _createdAt; - List _tags = []; - List _images = []; // 图片路径列表 - String _contentType = 'plain_text'; // markdown / plain_text - bool _isEditing = false; + List _images = []; final ImagePicker _picker = ImagePicker(); - String? _tempNoteId; // 新建模式时使用的临时笔记ID + bool _isPreviewMode = false; + late final ScrollController _scrollController; + late final FocusNode _contentFocusNode; + + bool get _isNewNote => widget.note == null; @override void initState() { super.initState(); final note = widget.note; - _contentController = TextEditingController(text: note?.content ?? ''); _createdAt = note?.createdAt ?? DateTime.now(); - _tags = note != null ? List.from(note.tags) : []; _images = note != null ? List.from(note.images) : []; - _contentType = note?.contentType ?? 'plain_text'; - _isEditing = note != null; + _titleController = TextEditingController(text: note?.title ?? ''); + _contentController = MarkdownEditingController(text: note?.content ?? ''); + _scrollController = ScrollController(); + _contentFocusNode = FocusNode(); } @override void dispose() { _contentController.dispose(); + _titleController.dispose(); + _scrollController.dispose(); + _contentFocusNode.dispose(); super.dispose(); } @@ -50,433 +58,360 @@ class _NoteFormPageState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, + resizeToAvoidBottomInset: true, appBar: AppBar( - title: Text(_isEditing ? '编辑笔记' : '新建笔记'), + elevation: 0, + backgroundColor: Colors.white, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Color(0xFF333333)), + onPressed: () => Navigator.pop(context), + ), actions: [ + // 预览/编辑切换 IconButton( - onPressed: _saveNote, - icon: const Icon(Icons.save_outlined), - tooltip: '保存', + icon: Icon( + _isPreviewMode ? Icons.edit_outlined : Icons.visibility_outlined, + color: const Color(0xFF333333), + ), + onPressed: () => setState(() => _isPreviewMode = !_isPreviewMode), + ), + // 保存 + IconButton( + icon: const Icon(Icons.check, color: Color(0xFF333333)), + onPressed: _saveNote, ), - const SizedBox(width: 8), ], ), body: Column( children: [ - // 顶部信息栏:创建时间 + 格式选择 + 标签 - Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: Color(0xFFE8E8E8), width: 0.5), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - // 创建时间 - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - child: Text( - _formatDateTime(_createdAt), - style: const TextStyle( - fontSize: 12, - color: Color(0xFF666666), - ), - ), - ), - const Spacer(), - // 格式选择 - _buildFormatSelector(), - ], - ), - const SizedBox(height: 12), - // 标签 - _buildTagSelector(), - ], - ), - ), - - // 书写区域 + // 标题输入 + _buildTitleField(), + const Divider(height: 1, color: Color(0xFFF0F0F0)), + + // 内容区域 Expanded( - child: TextField( - controller: _contentController, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - style: const TextStyle( - fontSize: 16, - color: Color(0xFF1A1A1A), - height: 1.6, - ), - decoration: InputDecoration( - hintText: _contentType == 'markdown' - ? '使用 Markdown 格式书写...' - : '开始书写...', - hintStyle: const TextStyle( - fontSize: 16, - color: Color(0xFFCCCCCC), - ), - border: InputBorder.none, - contentPadding: const EdgeInsets.all(16), - ), - ), + child: _isPreviewMode ? _buildPreview() : _buildEditor(), ), - - // 图片区域(放在内容下方) - if (_contentType == 'plain_text' && _images.isNotEmpty) - Container( - height: 100, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: const BoxDecoration( - border: Border( - top: BorderSide(color: Color(0xFFE8E8E8), width: 0.5), - ), - ), - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _images.length, - separatorBuilder: (context, index) => const SizedBox(width: 8), - itemBuilder: (context, index) { - return _buildHorizontalImageItem(index); - }, - ), - ), - - // 底部工具栏(纯文本模式下显示添加图片按钮) - if (_contentType == 'plain_text') - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: const BoxDecoration( - border: Border( - top: BorderSide(color: Color(0xFFE8E8E8), width: 0.5), - ), - ), - child: Row( - children: [ - // 图片数量 - if (_images.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.image_outlined, - size: 14, - color: Color(0xFF666666), - ), - const SizedBox(width: 4), - Text( - '${_images.length}', - style: const TextStyle( - fontSize: 12, - color: Color(0xFF666666), - ), - ), - ], - ), - ), - const Spacer(), - // 添加图片按钮 - InkWell( - onTap: _pickImage, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(8), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.add_photo_alternate_outlined, - size: 18, - color: Colors.white, - ), - SizedBox(width: 6), - Text( - '添加图片', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - ], - ), - ), - ), - ], - ), - ), + + // 图片区域 + if (_images.isNotEmpty) _buildImageSection(), + + // 底部工具栏 - 键盘弹出时自动上移 + if (!_isPreviewMode) _buildMarkdownToolbarContent(), ], ), ); } - /// 构建格式选择器 - Widget _buildFormatSelector() { - return GestureDetector( - onTap: () => _showFormatSelector(), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), + // ==================== 标题输入 ==================== + + /// 构建标题输入框 + Widget _buildTitleField() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _titleController, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + height: 1.4, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _contentType == 'markdown' ? Icons.code : Icons.text_fields, - size: 16, + decoration: const InputDecoration( + hintText: '笔记标题', + hintStyle: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: Color(0xFFCCCCCC), + ), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + maxLines: 1, + ), + ); + } + + // ==================== Markdown 快捷工具栏 ==================== + + /// 构建 Markdown 快捷工具栏 + Widget _buildMarkdownToolbarContent() { + return Container( + height: 48, + color: const Color(0xFFF5F5F5), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 文本格式 + _buildToolbarButton(Icons.format_bold, '粗体', _insertBold), + _buildToolbarButton(Icons.format_italic, '斜体', _insertItalic), + _buildToolbarButton(Icons.format_strikethrough, '删除线', _insertStrikethrough), + _buildToolbarDivider(), + // 段落 + _buildToolbarButton(Icons.title, '标题', () => _insertHeader(1)), + _buildToolbarButton(Icons.format_quote, '引用', _insertQuote), + _buildToolbarButton(Icons.code, '代码块', _insertCodeBlock), + _buildToolbarButton(Icons.horizontal_rule, '分割线', _insertDivider), + _buildToolbarDivider(), + // 列表 + _buildToolbarButton(Icons.format_list_bulleted, '无序列表', _insertUnorderedList), + _buildToolbarButton(Icons.format_list_numbered, '有序列表', _insertOrderedList), + _buildToolbarDivider(), + // 插入 + _buildToolbarButton(Icons.link, '链接', _insertLink), + _buildToolbarButton(Icons.image, '图片', _pickImage), + _buildToolbarButton(Icons.calendar_today, '日期', _insertDate), + _buildToolbarButton(Icons.access_time, '时间', _insertTime), + ], + ), + ), + ), + ); + } + + /// 构建工具栏分隔线 + Widget _buildToolbarDivider() { + return Container( + width: 1, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: const Color(0xFFDCDCDC), + ); + } + + /// 构建工具栏按钮 + Widget _buildToolbarButton(IconData icon, String tooltip, VoidCallback onPressed) { + return Tooltip( + message: tooltip, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(4), + splashColor: const Color(0x1F000000), + child: Container( + width: 40, + height: 40, + alignment: Alignment.center, + child: Icon( + icon, + size: 20, color: const Color(0xFF666666), ), - const SizedBox(width: 6), - Text( - _contentType == 'markdown' ? 'Markdown' : '纯文本', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Color(0xFF666666), - ), - ), - const SizedBox(width: 4), - const Icon( - Icons.arrow_drop_down, - size: 18, - color: Color(0xFF999999), - ), - ], + ), ), ), ); } - /// 显示格式选择对话框 - void _showFormatSelector() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + // ==================== 编辑器主体 ==================== + + /// 构建编辑器主体 - 使用 MarkdownEditingController 实现所见即所得 + Widget _buildEditor() { + return TextField( + controller: _contentController, + scrollController: _scrollController, + focusNode: _contentFocusNode, + style: const TextStyle( + fontSize: 16, + color: Color(0xFF333333), + height: 1.8, ), - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 顶部指示条 - Container( - margin: const EdgeInsets.only(top: 12, bottom: 16), - width: 40, - height: 4, - decoration: BoxDecoration( - color: const Color(0xFFE0E0E0), - borderRadius: BorderRadius.circular(2), - ), - ), - // 标题 - const Padding( - padding: EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Row( - children: [ - Text( - '选择格式', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Color(0xFF1A1A1A), - ), - ), - ], - ), - ), - const SizedBox(height: 8), - _buildFormatOption( - icon: Icons.code, - title: 'Markdown', - subtitle: '支持 Markdown 语法', - isSelected: _contentType == 'markdown', - onTap: () { - setState(() => _contentType = 'markdown'); - Navigator.pop(context); - }, - ), - _buildFormatOption( - icon: Icons.text_fields, - title: '纯文本', - subtitle: '普通文本格式,支持图片', - isSelected: _contentType == 'plain_text', - onTap: () { - setState(() => _contentType = 'plain_text'); - Navigator.pop(context); - }, - ), - const SizedBox(height: 16), - ], + decoration: const InputDecoration( + hintText: '开始书写 Markdown...\n' + '支持 # 标题、**粗体**、*斜体*、\`代码\` 等', + hintStyle: TextStyle( + fontSize: 16, + color: Color(0xFFCCCCCC), + height: 1.8, ), + border: InputBorder.none, + contentPadding: EdgeInsets.all(16), + ), + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + keyboardType: TextInputType.multiline, + ); + } + + // ==================== 预览区域 ==================== + + /// 构建预览区域 + Widget _buildPreview() { + return SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + child: MarkdownBody( + data: _contentController.text.isEmpty + ? '*预览区域 - 开始输入 Markdown 内容...*' + : _contentController.text, + styleSheet: _buildMarkdownStyleSheet(), ), ); } - /// 构建格式选项 - Widget _buildFormatOption({ - required IconData icon, - required String title, - required String subtitle, - required bool isSelected, - required VoidCallback onTap, - }) { - return InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: const Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - icon, - size: 22, - color: const Color(0xFF666666), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Color(0xFF1A1A1A), - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: const TextStyle( - fontSize: 13, - color: Color(0xFF999999), - ), - ), - ], - ), - ), - if (isSelected) - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: const Color(0xFF1A1A1A), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.check, - size: 16, - color: Colors.white, - ), - ), - ], - ), + /// 构建 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), ), ); } - /// 构建标签选择器 - Widget _buildTagSelector() { - return Wrap( - spacing: 10, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, + // ==================== 图片区域 ==================== + + /// 构建图片区域 + Widget _buildImageSection() { + return Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + color: Color(0xFFFAFAFA), + border: Border( + top: BorderSide(color: Color(0xFFE8E8E8), width: 0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '附件图片', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF666666)), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _images.map((path) => _buildImageItem(path)).toList(), + ), + ], + ), + ); + } + + /// 构建单个图片项 + Widget _buildImageItem(String imagePath) { + return Stack( children: [ - ..._tags.asMap().entries.map((entry) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - entry.value, - style: const TextStyle( - fontSize: 13, - color: Color(0xFF666666), - ), - ), - const SizedBox(width: 6), - GestureDetector( - onTap: () => setState(() => _tags.removeAt(entry.key)), - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: const Color(0xFFE8E8E8), - borderRadius: BorderRadius.circular(4), - ), - child: const Icon( - Icons.close, - size: 10, - color: Color(0xFF999999), - ), - ), - ), - ], - ), - ); - }), - // 添加标签按钮 - GestureDetector( - onTap: () => _showAddTagDialog(), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.add, - size: 14, - color: Color(0xFF999999), - ), - SizedBox(width: 4), - Text( - '标签', - style: TextStyle( - fontSize: 13, - color: Color(0xFF999999), - ), - ), - ], + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(imagePath), + width: 80, + height: 80, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: 80, + height: 80, + color: const Color(0xFFF5F5F5), + child: const Icon(Icons.broken_image, size: 24, color: Color(0xFFCCCCCC)), + ); + }, + ), + ), + Positioned( + top: 2, + right: 2, + child: GestureDetector( + onTap: () => setState(() => _images.remove(imagePath)), + child: Container( + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration(color: Colors.black54, shape: BoxShape.circle), + child: const Icon(Icons.close, size: 12, color: Colors.white), ), ), ), @@ -484,202 +419,219 @@ class _NoteFormPageState extends State { ); } - /// 显示添加标签对话框 - void _showAddTagDialog() { - final controller = TextEditingController(); - - // 获取所有已有标签(从所有笔记中收集) - final provider = context.read(); - final allTags = _getAllExistingTags(provider); - // 过滤掉已添加的标签 - final availableTags = allTags.where((tag) => !_tags.contains(tag)).toList(); - - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: const Text( - '添加标签', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - content: SizedBox( - width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 输入框 - TextField( - controller: controller, - autofocus: true, - decoration: InputDecoration( - hintText: '输入新标签名称', - hintStyle: const TextStyle( - fontSize: 14, - color: Color(0xFF999999), - ), - filled: true, - fillColor: const Color(0xFFFAFAFA), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFFE8E8E8), width: 0.5), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: Color(0xFF1A1A1A), width: 1), - ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - ), - onSubmitted: (value) { - _addTag(value); - Navigator.pop(context); - }, - ), - - // 已有标签列表 - if (availableTags.isNotEmpty) ...[ - const SizedBox(height: 20), - const Text( - '或选择已有标签:', - style: TextStyle( - fontSize: 13, - color: Color(0xFF999999), - ), - ), - const SizedBox(height: 12), - Wrap( - spacing: 10, - runSpacing: 10, - children: availableTags.map((tag) { - return GestureDetector( - onTap: () { - _addTag(tag); - Navigator.pop(context); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - child: Text( - tag, - style: const TextStyle( - fontSize: 14, - color: Color(0xFF666666), - ), - ), - ), - ); - }).toList(), - ), - ], - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - style: TextButton.styleFrom( - foregroundColor: const Color(0xFF666666), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - child: const Text('取消'), - ), - ElevatedButton( - onPressed: () { - _addTag(controller.text); - Navigator.pop(context); - }, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1A1A1A), - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - child: const Text('添加'), - ), - ], - actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), + /// 构建添加图片的浮动按钮 + + // ==================== 图片选择 ==================== + + /// 选择图片 + Future _pickImage() async { + try { + final XFile? pickedFile = await _picker.pickImage( + source: ImageSource.gallery, + maxWidth: 1200, + maxHeight: 1200, + imageQuality: 85, + ); + + if (pickedFile != null) { + final fileName = 'note_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final noteId = widget.note?.id ?? DateTime.now().millisecondsSinceEpoch.toString(); + + final targetPath = await ImagePathHelper.instance.getNoteImagePath(noteId, fileName); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + + await File(pickedFile.path).copy(targetPath); + + setState(() => _images.add(targetPath)); + + // 在 Markdown 内容中插入图片链接 + _insertImageLink(targetPath, fileName); + } + } catch (e) { + if (mounted) { + ToastUtil.show(context, '选择图片失败: $e'); + } + } + } + + // ==================== Markdown 快捷插入 ==================== + + /// 在光标位置插入文本 + void _insertText(String text, {String? wrapPrefix, String? wrapSuffix}) { + final currentText = _contentController.text; + final selection = _contentController.selection; + final start = selection.start; + final end = selection.end; + + if (start < 0 || end < 0) { + // 没有焦点,直接追加到末尾 + if (wrapPrefix != null) { + final suffix = wrapSuffix ?? wrapPrefix; + final newText = '$wrapPrefix$text$suffix'; + _contentController.text = currentText + newText; + _contentController.selection = TextSelection.collapsed(offset: _contentController.text.length); + } else { + _contentController.text = currentText + text; + _contentController.selection = TextSelection.collapsed(offset: _contentController.text.length); + } + _contentFocusNode.requestFocus(); + return; + } + + final before = currentText.substring(0, start); + final selected = start < end ? currentText.substring(start, end) : null; + final after = currentText.substring(end); + + if (wrapPrefix != null) { + final suffix = wrapSuffix ?? wrapPrefix; + final content = selected ?? text; + final newText = '$before$wrapPrefix$content$suffix$after'; + _contentController.text = newText; + final cursorOffset = before.length + wrapPrefix.length + content.length + suffix.length; + _contentController.selection = TextSelection.collapsed(offset: cursorOffset); + } else { + _contentController.text = '$before$text$after'; + _contentController.selection = TextSelection.collapsed(offset: before.length + text.length); + } + // 重新请求焦点,确保键盘弹出且光标位置正确 + _contentFocusNode.requestFocus(); + } + + /// 插入粗体 + void _insertBold() { + _insertText('粗体', wrapPrefix: '**', wrapSuffix: '**'); + } + + /// 插入斜体 + void _insertItalic() { + _insertText('斜体', wrapPrefix: '*', wrapSuffix: '*'); + } + + /// 插入删除线 + void _insertStrikethrough() { + _insertText('删除线', wrapPrefix: '~~', wrapSuffix: '~~'); + } + + /// 插入标题 + void _insertHeader(int level) { + final prefix = '#' * level + ' '; + _insertText('$prefix标题\n', wrapPrefix: null); + } + + /// 插入引用 + void _insertQuote() { + _insertText('> 引用内容\n', wrapPrefix: null); + } + + /// 插入日期 + void _insertDate() { + final now = DateTime.now(); + final date = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + _insertText(date); + } + + /// 插入时间 + void _insertTime() { + final now = DateTime.now(); + final time = '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}'; + _insertText(time); + } + + /// 插入无序列表 + void _insertUnorderedList() { + _insertText('- 列表项\n', wrapPrefix: null); + } + + /// 插入有序列表 + void _insertOrderedList() { + _insertText('1. 列表项\n', wrapPrefix: null); + } + + /// 插入行内代码 + void _insertInlineCode() { + _insertText('code', wrapPrefix: '`', wrapSuffix: '`'); + } + + /// 插入代码块 + void _insertCodeBlock() { + final currentText = _contentController.text; + final selection = _contentController.selection; + final start = selection.start; + final before = start < 0 ? currentText : currentText.substring(0, start); + final after = start < 0 ? '' : currentText.substring(start); + + const codeBlock = '\n```\n// 代码块\n```\n'; + _contentController.text = '$before$codeBlock$after'; + _contentController.selection = TextSelection.collapsed( + offset: before.length + codeBlock.length - 5, ); } - - /// 获取所有已有标签(从所有笔记中收集) - List _getAllExistingTags(AppProvider provider) { - final allTags = {}; - for (final note in provider.notes) { - allTags.addAll(note.tags); - } - return allTags.toList()..sort(); + + /// 插入链接 + void _insertLink() { + _insertText('[链接文本](https://example.com)', wrapPrefix: null); } - /// 添加标签 - void _addTag(String tag) { - final trimmed = tag.trim(); - if (trimmed.isNotEmpty && !_tags.contains(trimmed)) { - setState(() => _tags.add(trimmed)); + /// 插入分割线 + void _insertDivider() { + _insertText('\n---\n', wrapPrefix: null); + } + + /// 插入图片链接 + void _insertImageLink(String path, String fileName) { + final imageMarkdown = '![$fileName]($path)\n'; + final currentText = _contentController.text; + final selection = _contentController.selection; + final start = selection.start; + + if (start >= 0) { + final before = currentText.substring(0, start); + final after = currentText.substring(start); + _contentController.text = '$before\n$imageMarkdown$after'; + _contentController.selection = TextSelection.collapsed( + offset: before.length + imageMarkdown.length + 1, + ); + } else { + _contentController.text = '$currentText\n$imageMarkdown'; } } - /// 格式化日期时间 - String _formatDateTime(DateTime date) { - return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; - } + // ==================== 保存笔记 ==================== /// 保存笔记 Future _saveNote() async { + final title = _titleController.text.trim(); final content = _contentController.text.trim(); - - if (content.isEmpty) { + + if (content.isEmpty && title.isEmpty) { ToastUtil.show(context, '笔记内容不能为空'); return; } + // 自动提取标签 + final tags = _extractTags('$title\n$content'); + final now = DateTime.now(); - if (_isEditing) { - // 更新现有笔记 + if (!_isNewNote) { final updatedNote = widget.note!.copyWith( + title: title.isEmpty ? widget.note!.title : title, content: content, - contentType: _contentType, - tags: _tags, + contentType: 'markdown', + tags: tags.isEmpty ? widget.note!.tags : tags, images: _images, updatedAt: now, ); await context.read().updateNote(updatedNote); } else { - // 添加新笔记 - 先创建笔记获取ID final noteId = now.millisecondsSinceEpoch.toString(); - - // 如果有图片,需要移动到正确的ID目录 - List finalImages = []; - if (_images.isNotEmpty) { - // 使用保存的临时ID,如果没有则使用当前noteId(理论上不会走到这里) - final oldNoteId = _tempNoteId ?? noteId; - final newNoteId = noteId; - finalImages = await _moveImagesToNewId(oldNoteId, newNoteId); - } - final newNote = Note( id: noteId, + title: title.isEmpty ? '无标题笔记' : title, content: content, - contentType: _contentType, - tags: _tags, - images: finalImages.isNotEmpty ? finalImages : _images, + contentType: 'markdown', + tags: tags, + images: _images, createdAt: _createdAt, updatedAt: now, ); @@ -688,208 +640,26 @@ class _NoteFormPageState extends State { if (!mounted) return; - ToastUtil.show(context, _isEditing ? '保存成功' : '添加成功'); - - // 刷新笔记列表 + ToastUtil.show(context, _isNewNote ? '添加成功' : '保存成功'); await context.read().loadNotes(); - + if (!mounted) return; Navigator.pop(context); } - - /// 将图片从临时ID目录移动到新ID目录 - Future> _moveImagesToNewId(String oldNoteId, String newNoteId) async { - final List newPaths = []; - - final newDir = await ImagePathHelper.instance.getNoteImagesDir(newNoteId); - - for (final imagePath in _images) { - // 使用路径分隔符检查,兼容 Windows 和 Unix - final normalizedPath = imagePath.replaceAll('\\', '/'); - if (normalizedPath.contains('/notes/$oldNoteId/')) { - // 需要移动的文件 - final fileName = p.basename(imagePath); - final newPath = p.join(newDir, fileName); - - await ImagePathHelper.instance.ensureDirExists(newDir); - - // 检查源文件是否存在 - final sourceFile = File(imagePath); - if (await sourceFile.exists()) { - await sourceFile.rename(newPath); - newPaths.add(newPath); - } - } else { - // 已经在正确位置的文件 - newPaths.add(imagePath); + + /// 从内容中提取标签 + List _extractTags(String content) { + final tags = {}; + final regex = RegExp(r'#(\w+)'); + final matches = regex.allMatches(content); + + for (final match in matches) { + final tag = match.group(1); + if (tag != null && tag.isNotEmpty) { + tags.add(tag); } } - - // 删除旧目录 - try { - await ImagePathHelper.instance.deleteNoteImages(oldNoteId); - } catch (e) { - // 忽略删除失败 - } - - return newPaths; - } - /// 选择图片 - Future _pickImage() async { - try { - final XFile? image = await _picker.pickImage( - source: ImageSource.gallery, - maxWidth: 1920, - maxHeight: 1920, - imageQuality: 85, - ); - - if (image != null) { - // 生成唯一的文件名 - final fileName = '${DateTime.now().millisecondsSinceEpoch}.jpg'; - - // 如果是编辑模式,使用现有笔记ID;如果是新建模式,使用临时ID(保存时会替换) - String noteId; - if (_isEditing) { - noteId = widget.note!.id; - } else { - // 新建模式:使用已存在的临时ID或生成新的 - noteId = _tempNoteId ?? DateTime.now().millisecondsSinceEpoch.toString(); - _tempNoteId = noteId; - } - - // 复制图片到应用目录: images/notes/{noteId}/{fileName} - final targetDir = await ImagePathHelper.instance.getNoteImagesDir(noteId); - await ImagePathHelper.instance.ensureDirExists(targetDir); - final targetPath = p.join(targetDir, fileName); - - await File(image.path).copy(targetPath); - - setState(() => _images.add(targetPath)); - } - } catch (e) { - ToastUtil.show(context, '选择图片失败: $e'); - } - } - - /// 构建图片项 - Widget _buildImageItem(int index) { - return InkWell( - onTap: () => _showImagePreview(index), - onLongPress: () => _showDeleteImageDialog(index), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - clipBehavior: Clip.antiAlias, - child: Image.file( - File(_images[index]), - fit: BoxFit.cover, - ), - ), - ); - } - - /// 构建横向图片项(用于底部图片栏) - Widget _buildHorizontalImageItem(int index) { - return InkWell( - onTap: () => _showImagePreview(index), - onLongPress: () => _showDeleteImageDialog(index), - child: Container( - width: 84, - height: 84, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE8E8E8), width: 0.5), - ), - clipBehavior: Clip.antiAlias, - child: Image.file( - File(_images[index]), - fit: BoxFit.cover, - ), - ), - ); - } - - /// 显示图片预览 - void _showImagePreview(int index) { - showDialog( - context: context, - barrierDismissible: true, - builder: (context) => GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - color: Colors.black.withOpacity(0.9), - child: Center( - child: InteractiveViewer( - panEnabled: true, - boundaryMargin: const EdgeInsets.all(20), - minScale: 0.5, - maxScale: 4, - child: Image.file( - File(_images[index]), - fit: BoxFit.contain, - ), - ), - ), - ), - ), - ); - } - - /// 显示删除图片确认对话框 - void _showDeleteImageDialog(int index) { - showDialog( - context: context, - builder: (context) => AlertDialog( - backgroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - title: const Text( - '确认删除', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - ), - ), - content: const Text( - '确定要删除这张图片吗?此操作不可恢复。', - style: TextStyle( - fontSize: 14, - color: Color(0xFF666666), - height: 1.5, - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - style: TextButton.styleFrom( - foregroundColor: const Color(0xFF666666), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - child: const Text('取消'), - ), - ElevatedButton( - onPressed: () { - setState(() => _images.removeAt(index)); - Navigator.pop(context); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - ), - child: const Text('删除'), - ), - ], - actionsPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - ), - ); + return tags.toList(); } } diff --git a/lib/pages/note/note_tab_page.dart b/lib/pages/note/note_tab_page.dart index 6e289e0..8382828 100644 --- a/lib/pages/note/note_tab_page.dart +++ b/lib/pages/note/note_tab_page.dart @@ -257,6 +257,7 @@ class _NoteTabPageState extends State { 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 { ), Note( id: '2', + title: '《活着》读后感', content: '余华的《活着》真的是一部让人深思的作品。福贵的一生经历了太多的苦难,但他依然坚强地活着。生命的意义或许就在于活着本身。', tags: ['阅读', '感悟', '书籍'], createdAt: now.subtract(const Duration(days: 5)), @@ -271,6 +273,7 @@ class _NoteTabPageState extends State { ), Note( id: '3', + title: '诺兰电影观后感', content: '诺兰的电影总是充满想象力。《星际穿越》将科幻与亲情完美结合,五维空间的呈现方式令人震撼。配乐也是一绝。', tags: ['观影', '科幻', '电影'], createdAt: now.subtract(const Duration(days: 10)), @@ -278,6 +281,7 @@ class _NoteTabPageState extends State { ), 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 { ), Note( id: '5', + title: '春日随笔', content: '春天来了,天气渐暖。周末去公园散步,看到花开得很好。生活中的小确幸值得记录。', tags: ['生活', '随笔'], createdAt: now.subtract(const Duration(hours: 5)), diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index 0cb4a76..eaa8b0b 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -673,9 +673,23 @@ class _ProfilePageState extends State { } /// 设置页面 -class SettingsPage extends StatelessWidget { +class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + 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 _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 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( diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 87a7ef8..b0a0685 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -30,6 +30,9 @@ class AppProvider extends ChangeNotifier { // 当前底部导航选中的索引 (0: 主页,1: 新增,2: 我的) int _bottomNavIndex = 0; + + // 底部导航栏是否可见 + bool _bottomNavVisible = true; // 观影选中的状态 (0: 已看,1: 想看,2: 在看) int _movieStatusIndex = 0; @@ -71,6 +74,7 @@ class AppProvider extends ChangeNotifier { int get movieStatusIndex => _movieStatusIndex; int get bookStatusIndex => _bookStatusIndex; bool get drawerOpen => _drawerOpen; + bool get bottomNavVisible => _bottomNavVisible; List get movies => _movies; List get books => _books; List get notes => _notes; @@ -93,9 +97,17 @@ class AppProvider extends ChangeNotifier { void setBottomNavIndex(int index) { _bottomNavIndex = index; + _bottomNavVisible = true; // 切换页面时自动显示导航栏 notifyListeners(); } + void setBottomNavVisible(bool visible) { + if (_bottomNavVisible != visible) { + _bottomNavVisible = visible; + notifyListeners(); + } + } + void setMovieStatusIndex(int index) { _movieStatusIndex = index; notifyListeners(); diff --git a/lib/utils/data_migration.dart b/lib/utils/data_migration.dart new file mode 100644 index 0000000..a5af499 --- /dev/null +++ b/lib/utils/data_migration.dart @@ -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 migrateIfNeeded() async { + if (_hasMigrated) return; + _hasMigrated = true; + + try { + await _migrateMovies(); + await _migrateBooks(); + await _migrateNotes(); + debugPrint('数据迁移完成'); + } catch (e, stack) { + debugPrint('数据迁移失败: $e'); + debugPrint('堆栈: $stack'); + } + } + + /// 迁移影视数据 + Future _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 _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 _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 _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 _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 _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 _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 _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 _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 _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 _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 _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 _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 _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> _listSubdirNames(String dirPath) async { + try { + final dir = Directory(dirPath); + if (!await dir.exists()) return []; + final entities = await dir.list().toList(); + return entities + .whereType() + .map((e) => p.basename(e.path)) + .toList(); + } catch (e) { + return []; + } + } + + /// 读取 JSON 文件 + Future?> _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; + } catch (e) { + return null; + } + } + + /// 列出目录中的 JSON 文件并解析 + Future>> _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 = >[]; + for (final file in files) { + final data = await _readJsonFile(file.path); + if (data != null) results.add(data); + } + return results; + } catch (e) { + return []; + } + } +} diff --git a/lib/utils/database_helper.dart b/lib/utils/database_helper.dart index 25119de..20f9c25 100644 --- a/lib/utils/database_helper.dart +++ b/lib/utils/database_helper.dart @@ -31,7 +31,7 @@ class DatabaseHelper { return await openDatabase( path, - version: 11, + version: 12, onCreate: _createDB, onUpgrade: _onUpgrade, ); @@ -82,6 +82,10 @@ class DatabaseHelper { // 为书籍表添加ISBN和出版时间字段 await _upgradeBooksTableV11(db); } + if (oldVersion < 12) { + // 确保notes表有title列 + await _upgradeNotesTableV12(db); + } } /// 升级books表到V11(添加ISBN和出版时间字段) @@ -110,6 +114,17 @@ class DatabaseHelper { } } + /// 升级notes表到V12(确保title列存在) + Future _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(添加图片字段) Future _upgradeNotesTableV9(Database db) async { // 检查是否存在 images 列 @@ -211,6 +226,7 @@ class DatabaseHelper { await db.execute(''' CREATE TABLE notes ( id TEXT PRIMARY KEY, + title TEXT DEFAULT '', content TEXT NOT NULL, content_type TEXT DEFAULT 'markdown', tags TEXT, @@ -219,19 +235,17 @@ class DatabaseHelper { ) '''); - // 迁移旧数据(将title合并到content中) + // 迁移旧数据(将title字段恢复) for (final row in oldData) { try { final now = DateTime.now().toIso8601String(); final title = row['title']?.toString() ?? ''; final content = row['content']?.toString() ?? ''; - final combinedContent = title.isNotEmpty - ? '# $title\n\n$content' - : content; await db.insert('notes', { 'id': row['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(), - 'content': combinedContent, + 'title': title, + 'content': content, 'content_type': 'markdown', 'tags': row['tags'] ?? '', 'created_at': row['created_at']?.toString() ?? now, @@ -404,6 +418,7 @@ class DatabaseHelper { await db.execute(''' CREATE TABLE notes ( id TEXT PRIMARY KEY, + title TEXT DEFAULT '', content TEXT NOT NULL, content_type TEXT DEFAULT 'markdown', tags TEXT, diff --git a/lib/utils/note/note_dao.dart b/lib/utils/note/note_dao.dart index 592717e..08cc239 100644 --- a/lib/utils/note/note_dao.dart +++ b/lib/utils/note/note_dao.dart @@ -101,8 +101,8 @@ class NoteDao { final db = await _dbHelper.database; final List> maps = await db.query( 'notes', - where: '(content LIKE ? OR tags LIKE ?) AND is_deleted = ?', - whereArgs: ['%$query%', '%$query%', 0], + where: '(title LIKE ? OR content LIKE ? OR tags LIKE ?) AND is_deleted = ?', + whereArgs: ['%$query%', '%$query%', '%$query%', 0], orderBy: 'created_at DESC', ); diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 2f2a3cd..80113e5 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -48,6 +48,10 @@ class UserPrefs { // ========== 主界面显示设置 ========== + /// 是否启用底部导航栏滚动隐藏(默认开启) + bool get hideBottomNavOnScroll => prefs.getBool('hideBottomNavOnScroll') ?? true; + Future setHideBottomNavOnScroll(bool value) => prefs.setBool('hideBottomNavOnScroll', value); + /// 是否显示观影标签 bool get showMovieTab => prefs.getBool('showMovieTab') ?? true; Future setShowMovieTab(bool value) => prefs.setBool('showMovieTab', value); diff --git a/lib/widgets/markdown_editing_controller.dart b/lib/widgets/markdown_editing_controller.dart new file mode 100644 index 0000000..8e90be8 --- /dev/null +++ b/lib/widgets/markdown_editing_controller.dart @@ -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 = []; + 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 _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'(? 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 = []; + 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, + }); +} diff --git a/lib/widgets/note_list_item.dart b/lib/widgets/note_list_item.dart index 41579c5..1b60056 100644 --- a/lib/widgets/note_list_item.dart +++ b/lib/widgets/note_list_item.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_provider.dart'; @@ -28,8 +27,6 @@ class _NoteListItemContent extends StatelessWidget { @override Widget build(BuildContext context) { - final isPlainText = note.contentType == 'plain_text'; - return InkWell( onTap: () { Navigator.pushNamed(context, '/note-detail', arguments: note).then((_) async { @@ -50,28 +47,10 @@ class _NoteListItemContent extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - // 顶部:格式标记 + 时间 + 图片数 + // 顶部:时间 + MD标记 Row( 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( _formatDateCached(note.updatedAt), style: const TextStyle( @@ -79,64 +58,60 @@ class _NoteListItemContent extends StatelessWidget { color: Color(0xFF999999), ), ), - const Spacer(), - // 图片数量(如果有图片) - if (note.images.isNotEmpty) ...[ - const Icon( - Icons.image_outlined, - size: 12, - color: Color(0xFF999999), + const SizedBox(width: 8), + // MD标记 + 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), ), - const SizedBox(width: 3), - Text( - '${note.images.length}', - style: const TextStyle( - fontSize: 11, + child: const Text( + 'MD', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, color: Color(0xFF999999), ), ), - ], + ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 6), - // 内容摘要(去除首尾空格) - Text( - note.summary.trim(), - style: TextStyle( - fontSize: 14, - color: const Color(0xFF1A1A1A), - height: isPlainText ? 1.5 : 1.45, + // 标题 + if (note.title.isNotEmpty) ...[ + Text( + note.title, + style: const TextStyle( + fontSize: 15, + 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, ), - // 图片预览区域(显示前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) ...[ - const SizedBox(height: 8), + const SizedBox(height: 6), Wrap( spacing: 6, 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) { 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 _dateFormatCache = {}; diff --git a/pubspec.lock b/pubspec.lock index 5811091..463b235 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -113,6 +113,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -214,6 +230,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_markdown: dependency: "direct main" description: @@ -222,6 +243,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -352,6 +381,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" json_annotation: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4232270..f4f9878 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter cupertino_icons: ^1.0.8 provider: ^6.1.2 fl_chart: ^0.69.0 @@ -28,6 +30,8 @@ dependencies: url_launcher: ^6.2.5 webview_flutter: ^4.8.0 package_info_plus: ^8.0.0 + flutter_markdown_plus: ^1.0.7 + extended_text_field: ^16.0.2 dev_dependencies: flutter_test: