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/data_sync.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; } try { var aNums = a.updateTime!.split('-').map(int.parse).toList(); var bNums = b.updateTime!.split('-').map(int.parse).toList(); for (int i = 0; i < aNums.length; i++) { if (aNums[i] != bNums[i]) { return bNums[i] - aNums[i]; } } return 0; } catch (_) { return 0; } }); } @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, ), const Spacer(), if (updatedComics.isNotEmpty) IconButton( icon: Icon(Icons.clear_all), onPressed: () { showConfirmDialog( context: App.rootContext, title: "Mark all as read".tl, content: "Do you want to mark all as read?".tl, onConfirm: () { for (var comic in updatedComics) { LocalFavoritesManager().markAsRead( comic.id, comic.type, ); } updateFollowUpdatesUI(); }, ); }, ), ], ), ), ), 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 bool _isInitialized = false; static void _check() async { if (_isChecking) { return; } var folder = appdata.settings["followUpdatesFolder"]; if (folder == null) { return; } bool isCanceled = false; _cancelChecking = () { isCanceled = true; }; _isChecking = true; while (DataSync().isDownloading) { await Future.delayed(const Duration(milliseconds: 100)); } 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(); } } } /// Initialize the checker. static void initChecker() { if (_isInitialized) return; _isInitialized = true; _check(); // A short interval will not affect the performance since every comic has a check time. Timer.periodic(const Duration(minutes: 5), (timer) { _check(); }); } } /// Update the UI of follow updates. void updateFollowUpdatesUI() { GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount(); GlobalState.findOrNull<_FollowUpdatesPageState>()?.updateComics(); }