diff --git a/android/app/build.gradle b/android/app/build.gradle index df9d802..16ab7ac 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -99,6 +99,17 @@ android { } } } + debug { + ndk { + abiFilters "armeabi-v7a", "arm64-v8a", "x86_64" + } + signingConfig signingConfigs.debug + applicationVariants.all { variant -> + variant.outputs.all { output -> + versionCodeOverride = variant.versionCode * 10 + 4 + } + } + } } dependenciesInfo { diff --git a/assets/translation.json b/assets/translation.json index 6493129..4362b1b 100644 --- a/assets/translation.json +++ b/assets/translation.json @@ -160,7 +160,7 @@ "Date Desc": "日期降序", "Start": "开始", "Export App Data": "导出应用数据", - "Import App Data (Please restart after success)": "导入应用数据(成功后请手动重启)", + "Import App Data": "导入应用数据", "Export": "导出", "Download Threads": "下载线程数", "Update Time": "更新时间", @@ -229,7 +229,7 @@ "Clear History": "清除历史", "Are you sure you want to clear your history?": "确定要清除您的历史记录吗?", "No Explore Pages": "没有探索页面", - "Add a comic source in home page": "在主页添加一个漫画源", + "Please add some sources": "请添加一些源", "Please check your settings": "请检查您的设置", "No Category Pages": "没有分类页面", "Chapter @ep": "第 @ep 章", @@ -314,7 +314,11 @@ "New Version": "新版本", "@c updates": "@c 项更新", "No updates": "无更新", - "Set comic source list url": "设置漫画源列表URL" + "Set comic source list url": "设置漫画源列表URL", + "Deselect All": "取消全选", + "Add keyword": "添加关键词", + "Keyword": "关键词", + "Manage": "管理" }, "zh_TW": { "Home": "首頁", @@ -477,7 +481,7 @@ "Start": "開始", "Reversed successfully": "反轉成功", "Export App Data": "匯出應用數據", - "Import App Data (Please restart after success)": "匯入應用數據(成功后請手動重啟)", + "Import App Data": "匯入應用數據", "Export": "匯出", "Download Threads": "下載線程數", "Update Time": "更新時間", @@ -546,7 +550,7 @@ "Clear History": "清除歷史", "Are you sure you want to clear your history?": "確定要清除您的歷史記錄嗎?", "No Explore Pages": "沒有探索頁面", - "Add a comic source in home page": "在主頁添加一個漫畫源", + "Please add some sources": "請添加一些源", "Please check your settings": "請檢查您的設定", "No Category Pages": "沒有分類頁面", "Chapter @ep": "第 @ep 章", @@ -631,6 +635,10 @@ "New Version": "新版本", "@c updates": "@c 項更新", "No updates": "無更新", - "Set comic source list url": "設置漫畫源列表URL" + "Set comic source list url": "設置漫畫源列表URL", + "Deselect All": "取消全選", + "Add keyword": "添加關鍵詞", + "Keyword": "關鍵詞", + "Manage": "管理" } } \ No newline at end of file diff --git a/lib/components/appbar.dart b/lib/components/appbar.dart index 56f5dc4..c0112e2 100644 --- a/lib/components/appbar.dart +++ b/lib/components/appbar.dart @@ -1,12 +1,14 @@ part of 'components.dart'; class Appbar extends StatefulWidget implements PreferredSizeWidget { - const Appbar( - {required this.title, - this.leading, - this.actions, - this.backgroundColor, - super.key}); + const Appbar({ + required this.title, + this.leading, + this.actions, + this.backgroundColor, + this.style = AppbarStyle.blur, + super.key, + }); final Widget title; @@ -16,6 +18,8 @@ class Appbar extends StatefulWidget implements PreferredSizeWidget { final Color? backgroundColor; + final AppbarStyle style; + @override State createState() => _AppbarState(); @@ -108,10 +112,18 @@ class _AppbarState extends State { ], ).paddingTop(context.padding.top), ); - return BlurEffect( - blur: _scrolledUnder ? 15 : 0, - child: content, - ); + if (widget.style == AppbarStyle.shadow) { + return Material( + color: context.colorScheme.surface, + elevation: _scrolledUnder ? 2 : 0, + child: content, + ); + } else { + return BlurEffect( + blur: _scrolledUnder ? 15 : 0, + child: content, + ); + } } } @@ -256,18 +268,25 @@ class _MySliverAppBarDelegate extends SliverPersistentHeaderDelegate { } } -class FilledTabBar extends StatefulWidget { - const FilledTabBar({super.key, this.controller, required this.tabs}); +class AppTabBar extends StatefulWidget { + const AppTabBar({ + super.key, + this.controller, + required this.tabs, + this.actionButton, + }); final TabController? controller; final List tabs; + final Widget? actionButton; + @override - State createState() => _FilledTabBarState(); + State createState() => _AppTabBarState(); } -class _FilledTabBarState extends State { +class _AppTabBarState extends State { late TabController _controller; late List keys; @@ -315,7 +334,7 @@ class _FilledTabBarState extends State { } @override - void didUpdateWidget(covariant FilledTabBar oldWidget) { + void didUpdateWidget(covariant AppTabBar oldWidget) { if (widget.controller != oldWidget.controller) { _controller = widget.controller ?? DefaultTabController.of(context); _controller.animation!.addListener(onTabChanged); @@ -366,25 +385,27 @@ class _FilledTabBarState extends State { painter: painter, child: _TabRow( callback: _tabLayoutCallback, - children: List.generate(widget.tabs.length, buildTab), + children: List.generate(widget.tabs.length, buildTab) + ..addIfNotNull(widget.actionButton?.padding(tabPadding)), ), ).paddingHorizontal(4), ); }, ); return Container( - key: tabBarKey, - height: _kTabHeight, - width: double.infinity, - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: context.colorScheme.outlineVariant, - width: 0.6, - ), + key: tabBarKey, + height: _kTabHeight, + width: double.infinity, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: context.colorScheme.outlineVariant, + width: 0.6, ), ), - child: widget.tabs.isEmpty ? const SizedBox() : child); + ), + child: widget.tabs.isEmpty ? const SizedBox() : child, + ); } int? previousIndex; @@ -544,7 +565,7 @@ class _IndicatorPainter extends CustomPainter { var rect = Rect.fromLTWH( tabLeft + padding.left + horizontalPadding, - _FilledTabBarState._kTabHeight - 3.6, + _AppTabBarState._kTabHeight - 3.6, tabRight - tabLeft - padding.horizontal - horizontalPadding * 2, 3, ); @@ -621,7 +642,6 @@ class _TabViewBodyState extends State { } } - class SearchBarController { _SearchBarMixin? _state; @@ -894,3 +914,42 @@ class _SearchBarState extends State with _SearchBarMixin { ); } } + +class TabActionButton extends StatelessWidget { + const TabActionButton({ + super.key, + required this.icon, + required this.text, + required this.onPressed, + }); + + final Icon icon; + + final String text; + + final void Function() onPressed; + + static const _kTabHeight = 46.0; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(8), + child: Container( + height: _kTabHeight, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: IconTheme( + data: IconThemeData(size: 20, color: context.colorScheme.primary), + child: Row( + children: [ + icon, + const SizedBox(width: 8), + Text(text, style: ts.withColor(context.colorScheme.primary)), + ], + ), + ), + ), + ); + } +} diff --git a/lib/components/code.dart b/lib/components/code.dart index 11d4b13..8d18d68 100644 --- a/lib/components/code.dart +++ b/lib/components/code.dart @@ -55,7 +55,7 @@ class _CodeEditorState extends State { Widget buildLineNumbers() { return SizedBox( - width: 32, + width: 36, child: Column( children: [ for (var i = 1; i <= lineCount; i++) diff --git a/lib/components/comic.dart b/lib/components/comic.dart index 9ffe801..57353a1 100644 --- a/lib/components/comic.dart +++ b/lib/components/comic.dart @@ -356,14 +356,13 @@ class ComicTile extends StatelessWidget { ), Padding( padding: const EdgeInsets.fromLTRB(4, 4, 4, 0), - child: TextScroll( + child: Text( comic.title.replaceAll('\n', ''), - mode: TextScrollMode.endless, + maxLines: 1, + overflow: TextOverflow.clip, style: const TextStyle( fontWeight: FontWeight.w500, ), - delayBefore: Duration(milliseconds: 500), - velocity: const Velocity(pixelsPerSecond: Offset(40, 0)), ), ), ], diff --git a/lib/components/components.dart b/lib/components/components.dart index 420309f..cef1056 100644 --- a/lib/components/components.dart +++ b/lib/components/components.dart @@ -9,7 +9,6 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:syntax_highlight/syntax_highlight.dart'; -import 'package:text_scroll/text_scroll.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/app_page_route.dart'; import 'package:venera/foundation/appdata.dart'; diff --git a/lib/components/gesture.dart b/lib/components/gesture.dart index 44fd07c..d8681f8 100644 --- a/lib/components/gesture.dart +++ b/lib/components/gesture.dart @@ -58,26 +58,12 @@ class _AnimatedTapRegionState extends State { }, child: GestureDetector( onTap: widget.onTap, - child: AnimatedContainer( + child: AnimatedPhysicalModel( duration: _fastAnimationDuration, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(widget.borderRadius), - boxShadow: isHovered - ? [ - BoxShadow( - color: context.colorScheme.outline, - blurRadius: 2, - offset: const Offset(0, 2), - ), - ] - : [ - BoxShadow( - color: context.colorScheme.outlineVariant, - blurRadius: 1, - offset: const Offset(0, 1), - ), - ], - ), + elevation: isHovered ? 3 : 1, + color: context.colorScheme.surface, + shadowColor: context.colorScheme.shadow, + borderRadius: BorderRadius.circular(widget.borderRadius), child: widget.child, ), ), diff --git a/lib/components/image.dart b/lib/components/image.dart index 4a95eb8..f46de4e 100644 --- a/lib/components/image.dart +++ b/lib/components/image.dart @@ -277,35 +277,38 @@ class _AnimatedImageState extends State if (_imageInfo != null) { if (widget.part != null) { - return CustomPaint( + result = CustomPaint( + isComplex: true, painter: ImagePainter( image: _imageInfo!.image, part: widget.part!, + fit: widget.fit ?? BoxFit.cover, ), child: SizedBox( width: widget.width, height: widget.height, ), ); + } else { + result = RawImage( + image: _imageInfo?.image, + width: widget.width, + height: widget.height, + debugImageLabel: _imageInfo?.debugLabel, + scale: _imageInfo?.scale ?? 1.0, + color: widget.color, + opacity: widget.opacity, + colorBlendMode: widget.colorBlendMode, + fit: BoxFit.cover, + alignment: widget.alignment, + repeat: widget.repeat, + centerSlice: widget.centerSlice, + matchTextDirection: widget.matchTextDirection, + invertColors: _invertColors, + isAntiAlias: widget.isAntiAlias, + filterQuality: widget.filterQuality, + ); } - result = RawImage( - image: _imageInfo?.image, - width: widget.width, - height: widget.height, - debugImageLabel: _imageInfo?.debugLabel, - scale: _imageInfo?.scale ?? 1.0, - color: widget.color, - opacity: widget.opacity, - colorBlendMode: widget.colorBlendMode, - fit: BoxFit.cover, - alignment: widget.alignment, - repeat: widget.repeat, - centerSlice: widget.centerSlice, - matchTextDirection: widget.matchTextDirection, - invertColors: _invertColors, - isAntiAlias: widget.isAntiAlias, - filterQuality: widget.filterQuality, - ); } else if (_lastException != null) { result = const Center( child: Icon(Icons.error), @@ -362,10 +365,13 @@ class ImagePainter extends CustomPainter { final ImagePart part; + final BoxFit fit; + /// Render a part of the image. const ImagePainter({ required this.image, this.part = const ImagePart(), + this.fit = BoxFit.cover, }); @override @@ -377,7 +383,8 @@ class ImagePainter extends CustomPainter { part.y2 ?? image.height.toDouble(), ), ); - final Rect dst = Offset.zero & size; + var fitted = applyBoxFit(fit, Size(src.width, src.height), size).destination; + var dst = Alignment.center.inscribe(fitted, Offset.zero & size); canvas.drawImageRect(image, src, dst, Paint()); } diff --git a/lib/components/layout.dart b/lib/components/layout.dart index 1d098a2..51351c2 100644 --- a/lib/components/layout.dart +++ b/lib/components/layout.dart @@ -2,7 +2,10 @@ part of 'components.dart'; class SliverGridViewWithFixedItemHeight extends StatelessWidget { const SliverGridViewWithFixedItemHeight( - {required this.delegate, required this.maxCrossAxisExtent, required this.itemHeight, super.key}); + {required this.delegate, + required this.maxCrossAxisExtent, + required this.itemHeight, + super.key}); final SliverChildDelegate delegate; @@ -62,7 +65,8 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate { @override bool shouldRelayout(covariant SliverGridDelegate oldDelegate) { if (oldDelegate is! SliverGridDelegateWithFixedHeight) return true; - if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent || oldDelegate.itemHeight != itemHeight) { + if (oldDelegate.maxCrossAxisExtent != maxCrossAxisExtent || + oldDelegate.itemHeight != itemHeight) { return true; } return false; @@ -70,28 +74,29 @@ class SliverGridDelegateWithFixedHeight extends SliverGridDelegate { } class SliverGridDelegateWithComics extends SliverGridDelegate { - SliverGridDelegateWithComics([this.useBriefMode = false, this.scale]); + SliverGridDelegateWithComics(); - final bool useBriefMode; + final bool useBriefMode = appdata.settings['comicDisplayMode'] == 'brief'; - final double? scale; + final double scale = (appdata.settings['comicTileScale'] as num).toDouble(); @override SliverGridLayout getLayout(SliverConstraints constraints) { - if (appdata.settings['comicDisplayMode'] == 'brief' || useBriefMode) { + if (useBriefMode) { return getBriefModeLayout( constraints, - scale ?? (appdata.settings['comicTileScale'] as num).toDouble(), + scale, ); } else { return getDetailedModeLayout( constraints, - scale ?? (appdata.settings['comicTileScale'] as num).toDouble(), + scale, ); } } - SliverGridLayout getDetailedModeLayout(SliverConstraints constraints, double scale) { + SliverGridLayout getDetailedModeLayout( + SliverConstraints constraints, double scale) { const minCrossAxisExtent = 360; final itemHeight = 152 * scale; final width = constraints.crossAxisExtent; @@ -106,11 +111,14 @@ class SliverGridDelegateWithComics extends SliverGridDelegate { reverseCrossAxis: false); } - SliverGridLayout getBriefModeLayout(SliverConstraints constraints, double scale) { + SliverGridLayout getBriefModeLayout( + SliverConstraints constraints, double scale) { final maxCrossAxisExtent = 192.0 * scale; - const childAspectRatio = 0.68; + const childAspectRatio = 0.64; const crossAxisSpacing = 0.0; - int crossAxisCount = (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)).ceil(); + int crossAxisCount = + (constraints.crossAxisExtent / (maxCrossAxisExtent + crossAxisSpacing)) + .ceil(); // Ensure a minimum count of 1, can be zero and result in an infinite extent // below when the window size is 0. crossAxisCount = math.max(1, crossAxisCount); @@ -132,6 +140,11 @@ class SliverGridDelegateWithComics extends SliverGridDelegate { @override bool shouldRelayout(covariant SliverGridDelegate oldDelegate) { - return true; + if (oldDelegate is! SliverGridDelegateWithComics) return true; + if (oldDelegate.scale != scale || + oldDelegate.useBriefMode != useBriefMode) { + return true; + } + return false; } } diff --git a/lib/components/loading.dart b/lib/components/loading.dart index 161bf8f..10f8224 100644 --- a/lib/components/loading.dart +++ b/lib/components/loading.dart @@ -6,6 +6,7 @@ class NetworkError extends StatelessWidget { required this.message, this.retry, this.withAppbar = true, + this.buttonText, }); final String message; @@ -14,6 +15,8 @@ class NetworkError extends StatelessWidget { final bool withAppbar; + final String? buttonText; + @override Widget build(BuildContext context) { var cfe = CloudflareException.fromString(message); @@ -60,7 +63,7 @@ class NetworkError extends StatelessWidget { else FilledButton( onPressed: retry, - child: Text('Retry'.tl), + child: Text(buttonText ?? 'Retry'.tl), ), ], ), diff --git a/lib/components/scroll.dart b/lib/components/scroll.dart index 9eeedd1..dd0df47 100644 --- a/lib/components/scroll.dart +++ b/lib/components/scroll.dart @@ -98,8 +98,17 @@ class _SmoothScrollProviderState extends State { _controller.position.maxScrollExtent, ); if (_futurePosition == old) return; - _controller.animateTo(_futurePosition!, - duration: _fastAnimationDuration, curve: Curves.linear); + var target = _futurePosition!; + _controller.animateTo( + _futurePosition!, + duration: _fastAnimationDuration, + curve: Curves.linear, + ).then((_) { + var current = _controller.position.pixels; + if (current == target && current == _futurePosition) { + _futurePosition = null; + } + }); } }, child: ScrollControllerProvider._( diff --git a/lib/foundation/app.dart b/lib/foundation/app.dart index edbbedb..ecdf6eb 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.1"; + final version = "1.2.2"; bool get isAndroid => Platform.isAndroid; diff --git a/lib/init.dart b/lib/init.dart index ec4ea7d..f3f4c0d 100644 --- a/lib/init.dart +++ b/lib/init.dart @@ -1,4 +1,6 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_saf/flutter_saf.dart'; +import 'package:rhttp/rhttp.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/cache_manager.dart'; import 'package:venera/foundation/comic_source/comic_source.dart'; @@ -8,12 +10,12 @@ 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/utils/app_links.dart'; import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/translations.dart'; - import 'foundation/appdata.dart'; -extension FutureInit on Future { +extension _FutureInit on Future { /// Prevent unhandled exception /// /// A unhandled exception occurred in init() will cause the app to crash. @@ -27,6 +29,7 @@ extension FutureInit on Future { } Future init() async { + await Rhttp.init(); await SAFTaskWorker().init().wait(); await AppTranslation.init().wait(); await appdata.init().wait(); @@ -39,4 +42,11 @@ Future init() async { await ComicSource.init().wait(); await LocalManager().init().wait(); CacheManager().setLimitSize(appdata.settings['cacheSize']); + if (App.isAndroid) { + handleLinks(); + } + FlutterError.onError = (details) { + Log.error( + "Unhandled Exception", "${details.exception}\n${details.stack}"); + }; } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 5f0208a..42c9c82 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,13 @@ import 'dart:async'; import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flex_seed_scheme/flex_seed_scheme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:rhttp/rhttp.dart'; import 'package:venera/foundation/log.dart'; import 'package:venera/pages/auth_page.dart'; import 'package:venera/pages/main_page.dart'; -import 'package:venera/utils/app_links.dart'; import 'package:venera/utils/io.dart'; import 'package:window_manager/window_manager.dart'; import 'components/components.dart'; @@ -18,21 +17,11 @@ import 'foundation/appdata.dart'; import 'init.dart'; void main(List args) { - if (runWebViewTitleBarWidget(args)) { - return; - } + if (runWebViewTitleBarWidget(args)) return; overrideIO(() { runZonedGuarded(() async { - await Rhttp.init(); WidgetsFlutterBinding.ensureInitialized(); await init(); - if (App.isAndroid) { - handleLinks(); - } - FlutterError.onError = (details) { - Log.error( - "Unhandled Exception", "${details.exception}\n${details.stack}"); - }; runApp(const MyApp()); if (App.isDesktop) { await windowManager.ensureInitialized(); @@ -55,7 +44,7 @@ void main(List args) { }); } }, (error, stack) { - Log.error("Unhandled Exception", "$error\n$stack"); + Log.error("Unhandled Exception", error, stack); }); }); } @@ -156,50 +145,44 @@ class _MyAppState extends State with WidgetsBindingObserver { home = const MainPage(); } return DynamicColorBuilder(builder: (light, dark) { + Color? primary, secondary, tertiary; if (appdata.settings['color'] != 'system' || light == null || dark == null) { - var color = translateColorSetting(); - light = ColorScheme.fromSeed( - seedColor: color, - surface: Colors.white, - ); - dark = ColorScheme.fromSeed( - seedColor: color, - brightness: Brightness.dark, - surface: Colors.black, - ); + primary = translateColorSetting(); } else { - light = ColorScheme.fromSeed( - seedColor: light.primary, - surface: Colors.white, - ); - dark = ColorScheme.fromSeed( - seedColor: dark.primary, - brightness: Brightness.dark, - surface: Colors.black, - ); + primary = light.primary; + secondary = light.secondary; + tertiary = light.tertiary; } return MaterialApp( home: home, debugShowCheckedModeBanner: false, theme: ThemeData( - colorScheme: light, - fontFamily: App.isWindows ? "Microsoft YaHei" : null, + colorScheme: SeedColorScheme.fromSeeds( + primaryKey: primary, + secondaryKey: secondary, + tertiaryKey: tertiary, + tones: FlexTones.vividBackground(Brightness.light), + ), ), navigatorKey: App.rootNavigatorKey, darkTheme: ThemeData( - colorScheme: dark, - fontFamily: App.isWindows ? "Microsoft YaHei" : null, + colorScheme: SeedColorScheme.fromSeeds( + primaryKey: primary, + secondaryKey: secondary, + tertiaryKey: tertiary, + brightness: Brightness.dark, + tones: FlexTones.vividBackground(Brightness.dark), + ), ), themeMode: switch (appdata.settings['theme_mode']) { 'light' => ThemeMode.light, 'dark' => ThemeMode.dark, _ => ThemeMode.system }, - localizationsDelegates: const [ + localizationsDelegates: [ GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, ], locale: () { @@ -215,9 +198,9 @@ class _MyAppState extends State with WidgetsBindingObserver { }; }(), supportedLocales: const [ - Locale('en'), Locale('zh', 'CN'), Locale('zh', 'TW'), + Locale('en'), ], builder: (context, widget) { ErrorWidget.builder = (details) { diff --git a/lib/network/cache.dart b/lib/network/cache.dart index 18bb514..7a08f58 100644 --- a/lib/network/cache.dart +++ b/lib/network/cache.dart @@ -1,5 +1,5 @@ import 'dart:typed_data'; -import 'package:dio/dio.dart'; +import 'package:venera/network/app_dio.dart'; class NetworkCache { final Uri uri; @@ -117,7 +117,7 @@ class NetworkCacheManager implements Interceptor { var o = options.copyWith( method: "HEAD", ); - var dio = Dio(); + var dio = AppDio(); var response = await dio.fetch(o); if (response.statusCode == 200 && compareHeaders(cache.responseHeaders, response.headers.map)) { diff --git a/lib/network/download.dart b/lib/network/download.dart index cb659d3..5227b0d 100644 --- a/lib/network/download.dart +++ b/lib/network/download.dart @@ -487,6 +487,7 @@ Future> runWithRetry(Future Function() task, if (i == retry - 1) { return Res.error(e.toString()); } + await Future.delayed(Duration(seconds: i + 1)); } } throw UnimplementedError(); diff --git a/lib/pages/categories_page.dart b/lib/pages/categories_page.dart index bb53b54..78f46c3 100644 --- a/lib/pages/categories_page.dart +++ b/lib/pages/categories_page.dart @@ -3,80 +3,128 @@ 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/pages/ranking_page.dart'; import 'package:venera/pages/search_result_page.dart'; +import 'package:venera/pages/settings/settings_page.dart'; +import 'package:venera/utils/ext.dart'; import 'package:venera/utils/translations.dart'; import 'category_comics_page.dart'; +import 'comic_source_page.dart'; -class CategoriesPage extends StatelessWidget { +class CategoriesPage extends StatefulWidget { const CategoriesPage({super.key}); + @override + State createState() => _CategoriesPageState(); +} + +class _CategoriesPageState extends State { + var categories = []; + + void onSettingsChanged() { + var categories = + List.from(appdata.settings["categories"]).whereType().toList(); + var allCategories = ComicSource.all() + .map((e) => e.categoryData?.key) + .where((element) => element != null) + .map((e) => e!) + .toList(); + categories = + categories.where((element) => allCategories.contains(element)).toList(); + if (!categories.isEqualsTo(this.categories)) { + setState(() { + this.categories = categories; + }); + } + } + + @override + void initState() { + super.initState(); + var categories = + List.from(appdata.settings["categories"]).whereType().toList(); + var allCategories = ComicSource.all() + .map((e) => e.categoryData?.key) + .where((element) => element != null) + .map((e) => e!) + .toList(); + this.categories = + categories.where((element) => allCategories.contains(element)).toList(); + appdata.settings.addListener(onSettingsChanged); + } + + void addPage() { + showPopUpWidget(App.rootContext, setCategoryPagesWidget()); + } + + @override + void dispose() { + super.dispose(); + appdata.settings.removeListener(onSettingsChanged); + } + + Widget buildEmpty() { + var msg = "No Category Pages".tl; + msg += '\n'; + VoidCallback onTap; + if (ComicSource.isEmpty) { + msg += "Please add some sources".tl; + onTap = () { + context.to(() => ComicSourcePage()); + }; + } else { + msg += "Please check your settings".tl; + onTap = addPage; + } + return NetworkError( + message: msg, + retry: onTap, + withAppbar: false, + buttonText: "Manage".tl, + ); + } + @override Widget build(BuildContext context) { - return StateBuilder( - tag: "category", - init: SimpleController(), - builder: (controller) { - var categories = List.from(appdata.settings["categories"]); - var allCategories = ComicSource.all() - .map((e) => e.categoryData?.key) - .where((element) => element != null) - .map((e) => e!) - .toList(); - categories = categories - .where((element) => allCategories.contains(element)) - .toList(); + if (categories.isEmpty) { + return buildEmpty(); + } - if(categories.isEmpty) { - var msg = "No Category Pages".tl; - msg += '\n'; - if(ComicSource.isEmpty) { - msg += "Add a comic source in home page".tl; - } else { - msg += "Please check your settings".tl; - } - return NetworkError( - message: msg, - retry: () { - controller.update(); - }, - withAppbar: false, - ); - } - - return Material( - child: DefaultTabController( - length: categories.length, - key: Key(categories.toString()), - child: Column( - children: [ - FilledTabBar( - key: PageStorageKey(categories.toString()), - tabs: categories.map((e) { - String title = e; - try { - title = getCategoryDataWithKey(e).title; - } catch (e) { - // - } - return Tab( - text: title, - key: Key(e), - ); - }).toList(), - ).paddingTop(context.padding.top), - Expanded( - child: TabBarView( - children: - categories.map((e) => _CategoryPage(e)).toList()), - ) - ], - ), - ), - ); - }, + return Material( + child: DefaultTabController( + length: categories.length, + key: Key(categories.toString()), + child: Column( + children: [ + AppTabBar( + key: PageStorageKey(categories.toString()), + tabs: categories.map((e) { + String title = e; + try { + title = getCategoryDataWithKey(e).title; + } catch (e) { + // + } + return Tab( + text: title, + key: Key(e), + ); + }).toList(), + actionButton: TabActionButton( + icon: const Icon(Icons.add), + text: "Add".tl, + onPressed: addPage, + ), + ).paddingTop(context.padding.top), + Expanded( + child: TabBarView( + children: categories.map((e) => _CategoryPage(e)).toList(), + ), + ) + ], + ), + ), ); } } diff --git a/lib/pages/comic_page.dart b/lib/pages/comic_page.dart index 6fdc3e6..7267d8e 100644 --- a/lib/pages/comic_page.dart +++ b/lib/pages/comic_page.dart @@ -1283,7 +1283,9 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> { y2 = double.parse(r.split('-')[1]); } } - } finally {} + } catch (_) { + // ignore + } part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2); } return Padding( @@ -1297,30 +1299,29 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> { child: InkWell( onTap: () => state.read(null, index + 1), borderRadius: - const BorderRadius.all(Radius.circular(16)), + const BorderRadius.all(Radius.circular(8)), child: Container( - decoration: BoxDecoration( - borderRadius: - const BorderRadius.all(Radius.circular(16)), + 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, - child: ClipRRect( - borderRadius: - const BorderRadius.all(Radius.circular(16)), - child: AnimatedImage( - image: CachedImageProvider( - url, - sourceKey: state.widget.sourceKey, - ), - fit: BoxFit.contain, - width: double.infinity, - height: double.infinity, - part: part, + clipBehavior: Clip.antiAlias, + child: AnimatedImage( + image: CachedImageProvider( + url, + sourceKey: state.widget.sourceKey, ), + fit: BoxFit.contain, + width: double.infinity, + height: double.infinity, + part: part, ), ), ), @@ -1336,7 +1337,7 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> { ), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 200, - childAspectRatio: 0.65, + childAspectRatio: 0.68, ), ), if (error != null) @@ -2000,6 +2001,7 @@ class _ComicPageLoadingPlaceHolder extends StatelessWidget { } return Shimmer( + color: context.isDarkMode ? Colors.grey.shade700 : Colors.white, child: Column( children: [ Appbar(title: Text(""), backgroundColor: context.colorScheme.surface), diff --git a/lib/pages/comic_source_page.dart b/lib/pages/comic_source_page.dart index dccc945..bdbb94c 100644 --- a/lib/pages/comic_source_page.dart +++ b/lib/pages/comic_source_page.dart @@ -19,8 +19,7 @@ class ComicSourcePage extends StatefulWidget { return 0; } var dio = AppDio(); - var res = await dio.get( - "https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json"); + var res = await dio.get(appdata.settings['comicSourceListUrl']); if (res.statusCode != 200) { return -1; } @@ -298,10 +297,10 @@ class _BodyState extends State<_Body> { // } } - context.to(() => _EditFilePage(source.filePath)).then((value) async { - await ComicSource.reload(); - setState(() {}); - }); + context.to(() => _EditFilePage(source.filePath, () async { + await ComicSource.reload(); + setState(() {}); + })); } static Future update(ComicSource source) async { @@ -419,7 +418,8 @@ class _BodyState extends State<_Body> { } void help() { - launchUrlString("https://github.com/venera-app/venera/blob/master/doc/comic_source.md"); + launchUrlString( + "https://github.com/venera-app/venera/blob/master/doc/comic_source.md"); } Future handleAddSource(String url) async { @@ -521,18 +521,29 @@ class _ComicSourceListState extends State<_ComicSourceList> { var key = json![index]["key"]; var action = currentKey.contains(key) ? const Icon(Icons.check, size: 20).paddingRight(8) - : Tooltip( - message: "Add", - child: Button.icon( - color: context.colorScheme.primary, - icon: const Icon(Icons.add), - onPressed: () async { - await widget.onAdd( - "https://raw.githubusercontent.com/venera-app/venera-configs/master/${json![index]["fileName"]}"); - setState(() {}); - }, - ), - ); + : Button.filled( + child: Text("Add".tl), + onPressed: () async { + var fileName = json![index]["fileName"]; + var url = json![index]["url"]; + if (url == null || !(url.toString()).isURL) { + var listUrl = + appdata.settings['comicSourceListUrl'] as String; + if (listUrl + .replaceFirst("https://", "") + .replaceFirst("http://", "") + .contains("/")) { + url = + listUrl.substring(0, listUrl.lastIndexOf("/") + 1) + + fileName; + } else { + url = '$listUrl/$fileName'; + } + } + await widget.onAdd(url); + setState(() {}); + }, + ).fixHeight(32); return ListTile( title: Text(json![index]["name"]), @@ -617,10 +628,12 @@ void _addAllPagesWithComicSource(ComicSource source) { } class _EditFilePage extends StatefulWidget { - const _EditFilePage(this.path); + const _EditFilePage(this.path, this.onExit); final String path; + final void Function() onExit; + @override State<_EditFilePage> createState() => __EditFilePageState(); } @@ -637,6 +650,7 @@ class __EditFilePageState extends State<_EditFilePage> { @override void dispose() { File(widget.path).writeAsStringSync(current); + widget.onExit(); super.dispose(); } diff --git a/lib/pages/comments_page.dart b/lib/pages/comments_page.dart index 4196a4d..9490620 100644 --- a/lib/pages/comments_page.dart +++ b/lib/pages/comments_page.dart @@ -73,6 +73,7 @@ class _CommentsPageState extends State { resizeToAvoidBottomInset: false, appBar: Appbar( title: Text("Comments".tl), + style: AppbarStyle.shadow, ), body: buildBody(context), ); @@ -529,6 +530,7 @@ class _Tag { 'u' => style.underline, 's' => style.lineThrough, 'a' => style.withColor(context.colorScheme.primary), + 'strong' => style.bold, 'span' => () { if (attributes.containsKey('style')) { var s = attributes['style']!; @@ -622,10 +624,14 @@ class RichCommentContent extends StatefulWidget { class _RichCommentContentState extends State { var textSpan = []; var images = <_CommentImage>[]; + bool isRendered = false; @override void didChangeDependencies() { - render(); + if (!isRendered) { + render(); + isRendered = true; + } super.didChangeDependencies(); } @@ -670,7 +676,7 @@ class _RichCommentContentState extends State { attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', ''); } } - const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span']; + const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span', 'strong']; if (acceptedTags.contains(tagName)) { writeBuffer(); if (tagName == 'img') { diff --git a/lib/pages/explore_page.dart b/lib/pages/explore_page.dart index 47866c5..421be80 100644 --- a/lib/pages/explore_page.dart +++ b/lib/pages/explore_page.dart @@ -5,7 +5,9 @@ import 'package:venera/foundation/appdata.dart'; import 'package:venera/foundation/comic_source/comic_source.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'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/translations.dart'; @@ -56,6 +58,10 @@ class _ExplorePageState extends State } } + void addPage() { + showPopUpWidget(App.rootContext, setExplorePagesWidget()); + } + NaviPaneState? naviPane; @override @@ -117,15 +123,21 @@ class _ExplorePageState extends State Widget buildEmpty() { var msg = "No Explore Pages".tl; msg += '\n'; + VoidCallback onTap; if (ComicSource.isEmpty) { - msg += "Add a comic source in home page".tl; + msg += "Please add some sources".tl; + onTap = () { + context.to(() => ComicSourcePage()); + }; } else { msg += "Please check your settings".tl; + onTap = addPage; } return NetworkError( message: msg, - retry: onSettingsChanged, + retry: onTap, withAppbar: false, + buttonText: "Manage".tl, ); } @@ -137,10 +149,15 @@ class _ExplorePageState extends State } Widget tabBar = Material( - child: FilledTabBar( + child: AppTabBar( key: PageStorageKey(pages.toString()), tabs: pages.map((e) => buildTab(e)).toList(), controller: controller, + actionButton: TabActionButton( + icon: const Icon(Icons.add), + text: "Add".tl, + onPressed: addPage, + ), ), ).paddingTop(context.padding.top); diff --git a/lib/pages/image_favorites_page/image_favorites_page.dart b/lib/pages/image_favorites_page/image_favorites_page.dart index ea1b7ac..83171c3 100644 --- a/lib/pages/image_favorites_page/image_favorites_page.dart +++ b/lib/pages/image_favorites_page/image_favorites_page.dart @@ -391,7 +391,7 @@ class _ImageFavoritesDialogState extends State<_ImageFavoritesDialog> { Widget build(BuildContext context) { Widget tabBar = Material( borderRadius: BorderRadius.circular(8), - child: FilledTabBar( + child: AppTabBar( key: PageStorageKey(optionTypes), tabs: optionTypes.map((e) => Tab(text: e.tl, key: Key(e))).toList(), ), diff --git a/lib/pages/reader/scaffold.dart b/lib/pages/reader/scaffold.dart index 00453d3..7c3927c 100644 --- a/lib/pages/reader/scaffold.dart +++ b/lib/pages/reader/scaffold.dart @@ -48,7 +48,7 @@ class _ReaderScaffoldState extends State<_ReaderScaffold> { var readerMode = context.reader.mode; if (value == 1 && showFloatingButtonValue == 0) { showFloatingButtonValue = 1; - _floatingButtonDragListener = _DragListener( + _floatingButtonDragListener = _DragListener( onMove: (offset) { if (readerMode == ReaderMode.continuousTopToBottom) { fABValue.value -= offset.dy; @@ -845,6 +845,7 @@ class _BatteryWidgetState extends State<_BatteryWidget> { late int _batteryLevel = 100; Timer? _timer; bool _hasBattery = false; + BatteryState state = BatteryState.unknown; @override void initState() { @@ -856,29 +857,23 @@ class _BatteryWidgetState extends State<_BatteryWidget> { void _checkBatteryAvailability() async { try { _batteryLevel = await _battery.batteryLevel; - if (_batteryLevel != -1) { + state = await _battery.batteryState; + if (_batteryLevel > 0 && state != BatteryState.unknown) { setState(() { _hasBattery = true; - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - _battery.batteryLevel.then((level) => { - if (_batteryLevel != level) - { - setState(() { - _batteryLevel = level; - }) - } - }); + }); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + _battery.batteryLevel.then((level) { + if (_batteryLevel != level) { + setState(() { + _batteryLevel = level; + }); + } }); }); - } else { - setState(() { - _hasBattery = false; - }); } - } catch (e) { - setState(() { - _hasBattery = false; - }); + } catch (_) { + // ignore } } @@ -900,7 +895,9 @@ class _BatteryWidgetState extends State<_BatteryWidget> { IconData batteryIcon; Color batteryColor = context.colorScheme.onSurface; - if (batteryLevel >= 96) { + if (state == BatteryState.charging) { + batteryIcon = Icons.battery_charging_full; + } else if (batteryLevel >= 96) { batteryIcon = Icons.battery_full_sharp; } else if (batteryLevel >= 84) { batteryIcon = Icons.battery_6_bar_sharp; diff --git a/lib/pages/settings/app.dart b/lib/pages/settings/app.dart index 368de45..c324af9 100644 --- a/lib/pages/settings/app.dart +++ b/lib/pages/settings/app.dart @@ -107,7 +107,7 @@ class _AppSettingsState extends State { actionTitle: 'Export'.tl, ).toSliver(), _CallbackSetting( - title: "Import App Data (Please restart after success)".tl, + title: "Import App Data".tl, callback: () async { var controller = showLoadingDialog(context); var file = await selectFile(ext: ['venera', 'picadata']); @@ -126,6 +126,7 @@ class _AppSettingsState extends State { context.showMessage(message: "Failed to import data".tl); } finally { cacheFile.deleteIgnoreError(); + App.forceRebuild(); } } controller.close(); diff --git a/lib/pages/settings/explore_settings.dart b/lib/pages/settings/explore_settings.dart index 459678b..dc40018 100644 --- a/lib/pages/settings/explore_settings.dart +++ b/lib/pages/settings/explore_settings.dart @@ -30,35 +30,11 @@ class _ExploreSettingsState extends State { ).toSliver(), _PopupWindowSetting( title: "Explore Pages".tl, - builder: () { - var pages = {}; - for (var c in ComicSource.all()) { - for (var page in c.explorePages) { - pages[page.title] = page.title; - } - } - return _MultiPagesFilter( - title: "Explore Pages".tl, - settingsIndex: "explore_pages", - pages: pages, - ); - }, + builder: setExplorePagesWidget, ).toSliver(), _PopupWindowSetting( title: "Category Pages".tl, - builder: () { - var pages = {}; - for (var c in ComicSource.all()) { - if (c.categoryData != null) { - pages[c.categoryData!.key] = c.categoryData!.title; - } - } - return _MultiPagesFilter( - title: "Category Pages".tl, - settingsIndex: "categories", - pages: pages, - ); - }, + builder: setCategoryPagesWidget, ).toSliver(), _PopupWindowSetting( title: "Network Favorite Pages".tl, @@ -132,8 +108,9 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> { return PopUpWidgetScaffold( title: "Keyword blocking".tl, tailing: [ - IconButton( + TextButton.icon( icon: const Icon(Icons.add), + label: Text("Add".tl), onPressed: add, ), ], @@ -159,7 +136,6 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> { void add() { showDialog( context: App.rootContext, - barrierColor: Colors.black.toOpacity(0.1), builder: (context) { var controller = TextEditingController(); String? error; @@ -205,3 +181,31 @@ class _ManageBlockingWordViewState extends State<_ManageBlockingWordView> { ); } } + +Widget setExplorePagesWidget() { + var pages = {}; + for (var c in ComicSource.all()) { + for (var page in c.explorePages) { + pages[page.title] = page.title.ts(c.key); + } + } + return _MultiPagesFilter( + title: "Explore Pages".tl, + settingsIndex: "explore_pages", + pages: pages, + ); +} + +Widget setCategoryPagesWidget() { + var pages = {}; + for (var c in ComicSource.all()) { + if (c.categoryData != null) { + pages[c.categoryData!.key] = c.categoryData!.title; + } + } + return _MultiPagesFilter( + title: "Category Pages".tl, + settingsIndex: "categories", + pages: pages, + ); +} \ No newline at end of file diff --git a/lib/pages/settings/setting_components.dart b/lib/pages/settings/setting_components.dart index 9b8d94a..a842a1f 100644 --- a/lib/pages/settings/setting_components.dart +++ b/lib/pages/settings/setting_components.dart @@ -376,6 +376,14 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> { super.initState(); } + @override + void dispose() { + super.dispose(); + Future.microtask(() { + updateSetting(); + }); + } + var reorderWidgetKey = UniqueKey(); var scrollController = ScrollController(); final _key = GlobalKey(); @@ -404,7 +412,6 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> { setState(() { keys = List.from(reorderFunc(keys)); }); - updateSetting(); }, children: tiles, builder: (children) { @@ -424,7 +431,11 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> { title: widget.title, tailing: [ if (keys.length < widget.pages.length) - IconButton(onPressed: showAddDialog, icon: const Icon(Icons.add)) + TextButton.icon( + label: Text("Add".tl), + icon: const Icon(Icons.add), + onPressed: showAddDialog, + ) ], body: view, ); @@ -438,9 +449,8 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> { setState(() { keys.remove(key); }); - updateSetting(); }, - icon: const Icon(Icons.delete)), + icon: const Icon(Icons.delete_outline)), ); return ListTile( @@ -463,30 +473,68 @@ class _MultiPagesFilterState extends State<_MultiPagesFilter> { canAdd[key] = value; } }); + var selected = []; showDialog( context: context, builder: (context) { - return ContentDialog( - title: "Add".tl, - content: Column( - mainAxisSize: MainAxisSize.min, - children: canAdd.entries - .map( - (e) => ListTile( - title: Text(e.value), - key: Key(e.key), - onTap: () { - context.pop(); - setState(() { - keys.add(e.key); - }); - updateSetting(); - }, - ), + return StatefulBuilder(builder: (context, setState) { + return ContentDialog( + title: "Add".tl, + content: Column( + mainAxisSize: MainAxisSize.min, + children: canAdd.entries + .map( + (e) => CheckboxListTile( + value: selected.contains(e.key), + title: Text(e.value), + key: Key(e.key), + onChanged: (value) { + setState(() { + if (value!) { + selected.add(e.key); + } else { + selected.remove(e.key); + } + }); + }, + ), + ) + .toList(), + ), + actions: [ + if (selected.length < canAdd.length) + TextButton( + child: Text("Select All".tl), + onPressed: () { + setState(() { + selected = canAdd.keys.toList(); + }); + }, ) - .toList(), - ), - ); + else + TextButton( + child: Text("Deselect All".tl), + onPressed: () { + setState(() { + selected.clear(); + }); + }, + ), + const SizedBox(width: 8), + FilledButton( + onPressed: selected.isNotEmpty + ? () { + this.setState(() { + keys.addAll(selected); + }); + Navigator.pop(context); + } + : null, + child: Text("Add".tl), + ), + ], + ); + }); }, ); } diff --git a/lib/pages/settings/settings_page.dart b/lib/pages/settings/settings_page.dart index 3827206..b08de03 100644 --- a/lib/pages/settings/settings_page.dart +++ b/lib/pages/settings/settings_page.dart @@ -170,7 +170,39 @@ class _SettingsPageState extends State implements PopEntry { ), ), ), - Expanded(child: buildRight()) + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, animation) { + return LayoutBuilder( + builder: (context, constrains) { + return AnimatedBuilder( + animation: animation, + builder: (context, _) { + var width = constrains.maxWidth; + var value = animation.isForwardOrCompleted + ? 1 - animation.value + : 1; + var left = width * value; + return Stack( + children: [ + Positioned( + top: 0, + bottom: 0, + left: left, + width: width, + child: child, + ), + ], + ); + }, + ); + }, + ); + }, + child: buildRight(), + ), + ) ], ); } else { @@ -179,14 +211,13 @@ class _SettingsPageState extends State implements PopEntry { Positioned.fill(child: buildLeft()), Positioned( left: offset, - right: 0, + width: MediaQuery.of(context).size.width, top: 0, bottom: 0, child: Listener( onPointerDown: handlePointerDown, child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - reverseDuration: const Duration(milliseconds: 300), + duration: const Duration(milliseconds: 200), switchInCurve: Curves.fastOutSlowIn, switchOutCurve: Curves.fastOutSlowIn, transitionBuilder: (child, animation) { @@ -198,11 +229,10 @@ class _SettingsPageState extends State implements PopEntry { child: child, ); }, - child: currentPage == -1 - ? const SizedBox( - key: Key("1"), - ) - : buildRight(), + child: Material( + key: ValueKey(currentPage), + child: buildRight(), + ), ), ), ) @@ -307,7 +337,7 @@ class _SettingsPageState extends State implements PopEntry { } Widget buildRight() { - final Widget body = switch (currentPage) { + return switch (currentPage) { -1 => const SizedBox(), 0 => const ExploreSettings(), 1 => const ReaderSettings(), @@ -318,10 +348,6 @@ class _SettingsPageState extends State implements PopEntry { 6 => const AboutSettings(), _ => throw UnimplementedError() }; - - return Material( - child: body, - ); } var canPop = ValueNotifier(true); diff --git a/lib/utils/data_sync.dart b/lib/utils/data_sync.dart index 285dd8d..7f6aebf 100644 --- a/lib/utils/data_sync.dart +++ b/lib/utils/data_sync.dart @@ -55,7 +55,7 @@ class DataSync with ChangeNotifier { } Future> uploadData() async { - if(isDownloading) return const Res(true); + if (isDownloading) return const Res(true); if (haveWaitingTask) return const Res(true); while (isUploading) { haveWaitingTask = true; @@ -109,7 +109,7 @@ class DataSync with ChangeNotifier { filename += '.venera'; var files = await client.readDir('/'); files = files.where((e) => e.name!.endsWith('.venera')).toList(); - var old = files.firstWhereOrNull( (e) => e.name!.startsWith("$time-")); + var old = files.firstWhereOrNull((e) => e.name!.startsWith("$time-")); if (old != null) { await client.remove(old.name!); } @@ -176,8 +176,11 @@ class DataSync with ChangeNotifier { var files = await client.readDir('/'); files.sort((a, b) => b.name!.compareTo(a.name!)); var file = files.firstWhereOrNull((e) => e.name!.endsWith('.venera')); + if (file == null) { + throw 'No data file found'; + } var version = - file!.name!.split('-').elementAtOrNull(1)?.split('.').first; + file.name!.split('-').elementAtOrNull(1)?.split('.').first; if (version != null && int.tryParse(version) != null) { var currentVersion = appdata.settings['dataVersion']; if (currentVersion != null && int.parse(version) <= currentVersion) { diff --git a/lib/utils/image.dart b/lib/utils/image.dart index b7488b8..0175365 100644 --- a/lib/utils/image.dart +++ b/lib/utils/image.dart @@ -26,7 +26,7 @@ class Image { var codec = await ui.instantiateImageCodec(data); var frame = await codec.getNextFrame(); codec.dispose(); - var info = await frame.image.toByteData(); + var info = await frame.image.toByteData(format: ui.ImageByteFormat.rawStraightRgba); if (info == null) { throw Exception('Failed to decode image'); } @@ -39,6 +39,14 @@ class Image { return image; } + Color getPixelAtIndex(int index) { + if (index < 0 || index >= _data.length) { + throw ArgumentError( + 'Invalid argument: index must be in the range of [0, ${_data.length}).'); + } + return Color.fromValue(_data[index]); + } + Image copyRange(int x, int y, int width, int height) { if (width + x > this.width) { throw ArgumentError(''' @@ -176,11 +184,11 @@ class Color { Color.fromValue(this.value); - int get r => (value >> 16) & 0xFF; + int get r => value & 0xFF; int get g => (value >> 8) & 0xFF; - int get b => value & 0xFF; + int get b => (value >> 16) & 0xFF; int get a => (value >> 24) & 0xFF; } diff --git a/lib/utils/pdf.dart b/lib/utils/pdf.dart index 95c8e2b..085ea1f 100644 --- a/lib/utils/pdf.dart +++ b/lib/utils/pdf.dart @@ -1,33 +1,28 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:isolate'; - -import 'package:pdf/widgets.dart'; +import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/local.dart'; +import 'package:venera/utils/image.dart'; import 'package:venera/utils/io.dart'; +import 'package:zip_flutter/zip_flutter.dart'; + +typedef DecodeImage = Future Function(Uint8List data); Future _createPdfFromComic({ required LocalComic comic, required String savePath, required String localPath, + required DecodeImage decodeImage, }) async { - final pdf = Document( - title: comic.title, - author: comic.subTitle ?? "", - producer: "Venera", - ); - - pdf.document.outline; + var images = []; var baseDir = comic.directory.contains('/') || comic.directory.contains('\\') ? comic.directory : FilePath.join(localPath, comic.directory); // add cover - var imageData = File(FilePath.join(baseDir, comic.cover)).readAsBytesSync(); - pdf.addPage(Page( - build: (Context context) { - return Image(MemoryImage(imageData), fit: BoxFit.contain); - }, - )); + images.add(FilePath.join(baseDir, comic.cover)); bool multiChapters = comic.chapters != null; @@ -51,42 +46,360 @@ Future _createPdfFromComic({ reorderFiles(files); for (var file in files) { - var imageData = (file as File).readAsBytesSync(); - pdf.addPage(Page( - build: (Context context) { - return Image(MemoryImage(imageData), fit: BoxFit.contain); - }, - )); + images.add(file.path); } } else { for (var chapter in comic.chapters!.keys) { var files = Directory(FilePath.join(baseDir, chapter)).listSync(); reorderFiles(files); for (var file in files) { - var imageData = (file as File).readAsBytesSync(); - pdf.addPage(Page( - build: (Context context) { - return Image(MemoryImage(imageData), fit: BoxFit.contain); - }, - )); + images.add(file.path); } } } - final file = File(savePath); - file.writeAsBytesSync(await pdf.save()); + var generator = PdfGenerator( + title: comic.title, + author: comic.subtitle, + imagePaths: images, + outputPath: savePath, + decodeImage: decodeImage, + ); + await generator.generate(); +} + +Future _runIsolate( + LocalComic comic, String savePath, SendPort sendPort) { + var localPath = LocalManager().path; + return Isolate.spawn( + (sendPort) => overrideIO( + () async { + var receivePort = ReceivePort(); + sendPort.send(receivePort.sendPort); + + Completer? completer; + + Future decodeImage(Uint8List data) async { + if (completer != null) { + throw Exception('Another image is being decoded'); + } + sendPort.send(data); + completer = Completer(); + return completer!.future; + } + + receivePort.listen((message) { + if (message is Image) { + if (completer == null) { + throw Exception('No image is being decoded'); + } + completer!.complete(message); + completer = null; + } + }); + + await _createPdfFromComic( + comic: comic, + savePath: savePath, + localPath: localPath, + decodeImage: decodeImage, + ); + + sendPort.send(null); + }, + ), + sendPort, + ); } Future createPdfFromComicIsolate({ required LocalComic comic, required String savePath, }) async { - var localPath = LocalManager().path; - return Isolate.run(() => overrideIO(() async { - return await _createPdfFromComic( - comic: comic, - savePath: savePath, - localPath: localPath, - ); - })); + var receivePort = ReceivePort(); + SendPort? sendPort; + Isolate? isolate; + var completer = Completer(); + receivePort.listen((message) { + if (message is SendPort) { + sendPort = message; + } else if (message is Uint8List) { + Image.decodeImage(message).then((image) { + sendPort!.send(image); + }); + } else if (message == null) { + receivePort.close(); + completer.complete(); + isolate!.kill(); + } + }); + isolate = await _runIsolate(comic, savePath, receivePort.sendPort); + return completer.future; +} + +class PdfGenerator { + final String title; + final String author; + final List imagePaths; + final String outputPath; + final DecodeImage decodeImage; + + // PDF文件的对象ID计数器 + int _objectId = 1; + + // 存储每个对象在PDF中的字节位置 + final Map _objectOffsets = {}; + + static const double a4Width = 595.0; // points + static const double a4Height = 842.0; // points + + PdfGenerator({ + required this.title, + required this.author, + required this.imagePaths, + required this.outputPath, + required this.decodeImage, + }); + + Future generate() async { + var file = File(outputPath); + final output = file.openWrite(); + + int length = 0; + + void write(String str) { + var data = utf8.encode(str); + output.add(data); + length += data.length; + } + + void writeData(Uint8List data) { + output.add(data); + length += data.length; + } + + int getCurrentLength() { + return length; + } + + // 1. 写入PDF头部 + write('%PDF-1.7\n%\xFF\xFF\xFF\xFF\n\n'); + + // 2. 写入Catalog对象 + _objectOffsets[_objectId] = getCurrentLength(); + write('$_objectId 0 obj\n'); + write('<<\n'); + write('/Type /Catalog\n'); + write('/Pages ${_objectId + 1} 0 R\n'); + write('>>\nendobj\n\n'); + + final catalogId = _objectId++; + + // 3. 写入Pages对象 + _objectOffsets[_objectId] = getCurrentLength(); + write('$_objectId 0 obj\n'); + write('<<\n'); + write('/Type /Pages\n'); + write('/Kids ['); + final pageIds = []; + for (var i = 0; i < imagePaths.length; i++) { + pageIds.add(_objectId + 1 + i * 3); + write('${_objectId + 1 + i * 3} 0 R '); + } + write(']\n'); + write('/Count ${imagePaths.length}\n'); + write('>>\nendobj\n\n'); + + final pagesId = _objectId++; + + // 4. 为每个图片创建Page和Image对象 + for (var i = 0; i < imagePaths.length; i++) { + final imagePath = imagePaths[i]; + final image = await _getImage(imagePath); + + // 写入Page对象 + _objectOffsets[_objectId] = getCurrentLength(); + write('$_objectId 0 obj\n'); + write('<<\n'); + write('/Type /Page\n'); + write('/Parent $pagesId 0 R\n'); + write('/Resources <<\n'); + write('/XObject << /Im${i + 1} ${_objectId + 1} 0 R >>\n'); + write('>>\n'); + write('/MediaBox [0 0 $a4Width $a4Height]\n'); + write('/Contents ${_objectId + 2} 0 R\n'); + write('>>\nendobj\n\n'); + + _objectId++; + + // 写入Image对象 + _objectOffsets[_objectId] = getCurrentLength(); + write('$_objectId 0 obj\n'); + write('<<\n'); + write('/Type /XObject\n'); + write('/Subtype /Image\n'); + write('/Width ${image.width}\n'); + write('/Height ${image.height}\n'); + write('/ColorSpace /DeviceRGB\n'); + write('/BitsPerComponent 8\n'); + write('/Filter /FlateDecode\n'); + write('/Length ${image.data.length}\n'); + write('>>\nstream\n'); + writeData(image.data); + write('\nendstream\nendobj\n\n'); + + _objectId++; + + // 写入Contents对象(绘制图片的指令) + _objectOffsets[_objectId] = getCurrentLength(); + write('$_objectId 0 obj\n'); + write('<<\n'); + var stream = ''; + stream += 'q\n'; + // Calculate scaling factors + var scaleX = a4Width / image.width; + var scaleY = a4Height / image.height; + var scale = scaleX < scaleY ? scaleX : scaleY; + // Calculate centering offsets + var offsetX = (a4Width - (image.width * scale)) / 2; + var offsetY = (a4Height - (image.height * scale)) / 2; + // Apply transformation matrix + stream += '1 0 0 1 $offsetX $offsetY cm\n'; // Translate + stream += '${scale * image.width} 0 0 ${scale * image.height} 0 0 cm\n'; + stream += '/Im${i + 1} Do\n'; + stream += 'Q\n'; + var streamData = utf8.encode(stream); + write('/Length ${streamData.length}\n'); + write('>>\nstream\n'); + writeData(streamData); + write('endstream\nendobj\n\n'); + + _objectId++; + } + + // 5. 写入Info对象(元数据) + final infoId = _objectId; + _objectOffsets[_objectId] = getCurrentLength(); + write('$_objectId 0 obj\n'); + write('<<\n'); + write('/Title <'); + writeData(_toPdfString(title)); + write('>\n'); + write('/Author <'); + writeData(_toPdfString(author)); + write('>\n'); + write('/Producer (venera v${App.version})\n'); + write('/CreationDate (D:${_formatDateTime(DateTime.now())})\n'); + write('>>\nendobj\n\n'); + + _objectId++; + + // 6. 写入交叉引用表 + final xrefOffset = getCurrentLength(); + write('xref\n'); + write('0 $_objectId\n'); + write('0000000000 65535 f\r\n'); + + for (var i = 1; i < _objectId; i++) { + final offset = _objectOffsets[i]!; + write('${offset.toString().padLeft(10, '0')} 00000 n\r\n'); // 使用\r\n + } + + // 7. 写入文件尾部 + write('trailer\n'); + write('<<\n'); + write('/Size $_objectId\n'); + write('/Root $catalogId 0 R\n'); + write('/Info $infoId 0 R\n'); + write('>>\n'); + write('startxref\n'); + write('$xrefOffset\n'); + write('%%EOF\n'); + + await output.close(); + } + + int _codeUnitForDigit(int digit) => + digit < 10 ? digit + 0x30 : digit + 0x61 - 10; + + Uint8List _toPdfString(String str) { + Uint8List data; + try { + data = latin1.encode(str); + } catch (e) { + data = Uint8List.fromList([0xfe, 0xff] + _encodeUtf16be(str)); + } + var result = []; + for (final byte in data) { + result.add(_codeUnitForDigit((byte & 0xF0) >> 4)); + result.add(_codeUnitForDigit(byte & 0x0F)); + } + return Uint8List.fromList(result); + } + + List _encodeUtf16be(String str) { + const unicodeReplacementCharacterCodePoint = 0xfffd; + const unicodeByteZeroMask = 0xff; + const unicodeByteOneMask = 0xff00; + const unicodeValidRangeMax = 0x10ffff; + const unicodePlaneOneMax = 0xffff; + const unicodeUtf16ReservedLo = 0xd800; + const unicodeUtf16ReservedHi = 0xdfff; + const unicodeUtf16Offset = 0x10000; + const unicodeUtf16SurrogateUnit0Base = 0xd800; + const unicodeUtf16SurrogateUnit1Base = 0xdc00; + const unicodeUtf16HiMask = 0xffc00; + const unicodeUtf16LoMask = 0x3ff; + + final encoding = []; + + void add(int unit) { + encoding.add((unit & unicodeByteOneMask) >> 8); + encoding.add(unit & unicodeByteZeroMask); + } + + for (final unit in str.codeUnits) { + if ((unit >= 0 && unit < unicodeUtf16ReservedLo) || + (unit > unicodeUtf16ReservedHi && unit <= unicodePlaneOneMax)) { + add(unit); + } else if (unit > unicodePlaneOneMax && unit <= unicodeValidRangeMax) { + final base = unit - unicodeUtf16Offset; + add(unicodeUtf16SurrogateUnit0Base + + ((base & unicodeUtf16HiMask) >> 10)); + add(unicodeUtf16SurrogateUnit1Base + (base & unicodeUtf16LoMask)); + } else { + add(unicodeReplacementCharacterCodePoint); + } + } + return encoding; + } + + // 格式化日期时间 + String _formatDateTime(DateTime dt) { + return dt + .toUtc() + .toString() + .replaceAll('-', '') + .replaceAll(':', '') + .replaceAll(' ', '') + .replaceAll('.', '') + .substring(0, 14); + } + + Future<({int width, int height, Uint8List data})> _getImage( + String imagePath) async { + var data = await File(imagePath).readAsBytes(); + var image = await decodeImage(data); + var width = image.width; + var height = image.height; + data = Uint8List(width * height * 3); + for (var i = 0; i < width * height; i++) { + var pixel = image.getPixelAtIndex(i); + data[i * 3] = pixel.r; + data[i * 3 + 1] = pixel.g; + data[i * 3 + 2] = pixel.b; + } + data = tdeflCompressData(data, true, true, 9); + return (width: width, height: height, data: data); + } } diff --git a/pubspec.lock b/pubspec.lock index bd2578e..d3e9212 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,14 +33,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - archive: - dependency: transitive - description: - name: archive - sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d - url: "https://pub.dev" - source: hosted - version: "3.6.1" args: dependency: transitive description: @@ -57,14 +49,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" - barcode: - dependency: transitive - description: - name: barcode - sha256: ab180ce22c6555d77d45f0178a523669db67f95856e3378259ef2ffeb43e6003 - url: "https://pub.dev" - source: hosted - version: "2.2.8" battery_plus: dependency: "direct main" description: @@ -81,14 +65,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" - bidi: - dependency: transitive - description: - name: bidi - sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d" - url: "https://pub.dev" - source: hosted - version: "2.0.12" boolean_selector: dependency: transitive description: @@ -298,6 +274,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + flex_seed_scheme: + dependency: "direct main" + description: + name: flex_seed_scheme + sha256: d3ba3c5c92d2d79d45e94b4c6c71d01fac3c15017da1545880c53864da5dfeb0 + url: "https://pub.dev" + source: hosted + version: "3.5.0" flutter: dependency: "direct main" description: flutter @@ -417,8 +401,8 @@ packages: dependency: "direct main" description: path: "." - ref: "598d50572a658f8e04775566fe3789954d9a01e3" - resolved-ref: "598d50572a658f8e04775566fe3789954d9a01e3" + ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3" + resolved-ref: "5978d0c7784fbbefcacc573547f0ab01ba59b7b3" url: "https://github.com/wgh136/flutter_qjs" source: git version: "0.3.7" @@ -521,14 +505,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.0" - image: - dependency: transitive - description: - name: image - sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d - url: "https://pub.dev" - source: hosted - version: "4.3.0" intl: dependency: "direct main" description: @@ -690,14 +666,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" path_provider: dependency: "direct main" description: @@ -746,14 +714,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - pdf: - dependency: "direct main" - description: - name: pdf - sha256: "05df53f8791587402493ac97b9869d3824eccbc77d97855f4545cf72df3cae07" - url: "https://pub.dev" - source: hosted - version: "3.11.1" petitparser: dependency: transitive description: @@ -795,14 +755,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.9.1" - qr: - dependency: transitive - description: - name: qr - sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" - url: "https://pub.dev" - source: hosted - version: "3.0.2" rhttp: dependency: "direct main" description: @@ -977,14 +929,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.3" - text_scroll: - dependency: "direct main" - description: - name: text_scroll - sha256: "7869d86a6fdd725dee56bdd150216a99f0372b82fbfcac319214dbd5f36e1908" - url: "https://pub.dev" - source: hosted - version: "0.2.0" typed_data: dependency: transitive description: @@ -1139,7 +1083,7 @@ packages: source: hosted version: "6.5.0" yaml: - dependency: "direct main" + dependency: transitive description: name: yaml sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" @@ -1150,10 +1094,10 @@ packages: dependency: "direct main" description: name: zip_flutter - sha256: ea7fdc86c988174ef3bb80dc26e8e8bfdf634c55930e2d18d7e77e991acf0483 + sha256: fe63ef9098bb2426b001adba2e28029820d71ce80cce957a36676bd6b3227245 url: "https://pub.dev" source: hosted - version: "0.0.8" + version: "0.0.9" sdks: dart: ">=3.6.0 <4.0.0" - flutter: ">=3.27.2" + flutter: ">=3.27.3" diff --git a/pubspec.yaml b/pubspec.yaml index c28528f..b723d14 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,18 +2,16 @@ name: venera description: "A comic app." publish_to: 'none' -version: 1.2.1+121 +version: 1.2.2+122 environment: sdk: '>=3.6.0 <4.0.0' - flutter: 3.27.2 + flutter: 3.27.3 dependencies: flutter: sdk: flutter path_provider: any - flutter_localizations: - sdk: flutter intl: ^0.19.0 window_manager: ^0.4.3 sqlite3: ^2.4.7 @@ -21,11 +19,11 @@ dependencies: flutter_qjs: git: url: https://github.com/wgh136/flutter_qjs - ref: 598d50572a658f8e04775566fe3789954d9a01e3 + ref: 5978d0c7784fbbefcacc573547f0ab01ba59b7b3 crypto: ^3.0.6 dio: ^5.7.0 html: ^0.15.5 - pointycastle: any + pointycastle: ^3.9.1 url_launcher: ^6.3.0 path: ^1.9.0 photo_view: @@ -40,7 +38,6 @@ dependencies: ref: 09e756b1f1b04e6298318d99ec20a787fb360f59 path: packages/scrollable_positioned_list flutter_reorderable_grid_view: ^5.4.0 - yaml: any uuid: ^4.5.1 desktop_webview_window: git: @@ -51,7 +48,7 @@ dependencies: sliver_tools: ^0.2.12 flutter_file_dialog: ^3.0.2 file_selector: ^1.0.3 - zip_flutter: ^0.0.8 + zip_flutter: ^0.0.9 lodepng_flutter: git: url: https://github.com/venera-app/lodepng_flutter @@ -67,16 +64,17 @@ dependencies: git: url: https://github.com/pkuislm/flutter_saf.git ref: 7637b8b67d0a831f3cd7e702b8173e300880d32e - pdf: ^3.11.1 dynamic_color: ^1.7.0 shimmer_animation: ^2.1.0 flutter_memory_info: ^0.0.1 syntax_highlight: ^0.4.0 - text_scroll: ^0.2.0 flutter_7zip: git: url: https://github.com/wgh136/flutter_7zip ref: b33344797f1d2469339e0e1b75f5f954f1da224c + flex_seed_scheme: ^3.5.0 + flutter_localizations: + sdk: flutter dev_dependencies: flutter_test: