From 68a8d7f6cec6528cde7e8edda5b6afddfbe90ed8 Mon Sep 17 00:00:00 2001 From: DelLevin-Home Date: Sun, 9 Aug 2026 15:29:49 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=B8=E5=86=8C=E5=8A=9F=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/gallery/gallery_dao.dart | 224 ++++++++++++++++ lib/data/game/game_dao.dart | 12 + lib/data/movie/movie_dao.dart | 12 + lib/models/data_models.dart | 35 +++ lib/pages/epub_reader/image_viewer.dart | 2 + lib/pages/explore/gallery_page.dart | 195 ++++++++++++++ lib/pages/explore/gallery_viewer_page.dart | 253 +++++++++++++++++++ lib/pages/game/screenshot_gallery_page.dart | 14 +- lib/pages/movies/poster_gallery_page.dart | 18 +- lib/pages/note/note_detail_page.dart | 2 + lib/pages/note/note_form_page.dart | 2 + lib/pages/profile/feature_settings_page.dart | 13 + lib/utils/image_saver.dart | 188 ++++++++++++++ lib/utils/user_prefs.dart | 3 + lib/widgets/custom_drawer.dart | 2 + 15 files changed, 963 insertions(+), 12 deletions(-) create mode 100644 lib/data/gallery/gallery_dao.dart create mode 100644 lib/pages/explore/gallery_page.dart create mode 100644 lib/pages/explore/gallery_viewer_page.dart create mode 100644 lib/utils/image_saver.dart diff --git a/lib/data/gallery/gallery_dao.dart b/lib/data/gallery/gallery_dao.dart new file mode 100644 index 0000000..88d8fa0 --- /dev/null +++ b/lib/data/gallery/gallery_dao.dart @@ -0,0 +1,224 @@ +import 'package:flutter/foundation.dart'; +import '../../models/data_models.dart'; +import '../database_helper.dart'; + +/// 图库数据访问对象 —— 聚合所有实体的图片 +class GalleryDao { + final DatabaseHelper _dbHelper = DatabaseHelper.instance; + + Future _wrap(String op, Future Function() fn) async { + try { + return await fn(); + } catch (e) { + debugPrint('[GalleryDao] $op error: $e'); + rethrow; + } + } + + DateTime _parseDate(String? str) { + if (str == null || str.isEmpty) return DateTime.now(); + return DateTime.tryParse(str)?.toLocal() ?? DateTime.now(); + } + + /// 获取所有图片(过滤软删除与空路径),按创建时间倒序 + Future> getAllImages() => _wrap('getAllImages', () async { + final db = await _dbHelper.database; + final items = []; + + // 1. 影视海报(movies.poster_path) + final movieMaps = await db.query( + 'movies', + columns: ['id', 'title', 'poster_path', 'created_at'], + where: 'is_deleted = ? AND poster_path IS NOT NULL AND poster_path != ?', + whereArgs: [0, ''], + ); + for (final m in movieMaps) { + items.add(GalleryItem( + path: m['poster_path'] as String, + category: 'movie_poster', + entityType: 'movie', + entityId: m['id'] as String, + entityTitle: (m['title'] as String?) ?? '', + createdAt: _parseDate(m['created_at'] as String?), + )); + } + + // 2. 影视海报墙(movie_posters JOIN movies) + final posterMaps = await db.rawQuery( + "SELECT p.poster_path, p.created_at, p.movie_id, m.title AS parent_title " + "FROM movie_posters p INNER JOIN movies m ON p.movie_id = m.id " + "WHERE p.is_deleted = ? AND m.is_deleted = ? " + "AND p.poster_path IS NOT NULL AND p.poster_path != ?", + [0, 0, ''], + ); + for (final p in posterMaps) { + items.add(GalleryItem( + path: p['poster_path'] as String, + category: 'movie_posters', + entityType: 'movie', + entityId: p['movie_id'] as String, + entityTitle: (p['parent_title'] as String?) ?? '', + createdAt: _parseDate(p['created_at'] as String?), + )); + } + + // 3. 书籍封面(books.cover_path) + final bookMaps = await db.query( + 'books', + columns: ['id', 'title', 'cover_path', 'created_at'], + where: 'is_deleted = ? AND cover_path IS NOT NULL AND cover_path != ?', + whereArgs: [0, ''], + ); + for (final b in bookMaps) { + items.add(GalleryItem( + path: b['cover_path'] as String, + category: 'book_cover', + entityType: 'book', + entityId: b['id'] as String, + entityTitle: (b['title'] as String?) ?? '', + createdAt: _parseDate(b['created_at'] as String?), + )); + } + + // 4. 笔记图片(notes.images JSON 数组) + final noteMaps = await db.query( + 'notes', + columns: ['id', 'title', 'images', 'created_at'], + where: 'is_deleted = ? AND images IS NOT NULL AND images != ?', + whereArgs: [0, ''], + ); + for (final n in noteMaps) { + final paths = parseStringListGeneric(n['images']); + final title = (n['title'] as String?) ?? ''; + final created = _parseDate(n['created_at'] as String?); + for (final imgPath in paths) { + if (imgPath.isEmpty) continue; + items.add(GalleryItem( + path: imgPath, + category: 'note_image', + entityType: 'note', + entityId: n['id'] as String, + entityTitle: title, + createdAt: created, + )); + } + } + + // 5. 游戏封面(games.cover_path) + final gameMaps = await db.query( + 'games', + columns: ['id', 'title', 'cover_path', 'created_at'], + where: 'is_deleted = ? AND cover_path IS NOT NULL AND cover_path != ?', + whereArgs: [0, ''], + ); + for (final g in gameMaps) { + items.add(GalleryItem( + path: g['cover_path'] as String, + category: 'game_cover', + entityType: 'game', + entityId: g['id'] as String, + entityTitle: (g['title'] as String?) ?? '', + createdAt: _parseDate(g['created_at'] as String?), + )); + } + + // 6. 游戏截图(game_screenshots JOIN games) + final shotMaps = await db.rawQuery( + "SELECT s.screenshot_path, s.created_at, s.game_id, g.title AS parent_title " + "FROM game_screenshots s INNER JOIN games g ON s.game_id = g.id " + "WHERE s.is_deleted = ? AND g.is_deleted = ? " + "AND s.screenshot_path IS NOT NULL AND s.screenshot_path != ?", + [0, 0, ''], + ); + for (final s in shotMaps) { + items.add(GalleryItem( + path: s['screenshot_path'] as String, + category: 'game_screenshot', + entityType: 'game', + entityId: s['game_id'] as String, + entityTitle: (s['parent_title'] as String?) ?? '', + createdAt: _parseDate(s['created_at'] as String?), + )); + } + + // 7. 人物照片(people.photo_path) + final personMaps = await db.query( + 'people', + columns: ['id', 'name', 'photo_path', 'created_at'], + where: 'is_deleted = ? AND photo_path IS NOT NULL AND photo_path != ?', + whereArgs: [0, ''], + ); + for (final p in personMaps) { + items.add(GalleryItem( + path: p['photo_path'] as String, + category: 'person_photo', + entityType: 'person', + entityId: p['id'] as String, + entityTitle: (p['name'] as String?) ?? '', + createdAt: _parseDate(p['created_at'] as String?), + )); + } + + // 8. 角色图片(movie/book/game_characters JOIN 父表) + final charMovieMaps = await db.rawQuery( + "SELECT c.image_path, c.name, c.movie_id, c.created_at, m.title AS parent_title " + "FROM movie_characters c INNER JOIN movies m ON c.movie_id = m.id " + "WHERE c.is_deleted = ? AND m.is_deleted = ? " + "AND c.image_path IS NOT NULL AND c.image_path != ?", + [0, 0, ''], + ); + for (final c in charMovieMaps) { + items.add(GalleryItem( + path: c['image_path'] as String, + category: 'movie_character', + entityType: 'movie', + entityId: c['movie_id'] as String, + entityTitle: (c['name'] as String?) ?? '', + parentTitle: (c['parent_title'] as String?) ?? '', + createdAt: _parseDate(c['created_at'] as String?), + )); + } + + final charBookMaps = await db.rawQuery( + "SELECT c.image_path, c.name, c.book_id, c.created_at, b.title AS parent_title " + "FROM book_characters c INNER JOIN books b ON c.book_id = b.id " + "WHERE c.is_deleted = ? AND b.is_deleted = ? " + "AND c.image_path IS NOT NULL AND c.image_path != ?", + [0, 0, ''], + ); + for (final c in charBookMaps) { + items.add(GalleryItem( + path: c['image_path'] as String, + category: 'book_character', + entityType: 'book', + entityId: c['book_id'] as String, + entityTitle: (c['name'] as String?) ?? '', + parentTitle: (c['parent_title'] as String?) ?? '', + createdAt: _parseDate(c['created_at'] as String?), + )); + } + + final charGameMaps = await db.rawQuery( + "SELECT c.image_path, c.name, c.game_id, c.created_at, g.title AS parent_title " + "FROM game_characters c INNER JOIN games g ON c.game_id = g.id " + "WHERE c.is_deleted = ? AND g.is_deleted = ? " + "AND c.image_path IS NOT NULL AND c.image_path != ?", + [0, 0, ''], + ); + for (final c in charGameMaps) { + items.add(GalleryItem( + path: c['image_path'] as String, + category: 'game_character', + entityType: 'game', + entityId: c['game_id'] as String, + entityTitle: (c['name'] as String?) ?? '', + parentTitle: (c['parent_title'] as String?) ?? '', + createdAt: _parseDate(c['created_at'] as String?), + )); + } + + // 按创建时间倒序 + items.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return items; + }); +} diff --git a/lib/data/game/game_dao.dart b/lib/data/game/game_dao.dart index 392b31d..79cce00 100644 --- a/lib/data/game/game_dao.dart +++ b/lib/data/game/game_dao.dart @@ -27,6 +27,18 @@ class GameDao { return List.generate(maps.length, (i) => Game.fromJson(maps[i])); }); + // 根据ID获取游戏记录 + Future getGameById(String id) => _wrap('getGameById', () async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'games', + where: 'id = ? AND is_deleted = ?', + whereArgs: [id, 0], + ); + if (maps.isEmpty) return null; + return Game.fromJson(maps.first); + }); + // 分页查询游戏记录 Future> getGamesPaged({String? status, int limit = 20, int offset = 0, int sortMode = 0}) => _wrap('getGamesPaged', () async { final db = await _dbHelper.database; diff --git a/lib/data/movie/movie_dao.dart b/lib/data/movie/movie_dao.dart index ffd7a09..aa59555 100644 --- a/lib/data/movie/movie_dao.dart +++ b/lib/data/movie/movie_dao.dart @@ -27,6 +27,18 @@ class MovieDao { return List.generate(maps.length, (i) => Movie.fromJson(maps[i])); }); + // 根据ID获取影视记录 + Future getMovieById(String id) => _wrap('getMovieById', () async { + final db = await _dbHelper.database; + final List> maps = await db.query( + 'movies', + where: 'id = ? AND is_deleted = ?', + whereArgs: [id, 0], + ); + if (maps.isEmpty) return null; + return Movie.fromJson(maps.first); + }); + // 分页查询影视记录 Future> getMoviesPaged({String? status, String? category, int limit = 20, int offset = 0, int sortMode = 0}) => _wrap('getMoviesPaged', () async { final db = await _dbHelper.database; diff --git a/lib/models/data_models.dart b/lib/models/data_models.dart index 9bae02c..af8d121 100644 --- a/lib/models/data_models.dart +++ b/lib/models/data_models.dart @@ -1689,3 +1689,38 @@ class GameCharacter { } } +/// 图库图片项 +class GalleryItem { + final String path; + final String category; + final String entityType; + final String entityId; + final String entityTitle; + final String? parentTitle; + final DateTime createdAt; + + // category 取值: + // 'movie_poster' | 'movie_posters' | 'book_cover' | 'note_image' + // | 'game_cover' | 'game_screenshot' | 'person_photo' + // | 'movie_character' | 'book_character' | 'game_character' + // + // entityType 与 category 的映射: + // movie_poster / movie_posters / movie_character → 'movie' + // book_cover / book_character → 'book' + // note_image → 'note' + // game_cover / game_screenshot / game_character → 'game' + // person_photo → 'person' + // + // 角色图片:entityId 存父作品 ID,entityTitle 存角色名,parentTitle 存父作品标题 + + const GalleryItem({ + required this.path, + required this.category, + required this.entityType, + required this.entityId, + required this.entityTitle, + this.parentTitle, + required this.createdAt, + }); +} + diff --git a/lib/pages/epub_reader/image_viewer.dart b/lib/pages/epub_reader/image_viewer.dart index 9a1166e..ff9d36c 100644 --- a/lib/pages/epub_reader/image_viewer.dart +++ b/lib/pages/epub_reader/image_viewer.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../utils/image_saver.dart'; class ImageViewer extends StatefulWidget { final Uint8List imageData; @@ -168,6 +169,7 @@ class _ImageViewerState extends State rect: currentRect, child: GestureDetector( onTap: _handleClose, + onLongPress: () => ImageSaver.showSaveFromBytesSheet(widget.imageData, context: context), child: Container( clipBehavior: Clip.antiAlias, decoration: const BoxDecoration( diff --git a/lib/pages/explore/gallery_page.dart b/lib/pages/explore/gallery_page.dart new file mode 100644 index 0000000..c51255b --- /dev/null +++ b/lib/pages/explore/gallery_page.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import '../../data/gallery/gallery_dao.dart'; +import '../../models/data_models.dart'; +import '../../widgets/fade_in_local_image.dart'; +import 'gallery_viewer_page.dart'; + +/// 类别显示名映射 +const _categoryLabels = { + 'movie_poster': '影视海报', + 'movie_posters': '海报墙', + 'book_cover': '书籍封面', + 'note_image': '笔记图片', + 'game_cover': '游戏封面', + 'game_screenshot': '游戏截图', + 'person_photo': '人物照片', + 'movie_character': '影视角色', + 'book_character': '书籍角色', + 'game_character': '游戏角色', +}; + +class GalleryPage extends StatefulWidget { + const GalleryPage({super.key}); + + @override + State createState() => _GalleryPageState(); +} + +class _GalleryPageState extends State { + final GalleryDao _dao = GalleryDao(); + List _allItems = []; + bool _loading = true; + String? _error; + String? _selectedCategory; // null = 全部 + bool _descending = true; // 按时间倒序 + + @override + void initState() { + super.initState(); + _loadImages(); + } + + Future _loadImages() async { + try { + final items = await _dao.getAllImages(); + if (!mounted) return; + setState(() { + _allItems = items; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = '加载失败:$e'; + _loading = false; + }); + } + } + + List get _filteredItems { + var items = _allItems; + if (_selectedCategory != null) { + items = items.where((i) => i.category == _selectedCategory).toList(); + } + if (!_descending) { + items = items.reversed.toList(); + } + return items; + } + + List get _availableCategories { + final set = _allItems.map((i) => i.category).toSet(); + // 按预定义顺序排列 + return _categoryLabels.keys.where((k) => set.contains(k)).toList(); + } + + void _openPreview(int index) { + final items = _filteredItems; + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => GalleryViewerPage(items: items, initialIndex: index), + ), + ); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: const Text('图库'), + actions: [ + if (!_loading && _allItems.isNotEmpty) + IconButton( + icon: Icon(_descending ? Icons.arrow_downward : Icons.arrow_upward, size: 20), + tooltip: _descending ? '当前:最新在前' : '当前:最早在前', + onPressed: () => setState(() => _descending = !_descending), + ), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(_error!, textAlign: TextAlign.center, style: TextStyle(color: colors.error)), + ), + ) + : _allItems.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.photo_library_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.2)), + const SizedBox(height: 12), + Text('还没有保存过图片', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.5))), + ], + ), + ) + : Column( + children: [ + // 类别筛选条 + if (_availableCategories.length > 1) + SizedBox( + height: 44, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + children: [ + _buildChip(null, '全部', colors), + ..._availableCategories.map((c) => _buildChip(c, _categoryLabels[c] ?? c, colors)), + ], + ), + ), + // 网格 + Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + ), + itemCount: _filteredItems.length, + itemBuilder: (context, index) { + final item = _filteredItems[index]; + return GestureDetector( + onTap: () => _openPreview(index), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: FadeInLocalImage( + path: item.path, + fit: BoxFit.cover, + errorWidget: Container( + color: colors.surfaceContainerHighest, + child: Icon(Icons.broken_image_outlined, color: colors.onSurface.withValues(alpha: 0.3)), + ), + ), + ), + ); + }, + ), + ), + // 底部计数 + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + '共 ${_filteredItems.length} 张图片', + style: TextStyle(fontSize: 12, color: colors.onSurface.withValues(alpha: 0.5)), + ), + ), + ), + ], + ), + ); + } + + Widget _buildChip(String? category, String label, ColorScheme colors) { + final selected = _selectedCategory == category; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(label), + selected: selected, + onSelected: (_) => setState(() => _selectedCategory = selected ? null : category), + showCheckmark: false, + padding: const EdgeInsets.symmetric(horizontal: 4), + ), + ); + } +} diff --git a/lib/pages/explore/gallery_viewer_page.dart b/lib/pages/explore/gallery_viewer_page.dart new file mode 100644 index 0000000..0696309 --- /dev/null +++ b/lib/pages/explore/gallery_viewer_page.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../data/movie/movie_dao.dart'; +import '../../data/book/book_dao.dart'; +import '../../data/note/note_dao.dart'; +import '../../data/game/game_dao.dart'; +import '../../models/data_models.dart'; +import '../../providers/app_provider.dart'; +import '../../widgets/fade_in_local_image.dart'; +import '../../utils/image_saver.dart'; +import '../movies/movie_detail_page.dart'; +import '../book/book_detail_page.dart'; +import '../note/note_detail_page.dart'; +import '../game/game_detail_page.dart'; +import '../people/person_detail_page.dart'; + +/// 图库全屏预览页 —— 支持左右滑动、双指缩放、归属信息展示与跳转 +class GalleryViewerPage extends StatefulWidget { + final List items; + final int initialIndex; + + const GalleryViewerPage({ + super.key, + required this.items, + required this.initialIndex, + }); + + @override + State createState() => _GalleryViewerPageState(); +} + +class _GalleryViewerPageState extends State { + late PageController _pageController; + late int _currentIndex; + bool _infoVisible = true; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + String _buildInfoText(GalleryItem item) { + if (item.parentTitle != null && item.parentTitle!.isNotEmpty) { + return '来自:《${item.parentTitle}》— ${item.entityTitle}'; + } + return '来自:《${item.entityTitle}》'; + } + + Future _navigateToDetail(GalleryItem item) async { + dynamic target; + switch (item.entityType) { + case 'movie': + target = await MovieDao().getMovieById(item.entityId); + break; + case 'book': + target = await BookDao().getBookById(item.entityId); + break; + case 'note': + target = await NoteDao().getNoteById(item.entityId); + break; + case 'game': + target = await GameDao().getGameById(item.entityId); + break; + case 'person': + if (!mounted) return; + target = await context.read().getPersonById(item.entityId); + break; + } + if (!mounted) return; + if (target == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('原记录已不存在'), duration: Duration(seconds: 2)), + ); + return; + } + Widget page; + switch (item.entityType) { + case 'movie': + page = MovieDetailPage(movie: target as Movie); + break; + case 'book': + page = BookDetailPage(book: target as Book); + break; + case 'note': + page = NoteDetailPage(note: target as Note); + break; + case 'game': + page = GameDetailPage(game: target as Game); + break; + case 'person': + page = PersonDetailPage(person: target as Person); + break; + default: + return; + } + Navigator.pop(context); // 关闭全屏预览 + Navigator.push(context, MaterialPageRoute(builder: (_) => page)); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // 图片页面视图 + PageView.builder( + controller: _pageController, + itemCount: widget.items.length, + onPageChanged: (index) => setState(() => _currentIndex = index), + itemBuilder: (context, index) { + final item = widget.items[index]; + return GestureDetector( + onTap: () => setState(() => _infoVisible = !_infoVisible), + onLongPress: () => ImageSaver.showSaveFromFileSheet(item.path, context: context), + child: InteractiveViewer( + minScale: 0.5, + maxScale: 3.0, + child: Center( + child: FadeInLocalImage( + path: item.path, + fit: BoxFit.contain, + ), + ), + ), + ); + }, + ), + + // 顶部导航栏 + if (_infoVisible) + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.transparent, + ], + ), + ), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back, color: Colors.white), + ), + const Spacer(), + Text( + '${_currentIndex + 1} / ${widget.items.length}', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + const SizedBox(width: 48), + ], + ), + ), + ), + ), + + // 底部归属信息条 + if (_infoVisible) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: SafeArea( + top: false, + child: GestureDetector( + onTap: () => _navigateToDetail(widget.items[_currentIndex]), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.transparent, + ], + ), + ), + child: Row( + children: [ + const Icon(Icons.link, color: Colors.white70, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + _buildInfoText(widget.items[_currentIndex]), + style: const TextStyle(color: Colors.white, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.chevron_right, color: Colors.white70, size: 20), + ], + ), + ), + ), + ), + ), + + // 底部圆点指示器(图片较多时不显示,避免溢出) + if (widget.items.length > 1 && widget.items.length <= 20 && _infoVisible) + Positioned( + bottom: 56, + left: 0, + right: 0, + child: SafeArea( + top: false, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + widget.items.length, + (index) => Container( + width: 8, + height: 8, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: index == _currentIndex + ? Colors.white + : Colors.white.withValues(alpha: 0.4), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/game/screenshot_gallery_page.dart b/lib/pages/game/screenshot_gallery_page.dart index 5ee047b..95ccc50 100644 --- a/lib/pages/game/screenshot_gallery_page.dart +++ b/lib/pages/game/screenshot_gallery_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../models/data_models.dart'; import '../../widgets/fade_in_local_image.dart'; +import '../../utils/image_saver.dart'; /// 游戏截图画廊页面 - 支持左右滑动浏览 class ScreenshotGalleryPage extends StatefulWidget { @@ -42,11 +43,14 @@ class _ScreenshotGalleryPageState extends State { onPageChanged: (index) => setState(() => _currentIndex = index), itemBuilder: (context, index) { final screenshot = widget.screenshots[index]; - return InteractiveViewer( - minScale: 0.5, - maxScale: 3.0, - child: Center( - child: FadeInLocalImage(path: screenshot.screenshotPath, fit: BoxFit.contain), + return GestureDetector( + onLongPress: () => ImageSaver.showSaveFromFileSheet(screenshot.screenshotPath, context: context), + child: InteractiveViewer( + minScale: 0.5, + maxScale: 3.0, + child: Center( + child: FadeInLocalImage(path: screenshot.screenshotPath, fit: BoxFit.contain), + ), ), ); }, diff --git a/lib/pages/movies/poster_gallery_page.dart b/lib/pages/movies/poster_gallery_page.dart index df80d10..4f2973a 100644 --- a/lib/pages/movies/poster_gallery_page.dart +++ b/lib/pages/movies/poster_gallery_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../models/data_models.dart'; import '../../widgets/fade_in_local_image.dart'; +import '../../utils/image_saver.dart'; /// 海报画廊页面 - 支持左右滑动浏览 class PosterGalleryPage extends StatefulWidget { @@ -49,13 +50,16 @@ class _PosterGalleryPageState extends State { }, itemBuilder: (context, index) { final poster = widget.posters[index]; - return InteractiveViewer( - minScale: 0.5, - maxScale: 3.0, - child: Center( - child: FadeInLocalImage( - path: poster.posterPath, - fit: BoxFit.contain, + return GestureDetector( + onLongPress: () => ImageSaver.showSaveFromFileSheet(poster.posterPath, context: context), + child: InteractiveViewer( + minScale: 0.5, + maxScale: 3.0, + child: Center( + child: FadeInLocalImage( + path: poster.posterPath, + fit: BoxFit.contain, + ), ), ), ); diff --git a/lib/pages/note/note_detail_page.dart b/lib/pages/note/note_detail_page.dart index b7633b4..c5d00af 100644 --- a/lib/pages/note/note_detail_page.dart +++ b/lib/pages/note/note_detail_page.dart @@ -10,6 +10,7 @@ import '../../widgets/fade_in_local_image.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; +import '../../utils/image_saver.dart'; import '../../utils/responsive.dart'; import '../../widgets/vditor_editor.dart'; import '../../widgets/tag_side_panel.dart'; @@ -808,6 +809,7 @@ class _NoteDetailPageState extends State { barrierDismissible: true, builder: (context) => GestureDetector( onTap: () => Navigator.pop(context), + onLongPress: () => ImageSaver.showSaveFromFileSheet(images[initialIndex], context: context), child: Container( color: Colors.black.withValues(alpha: 0.9), child: Center( diff --git a/lib/pages/note/note_form_page.dart b/lib/pages/note/note_form_page.dart index c448bb8..3459033 100644 --- a/lib/pages/note/note_form_page.dart +++ b/lib/pages/note/note_form_page.dart @@ -9,6 +9,7 @@ import 'package:uuid/uuid.dart'; import '../../models/data_models.dart'; import '../../utils/toast_util.dart'; import '../../utils/image_path_helper.dart'; +import '../../utils/image_saver.dart'; import '../../widgets/fade_in_local_image.dart'; import '../../widgets/tag_side_panel.dart'; import '../../widgets/vditor_editor.dart'; @@ -903,6 +904,7 @@ class _NoteFormPageState extends State { barrierDismissible: true, builder: (context) => GestureDetector( onTap: () => Navigator.pop(context), + onLongPress: () => ImageSaver.showSaveFromFileSheet(_images[index], context: context), child: Container( color: Colors.black.withValues(alpha: 0.9), child: Center( diff --git a/lib/pages/profile/feature_settings_page.dart b/lib/pages/profile/feature_settings_page.dart index 85fa117..8df8631 100644 --- a/lib/pages/profile/feature_settings_page.dart +++ b/lib/pages/profile/feature_settings_page.dart @@ -30,6 +30,7 @@ class _FeatureSettingsPageState extends State { bool _showPlaylist = true; bool _showCalendar = true; bool _showPerson = true; + bool _showGallery = true; bool _showTags = true; bool _showMdReader = true; bool _showEpub = true; @@ -57,6 +58,7 @@ class _FeatureSettingsPageState extends State { _showPlaylist = _userPrefs.showSidebarPlaylist; _showCalendar = _userPrefs.showSidebarCalendar; _showPerson = _userPrefs.showSidebarPerson; + _showGallery = _userPrefs.showSidebarGallery; _showTags = _userPrefs.showSidebarTags; _showMdReader = _userPrefs.showSidebarMdReader; _showEpub = _userPrefs.showSidebarEpub; @@ -297,6 +299,17 @@ class _FeatureSettingsPageState extends State { await _userPrefs.setShowSidebarPerson(v); setState(() => _showPerson = v); }), + Divider( + height: 0.5, + indent: 24, + endIndent: 24, + color: colors.outlineVariant), + _buildSwitchItem( + Icons.photo_library_outlined, '图库', '浏览所有保存过的图片', _showGallery, + (v) async { + await _userPrefs.setShowSidebarGallery(v); + setState(() => _showGallery = v); + }), Divider( height: 0.5, indent: 24, diff --git a/lib/utils/image_saver.dart b/lib/utils/image_saver.dart new file mode 100644 index 0000000..73ab964 --- /dev/null +++ b/lib/utils/image_saver.dart @@ -0,0 +1,188 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; + +/// 图片保存工具 —— 将图片复制/写入到 /sdcard/Pictures/mooknote/ +class ImageSaver { + /// 请求存储权限,返回是否已获取 + static Future requestPermission() async { + if (!Platform.isAndroid) return true; + var status = await Permission.manageExternalStorage.status; + if (status.isGranted) return true; + status = await Permission.manageExternalStorage.request(); + if (status.isGranted) return true; + status = await Permission.storage.status; + if (status.isGranted) return true; + status = await Permission.storage.request(); + return status.isGranted; + } + + /// 获取保存目录 + static Future _getSaveDir() async { + if (Platform.isAndroid) { + final dir = Directory('/sdcard/Pictures/mooknote'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + // 非 Android 平台使用临时目录 + return await getTemporaryDirectory(); + } + + /// 生成带时间戳的文件名,保留原扩展名 + static String _buildFileName(String? originalPath, {String defaultExt = 'png'}) { + final ts = DateTime.now().toLocal(); + final stamp = '${ts.year}${_pad(ts.month)}${_pad(ts.day)}_${_pad(ts.hour)}${_pad(ts.minute)}${_pad(ts.second)}'; + String ext = defaultExt; + if (originalPath != null && originalPath.isNotEmpty) { + final parsed = p.extension(originalPath).toLowerCase().replaceAll('.', ''); + if (parsed.isNotEmpty) ext = parsed; + } + return 'mooknote_$stamp.$ext'; + } + + static String _pad(int n) => n.toString().padLeft(2, '0'); + + /// 长按保存的统一入口:弹出底部确认框,点击「下载」后才执行保存 + static Future showSaveFromFileSheet( + String sourcePath, { + required BuildContext context, + }) async { + final messenger = ScaffoldMessenger.maybeOf(context); + final src = File(sourcePath); + if (!await src.exists()) { + _toast(messenger, '原文件不存在'); + return; + } + if (!context.mounted) return; + _showSheet( + context: context, + onConfirm: () => saveFromFile(sourcePath, context: context), + ); + } + + /// 长按保存(字节)的统一入口:弹出底部确认框,点击「下载」后才执行保存 + static Future showSaveFromBytesSheet( + Uint8List bytes, { + String? originalPath, + required BuildContext context, + }) async { + _showSheet( + context: context, + onConfirm: () => saveFromBytes(bytes, originalPath: originalPath, context: context), + ); + } + + static void _showSheet({ + required BuildContext context, + required Future Function() onConfirm, + }) { + final colors = Theme.of(context).colorScheme; + showModalBottomSheet( + context: context, + backgroundColor: colors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetCtx) => SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 32, height: 4, + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: colors.onSurface.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(2), + ), + ), + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + Navigator.pop(sheetCtx); + onConfirm(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + Icon(Icons.download_outlined, size: 20, color: colors.primary), + const SizedBox(width: 12), + const Text('下载图片'), + ], + ), + ), + ), + InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => Navigator.pop(sheetCtx), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + Icon(Icons.close, size: 20, color: colors.onSurface.withValues(alpha: 0.6)), + const SizedBox(width: 12), + Text('取消', style: TextStyle(color: colors.onSurface.withValues(alpha: 0.6))), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + /// 保存本地文件路径的图片,返回是否成功 + static Future saveFromFile( + String sourcePath, { + BuildContext? context, + }) async { + final messenger = context != null ? ScaffoldMessenger.maybeOf(context) : null; + final src = File(sourcePath); + if (!await src.exists()) { + _toast(messenger, '原文件不存在'); + return false; + } + return _saveBytes(await src.readAsBytes(), _buildFileName(sourcePath), messenger); + } + + /// 保存内存中的图片字节 + static Future saveFromBytes( + Uint8List bytes, { + String? originalPath, + BuildContext? context, + }) async { + final messenger = context != null ? ScaffoldMessenger.maybeOf(context) : null; + return _saveBytes(bytes, _buildFileName(originalPath), messenger); + } + + static Future _saveBytes(Uint8List bytes, String fileName, ScaffoldMessengerState? messenger) async { + if (!await requestPermission()) { + _toast(messenger, '存储权限被拒绝'); + return false; + } + try { + final dir = await _getSaveDir(); + final target = File(p.join(dir.path, fileName)); + await target.writeAsBytes(bytes); + _toast(messenger, '已保存到 ${dir.path}'); + return true; + } catch (e) { + _toast(messenger, '保存失败:$e'); + return false; + } + } + + static void _toast(ScaffoldMessengerState? messenger, String msg) { + if (messenger == null) return; + messenger.showSnackBar(SnackBar(content: Text(msg), duration: const Duration(seconds: 3))); + } +} diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index 96ebcf3..f20a8da 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -142,6 +142,9 @@ class UserPrefs { bool get showSidebarPerson => prefs.getBool('showSidebarPerson') ?? true; Future setShowSidebarPerson(bool value) => prefs.setBool('showSidebarPerson', value); + bool get showSidebarGallery => prefs.getBool('showSidebarGallery') ?? true; + Future setShowSidebarGallery(bool value) => prefs.setBool('showSidebarGallery', value); + bool get showSidebarTags => prefs.getBool('showSidebarTags') ?? true; Future setShowSidebarTags(bool value) => prefs.setBool('showSidebarTags', value); diff --git a/lib/widgets/custom_drawer.dart b/lib/widgets/custom_drawer.dart index b58e344..8fdc32d 100644 --- a/lib/widgets/custom_drawer.dart +++ b/lib/widgets/custom_drawer.dart @@ -8,6 +8,7 @@ import 'reviewed_stamp_icon.dart'; import '../pages/explore/encounter_page.dart'; import '../pages/explore/stroll_page.dart'; import '../pages/explore/reviewed_page.dart'; +import '../pages/explore/gallery_page.dart'; import '../pages/playlist/playlist_list_page.dart'; import '../pages/explore/media_calendar_page.dart'; import '../pages/people/person_list_page.dart'; @@ -331,6 +332,7 @@ class _CustomDrawerState extends State { final toolItems = <(Widget, String, Widget)>[]; if (userPrefs.showSidebarPerson) toolItems.add((SvgPicture.string('', color: colors.onSurface), '人物', const PersonListPage())); + if (userPrefs.showSidebarGallery) toolItems.add((SvgPicture.string('', color: colors.onSurface), '图库', const GalleryPage())); if (userPrefs.showSidebarTags) toolItems.add((SvgPicture.string('', color: colors.onSurface), '标签管理', const TagManagementPage())); if (userPrefs.showSidebarMdReader) toolItems.add((Icon(Icons.description_outlined, size: 20, color: colors.onSurface), 'MD阅读', const MdReaderTabPage())); if (userPrefs.showSidebarEpub) toolItems.add((SvgPicture.string('', color: colors.onSurface), 'EPUB阅读', const EpubLibraryPage()));