Files
venera/lib/foundation/history.dart

371 lines
8.7 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'dart:ffi' as ffi;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/common.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
import 'app.dart';
import 'consts.dart';
part "image_favorites.dart";
typedef HistoryType = ComicType;
abstract mixin class HistoryMixin {
String get title;
String? get subTitle;
String get cover;
String get id;
int? get maxPage => null;
HistoryType get historyType;
}
class History implements Comic {
HistoryType type;
DateTime time;
@override
String title;
@override
String subtitle;
@override
String cover;
int ep;
int page;
@override
String id;
/// readEpisode is a set of episode numbers that have been read.
///
/// The number of episodes is 1-based.
Set<int> readEpisode;
@override
int? maxPage;
History.fromModel(
{required HistoryMixin model,
required this.ep,
required this.page,
Set<int>? readChapters,
DateTime? time})
: type = model.historyType,
title = model.title,
subtitle = model.subTitle ?? '',
cover = model.cover,
id = model.id,
readEpisode = readChapters ?? <int>{},
time = time ?? DateTime.now();
Map<String, dynamic> toMap() => {
"type": type.value,
"time": time.millisecondsSinceEpoch,
"title": title,
"subtitle": subtitle,
"cover": cover,
"ep": ep,
"page": page,
"id": id,
"readEpisode": readEpisode.toList(),
"max_page": maxPage
};
History.fromMap(Map<String, dynamic> map)
: type = HistoryType(map["type"]),
time = DateTime.fromMillisecondsSinceEpoch(map["time"]),
title = map["title"],
subtitle = map["subtitle"],
cover = map["cover"],
ep = map["ep"],
page = map["page"],
id = map["id"],
readEpisode = Set<int>.from(
(map["readEpisode"] as List<dynamic>?)?.toSet() ?? const <int>{}),
maxPage = map["max_page"];
@override
String toString() {
return 'History{type: $type, time: $time, title: $title, subtitle: $subtitle, cover: $cover, ep: $ep, page: $page, id: $id}';
}
History.fromRow(Row row)
: type = HistoryType(row["type"]),
time = DateTime.fromMillisecondsSinceEpoch(row["time"]),
title = row["title"],
subtitle = row["subtitle"],
cover = row["cover"],
ep = row["ep"],
page = row["page"],
id = row["id"],
readEpisode = Set<int>.from((row["readEpisode"] as String)
.split(',')
.where((element) => element != "")
.map((e) => int.parse(e))),
maxPage = row["max_page"];
@override
bool operator ==(Object other) {
return other is History && type == other.type && id == other.id;
}
@override
int get hashCode => Object.hash(id, type);
@override
String get description {
var res = "";
if (ep >= 1) {
res += "Chapter @ep".tlParams({
"ep": ep,
});
}
if (page >= 1) {
if (ep >= 1) {
res += " - ";
}
res += "Page @page".tlParams({
"page": page,
});
}
return res;
}
@override
String? get favoriteId => null;
@override
String? get language => null;
@override
String get sourceKey => type == ComicType.local
? 'local'
: type.comicSource?.key ?? "Unknown:${type.value}";
@override
double? get stars => null;
@override
List<String>? get tags => null;
@override
Map<String, dynamic> toJson() {
throw UnimplementedError();
}
}
class HistoryManager with ChangeNotifier {
static HistoryManager? cache;
HistoryManager.create();
factory HistoryManager() =>
cache == null ? (cache = HistoryManager.create()) : cache!;
late Database _db;
int get length => _db.select("select count(*) from history;").first[0] as int;
/// Cache of history ids. Improve the performance of find operation.
Map<String, bool>? _cachedHistoryIds;
/// Cache records recently modified by the app. Improve the performance of listeners.
final cachedHistories = <String, History>{};
bool isInitialized = false;
Future<void> init() async {
if (isInitialized) {
return;
}
_db = sqlite3.open("${App.dataPath}/history.db");
_db.execute("""
create table if not exists history (
id text primary key,
title text,
subtitle text,
cover text,
time int,
type int,
ep int,
page int,
readEpisode text,
max_page int
);
""");
notifyListeners();
ImageFavoriteManager().init();
isInitialized = true;
}
static const _insertHistorySql = """
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""";
static Future<void> _addHistoryAsync(int dbAddr, History newItem) {
return Isolate.run(() {
var db = sqlite3.fromPointer(ffi.Pointer.fromAddress(dbAddr));
db.execute(_insertHistorySql, [
newItem.id,
newItem.title,
newItem.subtitle,
newItem.cover,
newItem.time.millisecondsSinceEpoch,
newItem.type.value,
newItem.ep,
newItem.page,
newItem.readEpisode.join(','),
newItem.maxPage
]);
});
}
bool _haveAsyncTask = false;
/// Create a isolate to add history to prevent blocking the UI thread.
Future<void> addHistoryAsync(History newItem) async {
while (_haveAsyncTask) {
await Future.delayed(Duration(milliseconds: 20));
}
_haveAsyncTask = true;
await _addHistoryAsync(_db.handle.address, newItem);
_haveAsyncTask = false;
}
/// add history. if exists, update time.
///
/// This function would be called when user start reading.
void addHistory(History newItem) {
_db.execute(_insertHistorySql, [
newItem.id,
newItem.title,
newItem.subtitle,
newItem.cover,
newItem.time.millisecondsSinceEpoch,
newItem.type.value,
newItem.ep,
newItem.page,
newItem.readEpisode.join(','),
newItem.maxPage
]);
if (_cachedHistoryIds == null) {
updateCache();
} else {
_cachedHistoryIds![newItem.id] = true;
}
cachedHistories[newItem.id] = newItem;
if (cachedHistories.length > 10) {
cachedHistories.remove(cachedHistories.keys.first);
}
notifyListeners();
}
void clearHistory() {
_db.execute("delete from history;");
updateCache();
notifyListeners();
}
void remove(String id, ComicType type) async {
_db.execute("""
delete from history
where id == ? and type == ?;
""", [id, type.value]);
updateCache();
notifyListeners();
}
void updateCache() {
_cachedHistoryIds = {};
var res = _db.select("""
select id from history;
""");
for (var element in res) {
_cachedHistoryIds![element["id"] as String] = true;
}
for (var key in cachedHistories.keys) {
if (!_cachedHistoryIds!.containsKey(key)) {
cachedHistories.remove(key);
}
}
}
History? find(String id, ComicType type) {
if (_cachedHistoryIds == null) {
updateCache();
}
if (!_cachedHistoryIds!.containsKey(id)) {
return null;
}
if (cachedHistories.containsKey(id)) {
return cachedHistories[id];
}
var res = _db.select("""
select * from history
where id == ? and type == ?;
""", [id, type.value]);
if (res.isEmpty) {
return null;
}
return History.fromRow(res.first);
}
List<History> getAll() {
var res = _db.select("""
select * from history
order by time DESC;
""");
return res.map((element) => History.fromRow(element)).toList();
}
/// 获取最近阅读的漫画
List<History> getRecent() {
var res = _db.select("""
select * from history
order by time DESC
limit 20;
""");
return res.map((element) => History.fromRow(element)).toList();
}
/// 获取历史记录的数量
int count() {
var res = _db.select("""
select count(*) from history;
""");
return res.first[0] as int;
}
void close() {
isInitialized = false;
_db.dispose();
}
}