diff --git a/assets/translation.json b/assets/translation.json index 1e6361f..fa5b53d 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -332,7 +332,28 @@ "Created successfully": "创建成功", "name": "名称", "Reverse tap to turn Pages": "反转点击翻页", - "Show all": "显示全部" + "Show all": "显示全部", + "Number of images preloaded": "预加载图片数量", + "Ascending": "升序", + "Descending": "降序", + "Last Reading: Chapter @ep Page @page": "上次阅读: 第 @ep 章 第 @page 页", + "Last Reading: Page @page": "上次阅读: 第 @page 页", + "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": "首頁", @@ -667,6 +688,27 @@ "Created successfully": "創建成功", "name": "名稱", "Reverse tap to turn Pages": "反轉點擊翻頁", - "Show all": "顯示全部" + "Show all": "顯示全部", + "Number of images preloaded": "預加載圖片數量", + "Ascending": "升序", + "Descending": "降序", + "Last Reading: Chapter @ep Page @page": "上次閱讀: 第 @ep 章 第 @page 頁", + "Last Reading: Page @page": "上次閱讀: 第 @page 頁", + "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/components/appbar.dart b/lib/components/appbar.dart index c0112e2..c29d696 100644 --- a/lib/components/appbar.dart +++ b/lib/components/appbar.dart @@ -274,6 +274,7 @@ class AppTabBar extends StatefulWidget { this.controller, required this.tabs, this.actionButton, + this.withUnderLine = true, }); final TabController? controller; @@ -282,6 +283,8 @@ class AppTabBar extends StatefulWidget { final Widget? actionButton; + final bool withUnderLine; + @override State createState() => _AppTabBarState(); } @@ -396,14 +399,16 @@ class _AppTabBarState extends State { key: tabBarKey, height: _kTabHeight, width: double.infinity, - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: context.colorScheme.outlineVariant, - width: 0.6, - ), - ), - ), + decoration: widget.withUnderLine + ? BoxDecoration( + border: Border( + bottom: BorderSide( + color: context.colorScheme.outlineVariant, + width: 0.6, + ), + ), + ) + : null, child: widget.tabs.isEmpty ? const SizedBox() : child, ); } diff --git a/lib/components/comic.dart b/lib/components/comic.dart index 28c4a23..aaad1ab 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -23,14 +23,16 @@ ImageProvider? _findImageProvider(Comic comic) { } class ComicTile extends StatelessWidget { - const ComicTile( - {super.key, - required this.comic, - this.enableLongPressed = true, - this.badge, - this.menuOptions, - this.onTap, - this.onLongPressed}); + const ComicTile({ + super.key, + required this.comic, + this.enableLongPressed = true, + this.badge, + this.menuOptions, + this.onTap, + this.onLongPressed, + this.heroID, + }); final Comic comic; @@ -44,6 +46,8 @@ class ComicTile extends StatelessWidget { final VoidCallback? onLongPressed; + final int? heroID; + void _onTap() { if (onTap != null) { onTap!(); @@ -55,6 +59,7 @@ class ComicTile extends StatelessWidget { sourceKey: comic.sourceKey, cover: comic.cover, title: comic.title, + heroID: heroID, ), ); } @@ -137,8 +142,7 @@ class ComicTile extends StatelessWidget { .isExist(comic.id, ComicType(comic.sourceKey.hashCode)) : false; var history = appdata.settings['showHistoryStatusOnTile'] - ? HistoryManager() - .findSync(comic.id, ComicType(comic.sourceKey.hashCode)) + ? HistoryManager().find(comic.id, ComicType(comic.sourceKey.hashCode)) : null; if (history?.page == 0) { history!.page = 1; @@ -210,63 +214,94 @@ class ComicTile extends StatelessWidget { Widget _buildDetailedMode(BuildContext context) { return LayoutBuilder(builder: (context, constrains) { final height = constrains.maxHeight - 16; - return InkWell( - borderRadius: BorderRadius.circular(12), - onTap: _onTap, - onLongPress: enableLongPressed ? () => _onLongPressed(context) : null, - onSecondaryTapDown: (detail) => onSecondaryTap(detail, context), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 24, 8), - child: Row( - children: [ - Hero( - tag: "cover${comic.id}${comic.sourceKey}", - child: Container( - width: height * 0.68, - height: double.infinity, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: context.colorScheme.outlineVariant, - blurRadius: 1, - offset: const Offset(0, 1), - ), - ], - ), - clipBehavior: Clip.antiAlias, - child: buildImage(context), - ), - ), - SizedBox.fromSize( - size: const Size(16, 5), - ), - Expanded( - child: _ComicDescription( - title: comic.maxPage == null - ? comic.title.replaceAll("\n", "") - : "[${comic.maxPage}P]${comic.title.replaceAll("\n", "")}", - subtitle: comic.subtitle ?? '', - description: comic.description, - badge: badge ?? comic.language, - tags: comic.tags, - maxLines: 2, - enableTranslate: ComicSource.find(comic.sourceKey) - ?.enableTagsTranslate ?? - false, - rating: comic.stars, - ), - ), - ], + + Widget image = Container( + width: height * 0.68, + height: double.infinity, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: context.colorScheme.outlineVariant, + blurRadius: 1, + offset: const Offset(0, 1), ), - )); + ], + ), + clipBehavior: Clip.antiAlias, + child: buildImage(context), + ); + + if (heroID != null) { + image = Hero( + tag: "cover$heroID", + child: image, + ); + } + + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: _onTap, + onLongPress: enableLongPressed ? () => _onLongPressed(context) : null, + onSecondaryTapDown: (detail) => onSecondaryTap(detail, context), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 24, 8), + child: Row( + children: [ + image, + SizedBox.fromSize( + size: const Size(16, 5), + ), + Expanded( + child: _ComicDescription( + title: comic.maxPage == null + ? comic.title.replaceAll("\n", "") + : "[${comic.maxPage}P]${comic.title.replaceAll("\n", "")}", + subtitle: comic.subtitle ?? '', + description: comic.description, + badge: badge ?? comic.language, + tags: comic.tags, + maxLines: 2, + enableTranslate: + ComicSource.find(comic.sourceKey)?.enableTagsTranslate ?? + false, + rating: comic.stars, + ), + ), + ], + ), + ), + ); }); } Widget _buildBriefMode(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { + Widget image = Container( + decoration: BoxDecoration( + color: context.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.toOpacity(0.2), + blurRadius: 2, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: buildImage(context), + ); + + if (heroID != null) { + image = Hero( + tag: "cover$heroID", + child: image, + ); + } + return InkWell( borderRadius: BorderRadius.circular(8), onTap: _onTap, @@ -278,24 +313,7 @@ class ComicTile extends StatelessWidget { child: Stack( children: [ Positioned.fill( - child: Hero( - tag: "cover${comic.id}${comic.sourceKey}", - child: Container( - decoration: BoxDecoration( - color: context.colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.toOpacity(0.2), - blurRadius: 2, - offset: const Offset(0, 2), - ), - ], - ), - clipBehavior: Clip.antiAlias, - child: buildImage(context), - ), - ), + child: image, ), Align( alignment: Alignment.bottomRight, @@ -445,7 +463,9 @@ class ComicTile extends StatelessWidget { children: [ for (var word in all) OptionChip( - text: word, + text: (comic.tags?.contains(word) ?? false) + ? word.translateTagIfNeed + : word, isSelected: words.contains(word), onTap: () { setState(() { @@ -538,10 +558,8 @@ class _ComicDescription extends StatelessWidget { softWrap: true, overflow: TextOverflow.ellipsis, ), - const SizedBox( - height: 4, - ), - if (tags != null) + const SizedBox(height: 4), + if (tags != null && tags!.isNotEmpty) Expanded( child: LayoutBuilder(builder: (context, constraints) { if (constraints.maxHeight < 22) { @@ -606,7 +624,7 @@ class _ComicDescription extends StatelessWidget { style: const TextStyle( fontSize: 12.0, ), - maxLines: 1, + maxLines: (tags == null || tags!.isEmpty) ? 3 : 2, overflow: TextOverflow.ellipsis, ), ], @@ -739,16 +757,27 @@ class SliverGridComics extends StatefulWidget { class _SliverGridComicsState extends State { List comics = []; + List heroIDs = []; + + static int _nextHeroID = 0; + + void generateHeroID() { + heroIDs.clear(); + for (var i = 0; i < comics.length; i++) { + heroIDs.add(_nextHeroID++); + } + } @override void didUpdateWidget(covariant SliverGridComics oldWidget) { - if (oldWidget.comics.isEqualTo(widget.comics)) { + if (!oldWidget.comics.isEqualTo(widget.comics)) { comics.clear(); for (var comic in widget.comics) { if (isBlocked(comic) == null) { comics.add(comic); } } + generateHeroID(); } super.didUpdateWidget(oldWidget); } @@ -760,6 +789,7 @@ class _SliverGridComicsState extends State { comics.add(comic); } } + generateHeroID(); HistoryManager().addListener(update); super.initState(); } @@ -785,6 +815,7 @@ class _SliverGridComicsState extends State { Widget build(BuildContext context) { return _SliverGridComics( comics: comics, + heroIDs: heroIDs, selection: widget.selections, onLastItemBuild: widget.onLastItemBuild, badgeBuilder: widget.badgeBuilder, @@ -798,6 +829,7 @@ class _SliverGridComicsState extends State { class _SliverGridComics extends StatelessWidget { const _SliverGridComics({ required this.comics, + required this.heroIDs, this.onLastItemBuild, this.badgeBuilder, this.menuBuilder, @@ -808,6 +840,8 @@ class _SliverGridComics extends StatelessWidget { final List comics; + final List heroIDs; + final Map? selection; final void Function()? onLastItemBuild; @@ -839,6 +873,7 @@ class _SliverGridComics extends StatelessWidget { onLongPressed: onLongPressed != null ? () => onLongPressed!(comics[index]) : null, + heroID: heroIDs[index], ); if (selection == null) { return comic; diff --git a/lib/components/components.dart b/lib/components/components.dart index cef1056..200c4d5 100644 --- a/lib/components/components.dart +++ b/lib/components/components.dart @@ -23,7 +23,7 @@ import 'package:venera/foundation/image_provider/local_comic_image.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/network/cloudflare.dart'; -import 'package:venera/pages/comic_page.dart'; +import 'package:venera/pages/comic_details_page/comic_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/tags_translation.dart'; diff --git a/lib/components/window_frame.dart b/lib/components/window_frame.dart index 5fd22ab..bc57a99 100644 --- a/lib/components/window_frame.dart +++ b/lib/components/window_frame.dart @@ -6,166 +6,99 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; -import 'package:venera/foundation/state_controller.dart'; import 'package:window_manager/window_manager.dart'; const _kTitleBarHeight = 36.0; -class WindowFrameController extends StateController { - bool useDarkTheme = false; - - bool isHideWindowFrame = false; - - void setDarkTheme() { - useDarkTheme = true; - update(); - } - - void resetTheme() { - useDarkTheme = false; - update(); - } - - VoidCallback openSideBar = () {}; - - void hideWindowFrame() { - isHideWindowFrame = true; - update(); - } - - void showWindowFrame() { - isHideWindowFrame = false; - update(); - } -} - -class WindowFrame extends StatelessWidget { +class WindowFrame extends StatefulWidget { const WindowFrame(this.child, {super.key}); final Widget child; @override - Widget build(BuildContext context) { - StateController.putIfNotExists( - WindowFrameController()); - if (App.isMobile) return child; - return StateBuilder(builder: (controller) { - if (controller.isHideWindowFrame) return child; - - var body = Stack( - children: [ - Positioned.fill( - child: MediaQuery( - data: MediaQuery.of(context).copyWith( - padding: const EdgeInsets.only(top: _kTitleBarHeight)), - child: child, - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: Material( - color: Colors.transparent, - child: Theme( - data: Theme.of(context).copyWith( - brightness: controller.useDarkTheme ? Brightness.dark : null, - ), - child: Builder(builder: (context) { - return SizedBox( - height: _kTitleBarHeight, - child: Row( - children: [ - if (App.isMacOS) - const DragToMoveArea( - child: SizedBox( - height: double.infinity, - width: 16, - ), - ).paddingRight(52) - else - const SizedBox(width: 12), - Expanded( - child: DragToMoveArea( - child: Text( - 'Venera', - style: TextStyle( - fontSize: 13, - color: (controller.useDarkTheme || - context.brightness == Brightness.dark) - ? Colors.white - : Colors.black, - ), - ).toAlign(Alignment.centerLeft).paddingLeft(4+(App.isMacOS?25:0)), - ), - ), - if (kDebugMode) - const TextButton( - onPressed: debug, - child: Text('Debug'), - ), - if (!App.isMacOS) const WindowButtons() - ], - ), - ); - }), - ), - ), - ) - ], - ); - - if (App.isLinux) { - return VirtualWindowFrame(child: body); - } else { - return body; - } - }); - } - - Widget buildMenuButton( - WindowFrameController controller, BuildContext context) { - return InkWell( - onTap: () { - controller.openSideBar(); - }, - child: SizedBox( - width: 42, - height: double.infinity, - child: Center( - child: CustomPaint( - size: const Size(18, 20), - painter: _MenuPainter( - color: (controller.useDarkTheme || - Theme.of(context).brightness == Brightness.dark) - ? Colors.white - : Colors.black), - ), - ), - )); - } + State createState() => _WindowFrameState(); } -class _MenuPainter extends CustomPainter { - final Color color; - - _MenuPainter({this.color = Colors.black}); +class _WindowFrameState extends State { + bool isHideWindowFrame = false; + bool useDarkTheme = false; @override - void paint(Canvas canvas, Size size) { - final paint = getPaint(color); - final path = Path() - ..moveTo(0, size.height / 4) - ..lineTo(size.width, size.height / 4) - ..moveTo(0, size.height / 4 * 2) - ..lineTo(size.width, size.height / 4 * 2) - ..moveTo(0, size.height / 4 * 3) - ..lineTo(size.width, size.height / 4 * 3); - canvas.drawPath(path, paint); + Widget build(BuildContext context) { + if (App.isMobile) return widget.child; + if (isHideWindowFrame) return widget.child; + + var body = Stack( + children: [ + Positioned.fill( + child: MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: const EdgeInsets.only(top: _kTitleBarHeight)), + child: widget.child, + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Material( + color: Colors.transparent, + child: Theme( + data: Theme.of(context).copyWith( + brightness: useDarkTheme ? Brightness.dark : null, + ), + child: Builder(builder: (context) { + return SizedBox( + height: _kTitleBarHeight, + child: Row( + children: [ + if (App.isMacOS) + const DragToMoveArea( + child: SizedBox( + height: double.infinity, + width: 16, + ), + ).paddingRight(52) + else + const SizedBox(width: 12), + Expanded( + child: DragToMoveArea( + child: Text( + 'Venera', + style: TextStyle( + fontSize: 13, + color: (useDarkTheme || + context.brightness == Brightness.dark) + ? Colors.white + : Colors.black, + ), + ) + .toAlign(Alignment.centerLeft) + .paddingLeft(4 + (App.isMacOS ? 25 : 0)), + ), + ), + if (kDebugMode) + const TextButton( + onPressed: debug, + child: Text('Debug'), + ), + if (!App.isMacOS) const WindowButtons() + ], + ), + ); + }), + ), + ), + ) + ], + ); + + if (App.isLinux) { + return VirtualWindowFrame(child: body); + } else { + return body; + } } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } class WindowButtons extends StatefulWidget { @@ -489,7 +422,7 @@ class WindowPlacement { static Future get current async { var rect = await windowManager.getBounds(); - if(validate(rect)) { + if (validate(rect)) { lastValidRect = rect; } else { rect = lastValidRect ?? defaultPlacement.rect; @@ -635,4 +568,4 @@ TransitionBuilder VirtualWindowFrameInit() { void debug() { ComicSource.reload(); -} \ No newline at end of file +} diff --git a/lib/foundation/app.dart b/lib/foundation/app.dart index 1af13eb..91b6509 100644 --- a/lib/foundation/app.dart +++ b/lib/foundation/app.dart @@ -10,7 +10,7 @@ export "widget_utils.dart"; export "context.dart"; class _App { - final version = "1.2.5"; + final version = "1.3.0"; bool get isAndroid => Platform.isAndroid; diff --git a/lib/foundation/appdata.dart b/lib/foundation/appdata.dart index 485e8cb..e6c1dcc 100644 --- a/lib/foundation/appdata.dart +++ b/lib/foundation/appdata.dart @@ -158,6 +158,8 @@ class _Settings with ChangeNotifier { 'sni': true, '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 7f08c83..8e39b46 100644 --- a/lib/foundation/comic_source/models.dart +++ b/lib/foundation/comic_source/models.dart @@ -130,6 +130,11 @@ class ComicDetails with HistoryMixin { /// id-name final Map? chapters; + /// key is group name. + /// When this field is not null, [chapters] will be a merged map of all groups. + /// Only available in some sources. + final Map>? groupedChapters; + final List? thumbnails; final List? recommend; @@ -171,15 +176,45 @@ class ComicDetails with HistoryMixin { return res; } + static Map? _getChapters(dynamic chapters) { + if (chapters == null) return null; + var result = {}; + if (chapters is Map) { + for (var entry in chapters.entries) { + var value = entry.value; + if (value is Map) { + result.addAll(Map.from(value)); + } else { + result[entry.key.toString()] = value.toString(); + } + } + } + return result; + } + + static Map>? _getGroupedChapters(dynamic chapters) { + if (chapters == null) return null; + var result = >{}; + if (chapters is Map) { + for (var entry in chapters.entries) { + var value = entry.value; + if (value is Map) { + result[entry.key.toString()] = Map.from(value); + } + } + } + if (result.isEmpty) return null; + return result; + } + ComicDetails.fromJson(Map json) : title = json["title"], subTitle = json["subtitle"], cover = json["cover"], description = json["description"], tags = _generateMap(json["tags"]), - chapters = json["chapters"] == null - ? null - : Map.from(json["chapters"]), + chapters = _getChapters(json["chapters"]), + groupedChapters = _getGroupedChapters(json["chapters"]), sourceKey = json["sourceKey"], comicId = json["comicId"], thumbnails = ListOrNull.from(json["thumbnails"]), @@ -260,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..422ddad 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()); @@ -429,7 +462,8 @@ class LocalFavoritesManager with ChangeNotifier { /// add comic to a folder. /// return true if success, false if already exists - bool addComic(String folder, FavoriteItem comic, [int? order]) { + bool addComic(String folder, FavoriteItem comic, + [int? order, String? updateTime]) { _modifiedAfterLastCache = true; if (!existsFolder(folder)) { throw Exception("Folder does not exists"); @@ -468,6 +502,18 @@ class LocalFavoritesManager with ChangeNotifier { values (?, ?, ?, ?, ?, ?, ?, ?, ?); """, [...params, minValue(folder) - 1]); } + if (updateTime != null) { + var columns = _db.select(""" + pragma table_info("$folder"); + """); + if (columns.any((element) => element["name"] == "last_update_time")) { + _db.execute(""" + update "$folder" + set last_update_time = ? + where id == ? and type == ?; + """, [updateTime, comic.id, comic.type.value]); + } + } notifyListeners(); return true; } @@ -596,9 +642,11 @@ class LocalFavoritesManager with ChangeNotifier { void onRead(String id, ComicType type) async { if (appdata.settings['moveFavoriteAfterRead'] == "none") { + markAsRead(id, type); return; } _modifiedAfterLastCache = true; + var followUpdatesFolder = appdata.settings['followUpdatesFolder']; for (final folder in folderNames) { var rows = _db.select(""" select * from "$folder" @@ -627,9 +675,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 +835,117 @@ 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 id, ComicType type) { + var folder = appdata.settings['followUpdatesFolder']; + if (!existsFolder(folder)) { + return; + } + _db.execute(""" + update "$folder" + set has_new_update = 0 + where id == ? and type == ?; + """, [id, type.value]); + } + void close() { _db.dispose(); } diff --git a/lib/foundation/global_state.dart b/lib/foundation/global_state.dart new file mode 100644 index 0000000..ff45aa8 --- /dev/null +++ b/lib/foundation/global_state.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; + +abstract class GlobalState { + static final _state = >[]; + + static void register(State state, [Object? key]) { + _state.add(Pair(key, state)); + } + + static T find([Object? key]) { + for (var pair in _state) { + if ((key == null || pair.left == key) && pair.right is T) { + return pair.right as T; + } + } + throw Exception('State not found'); + } + + static T? findOrNull([Object? key]) { + for (var pair in _state) { + if ((key == null || pair.left == key) && pair.right is T) { + return pair.right as T; + } + } + return null; + } + + static void unregister(State state, [Object? key]) { + _state.removeWhere( + (pair) => (key == null || pair.left == key) && pair.right == state); + } +} + +class Pair { + K left; + V right; + + Pair(this.left, this.right); +} + +abstract class AutomaticGlobalState + extends State { + @override + @mustCallSuper + void initState() { + super.initState(); + GlobalState.register(this, key); + } + + @override + @mustCallSuper + void dispose() { + super.dispose(); + GlobalState.unregister(this, key); + } + + Object? get key; + + void update() { + setState(() {}); + } + + void refresh() { + update(); + } +} diff --git a/lib/foundation/history.dart b/lib/foundation/history.dart index 062055f..bd21d1d 100644 --- a/lib/foundation/history.dart +++ b/lib/foundation/history.dart @@ -2,10 +2,12 @@ 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'; @@ -124,30 +126,6 @@ class History implements Comic { .map((e) => int.parse(e))), maxPage = row["max_page"]; - static Future findOrCreate( - HistoryMixin model, { - int ep = 0, - int page = 0, - }) async { - var history = await HistoryManager().find(model.id, model.historyType); - if (history != null) { - return history; - } - history = History.fromModel(model: model, ep: ep, page: page); - HistoryManager().addHistory(history); - return history; - } - - static Future createIfNull( - History? history, HistoryMixin model) async { - if (history != null) { - return history; - } - history = History.fromModel(model: model, ep: 0, page: 0); - HistoryManager().addHistory(history); - return history; - } - @override bool operator ==(Object other) { return other is History && type == other.type && id == other.id; @@ -210,7 +188,11 @@ class HistoryManager with ChangeNotifier { int get length => _db.select("select count(*) from history;").first[0] as int; - Map? _cachedHistory; + /// 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; @@ -240,14 +222,57 @@ class HistoryManager with ChangeNotifier { isInitialized = true; } + static const _insertHistorySql = """ + insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page) + 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 + ]); + }); + } + + 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. - Future addHistory(History newItem) async { - _db.execute(""" - insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?); - """, [ + void addHistory(History newItem) { + _db.execute(_insertHistorySql, [ newItem.id, newItem.title, newItem.subtitle, @@ -259,7 +284,15 @@ class HistoryManager with ChangeNotifier { newItem.readEpisode.join(','), newItem.maxPage ]); - updateCache(); + if (_cachedHistoryIds == null) { + updateCache(); + } else { + _cachedHistoryIds![newItem.id] = true; + } + cachedHistories[newItem.id] = newItem; + if (cachedHistories.length > 10) { + cachedHistories.remove(cachedHistories.keys.first); + } notifyListeners(); } @@ -278,27 +311,31 @@ class HistoryManager with ChangeNotifier { notifyListeners(); } - Future find(String id, ComicType type) async { - return findSync(id, type); - } - void updateCache() { - _cachedHistory = {}; + _cachedHistoryIds = {}; var res = _db.select(""" - select * from history; + select id from history; """); for (var element in res) { - _cachedHistory![element["id"] as String] = true; + _cachedHistoryIds![element["id"] as String] = true; + } + for (var key in cachedHistories.keys) { + if (!_cachedHistoryIds!.containsKey(key)) { + cachedHistories.remove(key); + } } } - History? findSync(String id, ComicType type) { - if (_cachedHistory == null) { + History? find(String id, ComicType type) { + if (_cachedHistoryIds == null) { updateCache(); } - if (!_cachedHistory!.containsKey(id)) { + if (!_cachedHistoryIds!.containsKey(id)) { return null; } + if (cachedHistories.containsKey(id)) { + return cachedHistories[id]; + } var res = _db.select(""" select * from history diff --git a/lib/foundation/image_favorites.dart b/lib/foundation/image_favorites.dart index 865ef9f..61a9b76 100644 --- a/lib/foundation/image_favorites.dart +++ b/lib/foundation/image_favorites.dart @@ -396,7 +396,7 @@ class ImageFavoriteManager with ChangeNotifier { var token = ServicesBinding.rootIsolateToken!; var count = ImageFavoriteManager().length; if (count == 0) { - return Future.value(ImageFavoritesComputed([], [], [])); + return Future.value(ImageFavoritesComputed([], [], [], 0)); } else if (count > 100) { return Isolate.run(() async { BackgroundIsolateBinaryMessenger.ensureInitialized(token); @@ -436,8 +436,10 @@ class ImageFavoriteManager with ChangeNotifier { Map authorCount = {}; Map comicImageCount = {}; Map comicMaxPages = {}; + int count = 0; for (var comic in comics) { + count += comic.images.length; for (var tag in comic.tags) { String finalTag = tag; tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1; @@ -492,6 +494,7 @@ class ImageFavoriteManager with ChangeNotifier { .map((comic) => TextWithCount(comic.key.title, comic.value)) .take(maxLength) .toList(), + count, ); } @@ -524,11 +527,14 @@ class ImageFavoritesComputed { /// 基于喜欢的图片数排序 final List comics; + final int count; + /// 计算后的图片收藏数据 const ImageFavoritesComputed( this.tags, this.authors, this.comics, + this.count, ); bool get isEmpty => tags.isEmpty && authors.isEmpty && comics.isEmpty; diff --git a/lib/foundation/local.dart b/lib/foundation/local.dart index 2eabae4..8cafdfc 100644 --- a/lib/foundation/local.dart +++ b/lib/foundation/local.dart @@ -105,8 +105,8 @@ class LocalComic with HistoryMixin implements Comic { @override int? get maxPage => null; - void read() async { - var history = await HistoryManager().find(id, comicType); + void read() { + var history = HistoryManager().find(id, comicType); App.rootContext.to( () => Reader( type: comicType, @@ -511,7 +511,7 @@ class LocalManager with ChangeNotifier { } // Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted. if (c.comicType == ComicType.local) { - if (HistoryManager().findSync(c.id, c.comicType) != null) { + if (HistoryManager().find(c.id, c.comicType) != null) { HistoryManager().remove(c.id, c.comicType); } var folders = LocalFavoritesManager().find(c.id, c.comicType); diff --git a/lib/foundation/state_controller.dart b/lib/foundation/state_controller.dart deleted file mode 100644 index 4e5fff3..0000000 --- a/lib/foundation/state_controller.dart +++ /dev/null @@ -1,238 +0,0 @@ -import 'package:flutter/material.dart'; - -class SimpleController extends StateController { - final void Function()? refreshFunction; - - final Map Function()? control; - - SimpleController({this.refreshFunction, this.control}); - - @override - void refresh() { - (refreshFunction ?? super.refresh)(); - } - - Map get controlMap => control?.call() ?? {}; -} - -abstract class StateController { - static final _controllers = []; - - static T put(T controller, - {Object? tag, bool autoRemove = false}) { - _controllers.add(StateControllerWrapped(controller, autoRemove, tag)); - return controller; - } - - static T putIfNotExists(T controller, - {Object? tag, bool autoRemove = false}) { - return findOrNull(tag: tag) ?? - put(controller, tag: tag, autoRemove: autoRemove); - } - - static T find({Object? tag}) { - try { - return _controllers - .lastWhere((element) => - element.controller is T && (tag == null || tag == element.tag)) - .controller as T; - } catch (e) { - throw StateError("$T with tag $tag Not Found"); - } - } - - static List findAll({Object? tag}) { - return _controllers - .where((element) => - element.controller is T && (tag == null || tag == element.tag)) - .map((e) => e.controller as T) - .toList(); - } - - static T? findOrNull({Object? tag}) { - try { - return _controllers - .lastWhere((element) => - element.controller is T && (tag == null || tag == element.tag)) - .controller as T; - } catch (e) { - return null; - } - } - - static void remove([Object? tag, bool check = false]) { - for (int i = _controllers.length - 1; i >= 0; i--) { - var element = _controllers[i]; - if (element.controller is T && (tag == null || tag == element.tag)) { - if (check && !element.autoRemove) { - continue; - } - _controllers.removeAt(i); - return; - } - } - } - - static SimpleController putSimpleController( - void Function() onUpdate, Object? tag, - {void Function()? refresh, Map Function()? control}) { - var controller = SimpleController(refreshFunction: refresh, control: control); - controller.stateUpdaters.add(Pair(null, onUpdate)); - _controllers.add(StateControllerWrapped(controller, false, tag)); - return controller; - } - - List> stateUpdaters = []; - - void update([List? ids]) { - if (ids == null) { - for (var element in stateUpdaters) { - element.right(); - } - } else { - for (var element in stateUpdaters) { - if (ids.contains(element.left)) { - element.right(); - } - } - } - } - - void dispose() { - _controllers.removeWhere((element) => element.controller == this); - } - - void refresh() { - update(); - } -} - -class StateControllerWrapped { - StateController controller; - bool autoRemove; - Object? tag; - - StateControllerWrapped(this.controller, this.autoRemove, this.tag); -} - -class StateBuilder extends StatefulWidget { - const StateBuilder({ - super.key, - this.init, - this.dispose, - this.initState, - this.tag, - required this.builder, - this.id, - }); - - final T? init; - - final void Function(T controller)? dispose; - - final void Function(T controller)? initState; - - final Object? tag; - - final Widget Function(T controller) builder; - - Widget builderWrapped(StateController controller) { - return builder(controller as T); - } - - void initStateWrapped(StateController controller) { - return initState?.call(controller as T); - } - - void disposeWrapped(StateController controller) { - return dispose?.call(controller as T); - } - - final Object? id; - - @override - State createState() => _StateBuilderState(); -} - -class _StateBuilderState - extends State { - late T controller; - - @override - void initState() { - if (widget.init != null) { - StateController.put(widget.init!, tag: widget.tag, autoRemove: true); - } - try { - controller = StateController.find(tag: widget.tag); - } catch (e) { - throw "Controller Not Found"; - } - controller.stateUpdaters.add(Pair(widget.id, () { - if (mounted) { - setState(() {}); - } - })); - widget.initStateWrapped(controller); - super.initState(); - } - - @override - void dispose() { - widget.disposeWrapped(controller); - StateController.remove(widget.tag, true); - super.dispose(); - } - - @override - Widget build(BuildContext context) => widget.builderWrapped(controller); -} - -abstract class StateWithController extends State { - late final SimpleController _controller; - - void refresh() { - _controller.update(); - } - - @override - @mustCallSuper - void initState() { - _controller = StateController.putSimpleController( - () { - if (mounted) { - setState(() {}); - } - }, - tag, - refresh: refresh, - control: () => control, - ); - super.initState(); - } - - @override - @mustCallSuper - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void update() { - _controller.update(); - } - - Object? get tag; - - Map get control => {}; -} - -class Pair{ - M left; - V right; - - Pair(this.left, this.right); - - Pair.fromMap(Map map, M key): left = key, right = map[key] - ?? (throw Exception("Pair not found")); -} 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/comic_details_page/actions.dart b/lib/pages/comic_details_page/actions.dart new file mode 100644 index 0000000..7636426 --- /dev/null +++ b/lib/pages/comic_details_page/actions.dart @@ -0,0 +1,435 @@ +part of 'comic_page.dart'; + +abstract mixin class _ComicPageActions { + void update(); + + ComicDetails get comic; + + ComicSource get comicSource => ComicSource.find(comic.sourceKey)!; + + History? get history; + + bool isLiking = false; + + bool isLiked = false; + + void likeOrUnlike() async { + if (isLiking) return; + isLiking = true; + update(); + var res = await comicSource.likeOrUnlikeComic!(comic.id, isLiked); + if (res.error) { + App.rootContext.showMessage(message: res.errorMessage!); + } else { + isLiked = !isLiked; + } + isLiking = false; + update(); + } + + /// whether the comic is added to local favorite + bool isAddToLocalFav = false; + + /// whether the comic is favorite on the server + bool isFavorite = false; + + FavoriteItem _toFavoriteItem() { + var tags = []; + for (var e in comic.tags.entries) { + tags.addAll(e.value.map((tag) => '${e.key}:$tag')); + } + return FavoriteItem( + id: comic.id, + name: comic.title, + coverPath: comic.cover, + author: comic.subTitle ?? comic.uploader ?? '', + type: comic.comicType, + tags: tags, + ); + } + + void openFavPanel() { + showSideBar( + App.rootContext, + _FavoritePanel( + cid: comic.id, + type: comic.comicType, + isFavorite: isFavorite, + onFavorite: (local, network) { + isFavorite = network ?? isFavorite; + isAddToLocalFav = local ?? isAddToLocalFav; + update(); + }, + favoriteItem: _toFavoriteItem(), + updateTime: comic.findUpdateTime(), + ), + ); + } + + void quickFavorite() { + var folder = appdata.settings['quickFavorite']; + if (folder is! String) { + return; + } + LocalFavoritesManager().addComic( + folder, + _toFavoriteItem(), + null, + comic.findUpdateTime(), + ); + isAddToLocalFav = true; + update(); + App.rootContext.showMessage(message: "Added".tl); + } + + void share() { + var text = comic.title; + if (comic.url != null) { + text += '\n${comic.url}'; + } + Share.shareText(text); + } + + /// read the comic + /// + /// [ep] the episode number, start from 1 + /// + /// [page] the page number, start from 1 + void read([int? ep, int? page]) { + App.rootContext + .to( + () => Reader( + type: comic.comicType, + cid: comic.id, + name: comic.title, + chapters: comic.chapters, + initialChapter: ep, + initialPage: page, + history: history ?? History.fromModel(model: comic, ep: 0, page: 0), + author: comic.findAuthor() ?? '', + tags: comic.plainTags, + ), + ) + .then((_) { + onReadEnd(); + }); + } + + void continueRead() { + var ep = history?.ep ?? 1; + var page = history?.page ?? 1; + read(ep, page); + } + + void onReadEnd(); + + void download() async { + if (LocalManager().isDownloading(comic.id, comic.comicType)) { + App.rootContext.showMessage(message: "The comic is downloading".tl); + return; + } + if (comic.chapters == null && + LocalManager().isDownloaded(comic.id, comic.comicType, 0)) { + App.rootContext.showMessage(message: "The comic is downloaded".tl); + return; + } + + if (comicSource.archiveDownloader != null) { + bool useNormalDownload = false; + List? archives; + int selected = -1; + bool isLoading = false; + bool isGettingLink = false; + await showDialog( + context: App.rootContext, + builder: (context) { + return StatefulBuilder( + builder: (context, setState) { + return ContentDialog( + title: "Download".tl, + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + value: -1, + groupValue: selected, + title: Text("Normal".tl), + onChanged: (v) { + setState(() { + selected = v!; + }); + }, + ), + ExpansionTile( + title: Text("Archive".tl), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), + collapsedShape: const RoundedRectangleBorder( + borderRadius: BorderRadius.zero, + ), + onExpansionChanged: (b) { + if (!isLoading && b && archives == null) { + isLoading = true; + comicSource.archiveDownloader! + .getArchives(comic.id) + .then((value) { + if (value.success) { + archives = value.data; + } else { + App.rootContext + .showMessage(message: value.errorMessage!); + } + setState(() { + isLoading = false; + }); + }); + } + }, + children: [ + if (archives == null) + const ListLoadingIndicator().toCenter() + else + for (int i = 0; i < archives!.length; i++) + RadioListTile( + value: i, + groupValue: selected, + onChanged: (v) { + setState(() { + selected = v!; + }); + }, + title: Text(archives![i].title), + subtitle: Text(archives![i].description), + ) + ], + ) + ], + ), + actions: [ + Button.filled( + isLoading: isGettingLink, + onPressed: () async { + if (selected == -1) { + useNormalDownload = true; + context.pop(); + return; + } + setState(() { + isGettingLink = true; + }); + var res = + await comicSource.archiveDownloader!.getDownloadUrl( + comic.id, + archives![selected].id, + ); + if (res.error) { + App.rootContext.showMessage(message: res.errorMessage!); + setState(() { + isGettingLink = false; + }); + } else if (context.mounted) { + LocalManager() + .addTask(ArchiveDownloadTask(res.data, comic)); + App.rootContext + .showMessage(message: "Download started".tl); + context.pop(); + } + }, + child: Text("Confirm".tl), + ), + ], + ); + }, + ); + }, + ); + if (!useNormalDownload) { + return; + } + } + + if (comic.chapters == null) { + LocalManager().addTask(ImagesDownloadTask( + source: comicSource, + comicId: comic.id, + comic: comic, + )); + } else { + List? selected; + var downloaded = []; + var localComic = LocalManager().find(comic.id, comic.comicType); + if (localComic != null) { + for (int i = 0; i < comic.chapters!.length; i++) { + if (localComic.downloadedChapters + .contains(comic.chapters!.keys.elementAt(i))) { + downloaded.add(i); + } + } + } + await showSideBar( + App.rootContext, + _SelectDownloadChapter( + comic.chapters!.values.toList(), + (v) => selected = v, + downloaded, + ), + ); + if (selected == null) return; + LocalManager().addTask(ImagesDownloadTask( + source: comicSource, + comicId: comic.id, + comic: comic, + chapters: selected!.map((i) { + return comic.chapters!.keys.elementAt(i); + }).toList(), + )); + } + App.rootContext.showMessage(message: "Download started".tl); + update(); + } + + void onTapTag(String tag, String namespace) { + var config = comicSource.handleClickTagEvent?.call(namespace, tag) ?? + { + 'action': 'search', + 'keyword': tag, + }; + var context = App.mainNavigatorKey!.currentContext!; + if (config['action'] == 'search') { + context.to(() => SearchResultPage( + text: config['keyword'] ?? '', + sourceKey: comicSource.key, + options: const [], + )); + } else if (config['action'] == 'category') { + context.to( + () => CategoryComicsPage( + category: config['keyword'] ?? '', + categoryKey: comicSource.categoryData!.key, + param: config['param'], + ), + ); + } + } + + void showMoreActions() { + var context = App.rootContext; + showMenuX( + context, + Offset( + context.width - 16, + context.padding.top, + ), + [ + MenuEntry( + icon: Icons.copy, + text: "Copy Title".tl, + onClick: () { + Clipboard.setData(ClipboardData(text: comic.title)); + context.showMessage(message: "Copied".tl); + }, + ), + MenuEntry( + icon: Icons.copy_rounded, + text: "Copy ID".tl, + onClick: () { + Clipboard.setData(ClipboardData(text: comic.id)); + context.showMessage(message: "Copied".tl); + }, + ), + if (comic.url != null) + MenuEntry( + icon: Icons.link, + text: "Copy URL".tl, + onClick: () { + Clipboard.setData(ClipboardData(text: comic.url!)); + context.showMessage(message: "Copied".tl); + }, + ), + if (comic.url != null) + MenuEntry( + icon: Icons.open_in_browser, + text: "Open in Browser".tl, + onClick: () { + launchUrlString(comic.url!); + }, + ), + ]); + } + + void showComments() { + showSideBar( + App.rootContext, + CommentsPage( + data: comic, + source: comicSource, + ), + ); + } + + void starRating() { + if (!comicSource.isLogged) { + return; + } + var rating = 0.0; + var isLoading = false; + showDialog( + context: App.rootContext, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setState) => SimpleDialog( + title: const Text("Rating"), + alignment: Alignment.center, + children: [ + SizedBox( + height: 100, + child: Center( + child: SizedBox( + width: 210, + child: Column( + children: [ + const SizedBox( + height: 10, + ), + RatingWidget( + padding: 2, + onRatingUpdate: (value) => rating = value, + value: 1, + selectable: true, + size: 40, + ), + const Spacer(), + Button.filled( + isLoading: isLoading, + onPressed: () { + setState(() { + isLoading = true; + }); + comicSource.starRatingFunc!(comic.id, rating.round()) + .then((value) { + if (value.success) { + App.rootContext + .showMessage(message: "Success".tl); + Navigator.of(dialogContext).pop(); + } else { + App.rootContext + .showMessage(message: value.errorMessage!); + setState(() { + isLoading = false; + }); + } + }); + }, + child: Text("Submit".tl), + ) + ], + ), + ), + ), + ) + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/comic_details_page/chapters.dart b/lib/pages/comic_details_page/chapters.dart new file mode 100644 index 0000000..364871c --- /dev/null +++ b/lib/pages/comic_details_page/chapters.dart @@ -0,0 +1,348 @@ +part of 'comic_page.dart'; + +class _ComicChapters extends StatelessWidget { + const _ComicChapters({this.history, required this.groupedMode}); + + final History? history; + + final bool groupedMode; + + @override + Widget build(BuildContext context) { + return groupedMode + ? _GroupedComicChapters(history) + : _NormalComicChapters(history); + } +} + +class _NormalComicChapters extends StatefulWidget { + const _NormalComicChapters(this.history); + + final History? history; + + @override + State<_NormalComicChapters> createState() => _NormalComicChaptersState(); +} + +class _NormalComicChaptersState extends State<_NormalComicChapters> { + late _ComicPageState state; + + bool reverse = false; + + bool showAll = false; + + late History? history; + + late Map chapters; + + @override + void initState() { + super.initState(); + history = widget.history; + } + + @override + void didChangeDependencies() { + state = context.findAncestorStateOfType<_ComicPageState>()!; + chapters = state.comic.chapters!; + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(covariant _NormalComicChapters oldWidget) { + super.didUpdateWidget(oldWidget); + setState(() { + history = widget.history; + }); + } + + @override + Widget build(BuildContext context) { + return SliverLayoutBuilder( + builder: (context, constrains) { + int length = chapters.length; + bool canShowAll = showAll; + if (!showAll) { + var width = constrains.crossAxisExtent - 16; + var crossItems = width ~/ 200; + if (width % 200 != 0) { + crossItems += 1; + } + length = math.min(length, crossItems * 8); + if (length == chapters.length) { + canShowAll = true; + } + } + + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: ListTile( + title: Text("Chapters".tl), + trailing: Tooltip( + message: "Order".tl, + child: IconButton( + icon: Icon(reverse + ? Icons.vertical_align_top + : Icons.vertical_align_bottom_outlined), + onPressed: () { + setState(() { + reverse = !reverse; + }); + }, + ), + ), + ), + ), + SliverGrid( + delegate: SliverChildBuilderDelegate( + childCount: length, + (context, i) { + if (reverse) { + i = chapters.length - i - 1; + } + var key = chapters.keys.elementAt(i); + var value = chapters[key]!; + bool visited = (history?.readEpisode ?? {}).contains(i + 1); + return Padding( + padding: const EdgeInsets.fromLTRB(6, 4, 6, 4), + child: Material( + color: context.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: () => state.read(i + 1), + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Center( + child: Text( + value, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: visited + ? context.colorScheme.outline + : null, + ), + ), + ), + ), + ), + ), + ); + }, + ), + gridDelegate: const SliverGridDelegateWithFixedHeight( + maxCrossAxisExtent: 200, + itemHeight: 48, + ), + ).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)), + if (!canShowAll) + SliverToBoxAdapter( + child: Align( + alignment: Alignment.center, + child: TextButton.icon( + icon: const Icon(Icons.arrow_drop_down), + onPressed: () { + setState(() { + showAll = true; + }); + }, + label: Text("${"Show all".tl} (${chapters.length})"), + ).paddingTop(12), + ), + ), + const SliverToBoxAdapter( + child: Divider(), + ), + ], + ); + }, + ); + } +} + +class _GroupedComicChapters extends StatefulWidget { + const _GroupedComicChapters(this.history); + + final History? history; + + @override + State<_GroupedComicChapters> createState() => _GroupedComicChaptersState(); +} + +class _GroupedComicChaptersState extends State<_GroupedComicChapters> + with SingleTickerProviderStateMixin { + late _ComicPageState state; + + bool reverse = false; + + bool showAll = false; + + late History? history; + + late Map> chapters; + + late TabController tabController; + + int index = 0; + + @override + void initState() { + super.initState(); + history = widget.history; + } + + @override + void didChangeDependencies() { + state = context.findAncestorStateOfType<_ComicPageState>()!; + chapters = state.comic.groupedChapters!; + tabController = TabController( + length: chapters.keys.length, + vsync: this, + ); + tabController.addListener(onTabChange); + super.didChangeDependencies(); + } + + void onTabChange() { + if (index != tabController.index) { + setState(() { + index = tabController.index; + }); + } + } + + @override + void didUpdateWidget(covariant _GroupedComicChapters oldWidget) { + super.didUpdateWidget(oldWidget); + setState(() { + history = widget.history; + }); + } + + @override + Widget build(BuildContext context) { + return SliverLayoutBuilder( + builder: (context, constrains) { + var group = chapters.values.elementAt(index); + int length = group.length; + bool canShowAll = showAll; + if (!showAll) { + var width = constrains.crossAxisExtent - 16; + var crossItems = width ~/ 200; + if (width % 200 != 0) { + crossItems += 1; + } + length = math.min(length, crossItems * 8); + if (length == group.length) { + canShowAll = true; + } + } + + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: ListTile( + title: Text("Chapters".tl), + trailing: Tooltip( + message: "Order".tl, + child: IconButton( + icon: Icon(reverse + ? Icons.vertical_align_top + : Icons.vertical_align_bottom_outlined), + onPressed: () { + setState(() { + reverse = !reverse; + }); + }, + ), + ), + ), + ), + SliverToBoxAdapter( + child: AppTabBar( + withUnderLine: false, + controller: tabController, + tabs: chapters.keys.map((e) => Tab(text: e)).toList(), + ), + ), + SliverPadding(padding: const EdgeInsets.only(top: 8)), + SliverGrid( + delegate: SliverChildBuilderDelegate( + childCount: length, + (context, i) { + if (reverse) { + i = group.length - i - 1; + } + var key = group.keys.elementAt(i); + var value = group[key]!; + var chapterIndex = 0; + for (var j = 0; j < chapters.length; j++) { + if (j == index) { + chapterIndex += i; + break; + } + chapterIndex += chapters.values.elementAt(j).length; + } + bool visited = + (history?.readEpisode ?? {}).contains(chapterIndex + 1); + return Padding( + padding: const EdgeInsets.fromLTRB(6, 4, 6, 4), + child: Material( + color: context.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: () => state.read(chapterIndex + 1), + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Center( + child: Text( + value, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: visited + ? context.colorScheme.outline + : null, + ), + ), + ), + ), + ), + ), + ); + }, + ), + gridDelegate: const SliverGridDelegateWithFixedHeight( + maxCrossAxisExtent: 200, + itemHeight: 48, + ), + ).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)), + if (!canShowAll) + SliverToBoxAdapter( + child: Align( + alignment: Alignment.center, + child: TextButton.icon( + icon: const Icon(Icons.arrow_drop_down), + onPressed: () { + setState(() { + showAll = true; + }); + }, + label: Text("${"Show all".tl} (${group.length})"), + ).paddingTop(12), + ), + ), + const SliverToBoxAdapter( + child: Divider(), + ), + ], + ); + }, + ); + } +} diff --git a/lib/pages/comic_details_page/comic_page.dart b/lib/pages/comic_details_page/comic_page.dart new file mode 100644 index 0000000..6cf8913 --- /dev/null +++ b/lib/pages/comic_details_page/comic_page.dart @@ -0,0 +1,936 @@ +import 'dart:collection'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shimmer_animation/shimmer_animation.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:venera/components/components.dart'; +import 'package:venera/foundation/app.dart'; +import 'package:venera/foundation/appdata.dart'; +import 'package:venera/foundation/comic_source/comic_source.dart'; +import 'package:venera/foundation/comic_type.dart'; +import 'package:venera/foundation/consts.dart'; +import 'package:venera/foundation/favorites.dart'; +import 'package:venera/foundation/history.dart'; +import 'package:venera/foundation/image_provider/cached_image.dart'; +import 'package:venera/foundation/local.dart'; +import 'package:venera/foundation/res.dart'; +import 'package:venera/network/download.dart'; +import 'package:venera/pages/category_comics_page.dart'; +import 'package:venera/pages/favorites/favorites_page.dart'; +import 'package:venera/pages/reader/reader.dart'; +import 'package:venera/pages/search_result_page.dart'; +import 'package:venera/utils/app_links.dart'; +import 'package:venera/utils/ext.dart'; +import 'package:venera/utils/io.dart'; +import 'package:venera/utils/tags_translation.dart'; +import 'package:venera/utils/translations.dart'; +import 'dart:math' as math; + +part 'comments_page.dart'; + +part 'chapters.dart'; + +part 'thumbnails.dart'; + +part 'favorite.dart'; + +part 'comments_preview.dart'; + +part 'actions.dart'; + +class ComicPage extends StatefulWidget { + const ComicPage({ + super.key, + required this.id, + required this.sourceKey, + this.cover, + this.title, + this.heroID, + }); + + final String id; + + final String sourceKey; + + final String? cover; + + final String? title; + + final int? heroID; + + @override + State createState() => _ComicPageState(); +} + +class _ComicPageState extends LoadingState + with _ComicPageActions { + @override + History? history; + + bool showAppbarTitle = false; + + var scrollController = ScrollController(); + + bool isDownloaded = false; + + @override + void onReadEnd() { + history ??= + HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode)); + update(); + } + + @override + Widget buildLoading() { + return _ComicPageLoadingPlaceHolder( + cover: widget.cover, + title: widget.title, + sourceKey: widget.sourceKey, + cid: widget.id, + heroID: widget.heroID, + ); + } + + @override + void initState() { + scrollController.addListener(onScroll); + super.initState(); + } + + @override + void dispose() { + scrollController.removeListener(onScroll); + super.dispose(); + } + + @override + void update() { + setState(() {}); + } + + @override + ComicDetails get comic => data!; + + void onScroll() { + if (scrollController.offset > 100) { + if (!showAppbarTitle) { + setState(() { + showAppbarTitle = true; + }); + } + } else { + if (showAppbarTitle) { + setState(() { + showAppbarTitle = false; + }); + } + } + } + + var isFirst = true; + + @override + Widget buildContent(BuildContext context, ComicDetails data) { + return SmoothCustomScrollView( + controller: scrollController, + slivers: [ + ...buildTitle(), + buildActions(), + buildDescription(), + buildInfo(), + buildChapters(), + buildComments(), + buildThumbnails(), + buildRecommend(), + SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), + ], + ); + } + + @override + Future> loadData() async { + if (widget.sourceKey == 'local') { + var localComic = LocalManager().find(widget.id, ComicType.local); + if (localComic == null) { + return const Res.error('Local comic not found'); + } + var history = HistoryManager().find(widget.id, ComicType.local); + if (isFirst) { + Future.microtask(() { + App.rootContext.to(() { + return Reader( + type: ComicType.local, + cid: widget.id, + name: localComic.title, + chapters: localComic.chapters, + history: history ?? + History.fromModel( + model: localComic, + ep: 0, + page: 0, + ), + author: localComic.subTitle ?? '', + tags: localComic.tags, + ); + }); + App.mainNavigatorKey!.currentContext!.pop(); + }); + isFirst = false; + } + await Future.delayed(const Duration(milliseconds: 200)); + return const Res.error('Local comic'); + } + var comicSource = ComicSource.find(widget.sourceKey); + if (comicSource == null) { + return const Res.error('Comic source not found'); + } + isAddToLocalFav = LocalFavoritesManager().isExist( + widget.id, + ComicType(widget.sourceKey.hashCode), + ); + history = + HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode)); + return comicSource.loadComicInfo!(widget.id); + } + + @override + Future onDataLoaded() async { + isLiked = comic.isLiked ?? false; + isFavorite = comic.isFavorite ?? false; + if (comic.chapters == null) { + isDownloaded = LocalManager().isDownloaded( + comic.id, + comic.comicType, + 0, + ); + } + } + + Iterable buildTitle() sync* { + yield SliverAppbar( + title: AnimatedOpacity( + opacity: showAppbarTitle ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: Text(comic.title), + ), + actions: [ + IconButton( + onPressed: showMoreActions, icon: const Icon(Icons.more_horiz)) + ], + ); + + yield const SliverPadding(padding: EdgeInsets.only(top: 8)); + + yield SliverLazyToBoxAdapter( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: 16), + Hero( + tag: "cover${widget.heroID}", + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: context.colorScheme.outlineVariant, + blurRadius: 1, + offset: const Offset(0, 1), + ), + ], + ), + height: 144, + width: 144 * 0.72, + clipBehavior: Clip.antiAlias, + child: AnimatedImage( + image: CachedImageProvider( + widget.cover ?? comic.cover, + sourceKey: comic.sourceKey, + cid: comic.id, + ), + width: double.infinity, + height: double.infinity, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText(comic.title, style: ts.s18), + if (comic.subTitle != null) + SelectableText(comic.subTitle!, style: ts.s14) + .paddingVertical(4), + Text( + (ComicSource.find(comic.sourceKey)?.name) ?? '', + style: ts.s12, + ), + ], + ), + ), + ], + ), + ); + } + + Widget buildActions() { + bool isMobile = context.width < changePoint; + bool hasHistory = history != null && (history!.ep > 1 || history!.page > 1); + return SliverLazyToBoxAdapter( + child: Column( + children: [ + ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + children: [ + if (hasHistory && !isMobile) + _ActionButton( + icon: const Icon(Icons.menu_book), + text: 'Continue'.tl, + onPressed: continueRead, + iconColor: context.useTextColor(Colors.yellow), + ), + if (!isMobile || hasHistory) + _ActionButton( + icon: const Icon(Icons.play_circle_outline), + text: 'Start'.tl, + onPressed: read, + iconColor: context.useTextColor(Colors.orange), + ), + if (!isMobile && !isDownloaded) + _ActionButton( + icon: const Icon(Icons.download), + text: 'Download'.tl, + onPressed: download, + iconColor: context.useTextColor(Colors.cyan), + ), + if (data!.isLiked != null) + _ActionButton( + icon: const Icon(Icons.favorite_border), + activeIcon: const Icon(Icons.favorite), + isActive: isLiked, + text: ((data!.likesCount != null) + ? (data!.likesCount! + (isLiked ? 1 : 0)) + : (isLiked ? 'Liked'.tl : 'Like'.tl)) + .toString(), + isLoading: isLiking, + onPressed: likeOrUnlike, + iconColor: context.useTextColor(Colors.red), + ), + _ActionButton( + icon: const Icon(Icons.bookmark_outline_outlined), + activeIcon: const Icon(Icons.bookmark), + isActive: isFavorite || isAddToLocalFav, + text: 'Favorite'.tl, + onPressed: openFavPanel, + onLongPressed: quickFavorite, + iconColor: context.useTextColor(Colors.purple), + ), + if (comicSource.commentsLoader != null) + _ActionButton( + icon: const Icon(Icons.comment), + text: (comic.commentCount ?? 'Comments'.tl).toString(), + onPressed: showComments, + iconColor: context.useTextColor(Colors.green), + ), + _ActionButton( + icon: const Icon(Icons.share), + text: 'Share'.tl, + onPressed: share, + iconColor: context.useTextColor(Colors.blue), + ), + ], + ).fixHeight(48), + if (isMobile) + Row( + children: [ + Expanded( + child: FilledButton.tonal( + onPressed: download, + child: Text("Download".tl), + ), + ), + const SizedBox(width: 16), + Expanded( + child: hasHistory + ? FilledButton( + onPressed: continueRead, child: Text("Continue".tl)) + : FilledButton(onPressed: read, child: Text("Read".tl)), + ) + ], + ).paddingHorizontal(16).paddingVertical(8), + if (history != null) + Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.history, color: context.useTextColor(Colors.teal)), + const SizedBox(width: 8), + Builder( + builder: (context) { + bool haveChapter = comic.chapters != null; + var page = history!.page; + var ep = history!.ep; + String text; + if (haveChapter) { + text = "Last Reading: Chapter @ep Page @page".tlParams({ + 'ep': ep, + 'page': page, + }); + } else { + text = "Last Reading: Page @page".tlParams({ + 'page': page, + }); + } + return Text(text); + }, + ), + const SizedBox(width: 4), + ], + ), + ).toAlign(Alignment.centerLeft), + const Divider(), + ], + ).paddingTop(16), + ); + } + + Widget buildDescription() { + if (comic.description == null || comic.description!.trim().isEmpty) { + return const SliverPadding(padding: EdgeInsets.zero); + } + return SliverLazyToBoxAdapter( + child: Column( + children: [ + ListTile( + title: Text("Description".tl), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SelectableText(comic.description!).fixWidth(double.infinity), + ), + const SizedBox(height: 16), + const Divider(), + ], + ), + ); + } + + Widget buildInfo() { + if (comic.tags.isEmpty && + comic.uploader == null && + comic.uploadTime == null && + comic.uploadTime == null) { + return const SliverPadding(padding: EdgeInsets.zero); + } + + int i = 0; + + Widget buildTag({ + required String text, + VoidCallback? onTap, + bool isTitle = false, + }) { + Color color; + if (isTitle) { + const colors = [ + Colors.blue, + Colors.cyan, + Colors.red, + Colors.pink, + Colors.purple, + Colors.indigo, + Colors.teal, + Colors.green, + Colors.lime, + Colors.yellow, + ]; + color = context.useBackgroundColor(colors[(i++) % (colors.length)]); + } else { + color = context.colorScheme.surfaceContainerLow; + } + + final borderRadius = BorderRadius.circular(12); + + const padding = EdgeInsets.symmetric(horizontal: 16, vertical: 6); + + if (onTap != null) { + return Material( + color: color, + borderRadius: borderRadius, + child: InkWell( + borderRadius: borderRadius, + onTap: onTap, + onLongPress: () { + Clipboard.setData(ClipboardData(text: text)); + context.showMessage(message: "Copied".tl); + }, + onSecondaryTapDown: (details) { + showMenuX(context, details.globalPosition, [ + MenuEntry( + icon: Icons.remove_red_eye, + text: "View".tl, + onClick: onTap, + ), + MenuEntry( + icon: Icons.copy, + text: "Copy".tl, + onClick: () { + Clipboard.setData(ClipboardData(text: text)); + context.showMessage(message: "Copied".tl); + }, + ), + ]); + }, + child: Text(text).padding(padding), + ), + ); + } else { + return Container( + decoration: BoxDecoration( + color: color, + borderRadius: borderRadius, + ), + child: Text(text).padding(padding), + ); + } + } + + String formatTime(String time) { + if (int.tryParse(time) != null) { + var t = int.tryParse(time); + if (t! > 1000000000000) { + return DateTime.fromMillisecondsSinceEpoch(t) + .toString() + .substring(0, 19); + } else { + return DateTime.fromMillisecondsSinceEpoch(t * 1000) + .toString() + .substring(0, 19); + } + } + if (time.contains('T') || time.contains('Z')) { + var t = DateTime.parse(time); + return t.toString().substring(0, 19); + } + return time; + } + + Widget buildWrap({required List children}) { + return Wrap( + runSpacing: 8, + spacing: 8, + children: children, + ).paddingHorizontal(16).paddingBottom(8); + } + + bool enableTranslation = + App.locale.languageCode == 'zh' && comicSource.enableTagsTranslate; + + return SliverLazyToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + title: Text("Information".tl), + ), + if (comic.stars != null) + Row( + children: [ + StarRating( + value: comic.stars!, + size: 24, + onTap: starRating, + ), + const SizedBox(width: 8), + Text(comic.stars!.toStringAsFixed(2)), + ], + ).paddingLeft(16).paddingVertical(8), + for (var e in comic.tags.entries) + buildWrap( + children: [ + if (e.value.isNotEmpty) + buildTag(text: e.key.ts(comicSource.key), isTitle: true), + for (var tag in e.value) + buildTag( + text: enableTranslation + ? TagsTranslation.translationTagWithNamespace( + tag, + e.key.toLowerCase(), + ) + : tag, + onTap: () => onTapTag(tag, e.key), + ), + ], + ), + if (comic.uploader != null) + buildWrap( + children: [ + buildTag(text: 'Uploader'.tl, isTitle: true), + buildTag(text: comic.uploader!), + ], + ), + if (comic.uploadTime != null) + buildWrap( + children: [ + buildTag(text: 'Upload Time'.tl, isTitle: true), + buildTag(text: formatTime(comic.uploadTime!)), + ], + ), + if (comic.updateTime != null) + buildWrap( + children: [ + buildTag(text: 'Update Time'.tl, isTitle: true), + buildTag(text: formatTime(comic.updateTime!)), + ], + ), + const SizedBox(height: 12), + const Divider(), + ], + ), + ); + } + + Widget buildChapters() { + if (comic.chapters == null) { + return const SliverPadding(padding: EdgeInsets.zero); + } + return _ComicChapters( + history: history, + groupedMode: comic.groupedChapters != null, + ); + } + + Widget buildThumbnails() { + if (comic.thumbnails == null && comicSource.loadComicThumbnail == null) { + return const SliverPadding(padding: EdgeInsets.zero); + } + return const _ComicThumbnails(); + } + + Widget buildRecommend() { + if (comic.recommend == null || comic.recommend!.isEmpty) { + return const SliverPadding(padding: EdgeInsets.zero); + } + return SliverMainAxisGroup(slivers: [ + SliverToBoxAdapter( + child: ListTile( + title: Text("Related".tl), + ), + ), + SliverGridComics(comics: comic.recommend!), + ]); + } + + Widget buildComments() { + if (comic.comments == null || comic.comments!.isEmpty) { + return const SliverPadding(padding: EdgeInsets.zero); + } + return _CommentsPart( + comments: comic.comments!, + showMore: showComments, + ); + } +} + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.icon, + required this.text, + required this.onPressed, + this.onLongPressed, + this.activeIcon, + this.isActive, + this.isLoading, + this.iconColor, + }); + + final Widget icon; + + final Widget? activeIcon; + + final bool? isActive; + + final String text; + + final void Function() onPressed; + + final bool? isLoading; + + final Color? iconColor; + + final void Function()? onLongPressed; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: context.colorScheme.outlineVariant, + width: 0.6, + ), + ), + child: InkWell( + onTap: () { + if (!(isLoading ?? false)) { + onPressed(); + } + }, + onLongPress: onLongPressed, + borderRadius: BorderRadius.circular(18), + child: IconTheme.merge( + data: IconThemeData(size: 20, color: iconColor), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isLoading ?? false) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 1.8), + ) + else + (isActive ?? false) ? (activeIcon ?? icon) : icon, + const SizedBox(width: 8), + Text(text), + ], + ).paddingHorizontal(16), + ), + ), + ); + } +} + +class _SelectDownloadChapter extends StatefulWidget { + const _SelectDownloadChapter(this.eps, this.finishSelect, this.downloadedEps); + + final List eps; + final void Function(List) finishSelect; + final List downloadedEps; + + @override + State<_SelectDownloadChapter> createState() => _SelectDownloadChapterState(); +} + +class _SelectDownloadChapterState extends State<_SelectDownloadChapter> { + List selected = []; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: Appbar( + title: Text("Download".tl), + backgroundColor: context.colorScheme.surfaceContainerLow, + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: widget.eps.length, + itemBuilder: (context, i) { + return CheckboxListTile( + title: Text(widget.eps[i]), + value: selected.contains(i) || + widget.downloadedEps.contains(i), + onChanged: widget.downloadedEps.contains(i) + ? null + : (v) { + setState(() { + if (selected.contains(i)) { + selected.remove(i); + } else { + selected.add(i); + } + }); + }); + }, + ), + ), + Container( + height: 50, + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: context.colorScheme.outlineVariant, + ), + ), + ), + child: Row( + children: [ + const SizedBox(width: 16), + Expanded( + child: TextButton( + onPressed: () { + var res = []; + for (int i = 0; i < widget.eps.length; i++) { + if (!widget.downloadedEps.contains(i)) { + res.add(i); + } + } + widget.finishSelect(res); + context.pop(); + }, + child: Text("Download All".tl), + ), + ), + const SizedBox(width: 16), + Expanded( + child: FilledButton( + onPressed: selected.isEmpty + ? null + : () { + widget.finishSelect(selected); + context.pop(); + }, + child: Text("Download Selected".tl), + ), + ), + const SizedBox(width: 16), + ], + ), + ), + SizedBox(height: MediaQuery.of(context).padding.bottom), + ], + ), + ); + } +} + +class _ComicPageLoadingPlaceHolder extends StatelessWidget { + const _ComicPageLoadingPlaceHolder({ + this.cover, + this.title, + required this.sourceKey, + required this.cid, + this.heroID, + }); + + final String? cover; + + final String? title; + + final String sourceKey; + + final String cid; + + final int? heroID; + + @override + Widget build(BuildContext context) { + Widget buildContainer(double? width, double? height, + {Color? color, double? radius}) { + return Container( + height: height, + width: width, + decoration: BoxDecoration( + color: color ?? context.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(radius ?? 4), + ), + ); + } + + return Shimmer( + color: context.isDarkMode ? Colors.grey.shade700 : Colors.white, + child: Column( + children: [ + Appbar(title: Text(""), backgroundColor: context.colorScheme.surface), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(width: 16), + buildImage(context), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) + Text(title ?? "", style: ts.s18) + else + buildContainer(200, 25), + const SizedBox(height: 8), + buildContainer(80, 20), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + if (context.width < changePoint) + Row( + children: [ + Expanded( + child: buildContainer(null, 36, radius: 18), + ), + const SizedBox(width: 16), + Expanded( + child: buildContainer(null, 36, radius: 18), + ), + ], + ).paddingHorizontal(16), + const Divider(), + const SizedBox(height: 8), + Center( + child: CircularProgressIndicator( + strokeWidth: 2.4, + ).fixHeight(24).fixWidth(24), + ) + ], + ), + ); + } + + Widget buildImage(BuildContext context) { + Widget child; + if (cover != null) { + child = AnimatedImage( + image: CachedImageProvider( + cover!, + sourceKey: sourceKey, + cid: cid, + ), + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ); + } else { + child = const SizedBox(); + } + + return Hero( + tag: "cover$heroID", + child: Container( + decoration: BoxDecoration( + color: context.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: context.colorScheme.outlineVariant, + blurRadius: 1, + offset: const Offset(0, 1), + ), + ], + ), + height: 144, + width: 144 * 0.72, + clipBehavior: Clip.antiAlias, + child: child, + ), + ); + } +} diff --git a/lib/pages/comments_page.dart b/lib/pages/comic_details_page/comments_page.dart similarity index 88% rename from lib/pages/comments_page.dart rename to lib/pages/comic_details_page/comments_page.dart index befc94f..de2589b 100644 --- a/lib/pages/comments_page.dart +++ b/lib/pages/comic_details_page/comments_page.dart @@ -1,25 +1,18 @@ -import 'dart:collection'; - -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher_string.dart'; -import 'package:venera/components/components.dart'; -import 'package:venera/foundation/app.dart'; -import 'package:venera/foundation/comic_source/comic_source.dart'; -import 'package:venera/foundation/image_provider/cached_image.dart'; -import 'package:venera/utils/app_links.dart'; -import 'package:venera/utils/ext.dart'; -import 'package:venera/utils/translations.dart'; +part of 'comic_page.dart'; class CommentsPage extends StatefulWidget { - const CommentsPage( - {super.key, required this.data, required this.source, this.replyId}); + const CommentsPage({ + super.key, + required this.data, + required this.source, + this.replyComment, + }); final ComicDetails data; final ComicSource source; - final String? replyId; + final Comment? replyComment; @override State createState() => _CommentsPageState(); @@ -36,7 +29,7 @@ class _CommentsPageState extends State { void firstLoad() async { var res = await widget.source.commentsLoader!( - widget.data.comicId, widget.data.subId, 1, widget.replyId); + widget.data.comicId, widget.data.subId, 1, widget.replyComment?.id); if (res.error) { setState(() { _error = res.errorMessage; @@ -53,7 +46,11 @@ class _CommentsPageState extends State { void loadMore() async { var res = await widget.source.commentsLoader!( - widget.data.comicId, widget.data.subId, _page + 1, widget.replyId); + widget.data.comicId, + widget.data.subId, + _page + 1, + widget.replyComment?.id, + ); if (res.error) { context.showMessage(message: res.errorMessage ?? "Unknown Error"); } else { @@ -105,8 +102,44 @@ class _CommentsPageState extends State { child: ListView.builder( primary: false, padding: EdgeInsets.zero, - itemCount: _comments!.length + 1, + itemCount: _comments!.length + 2, itemBuilder: (context, index) { + if (index == 0) { + if (widget.replyComment != null) { + return Column( + children: [ + _CommentTile( + comment: widget.replyComment!, + source: widget.source, + comic: widget.data, + showAvatar: showAvatar, + showActions: false, + ), + const SizedBox(height: 8), + Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: context.colorScheme.outlineVariant, + width: 0.6, + ), + ), + ), + child: Text( + "Replies".tl, + style: ts.s18, + ), + ), + ], + ); + } else { + return const SizedBox(); + } + } + index--; + if (index == _comments!.length) { if (_page < (maxPage ?? _page + 1)) { loadMore(); @@ -141,6 +174,12 @@ class _CommentsPageState extends State { padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, + border: Border( + top: BorderSide( + color: context.colorScheme.outlineVariant, + width: 0.6, + ), + ), ), child: Material( color: context.colorScheme.surfaceContainer, @@ -160,7 +199,7 @@ class _CommentsPageState extends State { ), if (sending) const Padding( - padding: EdgeInsets.all(8.5), + padding: EdgeInsets.all(8), child: SizedBox( width: 24, height: 24, @@ -182,7 +221,7 @@ class _CommentsPageState extends State { widget.data.comicId, widget.data.subId, controller.text, - widget.replyId); + widget.replyComment?.id); if (!b.error) { controller.text = ""; setState(() { @@ -205,7 +244,7 @@ class _CommentsPageState extends State { ), ) ], - ).paddingVertical(2).paddingLeft(16).paddingRight(4), + ).paddingLeft(16).paddingRight(4), ), ); } @@ -217,6 +256,7 @@ class _CommentTile extends StatefulWidget { required this.source, required this.comic, required this.showAvatar, + this.showActions = true, }); final Comment comment; @@ -227,6 +267,8 @@ class _CommentTile extends StatefulWidget { final bool showAvatar; + final bool showActions; + @override State<_CommentTile> createState() => _CommentTileState(); } @@ -243,24 +285,17 @@ class _CommentTileState extends State<_CommentTile> { @override Widget build(BuildContext context) { return Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).colorScheme.outlineVariant, - width: 0.6, - ), - ), - ), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.showAvatar) Container( - width: 40, - height: 40, + width: 36, + height: 36, clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(18), color: Theme.of(context).colorScheme.secondaryContainer), child: widget.comment.avatar == null ? null @@ -270,7 +305,7 @@ class _CommentTileState extends State<_CommentTile> { sourceKey: widget.source.key, ), ), - ).paddingRight(12), + ).paddingRight(8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -288,11 +323,14 @@ class _CommentTileState extends State<_CommentTile> { ), ) ], - ).paddingAll(16), + ), ); } Widget buildActions() { + if (!widget.showActions) { + return const SizedBox(); + } if (widget.comment.score == null && widget.comment.replyCount == null) { return const SizedBox(); } @@ -331,7 +369,7 @@ class _CommentTileState extends State<_CommentTile> { CommentsPage( data: widget.comic, source: widget.source, - replyId: widget.comment.id, + replyComment: widget.comment, ), showBarrier: false, ); @@ -676,7 +714,17 @@ class _RichCommentContentState extends State { attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', ''); } } - const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span', 'strong']; + const acceptedTags = [ + 'img', + 'a', + 'b', + 'i', + 'u', + 's', + 'br', + 'span', + 'strong' + ]; if (acceptedTags.contains(tagName)) { writeBuffer(); if (tagName == 'img') { diff --git a/lib/pages/comic_details_page/comments_preview.dart b/lib/pages/comic_details_page/comments_preview.dart new file mode 100644 index 0000000..b0b4c43 --- /dev/null +++ b/lib/pages/comic_details_page/comments_preview.dart @@ -0,0 +1,150 @@ +part of 'comic_page.dart'; + +class _CommentsPart extends StatefulWidget { + const _CommentsPart({ + required this.comments, + required this.showMore, + }); + + final List comments; + + final void Function() showMore; + + @override + State<_CommentsPart> createState() => _CommentsPartState(); +} + +class _CommentsPartState extends State<_CommentsPart> { + final scrollController = ScrollController(); + + late List comments; + + @override + void initState() { + comments = widget.comments; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return MultiSliver( + children: [ + SliverLazyToBoxAdapter( + child: ListTile( + title: Text("Comments".tl), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: () { + scrollController.animateTo( + scrollController.position.pixels - 340, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + }, + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: () { + scrollController.animateTo( + scrollController.position.pixels + 340, + duration: const Duration(milliseconds: 200), + curve: Curves.ease, + ); + }, + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 184, + child: MediaQuery.removePadding( + removeTop: true, + context: context, + child: ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + itemCount: comments.length, + itemBuilder: (context, index) { + return _CommentWidget(comment: comments[index]); + }, + ), + ), + ), + const SizedBox(height: 8), + _ActionButton( + icon: const Icon(Icons.comment), + text: "View more".tl, + onPressed: widget.showMore, + iconColor: context.useTextColor(Colors.green), + ).fixHeight(48).paddingRight(8).toAlign(Alignment.centerRight), + const SizedBox(height: 8), + ], + ), + ), + const SliverToBoxAdapter( + child: Divider(), + ), + ], + ); + } +} + +class _CommentWidget extends StatelessWidget { + const _CommentWidget({required this.comment}); + + final Comment comment; + + @override + Widget build(BuildContext context) { + return Container( + height: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 8, 0, 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + width: 324, + decoration: BoxDecoration( + color: context.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + children: [ + if (comment.avatar != null) + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: context.colorScheme.surfaceContainer, + ), + clipBehavior: Clip.antiAlias, + child: Image( + image: CachedImageProvider(comment.avatar!), + width: 36, + height: 36, + fit: BoxFit.cover, + ), + ).paddingRight(8), + Text(comment.userName, style: ts.bold), + ], + ), + const SizedBox(height: 4), + Expanded( + child: RichCommentContent(text: comment.content).fixWidth(324), + ), + const SizedBox(height: 4), + if (comment.time != null) + Text(comment.time!, style: ts.s12).toAlign(Alignment.centerLeft), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/comic_details_page/favorite.dart b/lib/pages/comic_details_page/favorite.dart new file mode 100644 index 0000000..697122e --- /dev/null +++ b/lib/pages/comic_details_page/favorite.dart @@ -0,0 +1,432 @@ +part of 'comic_page.dart'; + +class _FavoritePanel extends StatefulWidget { + const _FavoritePanel({ + required this.cid, + required this.type, + required this.isFavorite, + required this.onFavorite, + required this.favoriteItem, + this.updateTime, + }); + + final String cid; + + final ComicType type; + + /// whether the comic is in the network favorite list + /// + /// if null, the comic source does not support favorite or support multiple favorite lists + final bool? isFavorite; + + final void Function(bool?, bool?) onFavorite; + + final FavoriteItem favoriteItem; + + final String? updateTime; + + @override + State<_FavoritePanel> createState() => _FavoritePanelState(); +} + +class _FavoritePanelState extends State<_FavoritePanel> + with SingleTickerProviderStateMixin { + late ComicSource comicSource; + + late TabController tabController; + + late bool hasNetwork; + + @override + void initState() { + comicSource = widget.type.comicSource!; + localFolders = LocalFavoritesManager().folderNames; + added = LocalFavoritesManager().find(widget.cid, widget.type); + hasNetwork = comicSource.favoriteData != null && comicSource.isLogged; + var initIndex = 0; + if (appdata.implicitData['favoritePanelIndex'] is int) { + initIndex = appdata.implicitData['favoritePanelIndex']; + } + initIndex = initIndex.clamp(0, hasNetwork ? 1 : 0); + tabController = TabController( + initialIndex: initIndex, + length: hasNetwork ? 2 : 1, + vsync: this, + ); + super.initState(); + } + + @override + void dispose() { + var currentIndex = tabController.index; + appdata.implicitData['favoritePanelIndex'] = currentIndex; + appdata.writeImplicitData(); + tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: Appbar( + title: Text("Favorite".tl), + ), + body: Column( + children: [ + TabBar( + controller: tabController, + tabs: [ + Tab(text: "Local".tl), + if (hasNetwork) Tab(text: "Network".tl), + ], + ), + Expanded( + child: TabBarView( + controller: tabController, + children: [ + buildLocal(), + if (hasNetwork) buildNetwork(), + ], + ), + ), + ], + ), + ); + } + + late List localFolders; + + late List added; + + var selectedLocalFolders = {}; + + Widget buildLocal() { + var isRemove = selectedLocalFolders.isNotEmpty && + added.contains(selectedLocalFolders.first); + return Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: localFolders.length + 1, + itemBuilder: (context, index) { + if (index == localFolders.length) { + return SizedBox( + height: 36, + child: Center( + child: TextButton( + onPressed: () { + newFolder().then((v) { + setState(() { + localFolders = LocalFavoritesManager().folderNames; + }); + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add, size: 20), + const SizedBox(width: 4), + Text("New Folder".tl) + ], + ), + ), + ), + ); + } + var folder = localFolders[index]; + var disabled = false; + if (selectedLocalFolders.isNotEmpty) { + if (added.contains(folder) && + !added.contains(selectedLocalFolders.first)) { + disabled = true; + } else if (!added.contains(folder) && + added.contains(selectedLocalFolders.first)) { + disabled = true; + } + } + return CheckboxListTile( + title: Row( + children: [ + Text(folder), + const SizedBox(width: 8), + if (added.contains(folder)) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: context.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text("Added".tl, style: ts.s12), + ), + ], + ), + value: selectedLocalFolders.contains(folder), + onChanged: disabled + ? null + : (v) { + setState(() { + if (v!) { + selectedLocalFolders.add(folder); + } else { + selectedLocalFolders.remove(folder); + } + }); + }, + ); + }, + ), + ), + Center( + child: FilledButton( + onPressed: () { + if (selectedLocalFolders.isEmpty) { + return; + } + if (isRemove) { + for (var folder in selectedLocalFolders) { + LocalFavoritesManager() + .deleteComicWithId(folder, widget.cid, widget.type); + } + widget.onFavorite(false, null); + } else { + for (var folder in selectedLocalFolders) { + LocalFavoritesManager().addComic( + folder, + widget.favoriteItem, + null, + widget.updateTime, + ); + } + widget.onFavorite(true, null); + } + context.pop(); + }, + child: isRemove ? Text("Remove".tl) : Text("Add".tl), + ).paddingVertical(8), + ), + ], + ); + } + + Widget buildNetwork() { + return _NetworkFavorites( + cid: widget.cid, + comicSource: comicSource, + isFavorite: widget.isFavorite, + onFavorite: (network) { + widget.onFavorite(null, network); + }, + ); + } +} + +class _NetworkFavorites extends StatefulWidget { + const _NetworkFavorites({ + required this.cid, + required this.comicSource, + required this.isFavorite, + required this.onFavorite, + }); + + final String cid; + + final ComicSource comicSource; + + final bool? isFavorite; + + final void Function(bool) onFavorite; + + @override + State<_NetworkFavorites> createState() => _NetworkFavoritesState(); +} + +class _NetworkFavoritesState extends State<_NetworkFavorites> { + @override + Widget build(BuildContext context) { + bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null; + + return isMultiFolder ? buildMultiFolder() : buildSingleFolder(); + } + + bool isLoading = false; + + Widget buildSingleFolder() { + var isFavorite = widget.isFavorite ?? false; + return Column( + children: [ + Expanded( + child: Center( + child: Text(isFavorite ? "Added to favorites".tl : "Not added".tl), + ), + ), + Center( + child: Button.filled( + isLoading: isLoading, + onPressed: () async { + setState(() { + isLoading = true; + }); + + var res = await widget.comicSource.favoriteData! + .addOrDelFavorite!(widget.cid, '', !isFavorite, null); + if (res.success) { + widget.onFavorite(!isFavorite); + context.pop(); + App.rootContext.showMessage( + message: isFavorite ? "Removed".tl : "Added".tl); + } else { + setState(() { + isLoading = false; + }); + context.showMessage(message: res.errorMessage!); + } + }, + child: isFavorite ? Text("Remove".tl) : Text("Add".tl), + ).paddingVertical(8), + ), + ], + ); + } + + Map? folders; + + var addedFolders = {}; + + var isLoadingFolders = true; + + // for network favorites, only one selection is allowed + String? selected; + + void loadFolders() async { + var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid); + if (res.error) { + context.showMessage(message: res.errorMessage!); + } else { + folders = res.data; + if (res.subData is List) { + addedFolders = List.from(res.subData).toSet(); + } + setState(() { + isLoadingFolders = false; + }); + } + } + + Widget buildMultiFolder() { + if (widget.isFavorite == true && + widget.comicSource.favoriteData!.singleFolderForSingleComic) { + return Column( + children: [ + Expanded( + child: Center( + child: Text("Added to favorites".tl), + ), + ), + Center( + child: Button.filled( + isLoading: isLoading, + onPressed: () async { + setState(() { + isLoading = true; + }); + + var res = await widget.comicSource.favoriteData! + .addOrDelFavorite!(widget.cid, '', false, null); + if (res.success) { + widget.onFavorite(false); + context.pop(); + App.rootContext.showMessage(message: "Removed".tl); + } else { + setState(() { + isLoading = false; + }); + context.showMessage(message: res.errorMessage!); + } + }, + child: Text("Remove".tl), + ).paddingVertical(8), + ), + ], + ); + } + if (isLoadingFolders) { + loadFolders(); + return const Center(child: CircularProgressIndicator()); + } else { + return Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: folders!.length, + itemBuilder: (context, index) { + var name = folders!.values.elementAt(index); + var id = folders!.keys.elementAt(index); + return CheckboxListTile( + title: Row( + children: [ + Text(name), + const SizedBox(width: 8), + if (addedFolders.contains(id)) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: context.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text("Added".tl, style: ts.s12), + ), + ], + ), + value: selected == id, + onChanged: (v) { + setState(() { + selected = id; + }); + }, + ); + }, + ), + ), + Center( + child: Button.filled( + isLoading: isLoading, + onPressed: () async { + if (selected == null) { + return; + } + setState(() { + isLoading = true; + }); + var res = + await widget.comicSource.favoriteData!.addOrDelFavorite!( + widget.cid, + selected!, + !addedFolders.contains(selected!), + null, + ); + if (res.success) { + context.showMessage(message: "Success".tl); + context.pop(); + } else { + context.showMessage(message: res.errorMessage!); + setState(() { + isLoading = false; + }); + } + }, + child: selected != null && addedFolders.contains(selected!) + ? Text("Remove".tl) + : Text("Add".tl), + ).paddingVertical(8), + ), + ], + ); + } + } +} diff --git a/lib/pages/comic_details_page/thumbnails.dart b/lib/pages/comic_details_page/thumbnails.dart new file mode 100644 index 0000000..a945a4a --- /dev/null +++ b/lib/pages/comic_details_page/thumbnails.dart @@ -0,0 +1,169 @@ +part of 'comic_page.dart'; + +class _ComicThumbnails extends StatefulWidget { + const _ComicThumbnails(); + + @override + State<_ComicThumbnails> createState() => _ComicThumbnailsState(); +} + +class _ComicThumbnailsState extends State<_ComicThumbnails> { + late _ComicPageState state; + + late List thumbnails; + + bool isInitialLoading = true; + + String? next; + + String? error; + + bool isLoading = false; + + @override + void didChangeDependencies() { + state = context.findAncestorStateOfType<_ComicPageState>()!; + loadNext(); + thumbnails = List.from(state.comic.thumbnails ?? []); + super.didChangeDependencies(); + } + + void loadNext() async { + if (state.comicSource.loadComicThumbnail == null) return; + if (!isInitialLoading && next == null) { + return; + } + if (isLoading) return; + Future.microtask(() { + setState(() { + isLoading = true; + }); + }); + var res = await state.comicSource.loadComicThumbnail!(state.comic.id, next); + if (res.success) { + thumbnails.addAll(res.data); + next = res.subData; + isInitialLoading = false; + } else { + error = res.errorMessage; + } + if (mounted) { + setState(() { + isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return MultiSliver( + children: [ + SliverToBoxAdapter( + child: ListTile( + title: Text("Preview".tl), + ), + ), + SliverGrid( + delegate: SliverChildBuilderDelegate( + childCount: thumbnails.length, + (context, index) { + if (index == thumbnails.length - 1 && error == null) { + loadNext(); + } + var url = thumbnails[index]; + ImagePart? part; + if (url.contains('@')) { + var params = url.split('@')[1].split('&'); + url = url.split('@')[0]; + double? x1, y1, x2, y2; + try { + for (var p in params) { + if (p.startsWith('x')) { + var r = p.split('=')[1]; + x1 = double.parse(r.split('-')[0]); + x2 = double.parse(r.split('-')[1]); + } + if (p.startsWith('y')) { + var r = p.split('=')[1]; + y1 = double.parse(r.split('-')[0]); + y2 = double.parse(r.split('-')[1]); + } + } + } catch (_) { + // ignore + } + part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2); + } + return Padding( + padding: context.width < changePoint + ? const EdgeInsets.all(4) + : const EdgeInsets.all(8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: InkWell( + onTap: () => state.read(null, index + 1), + borderRadius: + const BorderRadius.all(Radius.circular(8)), + child: Container( + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.outline, + ), + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + width: double.infinity, + height: double.infinity, + clipBehavior: Clip.antiAlias, + child: AnimatedImage( + image: CachedImageProvider( + url, + sourceKey: state.widget.sourceKey, + ), + fit: BoxFit.contain, + width: double.infinity, + height: double.infinity, + part: part, + ), + ), + ), + ), + const SizedBox( + height: 4, + ), + Text((index + 1).toString()), + ], + ), + ); + }, + ), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + childAspectRatio: 0.68, + ), + ), + if (error != null) + SliverToBoxAdapter( + child: Column( + children: [ + Text(error!), + Button.outlined( + onPressed: loadNext, + child: Text("Retry".tl), + ) + ], + ), + ) + else if (isLoading) + const SliverListLoadingIndicator(), + const SliverToBoxAdapter( + child: Divider(), + ), + ], + ); + } +} diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart deleted file mode 100644 index caeb801..0000000 --- a/lib/pages/comic_page.dart +++ /dev/null @@ -1,2191 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:shimmer_animation/shimmer_animation.dart'; -import 'package:sliver_tools/sliver_tools.dart'; -import 'package:url_launcher/url_launcher_string.dart'; -import 'package:venera/components/components.dart'; -import 'package:venera/foundation/app.dart'; -import 'package:venera/foundation/appdata.dart'; -import 'package:venera/foundation/comic_source/comic_source.dart'; -import 'package:venera/foundation/comic_type.dart'; -import 'package:venera/foundation/consts.dart'; -import 'package:venera/foundation/favorites.dart'; -import 'package:venera/foundation/history.dart'; -import 'package:venera/foundation/image_provider/cached_image.dart'; -import 'package:venera/foundation/local.dart'; -import 'package:venera/foundation/res.dart'; -import 'package:venera/network/download.dart'; -import 'package:venera/pages/category_comics_page.dart'; -import 'package:venera/pages/favorites/favorites_page.dart'; -import 'package:venera/pages/reader/reader.dart'; -import 'package:venera/pages/search_result_page.dart'; -import 'package:venera/utils/io.dart'; -import 'package:venera/utils/tags_translation.dart'; -import 'package:venera/utils/translations.dart'; -import 'dart:math' as math; - -import 'comments_page.dart'; - -class ComicPage extends StatefulWidget { - const ComicPage({ - super.key, - required this.id, - required this.sourceKey, - this.cover, - this.title, - }); - - final String id; - - final String sourceKey; - - final String? cover; - - final String? title; - - @override - State createState() => _ComicPageState(); -} - -class _ComicPageState extends LoadingState - with _ComicPageActions { - @override - History? history; - - bool showAppbarTitle = false; - - var scrollController = ScrollController(); - - bool isDownloaded = false; - - @override - void onReadEnd() { - // The history is passed by reference, so it will be updated automatically. - update(); - } - - @override - Widget buildLoading() { - return _ComicPageLoadingPlaceHolder( - cover: widget.cover, - title: widget.title, - sourceKey: widget.sourceKey, - cid: widget.id, - ); - } - - @override - void initState() { - scrollController.addListener(onScroll); - super.initState(); - } - - @override - void dispose() { - scrollController.removeListener(onScroll); - super.dispose(); - } - - @override - void update() { - setState(() {}); - } - - @override - ComicDetails get comic => data!; - - void onScroll() { - if (scrollController.offset > 100) { - if (!showAppbarTitle) { - setState(() { - showAppbarTitle = true; - }); - } - } else { - if (showAppbarTitle) { - setState(() { - showAppbarTitle = false; - }); - } - } - } - - var isFirst = true; - - @override - Widget buildContent(BuildContext context, ComicDetails data) { - return SmoothCustomScrollView( - controller: scrollController, - slivers: [ - ...buildTitle(), - buildActions(), - buildDescription(), - buildInfo(), - buildChapters(), - buildComments(), - buildThumbnails(), - buildRecommend(), - SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), - ], - ); - } - - @override - Future> loadData() async { - if (widget.sourceKey == 'local') { - var localComic = LocalManager().find(widget.id, ComicType.local); - if (localComic == null) { - return const Res.error('Local comic not found'); - } - var history = await HistoryManager().find(widget.id, ComicType.local); - if (isFirst) { - Future.microtask(() { - App.rootContext.to(() { - return Reader( - type: ComicType.local, - cid: widget.id, - name: localComic.title, - chapters: localComic.chapters, - history: history ?? - History.fromModel( - model: localComic, - ep: 0, - page: 0, - ), - author: localComic.subTitle ?? '', - tags: localComic.tags, - ); - }); - App.mainNavigatorKey!.currentContext!.pop(); - }); - isFirst = false; - } - await Future.delayed(const Duration(milliseconds: 200)); - return const Res.error('Local comic'); - } - var comicSource = ComicSource.find(widget.sourceKey); - if (comicSource == null) { - return const Res.error('Comic source not found'); - } - isAddToLocalFav = LocalFavoritesManager().isExist( - widget.id, - ComicType(widget.sourceKey.hashCode), - ); - history = await HistoryManager() - .find(widget.id, ComicType(widget.sourceKey.hashCode)); - return comicSource.loadComicInfo!(widget.id); - } - - @override - Future onDataLoaded() async { - isLiked = comic.isLiked ?? false; - isFavorite = comic.isFavorite ?? false; - if (comic.chapters == null) { - isDownloaded = LocalManager().isDownloaded( - comic.id, - comic.comicType, - 0, - ); - } - } - - Iterable buildTitle() sync* { - yield SliverAppbar( - title: AnimatedOpacity( - opacity: showAppbarTitle ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - child: Text(comic.title), - ), - actions: [ - IconButton( - onPressed: showMoreActions, icon: const Icon(Icons.more_horiz)) - ], - ); - - yield const SliverPadding(padding: EdgeInsets.only(top: 8)); - - yield SliverLazyToBoxAdapter( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(width: 16), - Hero( - tag: "cover${comic.id}${comic.sourceKey}", - child: Container( - decoration: BoxDecoration( - color: context.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: context.colorScheme.outlineVariant, - blurRadius: 1, - offset: const Offset(0, 1), - ), - ], - ), - height: 144, - width: 144 * 0.72, - clipBehavior: Clip.antiAlias, - child: AnimatedImage( - image: CachedImageProvider( - widget.cover ?? comic.cover, - sourceKey: comic.sourceKey, - cid: comic.id, - ), - width: double.infinity, - height: double.infinity, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SelectableText(comic.title, style: ts.s18), - if (comic.subTitle != null) - SelectableText(comic.subTitle!, style: ts.s14) - .paddingVertical(4), - Text( - (ComicSource.find(comic.sourceKey)?.name) ?? '', - style: ts.s12, - ), - ], - ), - ), - ], - ), - ); - } - - Widget buildActions() { - bool isMobile = context.width < changePoint; - bool hasHistory = history != null && (history!.ep > 1 || history!.page > 1); - return SliverLazyToBoxAdapter( - child: Column( - children: [ - ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - children: [ - if (hasHistory && !isMobile) - _ActionButton( - icon: const Icon(Icons.menu_book), - text: 'Continue'.tl, - onPressed: continueRead, - iconColor: context.useTextColor(Colors.yellow), - ), - if (!isMobile || hasHistory) - _ActionButton( - icon: const Icon(Icons.play_circle_outline), - text: 'Start'.tl, - onPressed: read, - iconColor: context.useTextColor(Colors.orange), - ), - if (!isMobile && !isDownloaded) - _ActionButton( - icon: const Icon(Icons.download), - text: 'Download'.tl, - onPressed: download, - iconColor: context.useTextColor(Colors.cyan), - ), - if (data!.isLiked != null) - _ActionButton( - icon: const Icon(Icons.favorite_border), - activeIcon: const Icon(Icons.favorite), - isActive: isLiked, - text: ((data!.likesCount != null) - ? (data!.likesCount! + (isLiked ? 1 : 0)) - : (isLiked ? 'Liked'.tl : 'Like'.tl)) - .toString(), - isLoading: isLiking, - onPressed: likeOrUnlike, - iconColor: context.useTextColor(Colors.red), - ), - _ActionButton( - icon: const Icon(Icons.bookmark_outline_outlined), - activeIcon: const Icon(Icons.bookmark), - isActive: isFavorite || isAddToLocalFav, - text: 'Favorite'.tl, - onPressed: openFavPanel, - onLongPressed: quickFavorite, - iconColor: context.useTextColor(Colors.purple), - ), - if (comicSource.commentsLoader != null) - _ActionButton( - icon: const Icon(Icons.comment), - text: (comic.commentCount ?? 'Comments'.tl).toString(), - onPressed: showComments, - iconColor: context.useTextColor(Colors.green), - ), - _ActionButton( - icon: const Icon(Icons.share), - text: 'Share'.tl, - onPressed: share, - iconColor: context.useTextColor(Colors.blue), - ), - ], - ).fixHeight(48), - if (isMobile) - Row( - children: [ - Expanded( - child: FilledButton.tonal( - onPressed: download, - child: Text("Download".tl), - ), - ), - const SizedBox(width: 16), - Expanded( - child: hasHistory - ? FilledButton( - onPressed: continueRead, child: Text("Continue".tl)) - : FilledButton(onPressed: read, child: Text("Read".tl)), - ) - ], - ).paddingHorizontal(16).paddingVertical(8), - const Divider(), - ], - ).paddingTop(16), - ); - } - - Widget buildDescription() { - if (comic.description == null || comic.description!.trim().isEmpty) { - return const SliverPadding(padding: EdgeInsets.zero); - } - return SliverLazyToBoxAdapter( - child: Column( - children: [ - ListTile( - title: Text("Description".tl), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: SelectableText(comic.description!).fixWidth(double.infinity), - ), - const SizedBox(height: 16), - const Divider(), - ], - ), - ); - } - - Widget buildInfo() { - if (comic.tags.isEmpty && - comic.uploader == null && - comic.uploadTime == null && - comic.uploadTime == null) { - return const SliverPadding(padding: EdgeInsets.zero); - } - - int i = 0; - - Widget buildTag({ - required String text, - VoidCallback? onTap, - bool isTitle = false, - }) { - Color color; - if (isTitle) { - const colors = [ - Colors.blue, - Colors.cyan, - Colors.red, - Colors.pink, - Colors.purple, - Colors.indigo, - Colors.teal, - Colors.green, - Colors.lime, - Colors.yellow, - ]; - color = context.useBackgroundColor(colors[(i++) % (colors.length)]); - } else { - color = context.colorScheme.surfaceContainerLow; - } - - final borderRadius = BorderRadius.circular(12); - - const padding = EdgeInsets.symmetric(horizontal: 16, vertical: 6); - - if (onTap != null) { - return Material( - color: color, - borderRadius: borderRadius, - child: InkWell( - borderRadius: borderRadius, - onTap: onTap, - onLongPress: () { - Clipboard.setData(ClipboardData(text: text)); - context.showMessage(message: "Copied".tl); - }, - onSecondaryTapDown: (details) { - showMenuX(context, details.globalPosition, [ - MenuEntry( - icon: Icons.remove_red_eye, - text: "View".tl, - onClick: onTap, - ), - MenuEntry( - icon: Icons.copy, - text: "Copy".tl, - onClick: () { - Clipboard.setData(ClipboardData(text: text)); - context.showMessage(message: "Copied".tl); - }, - ), - ]); - }, - child: Text(text).padding(padding), - ), - ); - } else { - return Container( - decoration: BoxDecoration( - color: color, - borderRadius: borderRadius, - ), - child: Text(text).padding(padding), - ); - } - } - - String formatTime(String time) { - if (int.tryParse(time) != null) { - var t = int.tryParse(time); - if (t! > 1000000000000) { - return DateTime.fromMillisecondsSinceEpoch(t) - .toString() - .substring(0, 19); - } else { - return DateTime.fromMillisecondsSinceEpoch(t * 1000) - .toString() - .substring(0, 19); - } - } - if (time.contains('T') || time.contains('Z')) { - var t = DateTime.parse(time); - return t.toString().substring(0, 19); - } - return time; - } - - Widget buildWrap({required List children}) { - return Wrap( - runSpacing: 8, - spacing: 8, - children: children, - ).paddingHorizontal(16).paddingBottom(8); - } - - bool enableTranslation = - App.locale.languageCode == 'zh' && comicSource.enableTagsTranslate; - - return SliverLazyToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - title: Text("Information".tl), - ), - if (comic.stars != null) - Row( - children: [ - StarRating( - value: comic.stars!, - size: 24, - onTap: starRating, - ), - const SizedBox(width: 8), - Text(comic.stars!.toStringAsFixed(2)), - ], - ).paddingLeft(16).paddingVertical(8), - for (var e in comic.tags.entries) - buildWrap( - children: [ - if (e.value.isNotEmpty) - buildTag(text: e.key.ts(comicSource.key), isTitle: true), - for (var tag in e.value) - buildTag( - text: enableTranslation - ? TagsTranslation.translationTagWithNamespace( - tag, - e.key.toLowerCase(), - ) - : tag, - onTap: () => onTapTag(tag, e.key), - ), - ], - ), - if (comic.uploader != null) - buildWrap( - children: [ - buildTag(text: 'Uploader'.tl, isTitle: true), - buildTag(text: comic.uploader!), - ], - ), - if (comic.uploadTime != null) - buildWrap( - children: [ - buildTag(text: 'Upload Time'.tl, isTitle: true), - buildTag(text: formatTime(comic.uploadTime!)), - ], - ), - if (comic.updateTime != null) - buildWrap( - children: [ - buildTag(text: 'Update Time'.tl, isTitle: true), - buildTag(text: formatTime(comic.updateTime!)), - ], - ), - const SizedBox(height: 12), - const Divider(), - ], - ), - ); - } - - Widget buildChapters() { - if (comic.chapters == null) { - return const SliverPadding(padding: EdgeInsets.zero); - } - return _ComicChapters(history); - } - - Widget buildThumbnails() { - if (comic.thumbnails == null && comicSource.loadComicThumbnail == null) { - return const SliverPadding(padding: EdgeInsets.zero); - } - return const _ComicThumbnails(); - } - - Widget buildRecommend() { - if (comic.recommend == null || comic.recommend!.isEmpty) { - return const SliverPadding(padding: EdgeInsets.zero); - } - return SliverMainAxisGroup(slivers: [ - SliverToBoxAdapter( - child: ListTile( - title: Text("Related".tl), - ), - ), - SliverGridComics(comics: comic.recommend!), - ]); - } - - Widget buildComments() { - if (comic.comments == null || comic.comments!.isEmpty) { - return const SliverPadding(padding: EdgeInsets.zero); - } - return _CommentsPart( - comments: comic.comments!, - showMore: showComments, - ); - } -} - -abstract mixin class _ComicPageActions { - void update(); - - ComicDetails get comic; - - ComicSource get comicSource => ComicSource.find(comic.sourceKey)!; - - History? get history; - - bool isLiking = false; - - bool isLiked = false; - - void likeOrUnlike() async { - if (isLiking) return; - isLiking = true; - update(); - var res = await comicSource.likeOrUnlikeComic!(comic.id, isLiked); - if (res.error) { - App.rootContext.showMessage(message: res.errorMessage!); - } else { - isLiked = !isLiked; - } - isLiking = false; - update(); - } - - /// whether the comic is added to local favorite - bool isAddToLocalFav = false; - - /// whether the comic is favorite on the server - bool isFavorite = false; - - FavoriteItem _toFavoriteItem() { - var tags = []; - for (var e in comic.tags.entries) { - tags.addAll(e.value.map((tag) => '${e.key}:$tag')); - } - return FavoriteItem( - id: comic.id, - name: comic.title, - coverPath: comic.cover, - author: comic.subTitle ?? comic.uploader ?? '', - type: comic.comicType, - tags: tags, - ); - } - - void openFavPanel() { - showSideBar( - App.rootContext, - _FavoritePanel( - cid: comic.id, - type: comic.comicType, - isFavorite: isFavorite, - onFavorite: (local, network) { - isFavorite = network ?? isFavorite; - isAddToLocalFav = local ?? isAddToLocalFav; - update(); - }, - favoriteItem: _toFavoriteItem(), - ), - ); - } - - void quickFavorite() { - var folder = appdata.settings['quickFavorite']; - if (folder is! String) { - return; - } - LocalFavoritesManager().addComic( - folder, - _toFavoriteItem(), - ); - isAddToLocalFav = true; - update(); - App.rootContext.showMessage(message: "Added".tl); - } - - void share() { - var text = comic.title; - if (comic.url != null) { - text += '\n${comic.url}'; - } - Share.shareText(text); - } - - /// read the comic - /// - /// [ep] the episode number, start from 1 - /// - /// [page] the page number, start from 1 - void read([int? ep, int? page]) { - App.rootContext.to( - () => Reader( - type: comic.comicType, - cid: comic.id, - name: comic.title, - chapters: comic.chapters, - initialChapter: ep, - initialPage: page, - history: history ?? History.fromModel(model: comic, ep: 0, page: 0), - author: comic.findAuthor() ?? '', - tags: comic.plainTags, - ), - ).then((_) { - onReadEnd(); - }); - } - - void continueRead() { - var ep = history?.ep ?? 1; - var page = history?.page ?? 1; - read(ep, page); - } - - void onReadEnd(); - - void download() async { - if (LocalManager().isDownloading(comic.id, comic.comicType)) { - App.rootContext.showMessage(message: "The comic is downloading".tl); - return; - } - if (comic.chapters == null && - LocalManager().isDownloaded(comic.id, comic.comicType, 0)) { - App.rootContext.showMessage(message: "The comic is downloaded".tl); - return; - } - - if (comicSource.archiveDownloader != null) { - bool useNormalDownload = false; - List? archives; - int selected = -1; - bool isLoading = false; - bool isGettingLink = false; - await showDialog( - context: App.rootContext, - builder: (context) { - return StatefulBuilder( - builder: (context, setState) { - return ContentDialog( - title: "Download".tl, - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - RadioListTile( - value: -1, - groupValue: selected, - title: Text("Normal".tl), - onChanged: (v) { - setState(() { - selected = v!; - }); - }, - ), - ExpansionTile( - title: Text("Archive".tl), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.zero, - ), - collapsedShape: const RoundedRectangleBorder( - borderRadius: BorderRadius.zero, - ), - onExpansionChanged: (b) { - if (!isLoading && b && archives == null) { - isLoading = true; - comicSource.archiveDownloader! - .getArchives(comic.id) - .then((value) { - if (value.success) { - archives = value.data; - } else { - App.rootContext - .showMessage(message: value.errorMessage!); - } - setState(() { - isLoading = false; - }); - }); - } - }, - children: [ - if (archives == null) - const ListLoadingIndicator().toCenter() - else - for (int i = 0; i < archives!.length; i++) - RadioListTile( - value: i, - groupValue: selected, - onChanged: (v) { - setState(() { - selected = v!; - }); - }, - title: Text(archives![i].title), - subtitle: Text(archives![i].description), - ) - ], - ) - ], - ), - actions: [ - Button.filled( - isLoading: isGettingLink, - onPressed: () async { - if (selected == -1) { - useNormalDownload = true; - context.pop(); - return; - } - setState(() { - isGettingLink = true; - }); - var res = - await comicSource.archiveDownloader!.getDownloadUrl( - comic.id, - archives![selected].id, - ); - if (res.error) { - App.rootContext.showMessage(message: res.errorMessage!); - setState(() { - isGettingLink = false; - }); - } else if (context.mounted) { - LocalManager() - .addTask(ArchiveDownloadTask(res.data, comic)); - App.rootContext - .showMessage(message: "Download started".tl); - context.pop(); - } - }, - child: Text("Confirm".tl), - ), - ], - ); - }, - ); - }, - ); - if (!useNormalDownload) { - return; - } - } - - if (comic.chapters == null) { - LocalManager().addTask(ImagesDownloadTask( - source: comicSource, - comicId: comic.id, - comic: comic, - )); - } else { - List? selected; - var downloaded = []; - var localComic = LocalManager().find(comic.id, comic.comicType); - if (localComic != null) { - for (int i = 0; i < comic.chapters!.length; i++) { - if (localComic.downloadedChapters - .contains(comic.chapters!.keys.elementAt(i))) { - downloaded.add(i); - } - } - } - await showSideBar( - App.rootContext, - _SelectDownloadChapter( - comic.chapters!.values.toList(), - (v) => selected = v, - downloaded, - ), - ); - if (selected == null) return; - LocalManager().addTask(ImagesDownloadTask( - source: comicSource, - comicId: comic.id, - comic: comic, - chapters: selected!.map((i) { - return comic.chapters!.keys.elementAt(i); - }).toList(), - )); - } - App.rootContext.showMessage(message: "Download started".tl); - update(); - } - - void onTapTag(String tag, String namespace) { - var config = comicSource.handleClickTagEvent?.call(namespace, tag) ?? - { - 'action': 'search', - 'keyword': tag, - }; - var context = App.mainNavigatorKey!.currentContext!; - if (config['action'] == 'search') { - context.to(() => SearchResultPage( - text: config['keyword'] ?? '', - sourceKey: comicSource.key, - options: const [], - )); - } else if (config['action'] == 'category') { - context.to( - () => CategoryComicsPage( - category: config['keyword'] ?? '', - categoryKey: comicSource.categoryData!.key, - param: config['param'], - ), - ); - } - } - - void showMoreActions() { - var context = App.rootContext; - showMenuX( - context, - Offset( - context.width - 16, - context.padding.top, - ), - [ - MenuEntry( - icon: Icons.copy, - text: "Copy Title".tl, - onClick: () { - Clipboard.setData(ClipboardData(text: comic.title)); - context.showMessage(message: "Copied".tl); - }, - ), - MenuEntry( - icon: Icons.copy_rounded, - text: "Copy ID".tl, - onClick: () { - Clipboard.setData(ClipboardData(text: comic.id)); - context.showMessage(message: "Copied".tl); - }, - ), - if (comic.url != null) - MenuEntry( - icon: Icons.link, - text: "Copy URL".tl, - onClick: () { - Clipboard.setData(ClipboardData(text: comic.url!)); - context.showMessage(message: "Copied".tl); - }, - ), - if (comic.url != null) - MenuEntry( - icon: Icons.open_in_browser, - text: "Open in Browser".tl, - onClick: () { - launchUrlString(comic.url!); - }, - ), - ]); - } - - void showComments() { - showSideBar( - App.rootContext, - CommentsPage( - data: comic, - source: comicSource, - ), - ); - } - - void starRating() { - if (!comicSource.isLogged) { - return; - } - var rating = 0.0; - var isLoading = false; - showDialog( - context: App.rootContext, - builder: (dialogContext) => StatefulBuilder( - builder: (context, setState) => SimpleDialog( - title: const Text("Rating"), - alignment: Alignment.center, - children: [ - SizedBox( - height: 100, - child: Center( - child: SizedBox( - width: 210, - child: Column( - children: [ - const SizedBox( - height: 10, - ), - RatingWidget( - padding: 2, - onRatingUpdate: (value) => rating = value, - value: 1, - selectable: true, - size: 40, - ), - const Spacer(), - Button.filled( - isLoading: isLoading, - onPressed: () { - setState(() { - isLoading = true; - }); - comicSource.starRatingFunc!(comic.id, rating.round()) - .then((value) { - if (value.success) { - App.rootContext - .showMessage(message: "Success".tl); - Navigator.of(dialogContext).pop(); - } else { - App.rootContext - .showMessage(message: value.errorMessage!); - setState(() { - isLoading = false; - }); - } - }); - }, - child: Text("Submit".tl), - ) - ], - ), - ), - ), - ) - ], - ), - ), - ); - } -} - -class _ActionButton extends StatelessWidget { - const _ActionButton({ - required this.icon, - required this.text, - required this.onPressed, - this.onLongPressed, - this.activeIcon, - this.isActive, - this.isLoading, - this.iconColor, - }); - - final Widget icon; - - final Widget? activeIcon; - - final bool? isActive; - - final String text; - - final void Function() onPressed; - - final bool? isLoading; - - final Color? iconColor; - - final void Function()? onLongPressed; - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - border: Border.all( - color: context.colorScheme.outlineVariant, - width: 0.6, - ), - ), - child: InkWell( - onTap: () { - if (!(isLoading ?? false)) { - onPressed(); - } - }, - onLongPress: onLongPressed, - borderRadius: BorderRadius.circular(18), - child: IconTheme.merge( - data: IconThemeData(size: 20, color: iconColor), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isLoading ?? false) - const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 1.8), - ) - else - (isActive ?? false) ? (activeIcon ?? icon) : icon, - const SizedBox(width: 8), - Text(text), - ], - ).paddingHorizontal(16), - ), - ), - ); - } -} - -class _ComicChapters extends StatefulWidget { - const _ComicChapters(this.history); - - final History? history; - - @override - State<_ComicChapters> createState() => _ComicChaptersState(); -} - -class _ComicChaptersState extends State<_ComicChapters> { - late _ComicPageState state; - - bool reverse = false; - - bool showAll = false; - - late History? history; - - @override - void initState() { - super.initState(); - history = widget.history; - } - - @override - void didChangeDependencies() { - state = context.findAncestorStateOfType<_ComicPageState>()!; - super.didChangeDependencies(); - } - - @override - void didUpdateWidget(covariant _ComicChapters oldWidget) { - super.didUpdateWidget(oldWidget); - setState(() { - history = widget.history; - }); - } - - @override - Widget build(BuildContext context) { - final eps = state.comic.chapters!; - - return SliverLayoutBuilder( - builder: (context, constrains) { - int length = eps.length; - bool canShowAll = showAll; - if (!showAll) { - var width = constrains.crossAxisExtent - 16; - var crossItems = width ~/ 200; - if (width % 200 != 0) { - crossItems += 1; - } - length = math.min(length, crossItems * 8); - if (length == eps.length) { - canShowAll = true; - } - } - - return SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - child: ListTile( - title: Text("Chapters".tl), - trailing: Tooltip( - message: "Order".tl, - child: IconButton( - icon: Icon(reverse - ? Icons.vertical_align_top - : Icons.vertical_align_bottom_outlined), - onPressed: () { - setState(() { - reverse = !reverse; - }); - }, - ), - ), - ), - ), - SliverGrid( - delegate: SliverChildBuilderDelegate( - childCount: length, - (context, i) { - if (reverse) { - i = eps.length - i - 1; - } - var key = eps.keys.elementAt(i); - var value = eps[key]!; - bool visited = (history?.readEpisode ?? {}).contains(i + 1); - return Padding( - padding: const EdgeInsets.fromLTRB(6, 4, 6, 4), - child: Material( - color: context.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(16), - child: InkWell( - onTap: () => state.read(i + 1), - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Center( - child: Text( - value, - maxLines: 1, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: visited - ? context.colorScheme.outline - : null, - ), - ), - ), - ), - ), - ), - ); - }, - ), - gridDelegate: const SliverGridDelegateWithFixedHeight( - maxCrossAxisExtent: 200, - itemHeight: 48, - ), - ).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)), - if (eps.length > 20 && !canShowAll) - SliverToBoxAdapter( - child: Align( - alignment: Alignment.center, - child: TextButton.icon( - icon: const Icon(Icons.arrow_drop_down), - onPressed: () { - setState(() { - showAll = true; - }); - }, - label: Text("${"Show all".tl} (${eps.length})"), - ).paddingTop(12), - ), - ), - const SliverToBoxAdapter( - child: Divider(), - ), - ], - ); - }, - ); - } -} - -class _ComicThumbnails extends StatefulWidget { - const _ComicThumbnails(); - - @override - State<_ComicThumbnails> createState() => _ComicThumbnailsState(); -} - -class _ComicThumbnailsState extends State<_ComicThumbnails> { - late _ComicPageState state; - - late List thumbnails; - - bool isInitialLoading = true; - - String? next; - - String? error; - - bool isLoading = false; - - @override - void didChangeDependencies() { - state = context.findAncestorStateOfType<_ComicPageState>()!; - loadNext(); - thumbnails = List.from(state.comic.thumbnails ?? []); - super.didChangeDependencies(); - } - - void loadNext() async { - if (state.comicSource.loadComicThumbnail == null) return; - if (!isInitialLoading && next == null) { - return; - } - if (isLoading) return; - Future.microtask(() { - setState(() { - isLoading = true; - }); - }); - var res = await state.comicSource.loadComicThumbnail!(state.comic.id, next); - if (res.success) { - thumbnails.addAll(res.data); - next = res.subData; - isInitialLoading = false; - } else { - error = res.errorMessage; - } - if (mounted) { - setState(() { - isLoading = false; - }); - } - } - - @override - Widget build(BuildContext context) { - return MultiSliver( - children: [ - SliverToBoxAdapter( - child: ListTile( - title: Text("Preview".tl), - ), - ), - SliverGrid( - delegate: SliverChildBuilderDelegate( - childCount: thumbnails.length, - (context, index) { - if (index == thumbnails.length - 1 && error == null) { - loadNext(); - } - var url = thumbnails[index]; - ImagePart? part; - if (url.contains('@')) { - var params = url.split('@')[1].split('&'); - url = url.split('@')[0]; - double? x1, y1, x2, y2; - try { - for (var p in params) { - if (p.startsWith('x')) { - var r = p.split('=')[1]; - x1 = double.parse(r.split('-')[0]); - x2 = double.parse(r.split('-')[1]); - } - if (p.startsWith('y')) { - var r = p.split('=')[1]; - y1 = double.parse(r.split('-')[0]); - y2 = double.parse(r.split('-')[1]); - } - } - } catch (_) { - // ignore - } - part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2); - } - return Padding( - padding: context.width < changePoint - ? const EdgeInsets.all(4) - : const EdgeInsets.all(8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: InkWell( - onTap: () => state.read(null, index + 1), - borderRadius: - const BorderRadius.all(Radius.circular(8)), - child: Container( - foregroundDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: Theme.of(context).colorScheme.outline, - ), - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - ), - width: double.infinity, - height: double.infinity, - clipBehavior: Clip.antiAlias, - child: AnimatedImage( - image: CachedImageProvider( - url, - sourceKey: state.widget.sourceKey, - ), - fit: BoxFit.contain, - width: double.infinity, - height: double.infinity, - part: part, - ), - ), - ), - ), - const SizedBox( - height: 4, - ), - Text((index + 1).toString()), - ], - ), - ); - }, - ), - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - childAspectRatio: 0.68, - ), - ), - if (error != null) - SliverToBoxAdapter( - child: Column( - children: [ - Text(error!), - Button.outlined( - onPressed: loadNext, - child: Text("Retry".tl), - ) - ], - ), - ) - else if (isLoading) - const SliverListLoadingIndicator(), - const SliverToBoxAdapter( - child: Divider(), - ), - ], - ); - } -} - -class _FavoritePanel extends StatefulWidget { - const _FavoritePanel({ - required this.cid, - required this.type, - required this.isFavorite, - required this.onFavorite, - required this.favoriteItem, - }); - - final String cid; - - final ComicType type; - - /// whether the comic is in the network favorite list - /// - /// if null, the comic source does not support favorite or support multiple favorite lists - final bool? isFavorite; - - final void Function(bool?, bool?) onFavorite; - - final FavoriteItem favoriteItem; - - @override - State<_FavoritePanel> createState() => _FavoritePanelState(); -} - -class _FavoritePanelState extends State<_FavoritePanel> - with SingleTickerProviderStateMixin { - late ComicSource comicSource; - - late TabController tabController; - - late bool hasNetwork; - - @override - void initState() { - comicSource = widget.type.comicSource!; - localFolders = LocalFavoritesManager().folderNames; - added = LocalFavoritesManager().find(widget.cid, widget.type); - hasNetwork = comicSource.favoriteData != null && comicSource.isLogged; - var initIndex = 0; - if (appdata.implicitData['favoritePanelIndex'] is int) { - initIndex = appdata.implicitData['favoritePanelIndex']; - } - initIndex = initIndex.clamp(0, hasNetwork ? 1 : 0); - tabController = TabController( - initialIndex: initIndex, - length: hasNetwork ? 2 : 1, - vsync: this, - ); - super.initState(); - } - - @override - void dispose() { - var currentIndex = tabController.index; - appdata.implicitData['favoritePanelIndex'] = currentIndex; - appdata.writeImplicitData(); - tabController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: Appbar( - title: Text("Favorite".tl), - ), - body: Column( - children: [ - TabBar( - controller: tabController, - tabs: [ - Tab(text: "Local".tl), - if (hasNetwork) Tab(text: "Network".tl), - ], - ), - Expanded( - child: TabBarView( - controller: tabController, - children: [ - buildLocal(), - if (hasNetwork) buildNetwork(), - ], - ), - ), - ], - ), - ); - } - - late List localFolders; - - late List added; - - var selectedLocalFolders = {}; - - Widget buildLocal() { - var isRemove = selectedLocalFolders.isNotEmpty && - added.contains(selectedLocalFolders.first); - return Column( - children: [ - Expanded( - child: ListView.builder( - itemCount: localFolders.length + 1, - itemBuilder: (context, index) { - if (index == localFolders.length) { - return SizedBox( - height: 36, - child: Center( - child: TextButton( - onPressed: () { - newFolder().then((v) { - setState(() { - localFolders = LocalFavoritesManager().folderNames; - }); - }); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.add, size: 20), - const SizedBox(width: 4), - Text("New Folder".tl) - ], - ), - ), - ), - ); - } - var folder = localFolders[index]; - var disabled = false; - if (selectedLocalFolders.isNotEmpty) { - if (added.contains(folder) && - !added.contains(selectedLocalFolders.first)) { - disabled = true; - } else if (!added.contains(folder) && - added.contains(selectedLocalFolders.first)) { - disabled = true; - } - } - return CheckboxListTile( - title: Row( - children: [ - Text(folder), - const SizedBox(width: 8), - if (added.contains(folder)) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: context.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Text("Added".tl, style: ts.s12), - ), - ], - ), - value: selectedLocalFolders.contains(folder), - onChanged: disabled - ? null - : (v) { - setState(() { - if (v!) { - selectedLocalFolders.add(folder); - } else { - selectedLocalFolders.remove(folder); - } - }); - }, - ); - }, - ), - ), - Center( - child: FilledButton( - onPressed: () { - if (selectedLocalFolders.isEmpty) { - return; - } - if (isRemove) { - for (var folder in selectedLocalFolders) { - LocalFavoritesManager() - .deleteComicWithId(folder, widget.cid, widget.type); - } - widget.onFavorite(false, null); - } else { - for (var folder in selectedLocalFolders) { - LocalFavoritesManager().addComic(folder, widget.favoriteItem); - } - widget.onFavorite(true, null); - } - context.pop(); - }, - child: isRemove ? Text("Remove".tl) : Text("Add".tl), - ).paddingVertical(8), - ), - ], - ); - } - - Widget buildNetwork() { - return _NetworkFavorites( - cid: widget.cid, - comicSource: comicSource, - isFavorite: widget.isFavorite, - onFavorite: (network) { - widget.onFavorite(null, network); - }, - ); - } -} - -class _NetworkFavorites extends StatefulWidget { - const _NetworkFavorites({ - required this.cid, - required this.comicSource, - required this.isFavorite, - required this.onFavorite, - }); - - final String cid; - - final ComicSource comicSource; - - final bool? isFavorite; - - final void Function(bool) onFavorite; - - @override - State<_NetworkFavorites> createState() => _NetworkFavoritesState(); -} - -class _NetworkFavoritesState extends State<_NetworkFavorites> { - @override - Widget build(BuildContext context) { - bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null; - - return isMultiFolder ? buildMultiFolder() : buildSingleFolder(); - } - - bool isLoading = false; - - Widget buildSingleFolder() { - var isFavorite = widget.isFavorite ?? false; - return Column( - children: [ - Expanded( - child: Center( - child: Text(isFavorite ? "Added to favorites".tl : "Not added".tl), - ), - ), - Center( - child: Button.filled( - isLoading: isLoading, - onPressed: () async { - setState(() { - isLoading = true; - }); - - var res = await widget.comicSource.favoriteData! - .addOrDelFavorite!(widget.cid, '', !isFavorite, null); - if (res.success) { - widget.onFavorite(!isFavorite); - context.pop(); - App.rootContext.showMessage( - message: isFavorite ? "Removed".tl : "Added".tl); - } else { - setState(() { - isLoading = false; - }); - context.showMessage(message: res.errorMessage!); - } - }, - child: isFavorite ? Text("Remove".tl) : Text("Add".tl), - ).paddingVertical(8), - ), - ], - ); - } - - Map? folders; - - var addedFolders = {}; - - var isLoadingFolders = true; - - // for network favorites, only one selection is allowed - String? selected; - - void loadFolders() async { - var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid); - if (res.error) { - context.showMessage(message: res.errorMessage!); - } else { - folders = res.data; - if (res.subData is List) { - addedFolders = List.from(res.subData).toSet(); - } - setState(() { - isLoadingFolders = false; - }); - } - } - - Widget buildMultiFolder() { - if (widget.isFavorite == true && - widget.comicSource.favoriteData!.singleFolderForSingleComic) { - return Column( - children: [ - Expanded( - child: Center( - child: Text("Added to favorites".tl), - ), - ), - Center( - child: Button.filled( - isLoading: isLoading, - onPressed: () async { - setState(() { - isLoading = true; - }); - - var res = await widget.comicSource.favoriteData! - .addOrDelFavorite!(widget.cid, '', false, null); - if (res.success) { - widget.onFavorite(false); - context.pop(); - App.rootContext.showMessage(message: "Removed".tl); - } else { - setState(() { - isLoading = false; - }); - context.showMessage(message: res.errorMessage!); - } - }, - child: Text("Remove".tl), - ).paddingVertical(8), - ), - ], - ); - } - if (isLoadingFolders) { - loadFolders(); - return const Center(child: CircularProgressIndicator()); - } else { - return Column( - children: [ - Expanded( - child: ListView.builder( - itemCount: folders!.length, - itemBuilder: (context, index) { - var name = folders!.values.elementAt(index); - var id = folders!.keys.elementAt(index); - return CheckboxListTile( - title: Row( - children: [ - Text(name), - const SizedBox(width: 8), - if (addedFolders.contains(id)) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: context.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Text("Added".tl, style: ts.s12), - ), - ], - ), - value: selected == id, - onChanged: (v) { - setState(() { - selected = id; - }); - }, - ); - }, - ), - ), - Center( - child: Button.filled( - isLoading: isLoading, - onPressed: () async { - if (selected == null) { - return; - } - setState(() { - isLoading = true; - }); - var res = - await widget.comicSource.favoriteData!.addOrDelFavorite!( - widget.cid, - selected!, - !addedFolders.contains(selected!), - null, - ); - if (res.success) { - context.showMessage(message: "Success".tl); - context.pop(); - } else { - context.showMessage(message: res.errorMessage!); - setState(() { - isLoading = false; - }); - } - }, - child: selected != null && addedFolders.contains(selected!) - ? Text("Remove".tl) - : Text("Add".tl), - ).paddingVertical(8), - ), - ], - ); - } - } -} - -class _SelectDownloadChapter extends StatefulWidget { - const _SelectDownloadChapter(this.eps, this.finishSelect, this.downloadedEps); - - final List eps; - final void Function(List) finishSelect; - final List downloadedEps; - - @override - State<_SelectDownloadChapter> createState() => _SelectDownloadChapterState(); -} - -class _SelectDownloadChapterState extends State<_SelectDownloadChapter> { - List selected = []; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: Appbar( - title: Text("Download".tl), - backgroundColor: context.colorScheme.surfaceContainerLow, - ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: widget.eps.length, - itemBuilder: (context, i) { - return CheckboxListTile( - title: Text(widget.eps[i]), - value: selected.contains(i) || - widget.downloadedEps.contains(i), - onChanged: widget.downloadedEps.contains(i) - ? null - : (v) { - setState(() { - if (selected.contains(i)) { - selected.remove(i); - } else { - selected.add(i); - } - }); - }); - }, - ), - ), - Container( - height: 50, - decoration: BoxDecoration( - border: Border( - top: BorderSide( - color: context.colorScheme.outlineVariant, - ), - ), - ), - child: Row( - children: [ - const SizedBox(width: 16), - Expanded( - child: TextButton( - onPressed: () { - var res = []; - for (int i = 0; i < widget.eps.length; i++) { - if (!widget.downloadedEps.contains(i)) { - res.add(i); - } - } - widget.finishSelect(res); - context.pop(); - }, - child: Text("Download All".tl), - ), - ), - const SizedBox(width: 16), - Expanded( - child: FilledButton( - onPressed: selected.isEmpty - ? null - : () { - widget.finishSelect(selected); - context.pop(); - }, - child: Text("Download Selected".tl), - ), - ), - const SizedBox(width: 16), - ], - ), - ), - SizedBox(height: MediaQuery.of(context).padding.bottom), - ], - ), - ); - } -} - -class _CommentsPart extends StatefulWidget { - const _CommentsPart({ - required this.comments, - required this.showMore, - }); - - final List comments; - - final void Function() showMore; - - @override - State<_CommentsPart> createState() => _CommentsPartState(); -} - -class _CommentsPartState extends State<_CommentsPart> { - final scrollController = ScrollController(); - - late List comments; - - @override - void initState() { - comments = widget.comments; - super.initState(); - } - - @override - Widget build(BuildContext context) { - return MultiSliver( - children: [ - SliverLazyToBoxAdapter( - child: ListTile( - title: Text("Comments".tl), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.chevron_left), - onPressed: () { - scrollController.animateTo( - scrollController.position.pixels - 340, - duration: const Duration(milliseconds: 200), - curve: Curves.ease, - ); - }, - ), - IconButton( - icon: const Icon(Icons.chevron_right), - onPressed: () { - scrollController.animateTo( - scrollController.position.pixels + 340, - duration: const Duration(milliseconds: 200), - curve: Curves.ease, - ); - }, - ), - ], - ), - ), - ), - SliverToBoxAdapter( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 184, - child: MediaQuery.removePadding( - removeTop: true, - context: context, - child: ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - itemCount: comments.length, - itemBuilder: (context, index) { - return _CommentWidget(comment: comments[index]); - }, - ), - ), - ), - const SizedBox(height: 8), - _ActionButton( - icon: const Icon(Icons.comment), - text: "View more".tl, - onPressed: widget.showMore, - iconColor: context.useTextColor(Colors.green), - ).fixHeight(48).paddingRight(8).toAlign(Alignment.centerRight), - const SizedBox(height: 8), - ], - ), - ), - const SliverToBoxAdapter( - child: Divider(), - ), - ], - ); - } -} - -class _CommentWidget extends StatelessWidget { - const _CommentWidget({required this.comment}); - - final Comment comment; - - @override - Widget build(BuildContext context) { - return Container( - height: double.infinity, - margin: const EdgeInsets.fromLTRB(16, 8, 0, 8), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - width: 324, - decoration: BoxDecoration( - color: context.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - Row( - children: [ - if (comment.avatar != null) - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - color: context.colorScheme.surfaceContainer, - ), - clipBehavior: Clip.antiAlias, - child: Image( - image: CachedImageProvider(comment.avatar!), - width: 36, - height: 36, - fit: BoxFit.cover, - ), - ).paddingRight(8), - Text(comment.userName, style: ts.bold), - ], - ), - const SizedBox(height: 4), - Expanded( - child: RichCommentContent(text: comment.content).fixWidth(324), - ), - const SizedBox(height: 4), - if (comment.time != null) - Text(comment.time!, style: ts.s12).toAlign(Alignment.centerLeft), - ], - ), - ); - } -} - -class _ComicPageLoadingPlaceHolder extends StatelessWidget { - const _ComicPageLoadingPlaceHolder({ - this.cover, - this.title, - required this.sourceKey, - required this.cid, - }); - - final String? cover; - - final String? title; - - final String sourceKey; - - final String cid; - - @override - Widget build(BuildContext context) { - Widget buildContainer(double? width, double? height, - {Color? color, double? radius}) { - return Container( - height: height, - width: width, - decoration: BoxDecoration( - color: color ?? context.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(radius ?? 4), - ), - ); - } - - return Shimmer( - color: context.isDarkMode ? Colors.grey.shade700 : Colors.white, - child: Column( - children: [ - Appbar(title: Text(""), backgroundColor: context.colorScheme.surface), - const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(width: 16), - buildImage(context), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title != null) - Text(title ?? "", style: ts.s18) - else - buildContainer(200, 25), - const SizedBox(height: 8), - buildContainer(80, 20), - ], - ), - ), - ], - ), - const SizedBox(height: 8), - if (context.width < changePoint) - Row( - children: [ - Expanded( - child: buildContainer(null, 36, radius: 18), - ), - const SizedBox(width: 16), - Expanded( - child: buildContainer(null, 36, radius: 18), - ), - ], - ).paddingHorizontal(16), - const Divider(), - const SizedBox(height: 8), - Center( - child: CircularProgressIndicator( - strokeWidth: 2.4, - ).fixHeight(24).fixWidth(24), - ) - ], - ), - ); - } - - Widget buildImage(BuildContext context) { - Widget child; - if (cover != null) { - child = AnimatedImage( - image: CachedImageProvider( - cover!, - sourceKey: sourceKey, - cid: cid, - ), - width: double.infinity, - height: double.infinity, - fit: BoxFit.cover, - ); - } else { - child = const SizedBox(); - } - - return Hero( - tag: "cover$cid$sourceKey", - child: Container( - decoration: BoxDecoration( - color: context.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: context.colorScheme.outlineVariant, - blurRadius: 1, - offset: const Offset(0, 1), - ), - ], - ), - height: 144, - width: 144 * 0.72, - clipBehavior: Clip.antiAlias, - child: child, - ), - ); - } -} diff --git a/lib/pages/comic_source_page.dart b/lib/pages/comic_source_page.dart index fed0d8a..49d7d03 100644 --- a/lib/pages/comic_source_page.dart +++ b/lib/pages/comic_source_page.dart @@ -156,23 +156,27 @@ class _BodyState extends State<_Body> { ); } - static Future update(ComicSource source) async { + static Future update(ComicSource source, + [bool showLoading = true]) async { if (!source.url.isURL) { App.rootContext.showMessage(message: "Invalid url config"); return; } ComicSource.remove(source.key); bool cancel = false; - var controller = showLoadingDialog( - App.rootContext, - onCancel: () => cancel = true, - barrierDismissible: false, - ); + LoadingDialogController? controller; + if (showLoading) { + controller = showLoadingDialog( + App.rootContext, + onCancel: () => cancel = true, + barrierDismissible: false, + ); + } try { var res = await AppDio().get(source.url, options: Options(responseType: ResponseType.plain)); if (cancel) return; - controller.close(); + controller?.close(); await ComicSourceParser().parse(res.data!, source.filePath); await File(source.filePath).writeAsString(res.data!); if (ComicSource.availableUpdates.containsKey(source.key)) { @@ -551,13 +555,59 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> { } else if (count == 0) { context.showMessage(message: "No updates".tl); } else { - context.showMessage(message: "@c updates".tlParams({"c": count})); + showUpdateDialog(); } setState(() { isLoading = false; }); } + void showUpdateDialog() async { + var text = ComicSource.availableUpdates.entries.map((e) { + return "${ComicSource.find(e.key)!.name}: ${e.value}"; + }).join("\n"); + bool doUpdate = false; + await showDialog( + context: App.rootContext, + builder: (context) { + return ContentDialog( + title: "Updates".tl, + content: Text(text).paddingHorizontal(16), + actions: [ + FilledButton( + onPressed: () { + doUpdate = true; + context.pop(); + }, + child: Text("Update".tl), + ), + ], + ); + }, + ); + if (doUpdate) { + var loadingController = showLoadingDialog( + context, + message: "Updating".tl, + withProgress: true, + ); + int current = 0; + int total = ComicSource.availableUpdates.length; + try { + var shouldUpdate = ComicSource.availableUpdates.keys.toList(); + for (var key in shouldUpdate) { + var source = ComicSource.find(key)!; + await _BodyState.update(source, false); + current++; + loadingController.setProgress(current / total); + } + } catch (e) { + context.showMessage(message: e.toString()); + } + loadingController.close(); + } + } + @override Widget build(BuildContext context) { return Button.normal( diff --git a/lib/pages/explore_page.dart b/lib/pages/explore_page.dart index 21a32e9..415d7d0 100644 --- a/lib/pages/explore_page.dart +++ b/lib/pages/explore_page.dart @@ -3,8 +3,8 @@ import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; +import 'package:venera/foundation/global_state.dart'; import 'package:venera/foundation/res.dart'; -import 'package:venera/foundation/state_controller.dart'; import 'package:venera/pages/comic_source_page.dart'; import 'package:venera/pages/search_result_page.dart'; import 'package:venera/pages/settings/settings_page.dart'; @@ -52,9 +52,7 @@ class _ExplorePageState extends State if (index == 2) { int page = controller.index; String currentPageId = pages[page]; - StateController.find(tag: currentPageId) - .control!()['toTop'] - ?.call(); + GlobalState.find<_SingleExplorePageState>(currentPageId).toTop(); } } @@ -98,7 +96,7 @@ class _ExplorePageState extends State void refresh() { int page = controller.index; String currentPageId = pages[page]; - StateController.find(tag: currentPageId).refresh(); + GlobalState.find<_SingleExplorePageState>(currentPageId).refresh(); } Widget buildFAB() => Material( @@ -244,7 +242,7 @@ class _SingleExplorePage extends StatefulWidget { State<_SingleExplorePage> createState() => _SingleExplorePageState(); } -class _SingleExplorePageState extends StateWithController<_SingleExplorePage> +class _SingleExplorePageState extends AutomaticGlobalState<_SingleExplorePage> with AutomaticKeepAliveClientMixin<_SingleExplorePage> { late final ExplorePageData data; @@ -328,7 +326,7 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage> } @override - Object? get tag => widget.title; + Object? get key => widget.title; @override void refresh() { @@ -347,9 +345,6 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage> ); } } - - @override - Map get control => {"toTop": toTop}; } class _MixedExplorePage extends StatefulWidget { diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index e9dc5aa..015c4e0 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; @@ -14,7 +15,7 @@ import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/network/download.dart'; -import 'package:venera/pages/comic_page.dart'; +import 'package:venera/pages/comic_details_page/comic_page.dart'; import 'package:venera/pages/reader/reader.dart'; import 'package:venera/pages/settings/settings_page.dart'; import 'package:venera/utils/io.dart'; diff --git a/lib/pages/favorites/local_favorites_page.dart b/lib/pages/favorites/local_favorites_page.dart index 7be3e27..d714be9 100644 --- a/lib/pages/favorites/local_favorites_page.dart +++ b/lib/pages/favorites/local_favorites_page.dart @@ -351,6 +351,21 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> { text: "Download".tl, onClick: downloadSelected, ), + if (selectedComics.length == 1) + MenuEntry( + icon: Icons.copy, + text: "Copy Title".tl, + onClick: () { + Clipboard.setData( + ClipboardData( + text: selectedComics.keys.first.title, + ), + ); + context.showMessage( + message: "Copied".tl, + ); + }, + ), ]), ], ) 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 a550dbf..f11e6d2 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -9,9 +9,10 @@ import 'package:venera/foundation/favorites.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; -import 'package:venera/pages/comic_page.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)), @@ -821,6 +823,20 @@ class _ImageFavoritesState extends State { Center( child: Text('Image Favorites'.tl, style: ts.s18), ), + if (hasData) + Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + imageFavoritesCompute!.count.toString(), + style: ts.s12, + ), + ), const Spacer(), const Icon(Icons.arrow_right), ], diff --git a/lib/pages/image_favorites_page/image_favorites_page.dart b/lib/pages/image_favorites_page/image_favorites_page.dart index 83171c3..ca0c4f1 100644 --- a/lib/pages/image_favorites_page/image_favorites_page.dart +++ b/lib/pages/image_favorites_page/image_favorites_page.dart @@ -11,7 +11,7 @@ import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/image_provider/image_favorites_provider.dart'; -import 'package:venera/pages/comic_page.dart'; +import 'package:venera/pages/comic_details_page/comic_page.dart'; import 'package:venera/pages/image_favorites_page/type.dart'; import 'package:venera/pages/reader/reader.dart'; import 'package:venera/utils/ext.dart'; diff --git a/lib/pages/local_comics_page.dart b/lib/pages/local_comics_page.dart index cf5b6b6..409ed1f 100644 --- a/lib/pages/local_comics_page.dart +++ b/lib/pages/local_comics_page.dart @@ -4,7 +4,7 @@ import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/local.dart'; import 'package:venera/foundation/log.dart'; -import 'package:venera/pages/comic_page.dart'; +import 'package:venera/pages/comic_details_page/comic_page.dart'; import 'package:venera/pages/downloading_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/utils/cbz.dart'; 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; diff --git a/lib/pages/reader/comic_image.dart b/lib/pages/reader/comic_image.dart index 69fd50c..7f6d5e3 100644 --- a/lib/pages/reader/comic_image.dart +++ b/lib/pages/reader/comic_image.dart @@ -3,31 +3,30 @@ part of 'reader.dart'; class ComicImage extends StatefulWidget { /// Modified from flutter Image ComicImage({ - required ImageProvider image, - super.key, - double scale = 1.0, - this.semanticLabel, - this.excludeFromSemantics = false, - this.width, - this.height, - this.color, - this.opacity, - this.colorBlendMode, - this.fit, - this.alignment = Alignment.center, - this.repeat = ImageRepeat.noRepeat, - this.centerSlice, - this.matchTextDirection = false, - this.gaplessPlayback = false, - this.filterQuality = FilterQuality.medium, - this.isAntiAlias = false, - Map? headers, - int? cacheWidth, - int? cacheHeight, - } - ): image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image), - assert(cacheWidth == null || cacheWidth > 0), - assert(cacheHeight == null || cacheHeight > 0); + required ImageProvider image, + super.key, + double scale = 1.0, + this.semanticLabel, + this.excludeFromSemantics = false, + this.width, + this.height, + this.color, + this.opacity, + this.colorBlendMode, + this.fit, + this.alignment = Alignment.center, + this.repeat = ImageRepeat.noRepeat, + this.centerSlice, + this.matchTextDirection = false, + this.gaplessPlayback = false, + this.filterQuality = FilterQuality.medium, + this.isAntiAlias = false, + Map? headers, + int? cacheWidth, + int? cacheHeight, + }) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image), + assert(cacheWidth == null || cacheWidth > 0), + assert(cacheHeight == null || cacheHeight > 0); final ImageProvider image; @@ -138,8 +137,8 @@ class _ComicImageState extends State with WidgetsBindingObserver { } void _updateInvertColors() { - _invertColors = MediaQuery.maybeInvertColorsOf(context) - ?? SemanticsBinding.instance.accessibilityFeatures.invertColors; + _invertColors = MediaQuery.maybeInvertColorsOf(context) ?? + SemanticsBinding.instance.accessibilityFeatures.invertColors; } void _resolveImage() { @@ -148,16 +147,19 @@ class _ComicImageState extends State with WidgetsBindingObserver { imageProvider: widget.image, ); final ImageStream newStream = - provider.resolve(createLocalImageConfiguration( + provider.resolve(createLocalImageConfiguration( context, - size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null, + size: widget.width != null && widget.height != null + ? Size(widget.width!, widget.height!) + : null, )); _updateSourceStream(newStream); } ImageStreamListener? _imageStreamListener; + ImageStreamListener _getListener({bool recreateListener = false}) { - if(_imageStreamListener == null || recreateListener) { + if (_imageStreamListener == null || recreateListener) { _lastException = null; _imageStreamListener = ImageStreamListener( _handleImageFrame, @@ -191,7 +193,8 @@ class _ComicImageState extends State with WidgetsBindingObserver { void _replaceImage({required ImageInfo? info}) { final ImageInfo? oldImageInfo = _imageInfo; - SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose()); + SchedulerBinding.instance + .addPostFrameCallback((_) => oldImageInfo?.dispose()); _imageInfo = info; } @@ -208,7 +211,9 @@ class _ComicImageState extends State with WidgetsBindingObserver { } if (!widget.gaplessPlayback) { - setState(() { _replaceImage(info: null); }); + setState(() { + _replaceImage(info: null); + }); } setState(() { @@ -247,7 +252,9 @@ class _ComicImageState extends State with WidgetsBindingObserver { return; } - if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) { + if (keepStreamAlive && + _completerHandle == null && + _imageStream?.completer != null) { _completerHandle = _imageStream!.completer!.keepAlive(); } @@ -269,26 +276,41 @@ class _ComicImageState extends State with WidgetsBindingObserver { children: [ Expanded( child: Center( - child: Text(_lastException.toString(), maxLines: 3,), + child: Text( + _lastException.toString(), + maxLines: 3, + ), ), ), - const SizedBox(height: 4,), + const SizedBox( + height: 4, + ), MouseRegion( cursor: SystemMouseCursors.click, child: Listener( - onPointerDown: (details){ + onPointerDown: (details) { + GlobalState.find<_ReaderGestureDetectorState>().ignoreNextTap(); + setState(() { + _loadingProgress = null; + _lastException = null; + }); _resolveImage(); }, - child: const SizedBox( + child: SizedBox( width: 84, height: 36, child: Center( - child: Text("Retry", style: TextStyle(color: Colors.blue),), + child: Text( + "Retry".tl, + style: TextStyle(color: Colors.blue), + ), ), ), ), ), - const SizedBox(height: 16,), + const SizedBox( + height: 16, + ), ], ), ), @@ -300,34 +322,32 @@ class _ComicImageState extends State with WidgetsBindingObserver { var width = widget.width; var height = widget.height; - if(_imageInfo != null) { + if (_imageInfo != null) { // Record the height and the width of the image - _cache[widget.image.hashCode] = Size( - _imageInfo!.image.width.toDouble(), - _imageInfo!.image.height.toDouble() - ); + _cache[widget.image.hashCode] = Size(_imageInfo!.image.width.toDouble(), + _imageInfo!.image.height.toDouble()); } Size? cacheSize = _cache[widget.image.hashCode]; - if(cacheSize != null){ - if(width == double.infinity) { + if (cacheSize != null) { + if (width == double.infinity) { width = constrains.maxWidth; height = width * cacheSize.height / cacheSize.width; - } else if(height == double.infinity) { + } else if (height == double.infinity) { height = constrains.maxHeight; width = height * cacheSize.width / cacheSize.height; } } else { - if(width == double.infinity) { + if (width == double.infinity) { width = constrains.maxWidth; height = 300; - } else if(height == double.infinity) { + } else if (height == double.infinity) { height = constrains.maxHeight; width = 300; } } - if(_imageInfo != null){ + if (_imageInfo != null) { // build image Widget result = RawImage( // Do not clone the image, because RawImage is a stateless wrapper. @@ -379,12 +399,13 @@ class _ComicImageState extends State with WidgetsBindingObserver { height: 24, child: CircularProgressIndicator( strokeWidth: 3, - backgroundColor: context.colorScheme.surfaceContainerLow, + backgroundColor: context.colorScheme.surfaceContainer, value: (_loadingProgress != null && - _loadingProgress!.expectedTotalBytes!=null && - _loadingProgress!.expectedTotalBytes! != 0) - ?_loadingProgress!.cumulativeBytesLoaded / _loadingProgress!.expectedTotalBytes! - :0, + _loadingProgress!.expectedTotalBytes != null && + _loadingProgress!.expectedTotalBytes! != 0) + ? _loadingProgress!.cumulativeBytesLoaded / + _loadingProgress!.expectedTotalBytes! + : 0, ), ), ), @@ -398,8 +419,10 @@ class _ComicImageState extends State with WidgetsBindingObserver { super.debugFillProperties(description); description.add(DiagnosticsProperty('stream', _imageStream)); description.add(DiagnosticsProperty('pixels', _imageInfo)); - description.add(DiagnosticsProperty('loadingProgress', _loadingProgress)); + description.add(DiagnosticsProperty( + 'loadingProgress', _loadingProgress)); description.add(DiagnosticsProperty('frameNumber', _frameNumber)); - description.add(DiagnosticsProperty('wasSynchronouslyLoaded', _wasSynchronouslyLoaded)); + description.add(DiagnosticsProperty( + 'wasSynchronouslyLoaded', _wasSynchronouslyLoaded)); } } diff --git a/lib/pages/reader/gesture.dart b/lib/pages/reader/gesture.dart index 1da7f3e..3122339 100644 --- a/lib/pages/reader/gesture.dart +++ b/lib/pages/reader/gesture.dart @@ -9,7 +9,7 @@ class _ReaderGestureDetector extends StatefulWidget { State<_ReaderGestureDetector> createState() => _ReaderGestureDetectorState(); } -class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { +class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDetector> { late TapGestureRecognizer _tapGestureRecognizer; static const _kDoubleTapMaxTime = Duration(milliseconds: 200); @@ -26,6 +26,12 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { late _ReaderState reader; + bool ignoreNextTag = false; + + void ignoreNextTap() { + ignoreNextTag = true; + } + @override void initState() { _tapGestureRecognizer = TapGestureRecognizer() @@ -44,6 +50,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { behavior: HitTestBehavior.translucent, onPointerDown: (event) { fingers++; + if (ignoreNextTag) { + ignoreNextTag = false; + return; + } _lastTapPointer = event.pointer; _lastTapMoveDistance = Offset.zero; _tapGestureRecognizer.addPointer(event); @@ -290,6 +300,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> { void removeDragListener(_DragListener listener) { _dragListeners.remove(listener); } + + @override + Object? get key => "reader_gesture"; } class _DragListener { diff --git a/lib/pages/reader/images.dart b/lib/pages/reader/images.dart index 0801938..7b8eee0 100644 --- a/lib/pages/reader/images.dart +++ b/lib/pages/reader/images.dart @@ -105,7 +105,7 @@ class _GalleryModeState extends State<_GalleryMode> late List cached; - int get preCacheCount => 4; + int get preCacheCount => appdata.settings["preloadImageCount"]; var photoViewControllers = {}; @@ -371,6 +371,9 @@ class _ContinuousModeState extends State<_ContinuousMode> var fingers = 0; bool disableScroll = false; + late List cached; + int get preCacheCount => appdata.settings["preloadImageCount"]; + /// Whether the user was scrolling the page. /// The gesture detector has a delay to detect tap event. /// To handle the tap event, we need to know if the user was scrolling before the delay. @@ -388,6 +391,11 @@ class _ContinuousModeState extends State<_ContinuousMode> reader = context.reader; reader._imageViewController = this; itemPositionsListener.itemPositions.addListener(onPositionChanged); + cached = List.filled(reader.maxPage + 2, false); + Future.delayed( + const Duration(milliseconds: 100), + () => cacheImages(reader.page), + ); super.initState(); } @@ -404,6 +412,7 @@ class _ContinuousModeState extends State<_ContinuousMode> reader.setPage(page); context.readerScaffold.update(); } + cacheImages(page); } double? futurePosition; @@ -443,6 +452,15 @@ class _ContinuousModeState extends State<_ContinuousMode> } } + void cacheImages(int current) { + for (int i = current + 1; i <= current + preCacheCount; i++) { + if (i <= reader.maxPage && !cached[i]) { + _precacheImage(i, context); + cached[i] = true; + } + } + } + @override Widget build(BuildContext context) { Widget widget = ScrollablePositionedList.builder( @@ -473,8 +491,6 @@ class _ContinuousModeState extends State<_ContinuousMode> width = double.infinity; } - _precacheImage(index, context); - ImageProvider image = _createImageProvider(index, context); return ComicImage( @@ -555,18 +571,20 @@ class _ContinuousModeState extends State<_ContinuousMode> delayedSetIsScrolling(false); } - var length = reader.maxChapter; - if (!scrollController.hasClients) return false; - if (scrollController.position.pixels <= - scrollController.position.minScrollExtent && - reader.chapter != 1) { - context.readerScaffold.setFloatingButton(-1); - } else if (scrollController.position.pixels >= - scrollController.position.maxScrollExtent && - reader.chapter < length) { - context.readerScaffold.setFloatingButton(1); - } else { - context.readerScaffold.setFloatingButton(0); + if (notification is ScrollUpdateNotification) { + var length = reader.maxChapter; + if (!scrollController.hasClients) return false; + if (scrollController.position.pixels <= + scrollController.position.minScrollExtent && + reader.chapter != 1) { + context.readerScaffold.setFloatingButton(-1); + } else if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent && + reader.chapter < length) { + context.readerScaffold.setFloatingButton(1); + } else { + context.readerScaffold.setFloatingButton(0); + } } return true; diff --git a/lib/pages/reader/loading.dart b/lib/pages/reader/loading.dart index 37cdd8e..38bfd57 100644 --- a/lib/pages/reader/loading.dart +++ b/lib/pages/reader/loading.dart @@ -41,7 +41,7 @@ class _ReaderWithLoadingState @override Future> loadData() async { var comicSource = ComicSource.find(widget.sourceKey); - var history = HistoryManager().findSync( + var history = HistoryManager().find( widget.id, ComicType.fromKey(widget.sourceKey), ); diff --git a/lib/pages/reader/reader.dart b/lib/pages/reader/reader.dart index 7d4a686..ed1cb71 100644 --- a/lib/pages/reader/reader.dart +++ b/lib/pages/reader/reader.dart @@ -22,6 +22,7 @@ import 'package:venera/foundation/comic_source/comic_source.dart'; import 'package:venera/foundation/comic_type.dart'; import 'package:venera/foundation/consts.dart'; import 'package:venera/foundation/favorites.dart'; +import 'package:venera/foundation/global_state.dart'; import 'package:venera/foundation/history.dart'; import 'package:venera/foundation/image_provider/reader_image.dart'; import 'package:venera/foundation/local.dart'; @@ -229,6 +230,10 @@ class _ReaderState extends State with _ReaderLocation, _ReaderWindow { updateHistory(); } + /// Prevent multiple history updates in a short time. + /// `HistoryManager().addHistoryAsync` is a high-cost operation because it creates a new isolate. + Timer? _updateHistoryTimer; + void updateHistory() { if (history != null) { history!.page = page; @@ -237,9 +242,12 @@ class _ReaderState extends State with _ReaderLocation, _ReaderWindow { history!.maxPage = maxPage; } history!.readEpisode.add(chapter); - print(history!.readEpisode); history!.time = DateTime.now(); - HistoryManager().addHistory(history!); + _updateHistoryTimer?.cancel(); + _updateHistoryTimer = Timer(const Duration(seconds: 1), () { + HistoryManager().addHistoryAsync(history!); + _updateHistoryTimer = null; + }); } } diff --git a/lib/pages/search_page.dart b/lib/pages/search_page.dart index 81d7466..6190930 100644 --- a/lib/pages/search_page.dart +++ b/lib/pages/search_page.dart @@ -7,7 +7,7 @@ import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; -import 'package:venera/foundation/state_controller.dart'; +import 'package:venera/foundation/global_state.dart'; import 'package:venera/pages/aggregated_search_page.dart'; import 'package:venera/pages/search_result_page.dart'; import 'package:venera/pages/settings/settings_page.dart'; @@ -16,7 +16,7 @@ import 'package:venera/utils/ext.dart'; import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/translations.dart'; -import 'comic_page.dart'; +import 'comic_details_page/comic_page.dart'; import 'comic_source_page.dart'; class SearchPage extends StatefulWidget { diff --git a/lib/pages/search_result_page.dart b/lib/pages/search_result_page.dart index e7cff3d..94f700d 100644 --- a/lib/pages/search_result_page.dart +++ b/lib/pages/search_result_page.dart @@ -3,7 +3,7 @@ import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; -import 'package:venera/foundation/state_controller.dart'; +import 'package:venera/foundation/global_state.dart'; import 'package:venera/pages/search_page.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/tags_translation.dart'; diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 2aa382e..6f8ae5d 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -85,8 +85,8 @@ class _AboutSettingsState extends State { } Future checkUpdate() async { - var res = await AppDio().get( - "https://cdn.jsdelivr.net/gh/venera-app/venera@latest/pubspec.yaml"); + var res = await AppDio() + .get("https://cdn.jsdelivr.net/gh/venera-app/venera@master/pubspec.yaml"); if (res.statusCode == 200) { var data = loadYaml(res.data); if (data["version"] != null) { @@ -108,7 +108,7 @@ Future checkUpdateUi([bool showMessageIfNoUpdate = true]) async { content: Text( "A new version is available. Do you want to update now?" .tl) - .paddingHorizontal(8), + .paddingHorizontal(16), actions: [ Button.text( onPressed: () { @@ -137,6 +137,9 @@ bool _compareVersion(String version1, String version2) { if (int.parse(v1[i]) > int.parse(v2[i])) { return true; } + if (int.parse(v1[i]) < int.parse(v2[i])) { + return false; + } } return false; } diff --git a/lib/pages/settings/reader.dart b/lib/pages/settings/reader.dart index 82b088c..7efb632 100644 --- a/lib/pages/settings/reader.dart +++ b/lib/pages/settings/reader.dart @@ -143,6 +143,13 @@ class _ReaderSettingsState extends State { callback: () => context.to(() => _CustomImageProcessing()), actionTitle: "Edit".tl, ).toSliver(), + _SliderSetting( + title: "Number of images preloaded".tl, + settingsIndex: "preloadImageCount", + interval: 1, + min: 1, + max: 16, + ).toSliver(), ], ); } diff --git a/lib/utils/app_links.dart b/lib/utils/app_links.dart index e6947c7..4fdd6eb 100644 --- a/lib/utils/app_links.dart +++ b/lib/utils/app_links.dart @@ -1,7 +1,7 @@ import 'package:app_links/app_links.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; -import 'package:venera/pages/comic_page.dart'; +import 'package:venera/pages/comic_details_page/comic_page.dart'; void handleLinks() { final appLinks = AppLinks(); diff --git a/lib/utils/tags_translation.dart b/lib/utils/tags_translation.dart index b92b52e..f876283 100644 --- a/lib/utils/tags_translation.dart +++ b/lib/utils/tags_translation.dart @@ -52,6 +52,15 @@ extension TagsTranslation on String{ /// translate tag's text to chinese String get translateTagsToCN => _translateTags(this); + String get translateTagIfNeed { + var locale = App.locale; + if (locale.languageCode == "zh") { + return translateTagsToCN; + } else { + return this; + } + } + static String translateTag(String tag) { if(tag.contains(':') && tag.indexOf(':') == tag.lastIndexOf(':')) { var [namespace, text] = tag.split(':'); diff --git a/pubspec.lock b/pubspec.lock index dde4d00..d35693e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.12.0" battery_plus: dependency: "direct main" description: @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" build_cli_annotations: dependency: transitive description: @@ -85,26 +85,26 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.19.1" convert: dependency: transitive description: @@ -182,10 +182,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.2" ffi: dependency: transitive description: @@ -548,18 +548,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "10.0.7" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -629,10 +629,10 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -645,10 +645,10 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" mime: dependency: "direct main" description: @@ -669,10 +669,10 @@ packages: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_provider: dependency: "direct main" description: @@ -860,10 +860,10 @@ packages: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" sprintf: dependency: transitive description: @@ -892,26 +892,26 @@ packages: dependency: transitive description: name: stack_trace - sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.1" syntax_highlight: dependency: "direct main" description: @@ -924,18 +924,18 @@ packages: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.3" + version: "0.7.4" typed_data: dependency: transitive description: @@ -1036,10 +1036,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" url: "https://pub.dev" source: hosted - version: "14.3.0" + version: "14.3.1" web: dependency: transitive description: @@ -1106,5 +1106,5 @@ packages: source: hosted version: "0.0.10" sdks: - dart: ">=3.6.0 <4.0.0" - flutter: ">=3.27.4" + dart: ">=3.7.0-0 <4.0.0" + flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index d1cbe25..97295c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,17 +2,17 @@ name: venera description: "A comic app." publish_to: 'none' -version: 1.2.5+125 +version: 1.3.0+130 environment: sdk: '>=3.6.0 <4.0.0' - flutter: 3.27.4 + flutter: 3.29.0 dependencies: flutter: sdk: flutter path_provider: any - intl: ^0.19.0 + intl: any window_manager: ^0.4.3 sqlite3: ^2.4.7 sqlite3_flutter_libs: ^0.5.28