mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
Feat: Image favorites (#126)
* feat: 增加图片收藏 * feat: 主体图片收藏页面实现 * feat: 点击打开大图浏览 * feat: 数据结构变更 * feat: 基本完成 * feat: 翻译与bug修复 * feat: 实机测试和问题修复 * feat: jm导入, pica历史记录nhentai有问题, 一键反转 * fix: 大小写不一致, 一个htManga, 一个htmanga * feat: 拉取收藏优化 * feat: 改成以ep为准 * feat: 兜底一些可能报错场景 * chore: 没有用到 * feat: 尽量保证和网络收藏顺序一致 * feat: 支持显示热点tag * feat: 支持双击收藏, 不过此时禁止放大图片 * fix: 自动塞封面逻辑完善, 切换快速收藏图片立刻生效 * Refactor * fix updateValue * feat: 双击功能提示 * fix: 被确定取消收藏的才删除 * Refactor ImageFavoritesPage * translate author * feat: 功能提示改到dialog中 * fix text editing * fix text editing * feat: 功能提示放到邮件或长按菜单中 * fix: 修复tag过滤不生效问题 * Improve image loading * The default value of quickCollectImage should be false. * Refactor DragListener * Refactor ImageFavoriteItem & ImageFavoritePhotoView * Refactor * Fix `ImageFavoriteManager.has` * Fix UI * Improve UI --------- Co-authored-by: nyne <me@nyne.dev>
This commit is contained in:
287
lib/pages/image_favorites_page/image_favorites_item.dart
Normal file
287
lib/pages/image_favorites_page/image_favorites_item.dart
Normal file
@@ -0,0 +1,287 @@
|
||||
part of 'image_favorites_page.dart';
|
||||
|
||||
class _ImageFavoritesItem extends StatefulWidget {
|
||||
const _ImageFavoritesItem({
|
||||
required this.imageFavoritesComic,
|
||||
required this.selectedImageFavorites,
|
||||
required this.addSelected,
|
||||
required this.multiSelectMode,
|
||||
required this.finalImageFavoritesComicList,
|
||||
});
|
||||
|
||||
final ImageFavoritesComic imageFavoritesComic;
|
||||
final Function(ImageFavorite) addSelected;
|
||||
final Map<ImageFavorite, bool> selectedImageFavorites;
|
||||
final List<ImageFavoritesComic> finalImageFavoritesComicList;
|
||||
final bool multiSelectMode;
|
||||
|
||||
@override
|
||||
State<_ImageFavoritesItem> createState() => _ImageFavoritesItemState();
|
||||
}
|
||||
|
||||
class _ImageFavoritesItemState extends State<_ImageFavoritesItem> {
|
||||
late final imageFavorites = widget.imageFavoritesComic.images.toList();
|
||||
|
||||
void goComicInfo(ImageFavoritesComic comic) {
|
||||
App.mainNavigatorKey?.currentContext?.to(() => ComicPage(
|
||||
id: comic.id,
|
||||
sourceKey: comic.sourceKey,
|
||||
));
|
||||
}
|
||||
|
||||
void goReaderPage(ImageFavoritesComic comic, int ep, int page) {
|
||||
App.rootContext.to(
|
||||
() => ReaderWithLoading(
|
||||
id: comic.id,
|
||||
sourceKey: comic.sourceKey,
|
||||
initialEp: ep,
|
||||
initialPage: page,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void goPhotoView(ImageFavorite imageFavorite) {
|
||||
Navigator.of(App.rootContext).push(MaterialPageRoute(
|
||||
builder: (context) => ImageFavoritesPhotoView(
|
||||
comic: widget.imageFavoritesComic,
|
||||
imageFavorite: imageFavorite,
|
||||
)));
|
||||
}
|
||||
|
||||
void copyTitle() {
|
||||
Clipboard.setData(ClipboardData(text: widget.imageFavoritesComic.title));
|
||||
App.rootContext.showMessage(message: 'Copy the title successfully'.tl);
|
||||
}
|
||||
|
||||
void onLongPress() {
|
||||
var renderBox = context.findRenderObject() as RenderBox;
|
||||
var size = renderBox.size;
|
||||
var location = renderBox.localToGlobal(
|
||||
Offset((size.width - 242) / 2, size.height / 2),
|
||||
);
|
||||
showMenu(location, context);
|
||||
}
|
||||
|
||||
void onSecondaryTap(TapDownDetails details) {
|
||||
showMenu(details.globalPosition, context);
|
||||
}
|
||||
|
||||
void showMenu(Offset location, BuildContext context) {
|
||||
showMenuX(
|
||||
App.rootContext,
|
||||
location,
|
||||
[
|
||||
MenuEntry(
|
||||
icon: Icons.chrome_reader_mode_outlined,
|
||||
text: 'Details'.tl,
|
||||
onClick: () {
|
||||
goComicInfo(widget.imageFavoritesComic);
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.copy,
|
||||
text: 'Copy Title'.tl,
|
||||
onClick: () {
|
||||
copyTitle();
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.select_all,
|
||||
text: 'Select All'.tl,
|
||||
onClick: () {
|
||||
for (var ele in widget.imageFavoritesComic.images) {
|
||||
widget.addSelected(ele);
|
||||
}
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.read_more,
|
||||
text: 'Photo View'.tl,
|
||||
onClick: () {
|
||||
goPhotoView(widget.imageFavoritesComic.images.first);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 0.6,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onSecondaryTapDown: onSecondaryTap,
|
||||
onLongPress: onLongPress,
|
||||
onTap: () {
|
||||
if (widget.multiSelectMode) {
|
||||
for (var ele in widget.imageFavoritesComic.images) {
|
||||
widget.addSelected(ele);
|
||||
}
|
||||
} else {
|
||||
// 单击跳转漫画详情
|
||||
goComicInfo(widget.imageFavoritesComic);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildTop(),
|
||||
SizedBox(
|
||||
height: 145,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: buildItem,
|
||||
itemCount: imageFavorites.length,
|
||||
),
|
||||
).paddingHorizontal(8),
|
||||
buildBottom(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildItem(BuildContext context, int index) {
|
||||
var image = imageFavorites[index];
|
||||
bool isSelected = widget.selectedImageFavorites[image] ?? false;
|
||||
int curPage = image.page;
|
||||
String pageText = curPage == firstPage
|
||||
? '@a Cover'.tlParams({"a": image.epName})
|
||||
: curPage.toString();
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
// 单击去阅读页面, 跳转到当前点击的page
|
||||
if (widget.multiSelectMode) {
|
||||
widget.addSelected(image);
|
||||
} else {
|
||||
goReaderPage(widget.imageFavoritesComic, image.ep, curPage);
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
goPhotoView(image);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
width: 98,
|
||||
height: 128,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: null,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
height: 128,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Hero(
|
||||
tag: "${image.sourceKey}${image.ep}${image.page}",
|
||||
child: AnimatedImage(
|
||||
image: ImageFavoritesProvider(image),
|
||||
width: 96,
|
||||
height: 128,
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
pageText,
|
||||
style: ts.s10,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).paddingHorizontal(4);
|
||||
}
|
||||
|
||||
Widget buildTop() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.imageFavoritesComic.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 16.0,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
"${imageFavorites.length}/${widget.imageFavoritesComic.maxPageFromEp}",
|
||||
style: ts.s12),
|
||||
),
|
||||
],
|
||||
).paddingHorizontal(16).paddingVertical(8);
|
||||
}
|
||||
|
||||
Widget buildBottom() {
|
||||
var enableTranslate = App.locale.languageCode == 'zh';
|
||||
String time =
|
||||
DateFormat('yyyy-MM-dd').format(widget.imageFavoritesComic.time);
|
||||
List<String> tags = [];
|
||||
for (var tag in widget.imageFavoritesComic.tags) {
|
||||
var text = enableTranslate ? tag.translateTagsToCN : tag;
|
||||
if (text.contains(':')) {
|
||||
text = text.split(':').last;
|
||||
}
|
||||
tags.add(text);
|
||||
if (tags.length == 5) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
var comicSource = ComicSource.find(widget.imageFavoritesComic.sourceKey);
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
"$time | ${comicSource?.name ?? "Unknown"}",
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
),
|
||||
).paddingRight(8),
|
||||
if (tags.isNotEmpty)
|
||||
Expanded(
|
||||
child: Text(
|
||||
tags
|
||||
.map((e) => enableTranslate ? e.translateTagsToCN : e)
|
||||
.join(" "),
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.0,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
],
|
||||
).paddingHorizontal(8).paddingBottom(8);
|
||||
}
|
||||
}
|
539
lib/pages/image_favorites_page/image_favorites_page.dart
Normal file
539
lib/pages/image_favorites_page/image_favorites_page.dart
Normal file
@@ -0,0 +1,539 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:photo_view/photo_view_gallery.dart';
|
||||
import 'package:venera/components/components.dart';
|
||||
import 'package:venera/foundation/app.dart';
|
||||
import 'package:venera/foundation/appdata.dart';
|
||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||
import 'package:venera/foundation/consts.dart';
|
||||
import 'package:venera/foundation/history.dart';
|
||||
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
|
||||
import 'package:venera/pages/comic_page.dart';
|
||||
import 'package:venera/pages/image_favorites_page/type.dart';
|
||||
import 'package:venera/pages/reader/reader.dart';
|
||||
import 'package:venera/utils/ext.dart';
|
||||
import 'package:venera/utils/file_type.dart';
|
||||
import 'package:venera/utils/io.dart';
|
||||
import 'package:venera/utils/tags_translation.dart';
|
||||
import 'package:venera/utils/translations.dart';
|
||||
|
||||
part "image_favorites_item.dart";
|
||||
|
||||
part "image_favorites_photo_view.dart";
|
||||
|
||||
class ImageFavoritesPage extends StatefulWidget {
|
||||
const ImageFavoritesPage({super.key, this.initialKeyword});
|
||||
|
||||
final String? initialKeyword;
|
||||
|
||||
@override
|
||||
State<ImageFavoritesPage> createState() => _ImageFavoritesPageState();
|
||||
}
|
||||
|
||||
class _ImageFavoritesPageState extends State<ImageFavoritesPage> {
|
||||
late ImageFavoriteSortType sortType;
|
||||
late TimeRange timeFilterSelect;
|
||||
late int numFilterSelect;
|
||||
|
||||
// 所有的图片收藏
|
||||
List<ImageFavoritesComic> comics = [];
|
||||
|
||||
late var controller =
|
||||
TextEditingController(text: widget.initialKeyword ?? "");
|
||||
|
||||
String get keyword => controller.text;
|
||||
|
||||
// 进入关键词搜索模式
|
||||
bool searchMode = false;
|
||||
|
||||
bool multiSelectMode = false;
|
||||
|
||||
// 多选的时候选中的图片
|
||||
Map<ImageFavorite, bool> selectedImageFavorites = {};
|
||||
|
||||
void update() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void updateImageFavorites() async {
|
||||
comics = searchMode
|
||||
? ImageFavoriteManager().search(keyword)
|
||||
: ImageFavoriteManager().getAll();
|
||||
sortImageFavorites();
|
||||
update();
|
||||
}
|
||||
|
||||
void sortImageFavorites() {
|
||||
comics = searchMode
|
||||
? ImageFavoriteManager().search(keyword)
|
||||
: ImageFavoriteManager().getAll();
|
||||
// 筛选到最终列表
|
||||
comics = comics.where((ele) {
|
||||
bool isFilter = true;
|
||||
if (timeFilterSelect != TimeRange.all) {
|
||||
isFilter = timeFilterSelect.contains(ele.time);
|
||||
}
|
||||
if (numFilterSelect != numFilterList[0]) {
|
||||
isFilter = ele.images.length > numFilterSelect;
|
||||
}
|
||||
return isFilter;
|
||||
}).toList();
|
||||
// 给列表排序
|
||||
switch (sortType) {
|
||||
case ImageFavoriteSortType.title:
|
||||
comics.sort((a, b) => a.title.compareTo(b.title));
|
||||
case ImageFavoriteSortType.timeAsc:
|
||||
comics.sort((a, b) => a.time.compareTo(b.time));
|
||||
case ImageFavoriteSortType.timeDesc:
|
||||
comics.sort((a, b) => b.time.compareTo(a.time));
|
||||
case ImageFavoriteSortType.maxFavorites:
|
||||
comics.sort((a, b) => b.images.length
|
||||
.compareTo(a.images.length));
|
||||
case ImageFavoriteSortType.favoritesCompareComicPages:
|
||||
comics.sort((a, b) {
|
||||
double tempA = a.images.length / a.maxPageFromEp;
|
||||
double tempB = b.images.length / b.maxPageFromEp;
|
||||
return tempB.compareTo(tempA);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (widget.initialKeyword != null) {
|
||||
searchMode = true;
|
||||
}
|
||||
sortType = ImageFavoriteSortType.values.firstWhereOrNull(
|
||||
(e) => e.value == appdata.implicitData["image_favorites_sort"]) ??
|
||||
ImageFavoriteSortType.title;
|
||||
timeFilterSelect = TimeRange.fromString(
|
||||
appdata.implicitData["image_favorites_time_filter"]);
|
||||
numFilterSelect = appdata.implicitData["image_favorites_number_filter"] ??
|
||||
numFilterList[0];
|
||||
updateImageFavorites();
|
||||
ImageFavoriteManager().addListener(updateImageFavorites);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ImageFavoriteManager().removeListener(updateImageFavorites);
|
||||
scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget buildMultiSelectMenu() {
|
||||
return MenuButton(entries: [
|
||||
MenuEntry(
|
||||
icon: Icons.delete_outline,
|
||||
text: "Delete".tl,
|
||||
onClick: () {
|
||||
ImageFavoriteManager()
|
||||
.deleteImageFavorite(selectedImageFavorites.keys);
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
selectedImageFavorites.clear();
|
||||
});
|
||||
},
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
var scrollController = ScrollController();
|
||||
|
||||
void selectAll() {
|
||||
for (var c in comics) {
|
||||
for (var i in c.images) {
|
||||
selectedImageFavorites[i] = true;
|
||||
}
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
void deSelect() {
|
||||
setState(() {
|
||||
selectedImageFavorites.clear();
|
||||
});
|
||||
}
|
||||
|
||||
void addSelected(ImageFavorite i) {
|
||||
if (selectedImageFavorites[i] == null) {
|
||||
selectedImageFavorites[i] = true;
|
||||
} else {
|
||||
selectedImageFavorites.remove(i);
|
||||
}
|
||||
if (selectedImageFavorites.isEmpty) {
|
||||
multiSelectMode = false;
|
||||
} else {
|
||||
multiSelectMode = true;
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
List<Widget> selectActions = [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.select_all),
|
||||
tooltip: "Select All".tl,
|
||||
onPressed: selectAll),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.deselect),
|
||||
tooltip: "Deselect".tl,
|
||||
onPressed: deSelect),
|
||||
buildMultiSelectMenu(),
|
||||
];
|
||||
|
||||
var scrollWidget = SmoothCustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
if (!searchMode && !multiSelectMode)
|
||||
SliverAppbar(
|
||||
title: Text("Image Favorites".tl),
|
||||
actions: [
|
||||
Tooltip(
|
||||
message: "Search".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
searchMode = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: "Sort".tl,
|
||||
child: IconButton(
|
||||
isSelected: timeFilterSelect != TimeRange.all ||
|
||||
numFilterSelect != numFilterList[0],
|
||||
icon: const Icon(Icons.sort_rounded),
|
||||
onPressed: sort,
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: multiSelectMode
|
||||
? "Exit Multi-Select".tl
|
||||
: "Multi-Select".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.checklist),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
multiSelectMode = !multiSelectMode;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (multiSelectMode)
|
||||
SliverAppbar(
|
||||
leading: Tooltip(
|
||||
message: "Cancel".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
selectedImageFavorites.clear();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
title: Text(selectedImageFavorites.length.toString()),
|
||||
actions: selectActions,
|
||||
)
|
||||
else if (searchMode)
|
||||
SliverAppbar(
|
||||
leading: Tooltip(
|
||||
message: "Cancel".tl,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
setState(() {
|
||||
searchMode = false;
|
||||
controller.clear();
|
||||
updateImageFavorites();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
title: TextField(
|
||||
autofocus: true,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Search".tl,
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (v) {
|
||||
updateImageFavorites();
|
||||
},
|
||||
),
|
||||
),
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
return _ImageFavoritesItem(
|
||||
imageFavoritesComic: comics[index],
|
||||
selectedImageFavorites: selectedImageFavorites,
|
||||
addSelected: addSelected,
|
||||
multiSelectMode: multiSelectMode,
|
||||
finalImageFavoritesComicList: comics,
|
||||
);
|
||||
},
|
||||
childCount: comics.length,
|
||||
),
|
||||
),
|
||||
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
|
||||
],
|
||||
);
|
||||
Widget body = Scrollbar(
|
||||
controller: scrollController,
|
||||
thickness: App.isDesktop ? 8 : 12,
|
||||
radius: const Radius.circular(8),
|
||||
interactive: true,
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: context.width > changePoint
|
||||
? scrollWidget.paddingHorizontal(8)
|
||||
: scrollWidget,
|
||||
),
|
||||
);
|
||||
return PopScope(
|
||||
canPop: !multiSelectMode && !searchMode,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (multiSelectMode) {
|
||||
setState(() {
|
||||
multiSelectMode = false;
|
||||
selectedImageFavorites.clear();
|
||||
});
|
||||
} else if (searchMode) {
|
||||
controller.clear();
|
||||
searchMode = false;
|
||||
updateImageFavorites();
|
||||
}
|
||||
},
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
|
||||
void sort() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return _ImageFavoritesDialog(
|
||||
initSortType: sortType,
|
||||
initTimeFilterSelect: timeFilterSelect,
|
||||
initNumFilterSelect: numFilterSelect,
|
||||
updateConfig: (sortType, timeFilter, numFilter) {
|
||||
setState(() {
|
||||
this.sortType = sortType;
|
||||
timeFilterSelect = timeFilter;
|
||||
numFilterSelect = numFilter;
|
||||
});
|
||||
sortImageFavorites();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImageFavoritesDialog extends StatefulWidget {
|
||||
const _ImageFavoritesDialog({
|
||||
required this.initSortType,
|
||||
required this.initTimeFilterSelect,
|
||||
required this.initNumFilterSelect,
|
||||
required this.updateConfig,
|
||||
});
|
||||
|
||||
final ImageFavoriteSortType initSortType;
|
||||
final TimeRange initTimeFilterSelect;
|
||||
final int initNumFilterSelect;
|
||||
final Function updateConfig;
|
||||
|
||||
@override
|
||||
State<_ImageFavoritesDialog> createState() => _ImageFavoritesDialogState();
|
||||
}
|
||||
|
||||
class _ImageFavoritesDialogState extends State<_ImageFavoritesDialog> {
|
||||
List<String> optionTypes = ['Sort', 'Filter'];
|
||||
late var sortType = widget.initSortType;
|
||||
late var numFilter = widget.initNumFilterSelect;
|
||||
late TimeRangeType timeRangeType;
|
||||
DateTime? start;
|
||||
DateTime? end;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
timeRangeType = switch (widget.initTimeFilterSelect) {
|
||||
TimeRange.all => TimeRangeType.all,
|
||||
TimeRange.lastWeek => TimeRangeType.lastWeek,
|
||||
TimeRange.lastMonth => TimeRangeType.lastMonth,
|
||||
TimeRange.lastHalfYear => TimeRangeType.lastHalfYear,
|
||||
TimeRange.lastYear => TimeRangeType.lastYear,
|
||||
_ => TimeRangeType.custom,
|
||||
};
|
||||
if (timeRangeType == TimeRangeType.custom) {
|
||||
end = widget.initTimeFilterSelect.end;
|
||||
start = end!.subtract(widget.initTimeFilterSelect.duration);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget tabBar = Material(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: FilledTabBar(
|
||||
key: PageStorageKey(optionTypes),
|
||||
tabs: optionTypes.map((e) => Tab(text: e.tl, key: Key(e))).toList(),
|
||||
),
|
||||
).paddingTop(context.padding.top);
|
||||
return ContentDialog(
|
||||
content: DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
tabBar,
|
||||
TabViewBody(children: [
|
||||
Column(
|
||||
children: ImageFavoriteSortType.values
|
||||
.map(
|
||||
(e) => RadioListTile<ImageFavoriteSortType>(
|
||||
title: Text(e.value.tl),
|
||||
value: e,
|
||||
groupValue: sortType,
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
sortType = v!;
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text("Time Filter".tl),
|
||||
trailing: Select(
|
||||
current: timeRangeType.value.tl,
|
||||
values:
|
||||
TimeRangeType.values.map((e) => e.value.tl).toList(),
|
||||
minWidth: 64,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
timeRangeType = TimeRangeType.values[index];
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
if (timeRangeType == TimeRangeType.custom)
|
||||
Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text("Start Time".tl),
|
||||
trailing: TextButton(
|
||||
onPressed: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: start ?? DateTime.now(),
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: end ?? DateTime.now(),
|
||||
);
|
||||
if (date != null) {
|
||||
setState(() {
|
||||
start = date;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Text(start == null
|
||||
? "Select Date".tl
|
||||
: DateFormat("yyyy-MM-dd").format(start!)),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: Text("End Time".tl),
|
||||
trailing: TextButton(
|
||||
onPressed: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: end ?? DateTime.now(),
|
||||
firstDate: start ?? DateTime(2000),
|
||||
lastDate: DateTime.now(),
|
||||
);
|
||||
if (date != null) {
|
||||
setState(() {
|
||||
end = date;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Text(end == null
|
||||
? "Select Date".tl
|
||||
: DateFormat("yyyy-MM-dd").format(end!)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
ListTile(
|
||||
title: Text("Image Favorites Greater Than".tl),
|
||||
trailing: Select(
|
||||
current: numFilter.toString(),
|
||||
values: numFilterList.map((e) => e.toString()).toList(),
|
||||
minWidth: 64,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
numFilter = numFilterList[index];
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
appdata.implicitData["image_favorites_sort"] = sortType.value;
|
||||
TimeRange timeRange;
|
||||
if (timeRangeType == TimeRangeType.custom) {
|
||||
timeRange = TimeRange(
|
||||
end: end,
|
||||
duration: end!.difference(start!),
|
||||
);
|
||||
} else {
|
||||
timeRange = switch (timeRangeType) {
|
||||
TimeRangeType.all => TimeRange.all,
|
||||
TimeRangeType.lastWeek => TimeRange.lastWeek,
|
||||
TimeRangeType.lastMonth => TimeRange.lastMonth,
|
||||
TimeRangeType.lastHalfYear => TimeRange.lastHalfYear,
|
||||
TimeRangeType.lastYear => TimeRange.lastYear,
|
||||
_ => TimeRange.all,
|
||||
};
|
||||
}
|
||||
appdata.implicitData["image_favorites_time_filter"] =
|
||||
timeRange.toString();
|
||||
appdata.implicitData["image_favorites_number_filter"] = numFilter;
|
||||
appdata.writeImplicitData();
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
widget.updateConfig(sortType, timeRange, numFilter);
|
||||
}
|
||||
},
|
||||
child: Text("Confirm".tl),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
253
lib/pages/image_favorites_page/image_favorites_photo_view.dart
Normal file
253
lib/pages/image_favorites_page/image_favorites_photo_view.dart
Normal file
@@ -0,0 +1,253 @@
|
||||
part of 'image_favorites_page.dart';
|
||||
|
||||
class ImageFavoritesPhotoView extends StatefulWidget {
|
||||
const ImageFavoritesPhotoView({
|
||||
super.key,
|
||||
required this.comic,
|
||||
required this.imageFavorite,
|
||||
});
|
||||
|
||||
final ImageFavoritesComic comic;
|
||||
final ImageFavorite imageFavorite;
|
||||
|
||||
@override
|
||||
State<ImageFavoritesPhotoView> createState() =>
|
||||
_ImageFavoritesPhotoViewState();
|
||||
}
|
||||
|
||||
class _ImageFavoritesPhotoViewState extends State<ImageFavoritesPhotoView> {
|
||||
late PageController controller;
|
||||
Map<ImageFavorite, bool> cancelImageFavorites = {};
|
||||
|
||||
var images = <ImageFavorite>[];
|
||||
|
||||
int currentPage = 0;
|
||||
|
||||
bool isAppBarShow = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
var current = 0;
|
||||
for (var ep in widget.comic.imageFavoritesEp) {
|
||||
for (var image in ep.imageFavorites) {
|
||||
images.add(image);
|
||||
if (image == widget.imageFavorite) {
|
||||
current = images.length - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
currentPage = current;
|
||||
controller = PageController(initialPage: current);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void onPop() {
|
||||
List<ImageFavorite> tempList = cancelImageFavorites.entries
|
||||
.where((e) => e.value == true)
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
if (tempList.isNotEmpty) {
|
||||
ImageFavoriteManager().deleteImageFavorite(tempList);
|
||||
showToast(
|
||||
message: "Delete @a images".tlParams({'a': tempList.length}),
|
||||
context: context);
|
||||
}
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _buildItem(BuildContext context, int index) {
|
||||
var image = images[index];
|
||||
return PhotoViewGalleryPageOptions(
|
||||
// 图片加载器 支持本地、网络
|
||||
imageProvider: ImageFavoritesProvider(image),
|
||||
// 初始化大小 全部展示
|
||||
minScale: PhotoViewComputedScale.contained * 1.0,
|
||||
maxScale: PhotoViewComputedScale.covered * 10.0,
|
||||
onTapUp: (context, details, controllerValue) {
|
||||
setState(() {
|
||||
isAppBarShow = !isAppBarShow;
|
||||
});
|
||||
},
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: "${image.sourceKey}${image.ep}${image.page}",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (bool didPop, Object? result) async {
|
||||
if (didPop) {
|
||||
onPop();
|
||||
}
|
||||
},
|
||||
child: Listener(
|
||||
onPointerSignal: (event) {
|
||||
if (HardwareKeyboard.instance.isControlPressed) {
|
||||
return;
|
||||
}
|
||||
if (event is PointerScrollEvent) {
|
||||
if (event.scrollDelta.dy > 0) {
|
||||
if (controller.page! >= images.length - 1) {
|
||||
return;
|
||||
}
|
||||
controller.nextPage(
|
||||
duration: Duration(milliseconds: 180), curve: Curves.ease);
|
||||
} else {
|
||||
if (controller.page! <= 0) {
|
||||
return;
|
||||
}
|
||||
controller.previousPage(
|
||||
duration: Duration(milliseconds: 180), curve: Curves.ease);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Stack(children: [
|
||||
Positioned.fill(
|
||||
child: PhotoViewGallery.builder(
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: context.colorScheme.surface,
|
||||
),
|
||||
builder: _buildItem,
|
||||
itemCount: images.length,
|
||||
loadingBuilder: (context, event) => Center(
|
||||
child: SizedBox(
|
||||
width: 20.0,
|
||||
height: 20.0,
|
||||
child: CircularProgressIndicator(
|
||||
backgroundColor: context.colorScheme.surfaceContainerHigh,
|
||||
value: event == null || event.expectedTotalBytes == null
|
||||
? null
|
||||
: event.cumulativeBytesLoaded /
|
||||
event.expectedTotalBytes!,
|
||||
),
|
||||
),
|
||||
),
|
||||
pageController: controller,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
currentPage = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
buildPageInfo(),
|
||||
AnimatedPositioned(
|
||||
top: isAppBarShow ? 0 : -(context.padding.top + 52),
|
||||
left: 0,
|
||||
right: 0,
|
||||
duration: Duration(milliseconds: 180),
|
||||
child: buildAppBar(),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPageInfo() {
|
||||
var text = "${currentPage + 1}/${images.length}";
|
||||
return Positioned(
|
||||
height: 40,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Center(
|
||||
child: Stack(
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
foreground: Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.4
|
||||
..color = context.colorScheme.onInverseSurface,
|
||||
),
|
||||
),
|
||||
Text(text),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAppBar() {
|
||||
return Material(
|
||||
color: context.colorScheme.surface.toOpacity(0.72),
|
||||
child: BlurEffect(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
height: 52,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.comic.title,
|
||||
style: TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.more_vert),
|
||||
onPressed: showMenu,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
).paddingTop(context.padding.top),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void showMenu() {
|
||||
showMenuX(
|
||||
context,
|
||||
Offset(context.width, context.padding.top),
|
||||
[
|
||||
MenuEntry(
|
||||
icon: Icons.image_outlined,
|
||||
text: "Save Image".tl,
|
||||
onClick: () async {
|
||||
var temp = images[currentPage];
|
||||
var imageProvider = ImageFavoritesProvider(temp);
|
||||
var data = await imageProvider.load(null);
|
||||
var fileType = detectFileType(data);
|
||||
var fileName = "${currentPage + 1}.${fileType.ext}";
|
||||
await saveFile(filename: fileName, data: data);
|
||||
},
|
||||
),
|
||||
MenuEntry(
|
||||
icon: Icons.menu_book_outlined,
|
||||
text: "Read".tl,
|
||||
onClick: () async {
|
||||
var comic = widget.comic;
|
||||
var ep = images[currentPage].ep;
|
||||
var page = images[currentPage].page;
|
||||
App.rootContext.to(
|
||||
() => ReaderWithLoading(
|
||||
id: comic.id,
|
||||
sourceKey: comic.sourceKey,
|
||||
initialEp: ep,
|
||||
initialPage: page,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
101
lib/pages/image_favorites_page/type.dart
Normal file
101
lib/pages/image_favorites_page/type.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:venera/utils/ext.dart';
|
||||
|
||||
enum ImageFavoriteSortType {
|
||||
title("Title"),
|
||||
timeAsc("Time Asc"),
|
||||
timeDesc("Time Desc"),
|
||||
maxFavorites("Favorite Num"), // 单本收藏数最多排序
|
||||
favoritesCompareComicPages("Favorite Num Compare Comic Pages"); // 单本收藏数比上总页数
|
||||
|
||||
final String value;
|
||||
|
||||
const ImageFavoriteSortType(this.value);
|
||||
}
|
||||
|
||||
const numFilterList = [0, 1, 2, 5, 10, 20, 50, 100];
|
||||
|
||||
class TimeRange {
|
||||
/// End of the range, null means now
|
||||
final DateTime? end;
|
||||
|
||||
/// Duration of the range
|
||||
final Duration duration;
|
||||
|
||||
/// Create a time range
|
||||
const TimeRange({this.end, required this.duration});
|
||||
|
||||
static const all = TimeRange(end: null, duration: Duration.zero);
|
||||
|
||||
static const lastWeek = TimeRange(end: null, duration: Duration(days: 7));
|
||||
|
||||
static const lastMonth = TimeRange(end: null, duration: Duration(days: 30));
|
||||
|
||||
static const lastHalfYear =
|
||||
TimeRange(end: null, duration: Duration(days: 180));
|
||||
|
||||
static const lastYear = TimeRange(end: null, duration: Duration(days: 365));
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "${end?.millisecond}:${duration.inMilliseconds}";
|
||||
}
|
||||
|
||||
/// Parse a time range from a string, return [TimeRange.all] if failed
|
||||
factory TimeRange.fromString(String? str) {
|
||||
if (str == null) {
|
||||
return TimeRange.all;
|
||||
}
|
||||
final parts = str.split(":");
|
||||
if (parts.length != 2 || !parts[0].isInt || !parts[1].isInt) {
|
||||
return TimeRange.all;
|
||||
}
|
||||
final end = parts[0] == "null"
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(int.parse(parts[0]));
|
||||
final duration = Duration(milliseconds: int.parse(parts[1]));
|
||||
return TimeRange(end: end, duration: duration);
|
||||
}
|
||||
|
||||
/// Check if a time is in the range
|
||||
bool contains(DateTime time) {
|
||||
if (end != null && time.isAfter(end!)) {
|
||||
return false;
|
||||
}
|
||||
if (duration == Duration.zero) {
|
||||
return true;
|
||||
}
|
||||
final start = end == null
|
||||
? DateTime.now().subtract(duration)
|
||||
: end!.subtract(duration);
|
||||
return time.isAfter(start);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is TimeRange && other.end == end && other.duration == duration;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => end.hashCode ^ duration.hashCode;
|
||||
|
||||
static const List<TimeRange> values = [
|
||||
all,
|
||||
lastWeek,
|
||||
lastMonth,
|
||||
lastHalfYear,
|
||||
lastYear,
|
||||
];
|
||||
}
|
||||
|
||||
enum TimeRangeType {
|
||||
all("All"),
|
||||
lastWeek("Last Week"),
|
||||
lastMonth("Last Month"),
|
||||
lastHalfYear("Last Half Year"),
|
||||
lastYear("Last Year"),
|
||||
custom("Custom");
|
||||
|
||||
final String value;
|
||||
|
||||
const TimeRangeType(this.value);
|
||||
}
|
Reference in New Issue
Block a user