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

@@ -145,6 +145,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
ep: 0,
page: 0,
),
author: localComic.subTitle ?? '',
tags: localComic.tags,
);
});
App.mainNavigatorKey!.currentContext!.pop();
@@ -663,6 +665,8 @@ abstract mixin class _ComicPageActions {
initialChapter: ep,
initialPage: page,
history: History.fromModel(model: comic, ep: 0, page: 0),
author: comic.findAuthor() ?? '',
tags: comic.plainTags,
),
);
}

View File

@@ -147,13 +147,13 @@ Future<List<FavoriteItem>> updateComicsInfo(String folder) async {
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
var newTags = <String>[];
for(var entry in newInfo.tags.entries) {
for (var entry in newInfo.tags.entries) {
const shouldIgnore = ['author', 'artist', 'time'];
var namespace = entry.key;
if (shouldIgnore.contains(namespace.toLowerCase())) {
continue;
}
for(var tag in entry.value) {
for (var tag in entry.value) {
newTags.add("$namespace:$tag");
}
}
@@ -305,6 +305,7 @@ Future<void> sortFolders() async {
Future<void> importNetworkFolder(
String source,
int updatePageNum,
String? folder,
String? folderID,
) async {
@@ -312,7 +313,7 @@ Future<void> importNetworkFolder(
if (comicSource == null) {
return;
}
if(folder != null && folder.isEmpty) {
if (folder != null && folder.isEmpty) {
folder = null;
}
var resultName = folder ?? comicSource.name;
@@ -324,7 +325,7 @@ Future<void> importNetworkFolder(
return;
}
}
if(!exists) {
if (!exists) {
LocalFavoritesManager().createFolder(resultName);
LocalFavoritesManager().linkFolderToNetwork(
resultName,
@@ -332,37 +333,46 @@ Future<void> importNetworkFolder(
folderID ?? "",
);
}
bool isOldToNewSort = comicSource.favoriteData?.isOldToNewSort ?? false;
var current = 0;
int receivedComics = 0;
int requestCount = 0;
var isFinished = false;
int maxPage = 1;
List<FavoriteItem> comics = [];
String? next;
// 如果是从旧到新, 先取一下maxPage
if (isOldToNewSort) {
var res = await comicSource.favoriteData?.loadComic!(1, folderID);
maxPage = res?.subData ?? 1;
}
Future<void> fetchNext() async {
var retry = 3;
while (true) {
while (updatePageNum > requestCount && !isFinished) {
try {
if (comicSource.favoriteData?.loadComic != null) {
next ??= '1';
// 从旧到新的情况下, 假设有10页, 更新3页, 则从第8页开始, 8, 9, 10 三页
next ??=
isOldToNewSort ? (maxPage - updatePageNum + 1).toString() : '1';
var page = int.parse(next!);
var res = await comicSource.favoriteData!.loadComic!(page, folderID);
var count = 0;
receivedComics += res.data.length;
for (var c in res.data) {
var result = LocalFavoritesManager().addComic(
resultName,
FavoriteItem(
if (!LocalFavoritesManager()
.comicExists(resultName, c.id, ComicType(source.hashCode))) {
count++;
comics.add(FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
type: ComicType(source.hashCode),
author: c.subtitle ?? '',
tags: c.tags ?? [],
),
);
if (result) {
count++;
));
}
}
requestCount++;
current += count;
if (res.data.isEmpty || res.subData == page) {
isFinished = true;
@@ -373,22 +383,22 @@ Future<void> importNetworkFolder(
} else if (comicSource.favoriteData?.loadNext != null) {
var res = await comicSource.favoriteData!.loadNext!(next, folderID);
var count = 0;
receivedComics += res.data.length;
for (var c in res.data) {
var result = LocalFavoritesManager().addComic(
resultName,
FavoriteItem(
if (!LocalFavoritesManager()
.comicExists(resultName, c.id, ComicType(source.hashCode))) {
count++;
comics.add(FavoriteItem(
id: c.id,
name: c.title,
coverPath: c.cover,
type: ComicType(source.hashCode),
author: c.subtitle ?? '',
tags: c.tags ?? [],
),
);
if (result) {
count++;
));
}
}
requestCount++;
current += count;
if (res.data.isEmpty || res.subData == null) {
isFinished = true;
@@ -408,6 +418,8 @@ Future<void> importNetworkFolder(
continue;
}
}
// 跳出循环, 表示已经完成, 强制为 true, 避免死循环
isFinished = true;
}
bool isCanceled = false;
@@ -415,6 +427,7 @@ Future<void> importNetworkFolder(
bool isErrored() => errorMsg != null;
void Function()? updateDialog;
void Function()? closeDialog;
showDialog(
context: App.rootContext,
@@ -422,6 +435,7 @@ Future<void> importNetworkFolder(
return StatefulBuilder(
builder: (context, setState) {
updateDialog = () => setState(() {});
closeDialog = () => Navigator.pop(context);
return ContentDialog(
title: isFinished
? "Finished".tl
@@ -437,8 +451,11 @@ Future<void> importNetworkFolder(
value: isFinished ? 1 : null,
),
const SizedBox(height: 4),
Text("Imported @c comics".tlParams({
"c": current,
Text("Imported @a comics, loaded @b pages, received @c comics"
.tlParams({
"a": current,
"b": requestCount,
"c": receivedComics,
})),
const SizedBox(height: 4),
if (isErrored()) Text("Error: $errorMsg"),
@@ -476,4 +493,18 @@ Future<void> importNetworkFolder(
break;
}
}
try {
if (appdata.settings['newFavoriteAddTo'] == "start" && !isOldToNewSort) {
// 如果是插到最前, 并且是从新到旧, 反转一下
comics = comics.reversed.toList();
}
for (var c in comics) {
LocalFavoritesManager().addComic(resultName, c);
}
// 延迟一点, 让用户看清楚到底新增了多少
await Future.delayed(const Duration(milliseconds: 500));
closeDialog?.call();
} catch (e, stackTrace) {
Log.error("Unhandled Exception", e.toString(), stackTrace);
}
}

View File

@@ -11,6 +11,7 @@ import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_page.dart';
@@ -35,7 +36,7 @@ class FavoritesPage extends StatefulWidget {
State<FavoritesPage> createState() => _FavoritesPageState();
}
class _FavoritesPageState extends State<FavoritesPage> {
class _FavoritesPageState extends State<FavoritesPage> {
String? folder;
bool isNetwork = false;
@@ -58,7 +59,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
@override
void initState() {
var data = appdata.implicitData['favoriteFolder'];
if(data != null){
if (data != null) {
folder = data['name'];
isNetwork = data['isNetwork'] ?? false;
}
@@ -101,7 +102,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
alignment: Alignment.centerLeft,
child: Material(
child: SizedBox(
width: min(300, context.width-16),
width: min(300, context.width - 16),
child: _LeftBar(
withAppbar: true,
favPage: this,
@@ -153,14 +154,16 @@ class _FavoritesPageState extends State<FavoritesPage> {
);
}
if (!isNetwork) {
return _LocalFavoritesPage(folder: folder!, key: PageStorageKey("local_$folder"));
return _LocalFavoritesPage(
folder: folder!, key: PageStorageKey("local_$folder"));
} else {
var favoriteData = getFavoriteDataOrNull(folder!);
if (favoriteData == null) {
folder = null;
return buildBody();
} else {
return NetworkFavoritePage(favoriteData, key: PageStorageKey("network_$folder"));
return NetworkFavoritePage(favoriteData,
key: PageStorageKey("network_$folder"));
}
}
}

View File

@@ -136,17 +136,17 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
message: "Sync".tl,
child: Flyout(
flyoutBuilder: (context) {
var sourceName = ComicSource.find(networkSource!)?.name ??
networkSource!;
var text = "The folder is Linked to @source".tlParams({
"source": sourceName,
});
if (networkFolder != null && networkFolder!.isNotEmpty) {
text += "\n${"Source Folder".tl}: $networkFolder";
}
final GlobalKey<_SelectUpdatePageNumState>
selectUpdatePageNumKey =
GlobalKey<_SelectUpdatePageNumState>();
var updatePageWidget = _SelectUpdatePageNum(
networkSource: networkSource!,
networkFolder: networkFolder,
key: selectUpdatePageNumKey,
);
return FlyoutContent(
title: "Sync".tl,
content: Text(text),
content: updatePageWidget,
actions: [
Button.filled(
child: Text("Update".tl),
@@ -154,6 +154,8 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
context.pop();
importNetworkFolder(
networkSource!,
selectUpdatePageNumKey
.currentState!.updatePageNum,
widget.folder,
networkFolder!,
).then(
@@ -741,6 +743,17 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
);
},
),
IconButton(
icon: const Icon(Icons.swap_vert),
onPressed: () {
setState(() {
comics = comics.reversed.toList();
changed = true;
showToast(
message: "Reversed successfully".tl, context: context);
});
},
),
],
),
body: ReorderableBuilder<FavoriteItem>(
@@ -776,3 +789,76 @@ class _ReorderComicsPageState extends State<_ReorderComicsPage> {
);
}
}
class _SelectUpdatePageNum extends StatefulWidget {
const _SelectUpdatePageNum({
required this.networkSource,
this.networkFolder,
super.key,
});
final String? networkFolder;
final String networkSource;
@override
State<_SelectUpdatePageNum> createState() => _SelectUpdatePageNumState();
}
class _SelectUpdatePageNumState extends State<_SelectUpdatePageNum> {
int updatePageNum = 9999999;
String get _allPageText => 'All'.tl;
List<String> get pageNumList =>
['1', '2', '3', '5', '10', '20', '50', '100', '200', _allPageText];
@override
void initState() {
updatePageNum =
appdata.implicitData["local_favorites_update_page_num"] ?? 9999999;
super.initState();
}
@override
Widget build(BuildContext context) {
var source = ComicSource.find(widget.networkSource);
var sourceName = source?.name ?? widget.networkSource;
var text = "The folder is Linked to @source".tlParams({
"source": sourceName,
});
if (widget.networkFolder != null && widget.networkFolder!.isNotEmpty) {
text += "\n${"Source Folder".tl}: ${widget.networkFolder}";
}
return Column(
children: [
Row(
children: [Text(text)],
),
Row(
children: [
Text("Update the page number by the latest collection".tl),
Spacer(),
Select(
current: updatePageNum.toString() == '9999999'
? _allPageText
: updatePageNum.toString(),
values: pageNumList,
minWidth: 48,
onTap: (index) {
setState(() {
updatePageNum = int.parse(pageNumList[index] == _allPageText
? '9999999'
: pageNumList[index]);
appdata.implicitData["local_favorites_update_page_num"] =
updatePageNum;
appdata.writeImplicitData();
});
},
)
],
),
],
);
}
}

View File

@@ -20,8 +20,7 @@ Future<bool> _deleteComic(
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Remove".tl,
content: Text("Remove comic from favorite?".tl)
.paddingHorizontal(16),
content: Text("Remove comic from favorite?".tl).paddingHorizontal(16),
actions: [
Button.filled(
isLoading: loading,
@@ -94,9 +93,8 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
return ComicList(
key: comicListKey,
leadingSliver: SliverAppbar(
style: context.width < changePoint
? AppbarStyle.shadow
: AppbarStyle.blur,
style:
context.width < changePoint ? AppbarStyle.shadow : AppbarStyle.blur,
leading: Tooltip(
message: "Folders".tl,
child: context.width <= _kTwoPanelChangeWidth
@@ -117,7 +115,7 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
icon: Icons.sync,
text: "Convert to local".tl,
onClick: () {
importNetworkFolder(widget.data.key, null, null);
importNetworkFolder(widget.data.key, 9999999, null, null);
},
)
]),
@@ -215,9 +213,8 @@ class _MultiFolderFavoritesPageState extends State<_MultiFolderFavoritesPage> {
@override
Widget build(BuildContext context) {
var sliverAppBar = SliverAppbar(
style: context.width < changePoint
? AppbarStyle.shadow
: AppbarStyle.blur,
style:
context.width < changePoint ? AppbarStyle.shadow : AppbarStyle.blur,
leading: Tooltip(
message: "Folders".tl,
child: context.width <= _kTwoPanelChangeWidth
@@ -431,8 +428,7 @@ class _FolderTile extends StatelessWidget {
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Delete".tl,
content: Text("Delete folder?".tl)
.paddingHorizontal(16),
content: Text("Delete folder?".tl).paddingHorizontal(16),
actions: [
Button.filled(
isLoading: loading,
@@ -558,7 +554,7 @@ class _FavoriteFolder extends StatelessWidget {
icon: Icons.sync,
text: "Convert to local".tl,
onClick: () {
importNetworkFolder(data.key, title, folderID);
importNetworkFolder(data.key, 9999999, title, folderID);
},
)
]),

View File

@@ -9,14 +9,17 @@ import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/history_image_provider.dart';
import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/accounts_page.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/history_page.dart';
import 'package:venera/pages/image_favorites_page/image_favorites_page.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/data_sync.dart';
import 'package:venera/utils/import_comic.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'local_comics_page.dart';
@@ -35,6 +38,7 @@ class HomePage extends StatelessWidget {
const _Local(),
const _ComicSourceWidget(),
const _AccountsWidget(),
const ImageFavorites(),
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
],
);
@@ -83,7 +87,8 @@ class _SyncDataWidget extends StatefulWidget {
State<_SyncDataWidget> createState() => _SyncDataWidgetState();
}
class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObserver {
class _SyncDataWidgetState extends State<_SyncDataWidget>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
@@ -93,7 +98,7 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
}
void update() {
if(mounted) {
if (mounted) {
setState(() {});
}
}
@@ -110,8 +115,8 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if(state == AppLifecycleState.resumed) {
if(DateTime.now().difference(lastCheck) > const Duration(minutes: 10)) {
if (state == AppLifecycleState.resumed) {
if (DateTime.now().difference(lastCheck) > const Duration(minutes: 10)) {
lastCheck = DateTime.now();
DataSync().downloadData();
}
@@ -121,7 +126,7 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
@override
Widget build(BuildContext context) {
Widget child;
if(!DataSync().isEnabled) {
if (!DataSync().isEnabled) {
child = const SliverPadding(padding: EdgeInsets.zero);
} else if (DataSync().isUploading || DataSync().isDownloading) {
child = SliverToBoxAdapter(
@@ -159,17 +164,15 @@ class _SyncDataWidgetState extends State<_SyncDataWidget> with WidgetsBindingObs
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async {
DataSync().uploadData();
}
),
icon: const Icon(Icons.cloud_upload_outlined),
onPressed: () async {
DataSync().uploadData();
}),
IconButton(
icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async {
DataSync().downloadData();
}
),
icon: const Icon(Icons.cloud_download_outlined),
onPressed: () async {
DataSync().downloadData();
}),
],
),
),
@@ -518,50 +521,50 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
),
)
: Column(
key: key,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 600),
...List.generate(importMethods.length, (index) {
return RadioListTile(
title: Text(importMethods[index]),
value: index,
groupValue: type,
onChanged: (value) {
setState(() {
type = value as int;
});
},
);
}),
if(type != 3)
ListTile(
title: Text("Add to favorites".tl),
trailing: Select(
current: selectedFolder,
values: folders,
minWidth: 112,
onTap: (v) {
key: key,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 600),
...List.generate(importMethods.length, (index) {
return RadioListTile(
title: Text(importMethods[index]),
value: index,
groupValue: type,
onChanged: (value) {
setState(() {
selectedFolder = folders[v];
type = value as int;
});
},
),
).paddingHorizontal(8),
if(!App.isIOS && !App.isMacOS)
CheckboxListTile(
enabled: true,
title: Text("Copy to app local path".tl),
value: copyToLocalFolder,
onChanged:(v) {
setState(() {
copyToLocalFolder = !copyToLocalFolder;
});
}).paddingHorizontal(8),
const SizedBox(height: 8),
Text(info).paddingHorizontal(24),
],
),
);
}),
if (type != 3)
ListTile(
title: Text("Add to favorites".tl),
trailing: Select(
current: selectedFolder,
values: folders,
minWidth: 112,
onTap: (v) {
setState(() {
selectedFolder = folders[v];
});
},
),
).paddingHorizontal(8),
if (!App.isIOS && !App.isMacOS)
CheckboxListTile(
enabled: true,
title: Text("Copy to app local path".tl),
value: copyToLocalFolder,
onChanged: (v) {
setState(() {
copyToLocalFolder = !copyToLocalFolder;
});
}).paddingHorizontal(8),
const SizedBox(height: 8),
Text(info).paddingHorizontal(24),
],
),
actions: [
Button.text(
child: Row(
@@ -591,7 +594,9 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
help +=
"The directory name will be used as the comic title. And the name of chapter directories will be used as the chapter titles.\n"
.tl;
help +="If you import an EhViewer's database, program will automatically create folders according to the download label in that database.".tl;
help +=
"If you import an EhViewer's database, program will automatically create folders according to the download label in that database."
.tl;
return ContentDialog(
title: "Help".tl,
content: Text(help).paddingHorizontal(16),
@@ -624,9 +629,8 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
loading = true;
});
var importer = ImportComic(
selectedFolder: selectedFolder,
copyToLocal: copyToLocalFolder);
var result = switch(type) {
selectedFolder: selectedFolder, copyToLocal: copyToLocalFolder);
var result = switch (type) {
0 => await importer.directory(true),
1 => await importer.directory(false),
2 => await importer.cbz(),
@@ -634,7 +638,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
4 => await importer.ehViewer(),
int() => true,
};
if(result) {
if (result) {
context.pop();
} else {
setState(() {
@@ -911,3 +915,281 @@ class __AnimatedDownloadingIconState extends State<_AnimatedDownloadingIcon>
);
}
}
class ImageFavorites extends StatefulWidget {
const ImageFavorites({super.key});
@override
State<ImageFavorites> createState() => _ImageFavoritesState();
}
class _ImageFavoritesState extends State<ImageFavorites> {
ImageFavoritesComputed? imageFavoritesCompute;
int displayType = 0;
void refreshImageFavorites() async {
try {
imageFavoritesCompute =
await ImageFavoriteManager.computeImageFavorites();
if (mounted) {
setState(() {});
}
} catch (e, stackTrace) {
Log.error("Unhandled Exception", e.toString(), stackTrace);
}
}
@override
void initState() {
refreshImageFavorites();
ImageFavoriteManager().addListener(refreshImageFavorites);
super.initState();
}
@override
void dispose() {
ImageFavoriteManager().removeListener(refreshImageFavorites);
super.dispose();
}
@override
Widget build(BuildContext context) {
bool hasData =
imageFavoritesCompute != null && !imageFavoritesCompute!.isEmpty;
return SliverToBoxAdapter(
child: 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),
onTap: () {
context.to(() => const ImageFavoritesPage());
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 56,
child: Row(
children: [
Center(
child: Text('Image Favorites'.tl, style: ts.s18),
),
const Spacer(),
const Icon(Icons.arrow_right),
],
),
).paddingHorizontal(16),
if (hasData)
Row(
children: [
const Spacer(),
buildTypeButton(0, "Tags".tl),
const Spacer(),
buildTypeButton(1, "Authors".tl),
const Spacer(),
buildTypeButton(2, "Comics".tl),
const Spacer(),
],
),
if (hasData) const SizedBox(height: 8),
if (hasData)
buildChart(switch (displayType) {
0 => imageFavoritesCompute!.tags,
1 => imageFavoritesCompute!.authors,
2 => imageFavoritesCompute!.comics,
_ => [],
})
.paddingHorizontal(16)
.paddingBottom(16),
],
),
),
),
);
}
Widget buildTypeButton(int type, String text) {
const radius = 24.0;
return InkWell(
borderRadius: BorderRadius.circular(radius),
onTap: () async {
setState(() {
displayType = type;
});
await Future.delayed(const Duration(milliseconds: 20));
var scrollController = ScrollControllerProvider.of(context);
scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
},
child: AnimatedContainer(
width: 96,
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color:
displayType == type ? context.colorScheme.primaryContainer : null,
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(radius),
),
duration: const Duration(milliseconds: 200),
child: Center(
child: Text(
text,
style: ts.s16,
),
),
),
);
}
Widget buildChart(List<TextWithCount> data) {
if (data.isEmpty) {
return const SizedBox();
}
var maxCount = data.map((e) => e.count).reduce((a, b) => a > b ? a : b);
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: 164,
),
child: SingleChildScrollView(
child: Column(
key: ValueKey(displayType),
children: data.map((e) {
return _ChartLine(
text: e.text,
count: e.count,
maxCount: maxCount,
enableTranslation: displayType != 2,
onTap: (text) {
context.to(() => ImageFavoritesPage(initialKeyword: text));
},
);
}).toList(),
),
),
);
}
}
class _ChartLine extends StatefulWidget {
const _ChartLine({
required this.text,
required this.count,
required this.maxCount,
required this.enableTranslation,
this.onTap,
});
final String text;
final int count;
final int maxCount;
final bool enableTranslation;
final void Function(String text)? onTap;
@override
State<_ChartLine> createState() => __ChartLineState();
}
class __ChartLineState extends State<_ChartLine>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
value: 0,
)..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
var text = widget.text;
var enableTranslation =
App.locale.countryCode == 'CN' && widget.enableTranslation;
if (enableTranslation) {
text = text.translateTagsToCN;
}
if (widget.enableTranslation && text.contains(':')) {
text = text.split(':').last;
}
return Row(
children: [
InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () {
widget.onTap?.call(widget.text);
},
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
.paddingHorizontal(4)
.toAlign(Alignment.centerLeft)
.fixWidth(context.width > 600 ? 120 : 80)
.fixHeight(double.infinity),
),
const SizedBox(width: 8),
Expanded(
child: LayoutBuilder(builder: (context, constrains) {
var width = constrains.maxWidth * widget.count / widget.maxCount;
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
width: width * _controller.value,
height: 18,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
gradient: LinearGradient(
colors: context.isDarkMode
? [
Colors.blue.shade800,
Colors.blue.shade500,
]
: [
Colors.blue.shade300,
Colors.blue.shade600,
],
),
),
).toAlign(Alignment.centerLeft);
},
);
}),
),
const SizedBox(width: 8),
Text(
widget.count.toString(),
style: ts.s12,
).fixWidth(context.width > 600 ? 60 : 30),
],
).fixHeight(28);
}
}

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

View File

@@ -20,7 +20,7 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
static const _kTapToTurnPagePercent = 0.3;
_DragListener? dragListener;
final _dragListeners = <_DragListener>[];
int fingers = 0;
@@ -44,19 +44,23 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
_lastTapPointer = event.pointer;
_lastTapMoveDistance = Offset.zero;
_tapGestureRecognizer.addPointer(event);
if(_dragInProgress) {
dragListener?.onEnd?.call();
if (_dragInProgress) {
for (var dragListener in _dragListeners) {
dragListener.onStart?.call(event.position);
}
_dragInProgress = false;
}
Future.delayed(_kLongPressMinTime, () {
if (_lastTapPointer == event.pointer && fingers == 1) {
if(_lastTapMoveDistance!.distanceSquared < 20.0 * 20.0) {
if (_lastTapMoveDistance!.distanceSquared < 20.0 * 20.0) {
onLongPressedDown(event.position);
_longPressInProgress = true;
} else {
_dragInProgress = true;
dragListener?.onStart?.call(event.position);
dragListener?.onMove?.call(_lastTapMoveDistance!);
for (var dragListener in _dragListeners) {
dragListener.onStart?.call(event.position);
dragListener.onMove?.call(_lastTapMoveDistance!);
}
}
}
});
@@ -65,8 +69,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
if (event.pointer == _lastTapPointer) {
_lastTapMoveDistance = event.delta + _lastTapMoveDistance!;
}
if(_dragInProgress) {
dragListener?.onMove?.call(event.delta);
if (_dragInProgress) {
for (var dragListener in _dragListeners) {
dragListener.onMove?.call(event.delta);
}
}
},
onPointerUp: (event) {
@@ -74,8 +80,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
if (_longPressInProgress) {
onLongPressedUp(event.position);
}
if(_dragInProgress) {
dragListener?.onEnd?.call();
if (_dragInProgress) {
for (var dragListener in _dragListeners) {
dragListener.onEnd?.call();
}
_dragInProgress = false;
}
_lastTapPointer = null;
@@ -86,8 +94,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
if (_longPressInProgress) {
onLongPressedUp(event.position);
}
if(_dragInProgress) {
dragListener?.onEnd?.call();
if (_dragInProgress) {
for (var dragListener in _dragListeners) {
dragListener.onEnd?.call();
}
_dragInProgress = false;
}
_lastTapPointer = null;
@@ -261,6 +271,14 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
void onLongPressedDown(Offset location) {
context.reader._imageViewController?.handleLongPressDown(location);
}
void addDragListener(_DragListener listener) {
_dragListeners.add(listener);
}
void removeDragListener(_DragListener listener) {
_dragListeners.remove(listener);
}
}
class _DragListener {
@@ -269,4 +287,4 @@ class _DragListener {
void Function()? onEnd;
_DragListener({this.onMove, this.onEnd});
}
}

View File

@@ -263,6 +263,10 @@ class _GalleryModeState extends State<_GalleryMode>
@override
void handleDoubleTap(Offset location) {
if (appdata.settings['quickCollectImage'] == 'DoubleTap') {
context.readerScaffold.addImageFavorite();
return;
}
var controller = photoViewControllers[reader.page]!;
controller.onDoubleClick?.call();
}
@@ -461,7 +465,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
widget = Listener(
onPointerDown: (event) {
fingers++;
if(fingers > 1 && !disableScroll) {
if (fingers > 1 && !disableScroll) {
setState(() {
disableScroll = true;
});
@@ -475,7 +479,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
},
onPointerUp: (event) {
fingers--;
if(fingers <= 1 && disableScroll) {
if (fingers <= 1 && disableScroll) {
setState(() {
disableScroll = false;
});
@@ -564,6 +568,10 @@ class _ContinuousModeState extends State<_ContinuousMode>
@override
void handleDoubleTap(Offset location) {
if (appdata.settings['quickCollectImage'] == 'DoubleTap') {
context.readerScaffold.addImageFavorite();
return;
}
double target;
if (photoViewController.scale !=
photoViewController.getInitialScale?.call()) {

View File

@@ -5,12 +5,18 @@ class ReaderWithLoading extends StatefulWidget {
super.key,
required this.id,
required this.sourceKey,
this.initialEp,
this.initialPage,
});
final String id;
final String sourceKey;
final int? initialEp;
final int? initialPage;
@override
State<ReaderWithLoading> createState() => _ReaderWithLoadingState();
}
@@ -25,8 +31,10 @@ class _ReaderWithLoadingState
name: data.name,
chapters: data.chapters,
history: data.history,
initialChapter: data.history.ep,
initialPage: data.history.page,
initialChapter: widget.initialEp ?? data.history.ep,
initialPage: widget.initialPage ?? data.history.page,
author: data.author,
tags: data.tags,
);
}
@@ -57,6 +65,8 @@ class _ReaderWithLoadingState
ep: 0,
page: 0,
),
author: localComic.subtitle,
tags: localComic.tags,
),
);
} else {
@@ -76,6 +86,8 @@ class _ReaderWithLoadingState
ep: 0,
page: 0,
),
author: comic.data.findAuthor() ?? "",
tags: comic.data.plainTags,
),
);
}
@@ -93,11 +105,17 @@ class ReaderProps {
final History history;
final String author;
final List<String> tags;
const ReaderProps({
required this.type,
required this.cid,
required this.name,
required this.chapters,
required this.history,
required this.author,
required this.tags,
});
}

View File

@@ -20,6 +20,7 @@ import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/cache_manager.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/reader_image.dart';
import 'package:venera/foundation/local.dart';
@@ -27,8 +28,10 @@ import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/data_sync.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';
import 'package:venera/utils/volume.dart';
import 'package:window_manager/window_manager.dart';
@@ -57,10 +60,16 @@ class Reader extends StatefulWidget {
required this.history,
this.initialPage,
this.initialChapter,
required this.author,
required this.tags,
});
final ComicType type;
final String author;
final List<String> tags;
final String cid;
final String name;
@@ -114,12 +123,14 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
void _checkImagesPerPageChange() {
int currentImagesPerPage = imagesPerPage;
if (_lastImagesPerPage != currentImagesPerPage) {
_adjustPageForImagesPerPageChange(_lastImagesPerPage, currentImagesPerPage);
_adjustPageForImagesPerPageChange(
_lastImagesPerPage, currentImagesPerPage);
_lastImagesPerPage = currentImagesPerPage;
}
}
void _adjustPageForImagesPerPageChange(int oldImagesPerPage, int newImagesPerPage) {
void _adjustPageForImagesPerPageChange(
int oldImagesPerPage, int newImagesPerPage) {
int previousImageIndex = (page - 1) * oldImagesPerPage;
int newPage = (previousImageIndex ~/ newImagesPerPage) + 1;
page = newPage;
@@ -150,7 +161,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
updateHistory();
});
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
if(appdata.settings['enableTurnPageByVolumeKey']) {
if (appdata.settings['enableTurnPageByVolumeKey']) {
handleVolumeEvent();
}
setImageCacheSize();
@@ -170,7 +181,8 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
} else {
maxImageCacheSize = 500 << 20;
}
Log.info("Reader", "Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize");
Log.info("Reader",
"Detect available RAM: $availableRAM, set image cache size to $maxImageCacheSize");
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
}
@@ -215,7 +227,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
}
void updateHistory() {
if(history != null) {
if (history != null) {
history!.page = page;
history!.ep = chapter;
if (maxPage > 1) {
@@ -228,11 +240,11 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
}
void handleVolumeEvent() {
if(!App.isAndroid) {
if (!App.isAndroid) {
// Currently only support Android
return;
}
if(volumeListener != null) {
if (volumeListener != null) {
volumeListener?.cancel();
}
volumeListener = VolumeListener(
@@ -246,7 +258,7 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
}
void stopVolumeEvent() {
if(volumeListener != null) {
if (volumeListener != null) {
volumeListener?.cancel();
volumeListener = null;
}
@@ -306,7 +318,8 @@ abstract mixin class _ReaderLocation {
bool toPage(int page) {
if (_validatePage(page)) {
if (page == this.page) {
if(!(chapter == 1 && page == 1) && !(chapter == maxChapter && page == maxPage)) {
if (!(chapter == 1 && page == 1) &&
!(chapter == maxChapter && page == maxPage)) {
return false;
}
}

View File

@@ -18,8 +18,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
bool get isOpen => _isOpen;
bool get isReversed => context.reader.mode == ReaderMode.galleryRightToLeft ||
context.reader.mode == ReaderMode.continuousRightToLeft;
bool get isReversed =>
context.reader.mode == ReaderMode.galleryRightToLeft ||
context.reader.mode == ReaderMode.continuousRightToLeft;
int showFloatingButtonValue = 0;
@@ -29,6 +30,8 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
_ReaderGestureDetectorState? _gestureDetectorState;
_DragListener? _floatingButtonDragListener;
void setFloatingButton(int value) {
lastValue = showFloatingButtonValue;
if (value == 0) {
@@ -37,12 +40,15 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
fABValue.value = 0;
update();
}
_gestureDetectorState!.dragListener = null;
if (_floatingButtonDragListener != null) {
_gestureDetectorState!.removeDragListener(_floatingButtonDragListener!);
_floatingButtonDragListener = null;
}
}
var readerMode = context.reader.mode;
if (value == 1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = 1;
_gestureDetectorState!.dragListener = _DragListener(
_floatingButtonDragListener = _DragListener(
onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value -= offset.dy;
@@ -62,10 +68,11 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
fABValue.value = 0;
},
);
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update();
} else if (value == -1 && showFloatingButtonValue == 0) {
showFloatingButtonValue = -1;
_gestureDetectorState!.dragListener = _DragListener(
_floatingButtonDragListener = _DragListener(
onMove: (offset) {
if (readerMode == ReaderMode.continuousTopToBottom) {
fABValue.value += offset.dy;
@@ -85,10 +92,48 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
fABValue.value = 0;
},
);
_gestureDetectorState!.addDragListener(_floatingButtonDragListener!);
update();
}
}
_DragListener? _imageFavoriteDragListener;
void addDragListener() async {
if (!mounted) return;
var readerMode = context.reader.mode;
// 横向阅读的时候, 如果纵向滑就触发收藏, 纵向阅读的时候, 如果横向滑动就触发收藏
if (appdata.settings['quickCollectImage'] == 'Swipe') {
if (_imageFavoriteDragListener == null) {
double distance = 0;
_imageFavoriteDragListener = _DragListener(
onMove: (offset) {
switch (readerMode) {
case ReaderMode.continuousTopToBottom:
case ReaderMode.galleryTopToBottom:
distance += offset.dx;
case ReaderMode.continuousLeftToRight:
case ReaderMode.galleryLeftToRight:
case ReaderMode.galleryRightToLeft:
case ReaderMode.continuousRightToLeft:
distance += offset.dy;
}
},
onEnd: () {
if (distance.abs() > 150) {
addImageFavorite();
}
distance = 0;
},
);
}
_gestureDetectorState!.addDragListener(_imageFavoriteDragListener!);
} else if (_imageFavoriteDragListener != null) {
_gestureDetectorState!.removeDragListener(_imageFavoriteDragListener!);
}
}
@override
void initState() {
sliderFocus.canRequestFocus = false;
@@ -101,6 +146,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
super.initState();
Future.delayed(const Duration(milliseconds: 200), addDragListener);
}
@override
@@ -203,6 +249,123 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
);
}
bool isLiked() {
return ImageFavoriteManager().has(
context.reader.cid,
context.reader.type.sourceKey,
context.reader.eid,
context.reader.page,
context.reader.chapter,
);
}
void addImageFavorite() {
try {
if (context.reader.images![0].contains('file://')) {
showToast(
message: "Local comic collection is not supported at present".tl,
context: context);
return;
}
String id = context.reader.cid;
int ep = context.reader.chapter;
String eid = context.reader.eid;
String title = context.reader.history!.title;
String subTitle = context.reader.history!.subtitle;
int maxPage = context.reader.images!.length;
int page = context.reader.page;
String sourceKey = context.reader.type.sourceKey;
String imageKey = context.reader.images![page - 1];
List<String> tags = context.reader.widget.tags;
String author = context.reader.widget.author;
var epName = context.reader.widget.chapters?.values
.elementAtOrNull(context.reader.chapter - 1) ??
"E${context.reader.chapter}";
var translatedTags = tags.map((e) => e.translateTagsToCN).toList();
if (isLiked()) {
if (page == firstPage) {
showToast(
message: "The cover cannot be uncollected here".tl,
context: context,
);
return;
}
ImageFavoriteManager().deleteImageFavorite([
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName)
]);
showToast(
message: "Uncollected the image".tl,
context: context,
seconds: 1,
);
} else {
var imageFavoritesComic = ImageFavoriteManager().find(id, sourceKey) ??
ImageFavoritesComic(
id,
[],
title,
sourceKey,
tags,
translatedTags,
DateTime.now(),
author,
{},
subTitle,
maxPage,
);
ImageFavorite imageFavorite =
ImageFavorite(page, imageKey, null, eid, id, ep, sourceKey, epName);
ImageFavoritesEp? imageFavoritesEp =
imageFavoritesComic.imageFavoritesEp.firstWhereOrNull((e) {
return e.ep == ep;
});
if (imageFavoritesEp == null) {
if (page != firstPage) {
var copy = imageFavorite.copyWith(
page: firstPage,
isAutoFavorite: true,
imageKey: context.reader.images![0],
);
// 不是第一页的话, 自动塞一个封面进去
imageFavoritesEp = ImageFavoritesEp(
eid, ep, [copy, imageFavorite], epName, maxPage);
} else {
imageFavoritesEp =
ImageFavoritesEp(eid, ep, [imageFavorite], epName, maxPage);
}
imageFavoritesComic.imageFavoritesEp.add(imageFavoritesEp);
} else {
if (imageFavoritesEp.eid != eid) {
// 空字符串说明是从pica导入的, 那我们就手动刷一遍保证一致
if (imageFavoritesEp.eid == "") {
imageFavoritesEp.eid == eid;
} else {
// 避免多章节漫画源的章节顺序发生变化, 如果情况比较多, 做一个以eid为准更新ep的功能
showToast(
message:
"The chapter order of the comic may have changed, temporarily not supported for collection"
.tl,
context: context,
);
return;
}
}
imageFavoritesEp.imageFavorites.add(imageFavorite);
}
ImageFavoriteManager().addOrUpdateOrDelete(imageFavoritesComic);
showToast(
message: "Successfully collected".tl, context: context, seconds: 1);
}
update();
} catch (e, stackTrace) {
Log.error("Image Favorite", e, stackTrace);
showToast(message: e.toString(), context: context, seconds: 1);
}
}
Widget buildBottom() {
var text = "E${context.reader.chapter} : P${context.reader.page}";
if (context.reader.widget.chapters == null) {
@@ -233,13 +396,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
child: buildSlider(),
),
IconButton.filledTonal(
onPressed: () => !isReversed
? context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage)
: context.reader.chapter > 1
? context.reader.toPrevChapter()
: context.reader.toPage(1),
onPressed: () => !isReversed
? context.reader.chapter < context.reader.maxChapter
? context.reader.toNextChapter()
: context.reader.toPage(context.reader.maxPage)
: context.reader.chapter > 1
? context.reader.toPrevChapter()
: context.reader.toPage(1),
icon: const Icon(Icons.last_page)),
const SizedBox(
width: 8,
@@ -263,6 +426,13 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
),
),
const Spacer(),
Tooltip(
message: "Collect the image".tl,
child: IconButton(
icon: Icon(
isLiked() ? Icons.favorite : Icons.favorite_border),
onPressed: addImageFavorite),
),
if (App.isWindows)
Tooltip(
message: "${"Full Screen".tl}(F12)",
@@ -358,12 +528,14 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surface.toOpacity(0.82),
border: Border(
top: BorderSide(
color: Colors.grey.toOpacity(0.5),
width: 0.5,
),
),
border: isOpen
? Border(
top: BorderSide(
color: Colors.grey.toOpacity(0.5),
width: 0.5,
),
)
: null,
),
padding: EdgeInsets.only(bottom: context.padding.bottom),
child: child,
@@ -559,7 +731,6 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
onChanged: (key) {
if (key == "readerMode") {
context.reader.mode = ReaderMode.fromKey(appdata.settings[key]);
App.rootContext.pop();
}
if (key == "enableTurnPageByVolumeKey") {
if (appdata.settings[key]) {
@@ -568,6 +739,9 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> {
context.reader.stopVolumeEvent();
}
}
if (key == "quickCollectImage") {
addDragListener();
}
context.reader.update();
},
),

View File

@@ -107,15 +107,16 @@ class _AppSettingsState extends State<AppSettings> {
actionTitle: 'Export'.tl,
).toSliver(),
_CallbackSetting(
title: "Import App Data".tl,
title: "Import App Data (Please restart after success)".tl,
callback: () async {
var controller = showLoadingDialog(context);
var file = await selectFile(ext: ['venera', 'picadata']);
if (file != null) {
var cacheFile = File(FilePath.join(App.cachePath, "import_data_temp"));
var cacheFile =
File(FilePath.join(App.cachePath, "import_data_temp"));
await file.saveTo(cacheFile.path);
try {
if(file.name.endsWith('picadata')) {
if (file.name.endsWith('picadata')) {
await importPicaData(cacheFile);
} else {
await importAppData(cacheFile);
@@ -123,8 +124,7 @@ class _AppSettingsState extends State<AppSettings> {
} catch (e, s) {
Log.error("Import data", e.toString(), s);
context.showMessage(message: "Failed to import data".tl);
}
finally {
} finally {
cacheFile.deleteIgnoreError();
}
}

View File

@@ -33,7 +33,9 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
SelectSetting(
title: "Quick Favorite".tl,
settingKey: "quickFavorite",
help: "Long press on the favorite button to quickly add to this folder".tl,
help:
"Long press on the favorite button to quickly add to this folder"
.tl,
optionTranslation: {
for (var e in LocalFavoritesManager().folderNames) e: e
},
@@ -44,7 +46,8 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
var controller = showLoadingDialog(context);
var count = await LocalFavoritesManager().removeInvalid();
controller.close();
context.showMessage(message: "Deleted @a favorite items".tlParams({'a': count}));
context.showMessage(
message: "Deleted @a favorite items".tlParams({'a': count}));
},
actionTitle: 'Delete'.tl,
).toSliver(),

View File

@@ -116,6 +116,21 @@ class _ReaderSettingsState extends State<ReaderSettings> {
widget.onChanged?.call("enableClockAndBatteryInfoInReader");
},
).toSliver(),
SelectSetting(
title: "Quick collect image".tl,
settingKey: "quickCollectImage",
optionTranslation: {
"No": "Not enable".tl,
"DoubleTap": "Double Tap".tl,
"Swipe": "Swipe".tl,
},
onChanged: () {
widget.onChanged?.call("quickCollectImage");
},
help:
"On the image browsing page, you can quickly collect images by sliding horizontally or vertically according to your reading mode"
.tl,
).toSliver(),
_PopupWindowSetting(
title: "Custom Image Processing".tl,
builder: () => _CustomImageProcessing(),