相册功能优化

This commit is contained in:
DelLevin-Home
2026-08-09 15:29:49 +08:00
parent 6d76c33672
commit 68a8d7f6ce
15 changed files with 963 additions and 12 deletions

View File

@@ -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<T> _wrap<T>(String op, Future<T> 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<List<GalleryItem>> getAllImages() => _wrap('getAllImages', () async {
final db = await _dbHelper.database;
final items = <GalleryItem>[];
// 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;
});
}

View File

@@ -27,6 +27,18 @@ class GameDao {
return List.generate(maps.length, (i) => Game.fromJson(maps[i])); return List.generate(maps.length, (i) => Game.fromJson(maps[i]));
}); });
// 根据ID获取游戏记录
Future<Game?> getGameById(String id) => _wrap('getGameById', () async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'games',
where: 'id = ? AND is_deleted = ?',
whereArgs: [id, 0],
);
if (maps.isEmpty) return null;
return Game.fromJson(maps.first);
});
// 分页查询游戏记录 // 分页查询游戏记录
Future<List<Game>> getGamesPaged({String? status, int limit = 20, int offset = 0, int sortMode = 0}) => _wrap('getGamesPaged', () async { Future<List<Game>> getGamesPaged({String? status, int limit = 20, int offset = 0, int sortMode = 0}) => _wrap('getGamesPaged', () async {
final db = await _dbHelper.database; final db = await _dbHelper.database;

View File

@@ -27,6 +27,18 @@ class MovieDao {
return List.generate(maps.length, (i) => Movie.fromJson(maps[i])); return List.generate(maps.length, (i) => Movie.fromJson(maps[i]));
}); });
// 根据ID获取影视记录
Future<Movie?> getMovieById(String id) => _wrap('getMovieById', () async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'movies',
where: 'id = ? AND is_deleted = ?',
whereArgs: [id, 0],
);
if (maps.isEmpty) return null;
return Movie.fromJson(maps.first);
});
// 分页查询影视记录 // 分页查询影视记录
Future<List<Movie>> getMoviesPaged({String? status, String? category, int limit = 20, int offset = 0, int sortMode = 0}) => _wrap('getMoviesPaged', () async { Future<List<Movie>> getMoviesPaged({String? status, String? category, int limit = 20, int offset = 0, int sortMode = 0}) => _wrap('getMoviesPaged', () async {
final db = await _dbHelper.database; final db = await _dbHelper.database;

View File

@@ -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 存父作品 IDentityTitle 存角色名parentTitle 存父作品标题
const GalleryItem({
required this.path,
required this.category,
required this.entityType,
required this.entityId,
required this.entityTitle,
this.parentTitle,
required this.createdAt,
});
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../utils/image_saver.dart';
class ImageViewer extends StatefulWidget { class ImageViewer extends StatefulWidget {
final Uint8List imageData; final Uint8List imageData;
@@ -168,6 +169,7 @@ class _ImageViewerState extends State<ImageViewer>
rect: currentRect, rect: currentRect,
child: GestureDetector( child: GestureDetector(
onTap: _handleClose, onTap: _handleClose,
onLongPress: () => ImageSaver.showSaveFromBytesSheet(widget.imageData, context: context),
child: Container( child: Container(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration( decoration: const BoxDecoration(

View File

@@ -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<GalleryPage> createState() => _GalleryPageState();
}
class _GalleryPageState extends State<GalleryPage> {
final GalleryDao _dao = GalleryDao();
List<GalleryItem> _allItems = [];
bool _loading = true;
String? _error;
String? _selectedCategory; // null = 全部
bool _descending = true; // 按时间倒序
@override
void initState() {
super.initState();
_loadImages();
}
Future<void> _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<GalleryItem> 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<String> 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),
),
);
}
}

View File

@@ -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<GalleryItem> items;
final int initialIndex;
const GalleryViewerPage({
super.key,
required this.items,
required this.initialIndex,
});
@override
State<GalleryViewerPage> createState() => _GalleryViewerPageState();
}
class _GalleryViewerPageState extends State<GalleryViewerPage> {
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<void> _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<AppProvider>().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),
),
),
),
),
),
),
],
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import '../../widgets/fade_in_local_image.dart'; import '../../widgets/fade_in_local_image.dart';
import '../../utils/image_saver.dart';
/// 游戏截图画廊页面 - 支持左右滑动浏览 /// 游戏截图画廊页面 - 支持左右滑动浏览
class ScreenshotGalleryPage extends StatefulWidget { class ScreenshotGalleryPage extends StatefulWidget {
@@ -42,12 +43,15 @@ class _ScreenshotGalleryPageState extends State<ScreenshotGalleryPage> {
onPageChanged: (index) => setState(() => _currentIndex = index), onPageChanged: (index) => setState(() => _currentIndex = index),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final screenshot = widget.screenshots[index]; final screenshot = widget.screenshots[index];
return InteractiveViewer( return GestureDetector(
onLongPress: () => ImageSaver.showSaveFromFileSheet(screenshot.screenshotPath, context: context),
child: InteractiveViewer(
minScale: 0.5, minScale: 0.5,
maxScale: 3.0, maxScale: 3.0,
child: Center( child: Center(
child: FadeInLocalImage(path: screenshot.screenshotPath, fit: BoxFit.contain), child: FadeInLocalImage(path: screenshot.screenshotPath, fit: BoxFit.contain),
), ),
),
); );
}, },
), ),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import '../../widgets/fade_in_local_image.dart'; import '../../widgets/fade_in_local_image.dart';
import '../../utils/image_saver.dart';
/// 海报画廊页面 - 支持左右滑动浏览 /// 海报画廊页面 - 支持左右滑动浏览
class PosterGalleryPage extends StatefulWidget { class PosterGalleryPage extends StatefulWidget {
@@ -49,7 +50,9 @@ class _PosterGalleryPageState extends State<PosterGalleryPage> {
}, },
itemBuilder: (context, index) { itemBuilder: (context, index) {
final poster = widget.posters[index]; final poster = widget.posters[index];
return InteractiveViewer( return GestureDetector(
onLongPress: () => ImageSaver.showSaveFromFileSheet(poster.posterPath, context: context),
child: InteractiveViewer(
minScale: 0.5, minScale: 0.5,
maxScale: 3.0, maxScale: 3.0,
child: Center( child: Center(
@@ -58,6 +61,7 @@ class _PosterGalleryPageState extends State<PosterGalleryPage> {
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
), ),
),
); );
}, },
), ),

View File

@@ -10,6 +10,7 @@ import '../../widgets/fade_in_local_image.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import '../../utils/toast_util.dart'; import '../../utils/toast_util.dart';
import '../../utils/image_path_helper.dart'; import '../../utils/image_path_helper.dart';
import '../../utils/image_saver.dart';
import '../../utils/responsive.dart'; import '../../utils/responsive.dart';
import '../../widgets/vditor_editor.dart'; import '../../widgets/vditor_editor.dart';
import '../../widgets/tag_side_panel.dart'; import '../../widgets/tag_side_panel.dart';
@@ -808,6 +809,7 @@ class _NoteDetailPageState extends State<NoteDetailPage> {
barrierDismissible: true, barrierDismissible: true,
builder: (context) => GestureDetector( builder: (context) => GestureDetector(
onTap: () => Navigator.pop(context), onTap: () => Navigator.pop(context),
onLongPress: () => ImageSaver.showSaveFromFileSheet(images[initialIndex], context: context),
child: Container( child: Container(
color: Colors.black.withValues(alpha: 0.9), color: Colors.black.withValues(alpha: 0.9),
child: Center( child: Center(

View File

@@ -9,6 +9,7 @@ import 'package:uuid/uuid.dart';
import '../../models/data_models.dart'; import '../../models/data_models.dart';
import '../../utils/toast_util.dart'; import '../../utils/toast_util.dart';
import '../../utils/image_path_helper.dart'; import '../../utils/image_path_helper.dart';
import '../../utils/image_saver.dart';
import '../../widgets/fade_in_local_image.dart'; import '../../widgets/fade_in_local_image.dart';
import '../../widgets/tag_side_panel.dart'; import '../../widgets/tag_side_panel.dart';
import '../../widgets/vditor_editor.dart'; import '../../widgets/vditor_editor.dart';
@@ -903,6 +904,7 @@ class _NoteFormPageState extends State<NoteFormPage> {
barrierDismissible: true, barrierDismissible: true,
builder: (context) => GestureDetector( builder: (context) => GestureDetector(
onTap: () => Navigator.pop(context), onTap: () => Navigator.pop(context),
onLongPress: () => ImageSaver.showSaveFromFileSheet(_images[index], context: context),
child: Container( child: Container(
color: Colors.black.withValues(alpha: 0.9), color: Colors.black.withValues(alpha: 0.9),
child: Center( child: Center(

View File

@@ -30,6 +30,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
bool _showPlaylist = true; bool _showPlaylist = true;
bool _showCalendar = true; bool _showCalendar = true;
bool _showPerson = true; bool _showPerson = true;
bool _showGallery = true;
bool _showTags = true; bool _showTags = true;
bool _showMdReader = true; bool _showMdReader = true;
bool _showEpub = true; bool _showEpub = true;
@@ -57,6 +58,7 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
_showPlaylist = _userPrefs.showSidebarPlaylist; _showPlaylist = _userPrefs.showSidebarPlaylist;
_showCalendar = _userPrefs.showSidebarCalendar; _showCalendar = _userPrefs.showSidebarCalendar;
_showPerson = _userPrefs.showSidebarPerson; _showPerson = _userPrefs.showSidebarPerson;
_showGallery = _userPrefs.showSidebarGallery;
_showTags = _userPrefs.showSidebarTags; _showTags = _userPrefs.showSidebarTags;
_showMdReader = _userPrefs.showSidebarMdReader; _showMdReader = _userPrefs.showSidebarMdReader;
_showEpub = _userPrefs.showSidebarEpub; _showEpub = _userPrefs.showSidebarEpub;
@@ -297,6 +299,17 @@ class _FeatureSettingsPageState extends State<FeatureSettingsPage> {
await _userPrefs.setShowSidebarPerson(v); await _userPrefs.setShowSidebarPerson(v);
setState(() => _showPerson = 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( Divider(
height: 0.5, height: 0.5,
indent: 24, indent: 24,

188
lib/utils/image_saver.dart Normal file
View File

@@ -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<bool> 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<Directory> _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<void> 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<void> 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<void> 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<bool> 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<bool> saveFromBytes(
Uint8List bytes, {
String? originalPath,
BuildContext? context,
}) async {
final messenger = context != null ? ScaffoldMessenger.maybeOf(context) : null;
return _saveBytes(bytes, _buildFileName(originalPath), messenger);
}
static Future<bool> _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)));
}
}

View File

@@ -142,6 +142,9 @@ class UserPrefs {
bool get showSidebarPerson => prefs.getBool('showSidebarPerson') ?? true; bool get showSidebarPerson => prefs.getBool('showSidebarPerson') ?? true;
Future<bool> setShowSidebarPerson(bool value) => prefs.setBool('showSidebarPerson', value); Future<bool> setShowSidebarPerson(bool value) => prefs.setBool('showSidebarPerson', value);
bool get showSidebarGallery => prefs.getBool('showSidebarGallery') ?? true;
Future<bool> setShowSidebarGallery(bool value) => prefs.setBool('showSidebarGallery', value);
bool get showSidebarTags => prefs.getBool('showSidebarTags') ?? true; bool get showSidebarTags => prefs.getBool('showSidebarTags') ?? true;
Future<bool> setShowSidebarTags(bool value) => prefs.setBool('showSidebarTags', value); Future<bool> setShowSidebarTags(bool value) => prefs.setBool('showSidebarTags', value);

View File

@@ -8,6 +8,7 @@ import 'reviewed_stamp_icon.dart';
import '../pages/explore/encounter_page.dart'; import '../pages/explore/encounter_page.dart';
import '../pages/explore/stroll_page.dart'; import '../pages/explore/stroll_page.dart';
import '../pages/explore/reviewed_page.dart'; import '../pages/explore/reviewed_page.dart';
import '../pages/explore/gallery_page.dart';
import '../pages/playlist/playlist_list_page.dart'; import '../pages/playlist/playlist_list_page.dart';
import '../pages/explore/media_calendar_page.dart'; import '../pages/explore/media_calendar_page.dart';
import '../pages/people/person_list_page.dart'; import '../pages/people/person_list_page.dart';
@@ -331,6 +332,7 @@ class _CustomDrawerState extends State<CustomDrawer> {
final toolItems = <(Widget, String, Widget)>[]; final toolItems = <(Widget, String, Widget)>[];
if (userPrefs.showSidebarPerson) toolItems.add((SvgPicture.string('<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="20" height="20"><path d="M819.2 819.2h204.8v-102.4h-204.8zM716.8 307.2v102.4h307.2V307.2z m51.2 307.2h256v-102.4h-256z m-244.224-61.952a256 256 0 1 0-330.752 0A358.4 358.4 0 0 0 0 870.4a339.456 339.456 0 0 0 4.096 51.2h102.4A280.064 280.064 0 0 1 102.4 870.4a256 256 0 0 1 512 0 280.064 280.064 0 0 1-5.12 51.2h102.4a339.456 339.456 0 0 0 5.12-51.2 358.4 358.4 0 0 0-193.024-317.952zM358.4 512a153.6 153.6 0 1 1 153.6-153.6 153.6 153.6 0 0 1-153.6 153.6z" fill="currentColor"/></svg>', color: colors.onSurface), '人物', const PersonListPage())); if (userPrefs.showSidebarPerson) toolItems.add((SvgPicture.string('<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="20" height="20"><path d="M819.2 819.2h204.8v-102.4h-204.8zM716.8 307.2v102.4h307.2V307.2z m51.2 307.2h256v-102.4h-256z m-244.224-61.952a256 256 0 1 0-330.752 0A358.4 358.4 0 0 0 0 870.4a339.456 339.456 0 0 0 4.096 51.2h102.4A280.064 280.064 0 0 1 102.4 870.4a256 256 0 0 1 512 0 280.064 280.064 0 0 1-5.12 51.2h102.4a339.456 339.456 0 0 0 5.12-51.2 358.4 358.4 0 0 0-193.024-317.952zM358.4 512a153.6 153.6 0 1 1 153.6-153.6 153.6 153.6 0 0 1-153.6 153.6z" fill="currentColor"/></svg>', color: colors.onSurface), '人物', const PersonListPage()));
if (userPrefs.showSidebarGallery) toolItems.add((SvgPicture.string('<svg viewBox="0 0 1064 1024" xmlns="http://www.w3.org/2000/svg" width="20" height="20"><path d="M71.68 348.16A296.96 296.96 0 0 1 368.64 51.2h327.68a296.96 296.96 0 0 1 296.96 296.96v327.68A296.96 296.96 0 0 1 696.32 972.8H368.64a296.96 296.96 0 0 1-296.96-296.96v-327.68zM368.64 153.6A194.56 194.56 0 0 0 174.08 348.16v327.68A194.56 194.56 0 0 0 368.64 870.4h327.68a194.56 194.56 0 0 0 194.56-194.56v-327.68A194.56 194.56 0 0 0 696.32 153.6H368.64z" fill="currentColor"/><path d="M947.69152 606.08512a317.80864 317.80864 0 0 0-264.02816 209.7152l-96.54272-34.16064a420.20864 420.20864 0 0 1 349.34784-277.2992l11.22304 101.74464z" fill="currentColor"/><path d="M798.72 327.68a81.92 81.92 0 1 1-163.84 0 81.92 81.92 0 0 1 163.84 0z" fill="currentColor"/><path d="M163.84 542.72c-12.4928 0-24.86272 0.49152-37.0688 1.39264l-7.7824-102.11328c14.82752-1.10592 29.77792-1.67936 44.8512-1.67936 284.01664 0 520.6016 202.79296 572.90752 471.49056l-100.51584 19.57888C593.1008 709.87776 397.9264 542.72 163.84 542.72z" fill="currentColor"/></svg>', color: colors.onSurface), '图库', const GalleryPage()));
if (userPrefs.showSidebarTags) toolItems.add((SvgPicture.string('<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="20" height="20"><path d="M687.012733 1024c-29.085211 0-55.161606-10.029383-74.217434-30.088149L104.305583 487.428012c-21.061704-21.061704-33.096964-50.146915-32.094026-79.232126l7.020568-242.711067a96.282076 96.282076 0 0 1 97.285015-94.2762h233.684623c28.082272 0 55.161606 12.03526 76.22331 32.094025l508.489716 508.489716c22.064643 22.064643 33.096964 54.158668 29.085211 87.255632s-18.052889 61.179236-42.123408 84.246817L784.297747 981.876592c-23.067581 23.067581-53.15573 38.111655-84.246817 42.123408zM176.51714 150.440744c-10.029383 0-17.049951 7.020568-17.049951 17.049951l-7.020568 242.711068c0 7.020568 3.008815 15.044074 9.026445 20.058766l507.486777 507.486778c11.032321 11.032321 37.108717 8.023506 58.170421-13.038198l197.578845-197.578845c21.061704-21.061704 23.067581-48.141038 13.038198-58.170421L429.257591 160.470127c-5.014691-5.014691-13.038198-9.026445-19.055828-9.026444H176.51714z m-57.167483 16.047013z" fill="currentColor"/><path d="M316.928501 442.295788a130.381978 130.381978 0 1 1 130.381979-130.381978 130.381978 130.381978 0 0 1-130.381979 130.381978z m0-180.528893a50.146915 50.146915 0 1 0 50.146915 50.146915 50.146915 50.146915 0 0 0-50.146915-50.146915z" fill="currentColor"/><path d="M258.75808 362.060725c-15.044074 0-32.094025-3.008815-49.143976-8.023507-42.123408-13.038198-86.252693-40.117532-124.364349-78.229187S21.061704 194.570029 8.023506 152.446621c-7.020568-23.067581-9.026445-44.129285-7.020568-64.188051s12.03526-44.129285 27.079334-59.173359S71.208619 1.002938 100.29383 1.002938a40.120541 40.120541 0 1 1 1.002938 80.235064c-5.014691 0-13.038198 1.002938-17.049951 5.014691s-7.020568 23.067581-1.002938 43.126347 30.088149 62.182174 58.170421 90.264447 61.179236 49.143976 90.264446 58.170421 37.108717 6.01763 43.126347-1.002938a40.423428 40.423428 0 0 1 57.167483 57.167482c-15.044074 15.044074-36.105779 25.073457-59.17336 28.082273z" fill="currentColor"/></svg>', color: colors.onSurface), '标签管理', const TagManagementPage())); if (userPrefs.showSidebarTags) toolItems.add((SvgPicture.string('<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="20" height="20"><path d="M687.012733 1024c-29.085211 0-55.161606-10.029383-74.217434-30.088149L104.305583 487.428012c-21.061704-21.061704-33.096964-50.146915-32.094026-79.232126l7.020568-242.711067a96.282076 96.282076 0 0 1 97.285015-94.2762h233.684623c28.082272 0 55.161606 12.03526 76.22331 32.094025l508.489716 508.489716c22.064643 22.064643 33.096964 54.158668 29.085211 87.255632s-18.052889 61.179236-42.123408 84.246817L784.297747 981.876592c-23.067581 23.067581-53.15573 38.111655-84.246817 42.123408zM176.51714 150.440744c-10.029383 0-17.049951 7.020568-17.049951 17.049951l-7.020568 242.711068c0 7.020568 3.008815 15.044074 9.026445 20.058766l507.486777 507.486778c11.032321 11.032321 37.108717 8.023506 58.170421-13.038198l197.578845-197.578845c21.061704-21.061704 23.067581-48.141038 13.038198-58.170421L429.257591 160.470127c-5.014691-5.014691-13.038198-9.026445-19.055828-9.026444H176.51714z m-57.167483 16.047013z" fill="currentColor"/><path d="M316.928501 442.295788a130.381978 130.381978 0 1 1 130.381979-130.381978 130.381978 130.381978 0 0 1-130.381979 130.381978z m0-180.528893a50.146915 50.146915 0 1 0 50.146915 50.146915 50.146915 50.146915 0 0 0-50.146915-50.146915z" fill="currentColor"/><path d="M258.75808 362.060725c-15.044074 0-32.094025-3.008815-49.143976-8.023507-42.123408-13.038198-86.252693-40.117532-124.364349-78.229187S21.061704 194.570029 8.023506 152.446621c-7.020568-23.067581-9.026445-44.129285-7.020568-64.188051s12.03526-44.129285 27.079334-59.173359S71.208619 1.002938 100.29383 1.002938a40.120541 40.120541 0 1 1 1.002938 80.235064c-5.014691 0-13.038198 1.002938-17.049951 5.014691s-7.020568 23.067581-1.002938 43.126347 30.088149 62.182174 58.170421 90.264447 61.179236 49.143976 90.264446 58.170421 37.108717 6.01763 43.126347-1.002938a40.423428 40.423428 0 0 1 57.167483 57.167482c-15.044074 15.044074-36.105779 25.073457-59.17336 28.082273z" fill="currentColor"/></svg>', color: colors.onSurface), '标签管理', const TagManagementPage()));
if (userPrefs.showSidebarMdReader) toolItems.add((Icon(Icons.description_outlined, size: 20, color: colors.onSurface), 'MD阅读', const MdReaderTabPage())); if (userPrefs.showSidebarMdReader) toolItems.add((Icon(Icons.description_outlined, size: 20, color: colors.onSurface), 'MD阅读', const MdReaderTabPage()));
if (userPrefs.showSidebarEpub) toolItems.add((SvgPicture.string('<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="20" height="20"><path d="M900.829867 143.581867a34.133333 34.133333 0 0 1 43.690666 52.394666l-4.437333 3.6864a173.960533 173.960533 0 0 0-24.951467 27.6992C897.706667 251.460267 887.466667 278.254933 887.466667 307.2c0 28.945067 10.24 55.739733 27.648 79.854933 6.263467 8.635733 12.970667 16.247467 19.626666 22.715734l2.9696 2.833066c1.792 1.655467 3.191467 2.8672 4.096 3.584l0.5632 0.4608a34.133333 34.133333 0 0 1-41.540266 54.1696c-10.973867-8.3968-26.0608-23.074133-41.028267-43.776C834.56 392.123733 819.2 351.914667 819.2 307.2c0-44.714667 15.36-84.923733 40.618667-119.842133 14.9504-20.6848 30.037333-35.362133 41.0112-43.776zM75.3152 559.5136a34.133333 34.133333 0 0 1 47.854933-6.331733c10.9568 8.413867 26.0608 23.0912 41.028267 43.776C189.44 631.876267 204.8 672.085333 204.8 716.8c0 44.714667-15.36 84.923733-40.618667 119.842133-14.9504 20.701867-30.037333 35.3792-41.0112 43.776a34.133333 34.133333 0 0 1-43.690666-52.3776l4.437333-3.703466c1.365333-1.194667 3.191467-2.850133 5.358933-4.949334 6.656-6.485333 13.346133-14.097067 19.592534-22.7328C126.293333 772.539733 136.533333 745.745067 136.533333 716.8c0-28.945067-10.24-55.739733-27.648-79.837867a173.960533 173.960533 0 0 0-19.626666-22.715733l-4.232534-3.9936a77.858133 77.858133 0 0 0-2.816-2.440533l-0.580266-0.443734a34.133333 34.133333 0 0 1-6.314667-47.854933z" fill="currentColor"/><path d="M921.6 136.533333a34.133333 34.133333 0 0 1 2.56 68.181334L921.6 204.8H238.933333a102.4 102.4 0 0 0-3.84 204.731733L238.933333 409.6h682.666667a34.133333 34.133333 0 0 1 2.56 68.181333L921.6 477.866667H238.933333C144.674133 477.866667 68.266667 401.4592 68.266667 307.2c0-92.672 73.847467-168.072533 165.888-170.5984L238.933333 136.533333h682.666667zM785.066667 546.133333c94.2592 0 170.666667 76.407467 170.666666 170.666667 0 92.672-73.847467 168.072533-165.888 170.5984L785.066667 887.466667H102.4a34.133333 34.133333 0 0 1-2.56-68.164267L102.4 819.2h682.666667a102.4 102.4 0 0 0 3.84-204.731733L785.066667 614.4H102.4a34.133333 34.133333 0 0 1-2.56-68.164267L102.4 546.133333h682.666667z" fill="currentColor"/><path d="M375.466667 256v221.866667a34.133333 34.133333 0 1 1-68.266667 0V256h68.266667z" fill="#00B386"/></svg>', color: colors.onSurface), 'EPUB阅读', const EpubLibraryPage())); if (userPrefs.showSidebarEpub) toolItems.add((SvgPicture.string('<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="20" height="20"><path d="M900.829867 143.581867a34.133333 34.133333 0 0 1 43.690666 52.394666l-4.437333 3.6864a173.960533 173.960533 0 0 0-24.951467 27.6992C897.706667 251.460267 887.466667 278.254933 887.466667 307.2c0 28.945067 10.24 55.739733 27.648 79.854933 6.263467 8.635733 12.970667 16.247467 19.626666 22.715734l2.9696 2.833066c1.792 1.655467 3.191467 2.8672 4.096 3.584l0.5632 0.4608a34.133333 34.133333 0 0 1-41.540266 54.1696c-10.973867-8.3968-26.0608-23.074133-41.028267-43.776C834.56 392.123733 819.2 351.914667 819.2 307.2c0-44.714667 15.36-84.923733 40.618667-119.842133 14.9504-20.6848 30.037333-35.362133 41.0112-43.776zM75.3152 559.5136a34.133333 34.133333 0 0 1 47.854933-6.331733c10.9568 8.413867 26.0608 23.0912 41.028267 43.776C189.44 631.876267 204.8 672.085333 204.8 716.8c0 44.714667-15.36 84.923733-40.618667 119.842133-14.9504 20.701867-30.037333 35.3792-41.0112 43.776a34.133333 34.133333 0 0 1-43.690666-52.3776l4.437333-3.703466c1.365333-1.194667 3.191467-2.850133 5.358933-4.949334 6.656-6.485333 13.346133-14.097067 19.592534-22.7328C126.293333 772.539733 136.533333 745.745067 136.533333 716.8c0-28.945067-10.24-55.739733-27.648-79.837867a173.960533 173.960533 0 0 0-19.626666-22.715733l-4.232534-3.9936a77.858133 77.858133 0 0 0-2.816-2.440533l-0.580266-0.443734a34.133333 34.133333 0 0 1-6.314667-47.854933z" fill="currentColor"/><path d="M921.6 136.533333a34.133333 34.133333 0 0 1 2.56 68.181334L921.6 204.8H238.933333a102.4 102.4 0 0 0-3.84 204.731733L238.933333 409.6h682.666667a34.133333 34.133333 0 0 1 2.56 68.181333L921.6 477.866667H238.933333C144.674133 477.866667 68.266667 401.4592 68.266667 307.2c0-92.672 73.847467-168.072533 165.888-170.5984L238.933333 136.533333h682.666667zM785.066667 546.133333c94.2592 0 170.666667 76.407467 170.666666 170.666667 0 92.672-73.847467 168.072533-165.888 170.5984L785.066667 887.466667H102.4a34.133333 34.133333 0 0 1-2.56-68.164267L102.4 819.2h682.666667a102.4 102.4 0 0 0 3.84-204.731733L785.066667 614.4H102.4a34.133333 34.133333 0 0 1-2.56-68.164267L102.4 546.133333h682.666667z" fill="currentColor"/><path d="M375.466667 256v221.866667a34.133333 34.133333 0 1 1-68.266667 0V256h68.266667z" fill="#00B386"/></svg>', color: colors.onSurface), 'EPUB阅读', const EpubLibraryPage()));