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/sqlite3.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/favorites.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; /// index of chapters. 1-based. int ep; /// index of pages. 1-based. int page; /// index of chapter groups. 1-based. /// If [group] is not null, [ep] is the index of chapter in the group. int? group; @override String id; /// readEpisode is a set of episode numbers that have been read. /// For normal chapters, it is a set of chapter numbers. /// For grouped chapters, it is a set of strings in the format of "group_number-chapter_number". /// 1-based. Set readEpisode; @override int? maxPage; History.fromModel( {required HistoryMixin model, required this.ep, required this.page, this.group, Set? readChapters, DateTime? time}) : type = model.historyType, title = model.title, subtitle = model.subTitle ?? '', cover = model.cover, id = model.id, readEpisode = readChapters ?? {}, time = time ?? DateTime.now(); History.fromMap(Map 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.from( (map["readEpisode"] as List?)?.toSet() ?? const {}), 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.from((row["readEpisode"] as String) .split(',') .where((element) => element != "")), maxPage = row["max_page"], group = row["chapter_group"]; @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? get tags => null; @override Map 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? _cachedHistoryIds; /// Cache records recently modified by the app. Improve the performance of listeners. final cachedHistories = {}; bool isInitialized = false; Future 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, chapter_group int ); """); var columns = _db.select("PRAGMA table_info(history);"); if (!columns.any((element) => element["name"] == "chapter_group")) { _db.execute("alter table history add column chapter_group 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, chapter_group) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); """; static Future _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, newItem.group ]); }); } bool _haveAsyncTask = false; /// Create a isolate to add history to prevent blocking the UI thread. Future addHistoryAsync(History newItem) async { while (_haveAsyncTask) { await Future.delayed(Duration(milliseconds: 20)); } _haveAsyncTask = true; await _addHistoryAsync(_db.handle.address, newItem); _haveAsyncTask = false; if (_cachedHistoryIds == null) { updateCache(); } else { _cachedHistoryIds![newItem.id] = true; } cachedHistories[newItem.id] = newItem; if (cachedHistories.length > 10) { cachedHistories.remove(cachedHistories.keys.first); } notifyListeners(); } /// 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, newItem.group ]); 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 clearUnfavoritedHistory() { _db.execute('BEGIN TRANSACTION;'); try { final idAndTypes = _db.select(""" select id, type from history; """); for (var element in idAndTypes) { final id = element["id"] as String; final type = ComicType(element["type"] as int); if (!LocalFavoritesManager().isExist(id, type)) { _db.execute(""" delete from history where id == ? and type == ?; """, [id, type.value]); } } _db.execute('COMMIT;'); } catch (e) { _db.execute('ROLLBACK;'); rethrow; } 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.toList()) { 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 getAll() { var res = _db.select(""" select * from history order by time DESC; """); return res.map((element) => History.fromRow(element)).toList(); } /// 获取最近阅读的漫画 List 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(); } void batchDeleteHistories(List histories) { if (histories.isEmpty) return; _db.execute('BEGIN TRANSACTION;'); try { for (var history in histories) { _db.execute(""" delete from history where id == ? and type == ?; """, [history.id, history.type.value]); } _db.execute('COMMIT;'); } catch (e) { _db.execute('ROLLBACK;'); rethrow; } updateCache(); notifyListeners(); } }