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:
luckyray
2025-01-15 16:07:08 +08:00
committed by GitHub
parent 213c225e1e
commit d874920c88
42 changed files with 3054 additions and 226 deletions

View 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);
}
}

View 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),
),
],
);
}
}

View 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,
),
);
},
),
],
);
}
}

View 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);
}