From be2974146f07f45c7606f0f34932c9463a21576b Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sat, 21 Mar 2026 14:42:33 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/book/book_detail_page.dart | 7 - lib/pages/book/book_form_page.dart | 234 ++++++++++++++++ lib/pages/main_content_page.dart | 4 +- lib/pages/movies/douban_webview_page.dart | 16 +- lib/pages/movies/movie_form_page.dart | 184 ++++++++++++- lib/pages/movies/movie_posters_page.dart | 264 ++++++++++++++++++ lib/pages/note/note_detail_page.dart | 12 +- lib/pages/note/note_form_page.dart | 309 +++++++++------------- lib/pages/profile_page.dart | 212 +++++++++++++++ pubspec.yaml | 2 +- 10 files changed, 1048 insertions(+), 196 deletions(-) diff --git a/lib/pages/book/book_detail_page.dart b/lib/pages/book/book_detail_page.dart index e00bf0c..306317f 100644 --- a/lib/pages/book/book_detail_page.dart +++ b/lib/pages/book/book_detail_page.dart @@ -168,13 +168,6 @@ class _BookDetailPageState extends State { background: _buildCoverSection(book), ), actions: [ - // 下载封面按钮(仅当有封面时显示) - if (hasCover) - _buildActionButton( - icon: Icons.download_outlined, - onPressed: () => _downloadCover(book), - tooltip: '下载封面', - ), // 清空封面按钮(仅当有封面时显示) if (hasCover) _buildActionButton( diff --git a/lib/pages/book/book_form_page.dart b/lib/pages/book/book_form_page.dart index 82836a8..3d958dd 100644 --- a/lib/pages/book/book_form_page.dart +++ b/lib/pages/book/book_form_page.dart @@ -4,6 +4,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; +import 'package:http/http.dart' as http; import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; @@ -883,6 +884,78 @@ class _BookFormPageState extends State { /// 选择封面 Future _pickCover() async { + // 显示选择对话框 + final result = await showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 顶部指示条 + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + // 标题 + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '添加封面', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + ), + ), + const SizedBox(height: 16), + // 本地图片选项 + _buildCoverOption( + icon: Icons.photo_library_outlined, + title: '从相册选择', + onTap: () { + Navigator.pop(context, 0); + }, + ), + // 网络链接选项 + _buildCoverOption( + icon: Icons.link_outlined, + title: '网络链接', + onTap: () { + Navigator.pop(context, 1); + }, + ), + ], + ), + ), + ), + ); + + if (result == null) return; + + if (result == 0) { + await _pickCoverFromGallery(); + } else if (result == 1) { + await _pickCoverFromUrl(); + } + } + + /// 从相册选择封面 + Future _pickCoverFromGallery() async { try { final XFile? pickedFile = await _picker.pickImage( source: ImageSource.gallery, @@ -915,6 +988,167 @@ class _BookFormPageState extends State { } } } + + /// 从网络链接选择封面 + Future _pickCoverFromUrl() async { + final urlController = TextEditingController(); + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('添加网络图片'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '请输入图片链接地址', + style: TextStyle( + fontSize: 14, + color: Color(0xFF666666), + ), + ), + const SizedBox(height: 12), + TextField( + controller: urlController, + decoration: const InputDecoration( + hintText: 'https://book.doban.com/image.jpg', + hintStyle: TextStyle(color: Color(0xFFCCCCCC)), + border: UnderlineInputBorder(), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color.fromARGB(255, 58, 49, 49)), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFF1A1A1A)), + ), + ), + style: const TextStyle(fontSize: 14), + keyboardType: TextInputType.url, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('确定', style: TextStyle(color: Color(0xFF1A1A1A))), + ), + ], + ), + ); + + if (confirmed != true) return; + + final url = urlController.text.trim(); + if (url.isEmpty) { + ToastUtil.show(context, '请输入图片链接'); + return; + } + + try { + // 下载网络图片 + final response = await http.get( + Uri.parse(url), + headers: { + 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'Referer': 'https://book.douban.com/', + }, + ); + + if (response.statusCode != 200) { + throw Exception('下载失败: HTTP ${response.statusCode}'); + } + + // 检查内容类型 + final contentType = response.headers['content-type']; + if (contentType != null && !contentType.startsWith('image/')) { + throw Exception('链接返回的不是图片'); + } + + // 检查文件大小(最大 10MB) + if (response.bodyBytes.length > 10 * 1024 * 1024) { + throw Exception('图片太大'); + } + + // 生成文件名 + final fileName = 'cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + + // 如果是编辑模式,使用现有书籍ID;如果是新建模式,使用临时ID + final bookId = widget.book?.id ?? DateTime.now().millisecondsSinceEpoch.toString(); + + // 保存到新的路径结构 + final targetPath = await ImagePathHelper.instance.getBookCoverPath( + bookId, + fileName + ); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + + // 写入文件 + await File(targetPath).writeAsBytes(response.bodyBytes); + + setState(() => _coverPath = targetPath); + + if (mounted) { + ToastUtil.show(context, '添加成功'); + } + } catch (e) { + if (mounted) { + ToastUtil.show(context, '添加失败: $e'); + } + } + } + + /// 构建封面选项 + Widget _buildCoverOption({ + required IconData icon, + required String title, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, 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), + Text( + title, + style: const TextStyle( + fontSize: 16, + color: Color(0xFF1A1A1A), + ), + ), + const Spacer(), + const Icon( + Icons.chevron_right, + color: Color(0xFFCCCCCC), + size: 20, + ), + ], + ), + ), + ); + } /// 保存书籍 Future _saveBook() async { diff --git a/lib/pages/main_content_page.dart b/lib/pages/main_content_page.dart index 76f59f4..c7b365d 100644 --- a/lib/pages/main_content_page.dart +++ b/lib/pages/main_content_page.dart @@ -50,7 +50,7 @@ class _MainContentPageState extends State { /// 获取启用的标签列表 List<_TabItem> get _enabledTabs { final tabs = <_TabItem>[]; - if (_showMovieTab) tabs.add(_TabItem('观影', 0)); + if (_showMovieTab) tabs.add(_TabItem('影视', 0)); if (_showBookTab) tabs.add(_TabItem('阅读', 1)); if (_showNoteTab) tabs.add(_TabItem('笔记', 2)); return tabs; @@ -409,7 +409,7 @@ class _MainContentPageState extends State { String _getAppBarTitle(AppProvider provider) { switch (provider.mainTabIndex) { case 0: - return '观影'; + return '影视'; case 1: return '阅读'; case 2: diff --git a/lib/pages/movies/douban_webview_page.dart b/lib/pages/movies/douban_webview_page.dart index 44b8fe5..c7bcc65 100644 --- a/lib/pages/movies/douban_webview_page.dart +++ b/lib/pages/movies/douban_webview_page.dart @@ -172,8 +172,6 @@ class _DoubanWebViewPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildInfoRow('标题', movieInfo['title']?.toString() ?? '未提取到'), - _buildInfoRow('年份', movieInfo['year']?.toString() ?? '未提取到'), - _buildInfoRow('评分', movieInfo['rating']?.toString() ?? '未提取到'), _buildInfoRow('导演', movieInfo['director']?.toString() ?? '未提取到'), _buildInfoRow('类型', movieInfo['genres']?.toString() ?? '未提取到'), _buildInfoRow('上映日期', movieInfo['releaseDate']?.toString() ?? '未提取到'), @@ -273,7 +271,19 @@ class _DoubanWebViewPageState extends State { info.year = ''; } - + // 获取封面图 - 从 sub-cover 中的 img 标签获取 + const coverEl = document.querySelector('.sub-cover img'); + if (coverEl) { + let coverUrl = coverEl.src; + // 将 webp 转换为 jpg 格式,提高兼容性 + if (coverUrl && coverUrl.includes('.webp')) { + coverUrl = coverUrl.replace('.webp', '.jpg'); + } + info.coverUrl = coverUrl; + } else { + info.coverUrl = ''; + } + // 获取评分 - 移动版可能在 mark-item 中 const ratingEl = document.querySelector('.rating-num') || document.querySelector('.score'); info.rating = ratingEl ? ratingEl.textContent.trim() : ''; diff --git a/lib/pages/movies/movie_form_page.dart b/lib/pages/movies/movie_form_page.dart index 3995bd0..7bfcc64 100644 --- a/lib/pages/movies/movie_form_page.dart +++ b/lib/pages/movies/movie_form_page.dart @@ -4,6 +4,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; +import 'package:http/http.dart' as http; import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; @@ -150,7 +151,7 @@ class _MovieFormPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - '请输入豆瓣影视链接:', + '请输入分享的豆瓣影视链接:', style: TextStyle( fontSize: 14, color: Color(0xFF666666), @@ -307,6 +308,11 @@ class _MovieFormPageState extends State { } } + // 下载封面图 + if (info['coverUrl'] != null && info['coverUrl'].toString().isNotEmpty) { + _downloadCoverFromUrl(info['coverUrl'].toString()); + } + }); // 显示成功提示 @@ -317,6 +323,57 @@ class _MovieFormPageState extends State { } } + /// 从URL下载封面图 + Future _downloadCoverFromUrl(String url) async { + try { + // 下载网络图片,添加请求头模拟浏览器 + final response = await http.get( + Uri.parse(url), + headers: { + 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'Referer': 'https://movie.douban.com/', + }, + ); + + if (response.statusCode != 200) { + throw Exception('下载失败: HTTP ${response.statusCode}'); + } + + // 检查内容类型 + final contentType = response.headers['content-type']; + if (contentType != null && !contentType.startsWith('image/')) { + throw Exception('链接返回的不是图片'); + } + + // 检查文件大小(最大 10MB) + if (response.bodyBytes.length > 10 * 1024 * 1024) { + throw Exception('图片太大'); + } + + // 生成文件名 + final fileName = 'poster_${DateTime.now().millisecondsSinceEpoch}.jpg'; + + // 如果是编辑模式,使用现有影视ID;如果是新建模式,使用临时ID + final movieId = widget.movie?.id ?? DateTime.now().millisecondsSinceEpoch.toString(); + + // 保存到新的路径结构 + final targetPath = await ImagePathHelper.instance.getMoviePosterPath( + movieId, + fileName + ); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + + // 写入文件 + await File(targetPath).writeAsBytes(response.bodyBytes); + + setState(() => _posterPath = targetPath); + } catch (e) { + // 封面下载失败不影响其他信息填充 + debugPrint('封面下载失败: $e'); + } + } + @override Widget build(BuildContext context) { final isEdit = widget.movie != null; @@ -1065,6 +1122,15 @@ class _MovieFormPageState extends State { _pickCover(); }, ), + // 网络链接选项 + _buildCoverOption( + icon: Icons.link_outlined, + title: '网络链接', + onTap: () { + Navigator.pop(context); + _pickCoverFromUrl(); + }, + ), ], ), ), @@ -1173,6 +1239,122 @@ class _MovieFormPageState extends State { } } + /// 从网络链接选择封面 + Future _pickCoverFromUrl() async { + final urlController = TextEditingController(); + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('添加网络图片'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '请输入图片链接地址', + style: TextStyle( + fontSize: 14, + color: Color(0xFF666666), + ), + ), + const SizedBox(height: 12), + TextField( + controller: urlController, + decoration: const InputDecoration( + hintText: 'https://movie.douban.com/image.jpg', + hintStyle: TextStyle(color: Color(0xFFCCCCCC)), + border: UnderlineInputBorder(), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFFE5E5E5)), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFF1A1A1A)), + ), + ), + style: const TextStyle(fontSize: 14), + keyboardType: TextInputType.url, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('确定', style: TextStyle(color: Color(0xFF1A1A1A))), + ), + ], + ), + ); + + if (confirmed != true) return; + + final url = urlController.text.trim(); + if (url.isEmpty) { + ToastUtil.show(context, '请输入图片链接'); + return; + } + + try { + // 下载网络图片,添加请求头模拟浏览器 + final response = await http.get( + Uri.parse(url), + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + 'Referer': Uri.parse(url).replace(path: '/').toString(), + }, + ); + + if (response.statusCode != 200) { + throw Exception('下载失败: HTTP ${response.statusCode}'); + } + + // 检查内容类型 + final contentType = response.headers['content-type']; + if (contentType != null && !contentType.startsWith('image/')) { + throw Exception('链接返回的不是图片,可能是网页或需要登录'); + } + + // 检查文件大小(最大 10MB) + if (response.bodyBytes.length > 10 * 1024 * 1024) { + throw Exception('图片太大,请使用小于 10MB 的图片'); + } + + // 生成文件名 + final fileName = 'poster_${DateTime.now().millisecondsSinceEpoch}.jpg'; + + // 如果是编辑模式,使用现有影视ID;如果是新建模式,使用临时ID + final movieId = widget.movie?.id ?? DateTime.now().millisecondsSinceEpoch.toString(); + + // 保存到新的路径结构 + final targetPath = await ImagePathHelper.instance.getMoviePosterPath( + movieId, + fileName + ); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + + // 写入文件 + await File(targetPath).writeAsBytes(response.bodyBytes); + + setState(() => _posterPath = targetPath); + + if (mounted) { + ToastUtil.show(context, '添加成功'); + } + } catch (e) { + if (mounted) { + ToastUtil.show(context, '添加失败: $e'); + } + } + } + /// 选择上映日期 Future _selectReleaseDate() async { final picked = await showDatePicker( diff --git a/lib/pages/movies/movie_posters_page.dart b/lib/pages/movies/movie_posters_page.dart index 86a5c36..26ebefe 100644 --- a/lib/pages/movies/movie_posters_page.dart +++ b/lib/pages/movies/movie_posters_page.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:http/http.dart' as http; import '../../providers/app_provider.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; @@ -206,6 +207,77 @@ class _MoviePostersPageState extends State { } Future _pickPoster() async { + // 显示选择对话框 + final result = await showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 顶部指示条 + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + // 标题 + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Text( + '添加海报', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF1A1A1A), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + // 从相册选择 + _buildAddOption( + icon: Icons.photo_library_outlined, + title: '从相册选择', + subtitle: '选择本地图片', + onTap: () => Navigator.pop(context, 0), + ), + // 网络链接 + _buildAddOption( + icon: Icons.link_outlined, + title: '网络链接', + subtitle: '输入图片URL地址', + onTap: () => Navigator.pop(context, 1), + ), + ], + ), + ), + ), + ); + + if (result == null) return; + + if (result == 0) { + await _pickFromGallery(); + } else if (result == 1) { + await _pickFromUrl(); + } + } + + /// 从相册选择 + Future _pickFromGallery() async { try { final XFile? pickedFile = await _picker.pickImage( source: ImageSource.gallery, @@ -248,6 +320,198 @@ class _MoviePostersPageState extends State { } } + /// 从网络链接添加 + Future _pickFromUrl() async { + final urlController = TextEditingController(); + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('添加网络图片'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '请输入图片链接地址', + style: TextStyle( + fontSize: 14, + color: Color(0xFF666666), + ), + ), + const SizedBox(height: 12), + TextField( + controller: urlController, + decoration: const InputDecoration( + hintText: 'https://example.com/image.jpg', + hintStyle: TextStyle(color: Color(0xFFCCCCCC)), + border: UnderlineInputBorder(), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFFE5E5E5)), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFF1A1A1A)), + ), + ), + style: const TextStyle(fontSize: 14), + keyboardType: TextInputType.url, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('确定', style: TextStyle(color: Color(0xFF1A1A1A))), + ), + ], + ), + ); + + if (confirmed != true) return; + + final url = urlController.text.trim(); + if (url.isEmpty) { + ToastUtil.show(context, '请输入图片链接'); + return; + } + + try { + // 下载网络图片 + await _downloadAndSavePoster(url); + } catch (e) { + if (mounted) { + ToastUtil.show(context, '添加失败: $e'); + } + } + } + + /// 下载网络图片并保存 + Future _downloadAndSavePoster(String url) async { + try { + // 使用 http 下载图片,添加请求头模拟浏览器 + final response = await http.get( + Uri.parse(url), + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', + 'Referer': Uri.parse(url).replace(path: '/').toString(), + }, + ); + + if (response.statusCode != 200) { + throw Exception('下载失败: HTTP ${response.statusCode}'); + } + + // 检查内容类型 + final contentType = response.headers['content-type']; + if (contentType != null && !contentType.startsWith('image/')) { + throw Exception('链接返回的不是图片,可能是网页或需要登录'); + } + + // 检查文件大小(最大 10MB) + if (response.bodyBytes.length > 10 * 1024 * 1024) { + throw Exception('图片太大,请使用小于 10MB 的图片'); + } + + // 生成文件名 + final fileName = 'posterimg_${DateTime.now().millisecondsSinceEpoch}.jpg'; + + // 保存到 posterimgs 子目录 + final targetPath = await ImagePathHelper.instance.getMoviePosterImgPath( + widget.movie.id, + fileName + ); + await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath)); + + // 写入文件 + await File(targetPath).writeAsBytes(response.bodyBytes); + + final newPoster = MoviePoster( + id: DateTime.now().millisecondsSinceEpoch.toString(), + movieId: widget.movie.id, + posterPath: targetPath, + createdAt: DateTime.now(), + ); + + await context.read().addMoviePoster(newPoster); + _loadPosters(); + + if (mounted) { + ToastUtil.show(context, '添加成功'); + } + } catch (e) { + throw Exception('下载图片失败: $e'); + } + } + + /// 构建添加选项 + Widget _buildAddOption({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + 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: 2), + Text( + subtitle, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF999999), + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + color: Color(0xFFCCCCCC), + size: 20, + ), + ], + ), + ), + ); + } + void _showDeleteDialog(MoviePoster poster) { showDialog( context: context, diff --git a/lib/pages/note/note_detail_page.dart b/lib/pages/note/note_detail_page.dart index c01175c..631f4e5 100644 --- a/lib/pages/note/note_detail_page.dart +++ b/lib/pages/note/note_detail_page.dart @@ -28,7 +28,11 @@ class _NoteDetailPageState extends State { return Scaffold( backgroundColor: Colors.white, appBar: AppBar( - title: Text(_getTitle(note.content)), + title: Text( + _getTitle(note.content), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), actions: [ // 格式指示器 - 纯文本标记 if (note.contentType == 'markdown') @@ -337,15 +341,13 @@ 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')}'; } - /// 获取标题(内容前5个字) + /// 获取标题(内容第一行,去除换行) String _getTitle(String content) { if (content.isEmpty) return '无标题'; // 移除换行符和多余空格 final trimmed = content.replaceAll('\n', ' ').trim(); if (trimmed.isEmpty) return '无标题'; - // 取前5个字 - if (trimmed.length <= 5) return trimmed; - return '${trimmed.substring(0, 5)}...'; + return trimmed; } /// 跳转到编辑页面 diff --git a/lib/pages/note/note_form_page.dart b/lib/pages/note/note_form_page.dart index ff133ea..e56ca91 100644 --- a/lib/pages/note/note_form_page.dart +++ b/lib/pages/note/note_form_page.dart @@ -23,7 +23,7 @@ class _NoteFormPageState extends State { late DateTime _createdAt; List _tags = []; List _images = []; // 图片路径列表 - String _contentType = 'markdown'; // markdown / plain_text + String _contentType = 'plain_text'; // markdown / plain_text bool _isEditing = false; final ImagePicker _picker = ImagePicker(); String? _tempNoteId; // 新建模式时使用的临时笔记ID @@ -36,7 +36,7 @@ class _NoteFormPageState extends State { _createdAt = note?.createdAt ?? DateTime.now(); _tags = note != null ? List.from(note.tags) : []; _images = note != null ? List.from(note.images) : []; - _contentType = note?.contentType ?? 'markdown'; + _contentType = note?.contentType ?? 'plain_text'; _isEditing = note != null; } @@ -104,191 +104,125 @@ class _NoteFormPageState extends State { ), ), - // 书写区域(纯文本模式下占据35%高度,Markdown模式下占据全部) - if (_contentType == 'plain_text') - SizedBox( - height: MediaQuery.of(context).size.height * 0.35, - child: TextField( - controller: _contentController, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - style: const TextStyle( + // 书写区域 + 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(0xFF1A1A1A), - height: 1.6, + color: Color(0xFFCCCCCC), ), - decoration: const InputDecoration( - hintText: '开始书写...', - hintStyle: TextStyle( - fontSize: 16, - color: Color(0xFFCCCCCC), - ), - border: InputBorder.none, - contentPadding: EdgeInsets.all(16), + border: InputBorder.none, + contentPadding: const EdgeInsets.all(16), + ), + ), + ), + + // 图片区域(放在内容下方) + 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), ), ), - ) - else - Expanded( - child: TextField( - controller: _contentController, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - style: const TextStyle( - fontSize: 16, - color: Color(0xFF1A1A1A), - height: 1.6, - ), - decoration: const InputDecoration( - hintText: '使用 Markdown 格式书写...', - hintStyle: TextStyle( - fontSize: 16, - color: Color(0xFFCCCCCC), - ), - border: InputBorder.none, - contentPadding: EdgeInsets.all(16), - ), + 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') ...[ - // 图片网格区域 - Expanded( - child: Container( - padding: const EdgeInsets.all(20), - 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), - const Text( - '图片', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: Color(0xFF1A1A1A), - ), - ), - const SizedBox(width: 8), - Text( - '${_images.length}', - style: const TextStyle( - fontSize: 15, - color: Color(0xFF999999), - ), - ), - 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, - size: 18, - color: Colors.white, - ), - SizedBox(width: 6), - Text( - '添加', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - ], - ), - ), - ), - ], - ), - - const SizedBox(height: 16), - - // 图片网格(4列,正方形铺满) - Expanded( - child: _images.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(20), - ), - child: const Icon( - Icons.image_outlined, - size: 40, - color: Color(0xFFCCCCCC), - ), - ), - const SizedBox(height: 16), - const Text( - '暂无图片', - style: TextStyle( - fontSize: 15, - color: Color(0xFF999999), - ), - ), - const SizedBox(height: 8), - const Text( - '点击右上角添加按钮添加图片', - style: TextStyle( - fontSize: 13, - color: Color(0xFFBBBBBB), - ), - ), - ], - ), - ) - : GridView.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - childAspectRatio: 1.0, - ), - itemCount: _images.length, - itemBuilder: (context, index) { - return _buildImageItem(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, + ), + ), + ], + ), + ), + ), + ], + ), ), - ], ], ), ); @@ -858,6 +792,27 @@ class _NoteFormPageState extends State { ); } + /// 构建横向图片项(用于底部图片栏) + 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( diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index ca6fae4..0909be9 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -684,6 +684,16 @@ class SettingsPage extends StatelessWidget { ), body: ListView( children: [ + // 数据管理 + _buildSectionHeader('数据管理'), + _buildActionItem( + icon: Icons.cleaning_services_outlined, + title: '清除缓存数据', + subtitle: '清理未在数据库中引用的图片文件', + onTap: () => _showClearCacheDialog(context), + ), + const Divider(height: 0.5, indent: 24, endIndent: 24), + // 主界面功能显示入口 _buildSectionHeader('主界面显示'), _buildNavigationItem( @@ -870,6 +880,208 @@ class SettingsPage extends StatelessWidget { ); } + /// 构建操作项(无箭头) + Widget _buildActionItem({ + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onTap, + }) { + return InkWell( + onTap: onTap, + 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), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// 显示清除缓存对话框 + void _showClearCacheDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: Colors.white, + elevation: 0, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + title: const Text('清除缓存数据'), + content: const Text('这将删除所有未在数据库中引用的图片文件。确定要继续吗?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消', style: TextStyle(color: Color(0xFF666666))), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + await _clearCacheData(context); + }, + child: const Text('确定', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + + /// 清除缓存数据 + Future _clearCacheData(BuildContext context) async { + try { + // 显示进度提示 + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => const Center( + child: CircularProgressIndicator(), + ), + ); + + // 获取所有数据库中的图片路径 + final appProvider = context.read(); + final dbImagePaths = await _getAllDbImagePaths(appProvider); + + // 清理图片目录 + final deletedCount = await _cleanImageDirectory(dbImagePaths); + + // 关闭进度提示 + Navigator.pop(context); + + // 显示结果 + if (context.mounted) { + ToastUtil.show(context, '已清理 $deletedCount 个缓存文件'); + } + } catch (e) { + // 关闭进度提示 + Navigator.pop(context); + if (context.mounted) { + ToastUtil.show(context, '清理失败: $e'); + } + } + } + + /// 获取数据库中所有图片路径 + Future> _getAllDbImagePaths(AppProvider provider) async { + final paths = {}; + + // 获取所有影视的封面路径 + final movies = provider.movies; + for (final movie in movies) { + final posterPath = movie.posterPath; + if (posterPath != null && posterPath.isNotEmpty) { + paths.add(posterPath); + } + } + + // 获取所有书籍的封面路径 + final books = provider.books; + for (final book in books) { + final coverPath = book.coverPath; + if (coverPath != null && coverPath.isNotEmpty) { + paths.add(coverPath); + } + } + + // 获取所有笔记中的图片路径 + final notes = provider.notes; + for (final note in notes) { + for (final imagePath in note.images) { + if (imagePath.isNotEmpty) { + paths.add(imagePath); + } + } + } + + // 获取所有海报墙图片路径 + final movieIds = movies.map((m) => m.id).toList(); + for (final movieId in movieIds) { + final posters = await provider.getMoviePosters(movieId); + for (final poster in posters) { + final posterPath = poster.posterPath; + if (posterPath.isNotEmpty) { + paths.add(posterPath); + } + } + } + + return paths; + } + + /// 清理图片目录 + Future _cleanImageDirectory(Set dbImagePaths) async { + int deletedCount = 0; + + try { + // 获取应用文档目录 + final appDir = await getApplicationDocumentsDirectory(); + final imagesDir = Directory('${appDir.path}/images'); + + if (!await imagesDir.exists()) { + return 0; + } + + // 递归遍历所有文件 + await for (final entity in imagesDir.list(recursive: true, followLinks: false)) { + if (entity is File) { + final filePath = entity.path; + // 如果文件不在数据库中,删除它 + if (!dbImagePaths.contains(filePath)) { + try { + await entity.delete(); + deletedCount++; + } catch (e) { + // 忽略单个文件删除错误 + } + } + } + } + } catch (e) { + debugPrint('清理图片目录失败: $e'); + } + + return deletedCount; + } + /// 打开链接(应用内打开) void _launchUrl(BuildContext context, String url) { Navigator.push( diff --git a/pubspec.yaml b/pubspec.yaml index ca3aa25..75a4042 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: mooknote description: "app for tracking movies, books, and notes" publish_to: 'none' -version: 0.1.6+1 +version: 0.1.7+1 environment: sdk: ^3.5.0