mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
Feat: Image favorites (#126)
* feat: 增加图片收藏 * feat: 主体图片收藏页面实现 * feat: 点击打开大图浏览 * feat: 数据结构变更 * feat: 基本完成 * feat: 翻译与bug修复 * feat: 实机测试和问题修复 * feat: jm导入, pica历史记录nhentai有问题, 一键反转 * fix: 大小写不一致, 一个htManga, 一个htmanga * feat: 拉取收藏优化 * feat: 改成以ep为准 * feat: 兜底一些可能报错场景 * chore: 没有用到 * feat: 尽量保证和网络收藏顺序一致 * feat: 支持显示热点tag * feat: 支持双击收藏, 不过此时禁止放大图片 * fix: 自动塞封面逻辑完善, 切换快速收藏图片立刻生效 * Refactor * fix updateValue * feat: 双击功能提示 * fix: 被确定取消收藏的才删除 * Refactor ImageFavoritesPage * translate author * feat: 功能提示改到dialog中 * fix text editing * fix text editing * feat: 功能提示放到邮件或长按菜单中 * fix: 修复tag过滤不生效问题 * Improve image loading * The default value of quickCollectImage should be false. * Refactor DragListener * Refactor ImageFavoriteItem & ImageFavoritePhotoView * Refactor * Fix `ImageFavoriteManager.has` * Fix UI * Improve UI --------- Co-authored-by: nyne <me@nyne.dev>
This commit is contained in:
535
lib/foundation/image_favorites.dart
Normal file
535
lib/foundation/image_favorites.dart
Normal file
@@ -0,0 +1,535 @@
|
||||
part of "history.dart";
|
||||
|
||||
class ImageFavorite {
|
||||
final String eid;
|
||||
final String id; // 漫画id
|
||||
final int ep;
|
||||
final String epName;
|
||||
final String sourceKey;
|
||||
String imageKey;
|
||||
int page;
|
||||
bool? isAutoFavorite;
|
||||
|
||||
ImageFavorite(
|
||||
this.page,
|
||||
this.imageKey,
|
||||
this.isAutoFavorite,
|
||||
this.eid,
|
||||
this.id,
|
||||
this.ep,
|
||||
this.sourceKey,
|
||||
this.epName,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'page': page,
|
||||
'imageKey': imageKey,
|
||||
'isAutoFavorite': isAutoFavorite,
|
||||
'eid': eid,
|
||||
'id': id,
|
||||
'ep': ep,
|
||||
'sourceKey': sourceKey,
|
||||
'epName': epName,
|
||||
};
|
||||
}
|
||||
|
||||
ImageFavorite.fromJson(Map<String, dynamic> json)
|
||||
: page = json['page'],
|
||||
imageKey = json['imageKey'],
|
||||
isAutoFavorite = json['isAutoFavorite'],
|
||||
eid = json['eid'],
|
||||
id = json['id'],
|
||||
ep = json['ep'],
|
||||
sourceKey = json['sourceKey'],
|
||||
epName = json['epName'];
|
||||
|
||||
ImageFavorite copyWith({
|
||||
int? page,
|
||||
String? imageKey,
|
||||
bool? isAutoFavorite,
|
||||
String? eid,
|
||||
String? id,
|
||||
int? ep,
|
||||
String? sourceKey,
|
||||
String? epName,
|
||||
}) {
|
||||
return ImageFavorite(
|
||||
page ?? this.page,
|
||||
imageKey ?? this.imageKey,
|
||||
isAutoFavorite ?? this.isAutoFavorite,
|
||||
eid ?? this.eid,
|
||||
id ?? this.id,
|
||||
ep ?? this.ep,
|
||||
sourceKey ?? this.sourceKey,
|
||||
epName ?? this.epName,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ImageFavorite &&
|
||||
other.id == id &&
|
||||
other.sourceKey == sourceKey &&
|
||||
other.page == page &&
|
||||
other.eid == eid &&
|
||||
other.ep == ep;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, sourceKey, page, eid, ep);
|
||||
}
|
||||
|
||||
class ImageFavoritesEp {
|
||||
// 小心拷贝等多章节的可能更新章节顺序
|
||||
String eid;
|
||||
final int ep;
|
||||
int maxPage;
|
||||
String epName;
|
||||
List<ImageFavorite> imageFavorites;
|
||||
|
||||
ImageFavoritesEp(
|
||||
this.eid, this.ep, this.imageFavorites, this.epName, this.maxPage);
|
||||
|
||||
// 是否有封面
|
||||
bool get isHasFirstPage {
|
||||
return imageFavorites[0].page == firstPage;
|
||||
}
|
||||
|
||||
// 是否都有imageKey
|
||||
bool get isHasImageKey {
|
||||
return imageFavorites.every((e) => e.imageKey != "");
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'eid': eid,
|
||||
'ep': ep,
|
||||
'maxPage': maxPage,
|
||||
'epName': epName,
|
||||
'imageFavorites': imageFavorites.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ImageFavoritesComic {
|
||||
final String id;
|
||||
final String title;
|
||||
String subTitle;
|
||||
String author;
|
||||
final String sourceKey;
|
||||
|
||||
// 不一定是真的这本漫画的所有页数, 如果是多章节的时候
|
||||
int maxPage;
|
||||
List<String> tags;
|
||||
List<String> translatedTags;
|
||||
final DateTime time;
|
||||
List<ImageFavoritesEp> imageFavoritesEp;
|
||||
final Map<String, dynamic> other;
|
||||
|
||||
ImageFavoritesComic(
|
||||
this.id,
|
||||
this.imageFavoritesEp,
|
||||
this.title,
|
||||
this.sourceKey,
|
||||
this.tags,
|
||||
this.translatedTags,
|
||||
this.time,
|
||||
this.author,
|
||||
this.other,
|
||||
this.subTitle,
|
||||
this.maxPage,
|
||||
);
|
||||
|
||||
// 是否都有imageKey
|
||||
bool get isAllHasImageKey {
|
||||
return imageFavoritesEp
|
||||
.every((e) => e.imageFavorites.every((j) => j.imageKey != ""));
|
||||
}
|
||||
|
||||
int get maxPageFromEp {
|
||||
int temp = 0;
|
||||
for (var e in imageFavoritesEp) {
|
||||
temp += e.maxPage;
|
||||
}
|
||||
return temp;
|
||||
}
|
||||
|
||||
// 是否都有封面
|
||||
bool get isAllHasFirstPage {
|
||||
return imageFavoritesEp.every((e) => e.isHasFirstPage);
|
||||
}
|
||||
|
||||
Iterable<ImageFavorite> get images sync*{
|
||||
for (var e in imageFavoritesEp) {
|
||||
yield* e.imageFavorites;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ImageFavoritesComic &&
|
||||
other.id == id &&
|
||||
other.sourceKey == sourceKey;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, sourceKey);
|
||||
|
||||
factory ImageFavoritesComic.fromRow(Row r) {
|
||||
var tempImageFavoritesEp = jsonDecode(r["image_favorites_ep"]);
|
||||
List<ImageFavoritesEp> finalImageFavoritesEp = [];
|
||||
tempImageFavoritesEp.forEach((i) {
|
||||
List<ImageFavorite> temp = [];
|
||||
i["imageFavorites"].forEach((j) {
|
||||
temp.add(ImageFavorite(
|
||||
j["page"],
|
||||
j["imageKey"],
|
||||
j["isAutoFavorite"],
|
||||
i["eid"],
|
||||
r["id"],
|
||||
i["ep"],
|
||||
r["source_key"],
|
||||
i["epName"],
|
||||
));
|
||||
});
|
||||
finalImageFavoritesEp.add(ImageFavoritesEp(
|
||||
i["eid"], i["ep"], temp, i["epName"], i["maxPage"] ?? 1));
|
||||
});
|
||||
return ImageFavoritesComic(
|
||||
r["id"],
|
||||
finalImageFavoritesEp,
|
||||
r["title"],
|
||||
r["source_key"],
|
||||
r["tags"].split(","),
|
||||
r["translated_tags"].split(","),
|
||||
DateTime.fromMillisecondsSinceEpoch(r["time"]),
|
||||
r["author"],
|
||||
jsonDecode(r["other"]),
|
||||
r["sub_title"],
|
||||
r["max_page"],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImageFavoriteManager with ChangeNotifier {
|
||||
Database get _db => HistoryManager()._db;
|
||||
|
||||
List<ImageFavoritesComic> get comics => getAll();
|
||||
|
||||
static ImageFavoriteManager? _cache;
|
||||
|
||||
ImageFavoriteManager._();
|
||||
|
||||
factory ImageFavoriteManager() => (_cache ??= ImageFavoriteManager._());
|
||||
|
||||
/// 检查表image_favorites是否存在, 不存在则创建
|
||||
void init() {
|
||||
_db.execute("CREATE TABLE IF NOT EXISTS image_favorites ("
|
||||
"id TEXT,"
|
||||
"title TEXT NOT NULL,"
|
||||
"sub_title TEXT,"
|
||||
"author TEXT,"
|
||||
"tags TEXT,"
|
||||
"translated_tags TEXT,"
|
||||
"time int,"
|
||||
"max_page int,"
|
||||
"source_key TEXT NOT NULL,"
|
||||
"image_favorites_ep TEXT NOT NULL,"
|
||||
"other TEXT NOT NULL,"
|
||||
"PRIMARY KEY (id,source_key)"
|
||||
");");
|
||||
}
|
||||
|
||||
// 做排序和去重的操作
|
||||
void addOrUpdateOrDelete(ImageFavoritesComic favorite, [bool notify = true]) {
|
||||
// 没有章节了就删掉
|
||||
if (favorite.imageFavoritesEp.isEmpty) {
|
||||
_db.execute("""
|
||||
delete from image_favorites
|
||||
where id == ? and source_key == ?;
|
||||
""", [favorite.id, favorite.sourceKey]);
|
||||
} else {
|
||||
// 去重章节
|
||||
List<ImageFavoritesEp> tempImageFavoritesEp = [];
|
||||
for (var e in favorite.imageFavoritesEp) {
|
||||
int index = tempImageFavoritesEp.indexWhere((i) {
|
||||
return i.ep == e.ep;
|
||||
});
|
||||
// 再做一层保险, 防止出现ep为0的脏数据
|
||||
if (index == -1 && e.ep > 0) {
|
||||
tempImageFavoritesEp.add(e);
|
||||
}
|
||||
}
|
||||
tempImageFavoritesEp.sort((a, b) => a.ep.compareTo(b.ep));
|
||||
List<dynamic> finalImageFavoritesEp =
|
||||
jsonDecode(jsonEncode(tempImageFavoritesEp));
|
||||
for (var e in tempImageFavoritesEp) {
|
||||
List<Map> finalImageFavorites = [];
|
||||
int epIndex = tempImageFavoritesEp.indexOf(e);
|
||||
for (ImageFavorite j in e.imageFavorites) {
|
||||
int index =
|
||||
finalImageFavorites.indexWhere((i) => i["page"] == j.page);
|
||||
if (index == -1 && j.page > 0) {
|
||||
// isAutoFavorite 为 null 不写入数据库, 同时只保留需要的属性, 避免增加太多重复字段在数据库里
|
||||
if (j.isAutoFavorite != null) {
|
||||
finalImageFavorites.add({
|
||||
"page": j.page,
|
||||
"imageKey": j.imageKey,
|
||||
"isAutoFavorite": j.isAutoFavorite
|
||||
});
|
||||
} else {
|
||||
finalImageFavorites.add({"page": j.page, "imageKey": j.imageKey});
|
||||
}
|
||||
}
|
||||
}
|
||||
finalImageFavorites.sort((a, b) => a["page"].compareTo(b["page"]));
|
||||
finalImageFavoritesEp[epIndex]["imageFavorites"] = finalImageFavorites;
|
||||
}
|
||||
if (tempImageFavoritesEp.isEmpty) {
|
||||
throw "Error: No ImageFavoritesEp";
|
||||
}
|
||||
_db.execute("""
|
||||
insert or replace into image_favorites(id, title, sub_title, author, tags, translated_tags, time, max_page, source_key, image_favorites_ep, other)
|
||||
values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
""", [
|
||||
favorite.id,
|
||||
favorite.title,
|
||||
favorite.subTitle,
|
||||
favorite.author,
|
||||
favorite.tags.join(","),
|
||||
favorite.translatedTags.join(","),
|
||||
favorite.time.millisecondsSinceEpoch,
|
||||
favorite.maxPage,
|
||||
favorite.sourceKey,
|
||||
jsonEncode(finalImageFavoritesEp),
|
||||
jsonEncode(favorite.other)
|
||||
]);
|
||||
}
|
||||
if (notify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
bool has(String id, String sourceKey, String eid, int page, int ep) {
|
||||
var comic = find(id, sourceKey);
|
||||
if (comic == null) {
|
||||
return false;
|
||||
}
|
||||
var epIndex = comic.imageFavoritesEp.where((e) => e.eid == eid).firstOrNull;
|
||||
if (epIndex == null) {
|
||||
return false;
|
||||
}
|
||||
return epIndex.imageFavorites.any((e) => e.page == page && e.ep == ep);
|
||||
}
|
||||
|
||||
List<ImageFavoritesComic> getAll([String? keyword]) {
|
||||
ResultSet res;
|
||||
if (keyword == null || keyword == "") {
|
||||
res = _db.select("select * from image_favorites;");
|
||||
} else {
|
||||
res = _db.select(
|
||||
"""
|
||||
select * from image_favorites
|
||||
WHERE title LIKE ?
|
||||
OR sub_title LIKE ?
|
||||
OR LOWER(tags) LIKE LOWER(?)
|
||||
OR LOWER(translated_tags) LIKE LOWER(?)
|
||||
OR author LIKE ?;
|
||||
""",
|
||||
['%$keyword%', '%$keyword%', '%$keyword%', '%$keyword%', '%$keyword%'],
|
||||
);
|
||||
}
|
||||
try {
|
||||
return res.map((e) => ImageFavoritesComic.fromRow(e)).toList();
|
||||
} catch (e, stackTrace) {
|
||||
Log.error("Unhandled Exception", e.toString(), stackTrace);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
void deleteImageFavorite(Iterable<ImageFavorite> imageFavoriteList) {
|
||||
if (imageFavoriteList.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (var i in imageFavoriteList) {
|
||||
ImageFavoritesProvider.deleteFromCache(i);
|
||||
}
|
||||
var comics = <ImageFavoritesComic>{};
|
||||
for (var i in imageFavoriteList) {
|
||||
var comic = comics
|
||||
.where((c) => c.id == i.id && c.sourceKey == i.sourceKey)
|
||||
.firstOrNull ??
|
||||
find(i.id, i.sourceKey);
|
||||
if (comic == null) {
|
||||
continue;
|
||||
}
|
||||
var ep = comic.imageFavoritesEp.firstWhereOrNull((e) => e.ep == i.ep);
|
||||
if (ep == null) {
|
||||
continue;
|
||||
}
|
||||
ep.imageFavorites.remove(i);
|
||||
if (ep.imageFavorites.isEmpty) {
|
||||
comic.imageFavoritesEp.remove(ep);
|
||||
}
|
||||
comics.add(comic);
|
||||
}
|
||||
for (var i in comics) {
|
||||
addOrUpdateOrDelete(i, false);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int get length {
|
||||
var res = _db.select("select count(*) from image_favorites;");
|
||||
return res.first.values.first! as int;
|
||||
}
|
||||
|
||||
List<ImageFavoritesComic> search(String keyword) {
|
||||
if (keyword == "") {
|
||||
return [];
|
||||
}
|
||||
return getAll(keyword);
|
||||
}
|
||||
|
||||
static Future<ImageFavoritesComputed> computeImageFavorites() {
|
||||
var token = ServicesBinding.rootIsolateToken!;
|
||||
var count = ImageFavoriteManager().length;
|
||||
if (count == 0) {
|
||||
return Future.value(ImageFavoritesComputed([], [], []));
|
||||
} else if (count > 100) {
|
||||
return Isolate.run(() async {
|
||||
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
|
||||
await App.init();
|
||||
await HistoryManager().init();
|
||||
return _computeImageFavorites();
|
||||
});
|
||||
} else {
|
||||
return Future.value(_computeImageFavorites());
|
||||
}
|
||||
}
|
||||
|
||||
static ImageFavoritesComputed _computeImageFavorites() {
|
||||
const maxLength = 20;
|
||||
|
||||
var comics = ImageFavoriteManager().getAll();
|
||||
// 去掉这些没有意义的标签
|
||||
const List<String> exceptTags = [
|
||||
'連載中',
|
||||
'',
|
||||
'translated',
|
||||
'chinese',
|
||||
'sole male',
|
||||
'sole female',
|
||||
'original',
|
||||
'doujinshi',
|
||||
'manga',
|
||||
'multi-work series',
|
||||
'mosaic censorship',
|
||||
'dilf',
|
||||
'bbm',
|
||||
'uncensored',
|
||||
'full censorship'
|
||||
];
|
||||
|
||||
Map<String, int> tagCount = {};
|
||||
Map<String, int> authorCount = {};
|
||||
Map<ImageFavoritesComic, int> comicImageCount = {};
|
||||
Map<ImageFavoritesComic, int> comicMaxPages = {};
|
||||
|
||||
for (var comic in comics) {
|
||||
for (var tag in comic.tags) {
|
||||
String finalTag = tag;
|
||||
tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1;
|
||||
}
|
||||
|
||||
if (comic.author != "") {
|
||||
String finalAuthor = comic.author;
|
||||
authorCount[finalAuthor] =
|
||||
(authorCount[finalAuthor] ?? 0) + comic.images.length;
|
||||
}
|
||||
// 小于10页的漫画不统计
|
||||
if (comic.maxPageFromEp < 10) {
|
||||
continue;
|
||||
}
|
||||
comicImageCount[comic] =
|
||||
(comicImageCount[comic] ?? 0) + comic.images.length;
|
||||
comicMaxPages[comic] = (comicMaxPages[comic] ?? 0) + comic.maxPageFromEp;
|
||||
}
|
||||
|
||||
// 按数量排序标签
|
||||
List<String> sortedTags = tagCount.keys.toList()
|
||||
..sort((a, b) => tagCount[b]!.compareTo(tagCount[a]!));
|
||||
|
||||
// 按数量排序作者
|
||||
List<String> sortedAuthors = authorCount.keys.toList()
|
||||
..sort((a, b) => authorCount[b]!.compareTo(authorCount[a]!));
|
||||
|
||||
// 按收藏数量排序漫画
|
||||
List<MapEntry<ImageFavoritesComic, int>> sortedComicsByNum =
|
||||
comicImageCount.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
validateTag(String tag) {
|
||||
if (tag.startsWith("Category:")) {
|
||||
return false;
|
||||
}
|
||||
return !exceptTags.contains(tag.split(":").last.toLowerCase()) &&
|
||||
!tag.isNum;
|
||||
}
|
||||
|
||||
return ImageFavoritesComputed(
|
||||
sortedTags
|
||||
.where(validateTag)
|
||||
.map((tag) => TextWithCount(tag, tagCount[tag]!))
|
||||
.take(maxLength)
|
||||
.toList(),
|
||||
sortedAuthors
|
||||
.map((author) => TextWithCount(author, authorCount[author]!))
|
||||
.take(maxLength)
|
||||
.toList(),
|
||||
sortedComicsByNum
|
||||
.map((comic) => TextWithCount(comic.key.title, comic.value))
|
||||
.take(maxLength)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
ImageFavoritesComic? find(String id, String sourceKey) {
|
||||
var row = _db.select("""
|
||||
select * from image_favorites
|
||||
where id == ? and source_key == ?;
|
||||
""", [id, sourceKey]);
|
||||
if (row.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return ImageFavoritesComic.fromRow(row.first);
|
||||
}
|
||||
}
|
||||
|
||||
class TextWithCount {
|
||||
final String text;
|
||||
final int count;
|
||||
|
||||
const TextWithCount(this.text, this.count);
|
||||
}
|
||||
|
||||
class ImageFavoritesComputed {
|
||||
/// 基于收藏的标签数排序
|
||||
final List<TextWithCount> tags;
|
||||
|
||||
/// 基于收藏的作者数排序
|
||||
final List<TextWithCount> authors;
|
||||
|
||||
/// 基于喜欢的图片数排序
|
||||
final List<TextWithCount> comics;
|
||||
|
||||
/// 计算后的图片收藏数据
|
||||
const ImageFavoritesComputed(
|
||||
this.tags,
|
||||
this.authors,
|
||||
this.comics,
|
||||
);
|
||||
|
||||
bool get isEmpty => tags.isEmpty && authors.isEmpty && comics.isEmpty;
|
||||
}
|
Reference in New Issue
Block a user