From 317e0f87e50c635250802cd3c9657f07869202e5 Mon Sep 17 00:00:00 2001 From: nyne Date: Sat, 15 Feb 2025 16:05:38 +0800 Subject: [PATCH] Add follow updates feature. Close #189 --- assets/translation.json | 34 +- lib/foundation/appdata.dart | 1 + lib/foundation/comic_source/models.dart | 35 ++ lib/foundation/favorites.dart | 150 +++++- lib/init.dart | 23 + lib/main.dart | 1 + lib/pages/follow_updates_page.dart | 665 ++++++++++++++++++++++++ lib/pages/home_page.dart | 2 + lib/pages/main_page.dart | 18 - 9 files changed, 907 insertions(+), 22 deletions(-) create mode 100644 lib/pages/follow_updates_page.dart diff --git a/assets/translation.json b/assets/translation.json index 633c4e7..fa5b53d 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -338,7 +338,22 @@ "Descending": "降序", "Last Reading: Chapter @ep Page @page": "上次阅读: 第 @ep 章 第 @page 页", "Last Reading: Page @page": "上次阅读: 第 @page 页", - "Replies": "回复" + "Replies": "回复", + "Follow Updates": "追更", + "Not Configured": "未配置", + "Choose a folder to follow updates." : "选择一个文件夹以追更", + "Choose Folder": "选择文件夹", + "No folders available": "没有可用的文件夹", + "Updating comics...": "更新漫画中...", + "Automatic update checking enabled." : "已启用自动更新检查", + "The app will check for updates at most once a day." : "APP将每天最多检查一次更新", + "Change Folder": "更改文件夹", + "Check Now": "立即检查", + "Updates": "更新", + "No updates found": "未找到更新", + "All Comics": "全部漫画", + "The comic will be marked as no updates as soon as you read it.": "漫画将在您阅读后立即标记为无更新", + "Disable": "禁用" }, "zh_TW": { "Home": "首頁", @@ -679,6 +694,21 @@ "Descending": "降序", "Last Reading: Chapter @ep Page @page": "上次閱讀: 第 @ep 章 第 @page 頁", "Last Reading: Page @page": "上次閱讀: 第 @page 頁", - "Replies": "回覆" + "Replies": "回覆", + "Follow Updates": "追更", + "Not Configured": "未配置", + "Choose a folder to follow updates." : "選擇一個文件夾以追更", + "Choose Folder": "選擇文件夾", + "No folders available": "沒有可用的文件夾", + "Updating comics...": "更新漫畫中...", + "Automatic update checking enabled." : "已啟用自動更新檢查", + "The app will check for updates at most once a day." : "APP將每天最多檢查一次更新", + "Change Folder": "更改文件夾", + "Check Now": "立即檢查", + "Updates": "更新", + "No updates found": "未找到更新", + "All Comics": "全部漫畫", + "The comic will be marked as no updates as soon as you read it.": "漫畫將在您閱讀後立即標記為無更新", + "Disable": "禁用" } } \ No newline at end of file diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index 853bf1c..e6c1dcc 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -159,6 +159,7 @@ class _Settings with ChangeNotifier { 'autoAddLanguageFilter': 'none', // none, chinese, english, japanese 'comicSourceListUrl': "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json", 'preloadImageCount': 4, + 'followUpdatesFolder': null, }; operator [](String key) { diff --git a/lib/foundation/comic_source/models.dart b/lib/foundation/comic_source/models.dart index 67d5537..8e39b46 100644 --- a/lib/foundation/comic_source/models.dart +++ b/lib/foundation/comic_source/models.dart @@ -295,6 +295,41 @@ class ComicDetails with HistoryMixin { } return null; } + + String? _validateUpdateTime(String time) { + time = time.split(" ").first; + var segments = time.split("-"); + if (segments.length != 3) return null; + var year = int.tryParse(segments[0]); + var month = int.tryParse(segments[1]); + var day = int.tryParse(segments[2]); + if (year == null || month == null || day == null) return null; + if (year < 2000 || year > 3000) return null; + if (month < 1 || month > 12) return null; + if (day < 1 || day > 31) return null; + return "$year-$month-$day"; + } + + String? findUpdateTime() { + if (updateTime != null) { + return _validateUpdateTime(updateTime!); + } + const acceptedNamespaces = [ + "更新", + "最後更新", + "最后更新", + "update", + "last update", + ]; + for (var entry in tags.entries) { + if (acceptedNamespaces.contains(entry.key.toLowerCase()) && + entry.value.isNotEmpty) { + var value = entry.value.first; + return _validateUpdateTime(value); + } + } + return null; + } } class ArchiveInfo { diff --git a/lib/foundation/favorites.dart b/lib/foundation/favorites.dart index 48c76d8..a1f05ed 100644 --- a/lib/foundation/favorites.dart +++ b/lib/foundation/favorites.dart @@ -6,6 +6,7 @@ import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/image_provider/local_favorite_image.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; +import 'package:venera/pages/follow_updates_page.dart'; import 'package:venera/utils/tags_translation.dart'; import 'dart:io'; @@ -154,6 +155,38 @@ class FavoriteItemWithFolderInfo extends FavoriteItem { ); } +class FavoriteItemWithUpdateInfo extends FavoriteItem { + String? updateTime; + + DateTime? lastCheckTime; + + bool hasNewUpdate; + + FavoriteItemWithUpdateInfo( + FavoriteItem item, + this.updateTime, + this.hasNewUpdate, + int? lastCheckTime, + ) : lastCheckTime = lastCheckTime == null + ? null + : DateTime.fromMillisecondsSinceEpoch(lastCheckTime), + super( + id: item.id, + name: item.name, + coverPath: item.coverPath, + author: item.author, + type: item.type, + tags: item.tags, + ); + + @override + String get description { + var updateTime = this.updateTime ?? "Unknown"; + var sourceName = type.comicSource?.name ?? "Unknown"; + return "$updateTime | $sourceName"; + } +} + class LocalFavoritesManager with ChangeNotifier { factory LocalFavoritesManager() => cache ?? (cache = LocalFavoritesManager._create()); @@ -599,6 +632,7 @@ class LocalFavoritesManager with ChangeNotifier { return; } _modifiedAfterLastCache = true; + var followUpdatesFolder = appdata.settings['followUpdatesFolder']; for (final folder in folderNames) { var rows = _db.select(""" select * from "$folder" @@ -627,9 +661,13 @@ class LocalFavoritesManager with ChangeNotifier { UPDATE "$folder" SET $updateLocationSql + ${followUpdatesFolder == folder ? "has_new_update = 0," : ""} time = ? - WHERE id == ?; - """, [newTime, id]); + WHERE id == ? and type == ?; + """, [newTime, id, type.value]); + if (followUpdatesFolder == folder) { + updateFollowUpdatesUI(); + } } } notifyListeners(); @@ -783,6 +821,114 @@ class LocalFavoritesManager with ChangeNotifier { } } + void prepareTableForFollowUpdates(String table) { + // check if the table has the column "last_update_time" "has_new_update" "last_check_time" + var columns = _db.select(""" + pragma table_info("$table"); + """); + if (!columns.any((element) => element["name"] == "last_update_time")) { + _db.execute(""" + alter table "$table" + add column last_update_time TEXT; + """); + } + if (!columns.any((element) => element["name"] == "has_new_update")) { + _db.execute(""" + alter table "$table" + add column has_new_update int; + """); + } + _db.execute(""" + update "$table" + set has_new_update = 0; + """); + if (!columns.any((element) => element["name"] == "last_check_time")) { + _db.execute(""" + alter table "$table" + add column last_check_time int; + """); + } + } + + void updateUpdateTime( + String folder, + String id, + ComicType type, + String updateTime, + ) { + var oldTime = _db.select(""" + select last_update_time from "$folder" + where id == ? and type == ?; + """, [id, type.value]).first['last_update_time']; + var hasNewUpdate = oldTime != updateTime; + _db.execute(""" + update "$folder" + set last_update_time = ?, has_new_update = ?, last_check_time = ? + where id == ? and type == ?; + """, [ + updateTime, + hasNewUpdate ? 1 : 0, + DateTime.now().millisecondsSinceEpoch, + id, + type.value, + ]); + } + + int countUpdates(String folder) { + return _db.select(""" + select count(*) as c from "$folder" + where has_new_update == 1; + """).first['c']; + } + + List getUpdates(String folder) { + if (!existsFolder(folder)) { + return []; + } + var res = _db.select(""" + select * from "$folder" + where has_new_update == 1; + """); + return res + .map( + (e) => FavoriteItemWithUpdateInfo( + FavoriteItem.fromRow(e), + e['last_update_time'], + e['has_new_update'] == 1, + e['last_check_time'], + ), + ) + .toList(); + } + + List getComicsWithUpdatesInfo(String folder) { + if (!existsFolder(folder)) { + return []; + } + var res = _db.select(""" + select * from "$folder"; + """); + return res + .map( + (e) => FavoriteItemWithUpdateInfo( + FavoriteItem.fromRow(e), + e['last_update_time'], + e['has_new_update'] == 1, + e['last_check_time'], + ), + ) + .toList(); + } + + void markAsRead(String folder, String id, ComicType type) { + var folder = appdata.settings['followUpdatesFolder']; + _db.execute(""" + update "$folder" + set has_new_update = 0 + where id == ? and type == ?; + """, [id, type.value]); + } + void close() { _db.dispose(); } diff --git a/lib/init.dart b/lib/init.dart index 11c4bc3..972d857 100644 --- a/lib/init.dart +++ b/lib/init.dart @@ -10,6 +10,9 @@ import 'package:venera/foundation/js_engine.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/network/cookie_jar.dart'; +import 'package:venera/pages/comic_source_page.dart'; +import 'package:venera/pages/follow_updates_page.dart'; +import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/utils/app_links.dart'; import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/translations.dart'; @@ -55,3 +58,23 @@ Future init() async { Log.error("Unhandled Exception", "${details.exception}\n${details.stack}"); }; } + +Future _checkAppUpdates() async { + var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0; + var now = DateTime.now().millisecondsSinceEpoch; + if (now - lastCheck < 24 * 60 * 60 * 1000) { + return; + } + appdata.implicitData['lastCheckUpdate'] = now; + appdata.writeImplicitData(); + ComicSourcePage.checkComicSourceUpdate(); + if (appdata.settings['checkUpdateOnStart']) { + await Future.delayed(const Duration(milliseconds: 300)); + await checkUpdateUi(false); + } +} + +void checkUpdates() { + _checkAppUpdates(); + FollowUpdatesService.initChecker(); +} diff --git a/lib/main.dart b/lib/main.dart index b823639..efc2298 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -62,6 +62,7 @@ class _MyAppState extends State with WidgetsBindingObserver { App.registerForceRebuild(forceRebuild); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); WidgetsBinding.instance.addObserver(this); + checkUpdates(); super.initState(); } diff --git a/lib/pages/follow_updates_page.dart b/lib/pages/follow_updates_page.dart new file mode 100644 index 0000000..45fa8a5 --- /dev/null +++ b/lib/pages/follow_updates_page.dart @@ -0,0 +1,665 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:venera/components/components.dart'; +import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/appdata.dart'; +import 'package:venera/foundation/favorites.dart'; +import 'package:venera/foundation/log.dart'; +import 'package:venera/utils/translations.dart'; +import '../foundation/global_state.dart'; + +class FollowUpdatesWidget extends StatefulWidget { + const FollowUpdatesWidget({super.key}); + + @override + State createState() => _FollowUpdatesWidgetState(); +} + +class _FollowUpdatesWidgetState + extends AutomaticGlobalState { + int _count = 0; + + String? get folder => appdata.settings["followUpdatesFolder"]; + + void getCount() { + if (folder == null) { + _count = 0; + return; + } + if (!LocalFavoritesManager().folderNames.contains(folder)) { + _count = 0; + appdata.settings["followUpdatesFolder"] = null; + Future.microtask(() { + appdata.saveData(); + }); + } else { + _count = LocalFavoritesManager().countUpdates(folder!); + } + } + + void updateCount() { + setState(() { + getCount(); + }); + } + + @override + void initState() { + super.initState(); + getCount(); + } + + @override + Widget build(BuildContext context) { + 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(() => FollowUpdatesPage()); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 56, + child: Row( + children: [ + Center( + child: Text('Follow Updates'.tl, style: ts.s18), + ), + const Spacer(), + const Icon(Icons.arrow_right), + ], + ), + ).paddingHorizontal(16), + if (_count > 0) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 2), + margin: const EdgeInsets.only(bottom: 16, left: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.primaryContainer, + ), + child: Text( + '@c updates'.tlParams({ + 'c': _count, + }), + style: ts.s16, + ), + ), + ], + ), + ), + ), + ); + } + + @override + Object? get key => 'FollowUpdatesWidget'; +} + +class FollowUpdatesPage extends StatefulWidget { + const FollowUpdatesPage({super.key}); + + @override + State createState() => _FollowUpdatesPageState(); +} + +class _FollowUpdatesPageState extends AutomaticGlobalState { + String? get folder => appdata.settings["followUpdatesFolder"]; + + var updatedComics = []; + var allComics = []; + + /// Sort comics by update time in descending order with nulls at the end. + void sortComics() { + allComics.sort((a, b) { + if (a.updateTime == null && b.updateTime == null) { + return 0; + } else if (a.updateTime == null) { + return -1; + } else if (b.updateTime == null) { + return 1; + } + return b.updateTime!.compareTo(a.updateTime!); + }); + } + + @override + void initState() { + super.initState(); + if (folder != null) { + allComics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder!); + sortComics(); + updatedComics = allComics.where((c) => c.hasNewUpdate).toList(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SmoothCustomScrollView( + slivers: [ + SliverAppbar(title: Text('Follow Updates'.tl)), + if (folder == null) + buildNotConfigured(context) + else + buildConfigured(context), + SliverPadding(padding: const EdgeInsets.only(top: 8)), + buildUpdatedComics(), + buildAllComics(), + ], + ), + ); + } + + Widget buildNotConfigured(BuildContext context) { + 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: Icon(Icons.info_outline), + title: Text("Not Configured".tl), + ), + Text( + "Choose a folder to follow updates.".tl, + style: ts.s16, + ).paddingHorizontal(16), + const SizedBox(height: 8), + FilledButton.tonal( + onPressed: showSelector, + child: Text("Choose Folder".tl), + ).paddingHorizontal(16).toAlign(Alignment.centerRight), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget buildConfigured(BuildContext context) { + 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: Icon(Icons.stars_outlined), + title: Text(folder!), + ), + Text( + "Automatic update checking enabled.".tl, + style: ts.s14, + ).paddingHorizontal(16), + Text( + "The app will check for updates at most once a day.".tl, + style: ts.s14, + ).paddingHorizontal(16), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: showSelector, + child: Text("Change Folder".tl), + ), + FilledButton.tonal( + onPressed: checkNow, + child: Text("Check Now".tl), + ), + const SizedBox(width: 16), + ], + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Widget buildUpdatedComics() { + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, + ), + ), + ), + child: Row( + children: [ + Icon(Icons.update), + const SizedBox(width: 8), + Text( + "Updates".tl, + style: ts.s18, + ), + ], + ), + ), + ), + if (updatedComics.isNotEmpty) + SliverToBoxAdapter( + child: Text( + "The comic will be marked as no updates as soon as you read it." + .tl) + .paddingHorizontal(16) + .paddingVertical(4), + ), + if (updatedComics.isNotEmpty) + SliverGridComics(comics: updatedComics) + else + SliverToBoxAdapter( + child: Row( + children: [ + Container( + margin: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "No updates found".tl, + style: ts.s16, + ), + ], + ), + ) + ], + ), + ), + ], + ); + } + + Widget buildAllComics() { + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + width: 0.6, + ), + ), + ), + child: Row( + children: [ + Icon(Icons.list), + const SizedBox(width: 8), + Text( + "All Comics".tl, + style: ts.s18, + ), + ], + ), + ), + ), + SliverGridComics(comics: allComics), + ], + ); + } + + void showSelector() { + var folders = LocalFavoritesManager().folderNames; + if (folders.isEmpty) { + context.showMessage(message: "No folders available".tl); + return; + } + String? selectedFolder; + showDialog( + context: App.rootContext, + builder: (context) { + return StatefulBuilder(builder: (context, setState) { + return ContentDialog( + title: "Choose Folder".tl, + content: Column( + children: [ + ListTile( + title: Text("Folder".tl), + trailing: Select( + minWidth: 120, + current: selectedFolder, + values: folders, + onTap: (i) { + setState(() { + selectedFolder = folders[i]; + }); + }, + ), + ), + ], + ), + actions: [ + if (appdata.settings["followUpdatesFolder"] != null) + TextButton( + onPressed: () { + disable(); + context.pop(); + }, + child: Text("Disable".tl), + ), + FilledButton( + onPressed: selectedFolder == null + ? null + : () { + context.pop(); + setFolder(selectedFolder!); + }, + child: Text("Confirm".tl), + ), + ], + ); + }); + }, + ); + } + + void disable() { + appdata.settings["followUpdatesFolder"] = null; + appdata.saveData(); + updateFollowUpdatesUI(); + } + + void setFolder(String folder) async { + FollowUpdatesService.cancelChecking?.call(); + LocalFavoritesManager().prepareTableForFollowUpdates(folder); + + var count = LocalFavoritesManager().count(folder); + + if (count > 0) { + bool isCanceled = false; + void onCancel() { + isCanceled = true; + } + + var loadingController = showLoadingDialog( + App.rootContext, + withProgress: true, + cancelButtonText: "Cancel".tl, + onCancel: onCancel, + message: "Updating comics...".tl, + ); + + await for (var progress in _updateFolder(folder, true)) { + if (isCanceled) { + return; + } + loadingController.setProgress(progress.current / progress.total); + } + + loadingController.close(); + } + + setState(() { + appdata.settings["followUpdatesFolder"] = folder; + updatedComics = []; + allComics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder); + sortComics(); + }); + appdata.saveData(); + } + + void checkNow() async { + FollowUpdatesService.cancelChecking?.call(); + + bool isCanceled = false; + void onCancel() { + isCanceled = true; + } + + var loadingController = showLoadingDialog( + App.rootContext, + withProgress: true, + cancelButtonText: "Cancel".tl, + onCancel: onCancel, + message: "Updating comics...".tl, + ); + + int updated = 0; + + await for (var progress in _updateFolder(folder!, true)) { + if (isCanceled) { + return; + } + loadingController.setProgress(progress.current / progress.total); + updated = progress.updated; + } + + loadingController.close(); + + if (updated > 0) { + GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount(); + updateComics(); + } + } + + void updateComics() { + if (folder == null) { + setState(() { + allComics = []; + updatedComics = []; + }); + return; + } + setState(() { + allComics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder!); + sortComics(); + updatedComics = allComics.where((c) => c.hasNewUpdate).toList(); + }); + } + + @override + Object? get key => 'FollowUpdatesPage'; +} + +class _UpdateProgress { + final int total; + final int current; + final int errors; + final int updated; + + _UpdateProgress(this.total, this.current, this.errors, this.updated); +} + +void _updateFolderBase( + String folder, + StreamController<_UpdateProgress> stream, + bool ignoreCheckTime, +) async { + var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder); + int current = 0; + int errors = 0; + int updated = 0; + var futures = []; + const maxConcurrent = 5; + + for (int i = 0; i < comics.length; i++) { + if (stream.isClosed) { + return; + } + if (!ignoreCheckTime) { + var lastCheckTime = comics[i].lastCheckTime; + if (lastCheckTime != null && + DateTime.now().difference(lastCheckTime).inDays < 1) { + current++; + stream.add(_UpdateProgress(comics.length, current, errors, updated)); + continue; + } + } + + if (futures.length >= maxConcurrent) { + await Future.any(futures); + } + + var future = () async { + int retries = 3; + while (true) { + try { + var c = comics[i]; + var comicSource = c.type.comicSource; + if (comicSource == null) return; + var newInfo = (await comicSource.loadComicInfo!(c.id)).data; + + var newTags = []; + 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) { + newTags.add("$namespace:$tag"); + } + } + + var item = FavoriteItem( + id: c.id, + name: newInfo.title, + coverPath: newInfo.cover, + author: newInfo.subTitle ?? + newInfo.tags['author']?.firstOrNull ?? + c.author, + type: c.type, + tags: newTags, + ); + + LocalFavoritesManager().updateInfo(folder, item); + + var updateTime = newInfo.findUpdateTime(); + if (updateTime != null && updateTime != c.updateTime) { + LocalFavoritesManager().updateUpdateTime( + folder, + c.id, + c.type, + updateTime, + ); + } + updated++; + return; + } catch (e, s) { + Log.error("Check Updates", e, s); + retries--; + if (retries == 0) { + errors++; + return; + } + } finally { + current++; + stream.add(_UpdateProgress(comics.length, current, errors, updated)); + } + } + }(); + + future.then((_) { + futures.remove(future); + }); + + futures.add(future); + } + + await Future.wait(futures); + + stream.close(); +} + +Stream<_UpdateProgress> _updateFolder(String folder, bool ignoreCheckTime) { + var stream = StreamController<_UpdateProgress>(); + _updateFolderBase(folder, stream, ignoreCheckTime); + return stream.stream; +} + +/// Background service for checking updates +abstract class FollowUpdatesService { + static bool isChecking = false; + + static void Function()? cancelChecking; + + static void check() async { + if (isChecking) { + return; + } + var folder = appdata.settings["followUpdatesFolder"]; + if (folder == null) { + return; + } + bool isCanceled = false; + cancelChecking = () { + isCanceled = true; + }; + + isChecking = true; + int updated = 0; + try { + await for (var progress in _updateFolder(folder, false)) { + if (isCanceled) { + return; + } + updated = progress.updated; + } + } finally { + cancelChecking = null; + isChecking = false; + if (updated > 0) { + updateFollowUpdatesUI(); + } + } + } + + static void initChecker() { + Timer.periodic(const Duration(hours: 1), (timer) { + check(); + }); + } +} + +void updateFollowUpdatesUI() { + GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount(); + GlobalState.findOrNull<_FollowUpdatesPageState>()?.updateComics(); +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index a193a41..888438b 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -12,6 +12,7 @@ import 'package:venera/foundation/log.dart'; import 'package:venera/pages/comic_details_page/comic_page.dart'; import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/downloading_page.dart'; +import 'package:venera/pages/follow_updates_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'; @@ -34,6 +35,7 @@ class HomePage extends StatelessWidget { const _SyncDataWidget(), const _History(), const _Local(), + const FollowUpdatesWidget(), const _ComicSourceWidget(), const ImageFavorites(), SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)), diff --git a/lib/pages/main_page.dart b/lib/pages/main_page.dart index aa95c35..5ae04c5 100644 --- a/lib/pages/main_page.dart +++ b/lib/pages/main_page.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:venera/foundation/appdata.dart'; import 'package:venera/pages/categories_page.dart'; import 'package:venera/pages/search_page.dart'; import 'package:venera/pages/settings/settings_page.dart'; @@ -7,7 +6,6 @@ import 'package:venera/utils/translations.dart'; import '../components/components.dart'; import '../foundation/app.dart'; -import 'comic_source_page.dart'; import 'explore_page.dart'; import 'favorites/favorites_page.dart'; import 'home_page.dart'; @@ -36,24 +34,8 @@ class _MainPageState extends State { _navigatorKey!.currentContext!.pop(); } - void checkUpdates() async { - var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0; - var now = DateTime.now().millisecondsSinceEpoch; - if (now - lastCheck < 24 * 60 * 60 * 1000) { - return; - } - appdata.implicitData['lastCheckUpdate'] = now; - appdata.writeImplicitData(); - ComicSourcePage.checkComicSourceUpdate(); - if (appdata.settings['checkUpdateOnStart']) { - await Future.delayed(const Duration(milliseconds: 300)); - await checkUpdateUi(false); - } - } - @override void initState() { - checkUpdates(); _observer = NaviObserver(); _navigatorKey = GlobalKey(); App.mainNavigatorKey = _navigatorKey;