Merge pull request #157 from venera-app/dev

v1.2.2
This commit is contained in:
nyne
2025-01-26 14:29:13 +08:00
committed by GitHub
32 changed files with 974 additions and 457 deletions

View File

@@ -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 {

View File

@@ -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": "管理"
}
}

View File

@@ -1,12 +1,14 @@
part of 'components.dart';
class Appbar extends StatefulWidget implements PreferredSizeWidget {
const Appbar(
{required this.title,
const Appbar({
required this.title,
this.leading,
this.actions,
this.backgroundColor,
super.key});
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<Appbar> createState() => _AppbarState();
@@ -108,12 +112,20 @@ class _AppbarState extends State<Appbar> {
],
).paddingTop(context.padding.top),
);
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,
);
}
}
}
enum AppbarStyle {
blur,
@@ -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<Tab> tabs;
final Widget? actionButton;
@override
State<FilledTabBar> createState() => _FilledTabBarState();
State<AppTabBar> createState() => _AppTabBarState();
}
class _FilledTabBarState extends State<FilledTabBar> {
class _AppTabBarState extends State<AppTabBar> {
late TabController _controller;
late List<GlobalKey> keys;
@@ -315,7 +334,7 @@ class _FilledTabBarState extends State<FilledTabBar> {
}
@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,7 +385,8 @@ class _FilledTabBarState extends State<FilledTabBar> {
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),
);
@@ -384,7 +404,8 @@ class _FilledTabBarState extends State<FilledTabBar> {
),
),
),
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<TabViewBody> {
}
}
class SearchBarController {
_SearchBarMixin? _state;
@@ -894,3 +914,42 @@ class _SearchBarState extends State<AppSearchBar> 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)),
],
),
),
),
);
}
}

View File

@@ -55,7 +55,7 @@ class _CodeEditorState extends State<CodeEditor> {
Widget buildLineNumbers() {
return SizedBox(
width: 32,
width: 36,
child: Column(
children: [
for (var i = 1; i <= lineCount; i++)

View File

@@ -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)),
),
),
],

View File

@@ -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';

View File

@@ -58,26 +58,12 @@ class _AnimatedTapRegionState extends State<AnimatedTapRegion> {
},
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
child: AnimatedPhysicalModel(
duration: _fastAnimationDuration,
decoration: BoxDecoration(
elevation: isHovered ? 3 : 1,
color: context.colorScheme.surface,
shadowColor: context.colorScheme.shadow,
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),
),
],
),
child: widget.child,
),
),

View File

@@ -277,17 +277,19 @@ class _AnimatedImageState extends State<AnimatedImage>
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,
@@ -306,6 +308,7 @@ class _AnimatedImageState extends State<AnimatedImage>
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());
}

View File

@@ -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) {
if (oldDelegate is! SliverGridDelegateWithComics) return true;
if (oldDelegate.scale != scale ||
oldDelegate.useBriefMode != useBriefMode) {
return true;
}
return false;
}
}

View File

@@ -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),
),
],
),

View File

@@ -98,8 +98,17 @@ class _SmoothScrollProviderState extends State<SmoothScrollProvider> {
_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._(

View File

@@ -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;

View File

@@ -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<T> on Future<T> {
extension _FutureInit<T> on Future<T> {
/// Prevent unhandled exception
///
/// A unhandled exception occurred in init() will cause the app to crash.
@@ -27,6 +29,7 @@ extension FutureInit<T> on Future<T> {
}
Future<void> init() async {
await Rhttp.init();
await SAFTaskWorker().init().wait();
await AppTranslation.init().wait();
await appdata.init().wait();
@@ -39,4 +42,11 @@ Future<void> 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}");
};
}

View File

@@ -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<String> 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<String> args) {
});
}
}, (error, stack) {
Log.error("Unhandled Exception", "$error\n$stack");
Log.error("Unhandled Exception", error, stack);
});
});
}
@@ -156,50 +145,44 @@ class _MyAppState extends State<MyApp> 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<MyApp> with WidgetsBindingObserver {
};
}(),
supportedLocales: const [
Locale('en'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
Locale('en'),
],
builder: (context, widget) {
ErrorWidget.builder = (details) {

View File

@@ -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)) {

View File

@@ -487,6 +487,7 @@ Future<Res<T>> runWithRetry<T>(Future<T> Function() task,
if (i == retry - 1) {
return Res.error(e.toString());
}
await Future.delayed(Duration(seconds: i + 1));
}
}
throw UnimplementedError();

View File

@@ -3,56 +3,101 @@ 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
Widget build(BuildContext context) {
return StateBuilder<SimpleController>(
tag: "category",
init: SimpleController(),
builder: (controller) {
var categories = List.from(appdata.settings["categories"]);
State<CategoriesPage> createState() => _CategoriesPageState();
}
class _CategoriesPageState extends State<CategoriesPage> {
var categories = <String>[];
void onSettingsChanged() {
var categories =
List.from(appdata.settings["categories"]).whereType<String>().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();
categories =
categories.where((element) => allCategories.contains(element)).toList();
if (!categories.isEqualsTo(this.categories)) {
setState(() {
this.categories = categories;
});
}
}
if(categories.isEmpty) {
@override
void initState() {
super.initState();
var categories =
List.from(appdata.settings["categories"]).whereType<String>().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 += "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: () {
controller.update();
},
retry: onTap,
withAppbar: false,
buttonText: "Manage".tl,
);
}
@override
Widget build(BuildContext context) {
if (categories.isEmpty) {
return buildEmpty();
}
return Material(
child: DefaultTabController(
length: categories.length,
key: Key(categories.toString()),
child: Column(
children: [
FilledTabBar(
AppTabBar(
key: PageStorageKey(categories.toString()),
tabs: categories.map((e) {
String title = e;
@@ -66,18 +111,21 @@ class CategoriesPage extends StatelessWidget {
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()),
children: categories.map((e) => _CategoryPage(e)).toList(),
),
)
],
),
),
);
},
);
}
}

View File

@@ -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,20 +1299,20 @@ 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)),
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
url,
@@ -1324,7 +1326,6 @@ class _ComicThumbnailsState extends State<_ComicThumbnails> {
),
),
),
),
const SizedBox(
height: 4,
),
@@ -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),

View File

@@ -19,8 +19,7 @@ class ComicSourcePage extends StatefulWidget {
return 0;
}
var dio = AppDio();
var res = await dio.get<String>(
"https://raw.githubusercontent.com/venera-app/venera-configs/master/index.json");
var res = await dio.get<String>(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 {
context.to(() => _EditFilePage(source.filePath, () async {
await ComicSource.reload();
setState(() {});
});
}));
}
static Future<void> 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<void> 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),
: Button.filled(
child: Text("Add".tl),
onPressed: () async {
await widget.onAdd(
"https://raw.githubusercontent.com/venera-app/venera-configs/master/${json![index]["fileName"]}");
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();
}

View File

@@ -73,6 +73,7 @@ class _CommentsPageState extends State<CommentsPage> {
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<RichCommentContent> {
var textSpan = <InlineSpan>[];
var images = <_CommentImage>[];
bool isRendered = false;
@override
void didChangeDependencies() {
if (!isRendered) {
render();
isRendered = true;
}
super.didChangeDependencies();
}
@@ -670,7 +676,7 @@ class _RichCommentContentState extends State<RichCommentContent> {
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') {

View File

@@ -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<ExplorePage>
}
}
void addPage() {
showPopUpWidget(App.rootContext, setExplorePagesWidget());
}
NaviPaneState? naviPane;
@override
@@ -117,15 +123,21 @@ class _ExplorePageState extends State<ExplorePage>
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<ExplorePage>
}
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);

View File

@@ -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(),
),

View File

@@ -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)
{
_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;

View File

@@ -107,7 +107,7 @@ class _AppSettingsState extends State<AppSettings> {
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<AppSettings> {
context.showMessage(message: "Failed to import data".tl);
} finally {
cacheFile.deleteIgnoreError();
App.forceRebuild();
}
}
controller.close();

View File

@@ -30,35 +30,11 @@ class _ExploreSettingsState extends State<ExploreSettings> {
).toSliver(),
_PopupWindowSetting(
title: "Explore Pages".tl,
builder: () {
var pages = <String, String>{};
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 = <String, String>{};
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 = <String, String>{};
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 = <String, String>{};
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,
);
}

View File

@@ -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 = <String>[];
showDialog(
context: context,
builder: (context) {
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Add".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
children: canAdd.entries
.map(
(e) => ListTile(
(e) => CheckboxListTile(
value: selected.contains(e.key),
title: Text(e.value),
key: Key(e.key),
onTap: () {
context.pop();
onChanged: (value) {
setState(() {
keys.add(e.key);
if (value!) {
selected.add(e.key);
} else {
selected.remove(e.key);
}
});
updateSetting();
},
),
)
.toList(),
),
actions: [
if (selected.length < canAdd.length)
TextButton(
child: Text("Select All".tl),
onPressed: () {
setState(() {
selected = canAdd.keys.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),
),
],
);
});
},
);
}

View File

@@ -170,7 +170,39 @@ class _SettingsPageState extends State<SettingsPage> 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<SettingsPage> 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<SettingsPage> 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<SettingsPage> 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<SettingsPage> implements PopEntry {
6 => const AboutSettings(),
_ => throw UnimplementedError()
};
return Material(
child: body,
);
}
var canPop = ValueNotifier(true);

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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<Image> Function(Uint8List data);
Future<void> _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 = <String>[];
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<void> _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<Isolate> _runIsolate(
LocalComic comic, String savePath, SendPort sendPort) {
var localPath = LocalManager().path;
return Isolate.spawn<SendPort>(
(sendPort) => overrideIO(
() async {
var receivePort = ReceivePort();
sendPort.send(receivePort.sendPort);
Completer<Image>? completer;
Future<Image> 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<void> 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<void>();
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<String> imagePaths;
final String outputPath;
final DecodeImage decodeImage;
// PDF文件的对象ID计数器
int _objectId = 1;
// 存储每个对象在PDF中的字节位置
final Map<int, int> _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<void> 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 = <int>[];
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(<int>[0xfe, 0xff] + _encodeUtf16be(str));
}
var result = <int>[];
for (final byte in data) {
result.add(_codeUnitForDigit((byte & 0xF0) >> 4));
result.add(_codeUnitForDigit(byte & 0x0F));
}
return Uint8List.fromList(result);
}
List<int> _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 = <int>[];
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);
}
}

View File

@@ -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"

View File

@@ -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: