Merge pull request #197 from venera-app/v1.3.0-dev

V1.3.0
This commit is contained in:
nyne
2025-02-15 22:35:27 +08:00
committed by GitHub
46 changed files with 4202 additions and 2925 deletions

View File

@@ -332,7 +332,28 @@
"Created successfully": "创建成功",
"name": "名称",
"Reverse tap to turn Pages": "反转点击翻页",
"Show all": "显示全部"
"Show all": "显示全部",
"Number of images preloaded": "预加载图片数量",
"Ascending": "升序",
"Descending": "降序",
"Last Reading: Chapter @ep Page @page": "上次阅读: 第 @ep 章 第 @page 页",
"Last Reading: Page @page": "上次阅读: 第 @page 页",
"Replies": "回复",
"Follow Updates": "追更",
"Not Configured": "未配置",
"Choose a folder to follow updates." : "选择一个文件夹以追更",
"Choose Folder": "选择文件夹",
"No folders available": "没有可用的文件夹",
"Updating comics...": "更新漫画中...",
"Automatic update checking enabled." : "已启用自动更新检查",
"The app will check for updates at most once a day." : "APP将每天最多检查一次更新",
"Change Folder": "更改文件夹",
"Check Now": "立即检查",
"Updates": "更新",
"No updates found": "未找到更新",
"All Comics": "全部漫画",
"The comic will be marked as no updates as soon as you read it.": "漫画将在您阅读后立即标记为无更新",
"Disable": "禁用"
},
"zh_TW": {
"Home": "首頁",
@@ -667,6 +688,27 @@
"Created successfully": "創建成功",
"name": "名稱",
"Reverse tap to turn Pages": "反轉點擊翻頁",
"Show all": "顯示全部"
"Show all": "顯示全部",
"Number of images preloaded": "預加載圖片數量",
"Ascending": "升序",
"Descending": "降序",
"Last Reading: Chapter @ep Page @page": "上次閱讀: 第 @ep 章 第 @page 頁",
"Last Reading: Page @page": "上次閱讀: 第 @page 頁",
"Replies": "回覆",
"Follow Updates": "追更",
"Not Configured": "未配置",
"Choose a folder to follow updates." : "選擇一個文件夾以追更",
"Choose Folder": "選擇文件夾",
"No folders available": "沒有可用的文件夾",
"Updating comics...": "更新漫畫中...",
"Automatic update checking enabled." : "已啟用自動更新檢查",
"The app will check for updates at most once a day." : "APP將每天最多檢查一次更新",
"Change Folder": "更改文件夾",
"Check Now": "立即檢查",
"Updates": "更新",
"No updates found": "未找到更新",
"All Comics": "全部漫畫",
"The comic will be marked as no updates as soon as you read it.": "漫畫將在您閱讀後立即標記為無更新",
"Disable": "禁用"
}
}

View File

@@ -274,6 +274,7 @@ class AppTabBar extends StatefulWidget {
this.controller,
required this.tabs,
this.actionButton,
this.withUnderLine = true,
});
final TabController? controller;
@@ -282,6 +283,8 @@ class AppTabBar extends StatefulWidget {
final Widget? actionButton;
final bool withUnderLine;
@override
State<AppTabBar> createState() => _AppTabBarState();
}
@@ -396,14 +399,16 @@ class _AppTabBarState extends State<AppTabBar> {
key: tabBarKey,
height: _kTabHeight,
width: double.infinity,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
decoration: widget.withUnderLine
? BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
)
: null,
child: widget.tabs.isEmpty ? const SizedBox() : child,
);
}

View File

@@ -23,14 +23,16 @@ ImageProvider? _findImageProvider(Comic comic) {
}
class ComicTile extends StatelessWidget {
const ComicTile(
{super.key,
required this.comic,
this.enableLongPressed = true,
this.badge,
this.menuOptions,
this.onTap,
this.onLongPressed});
const ComicTile({
super.key,
required this.comic,
this.enableLongPressed = true,
this.badge,
this.menuOptions,
this.onTap,
this.onLongPressed,
this.heroID,
});
final Comic comic;
@@ -44,6 +46,8 @@ class ComicTile extends StatelessWidget {
final VoidCallback? onLongPressed;
final int? heroID;
void _onTap() {
if (onTap != null) {
onTap!();
@@ -55,6 +59,7 @@ class ComicTile extends StatelessWidget {
sourceKey: comic.sourceKey,
cover: comic.cover,
title: comic.title,
heroID: heroID,
),
);
}
@@ -137,8 +142,7 @@ class ComicTile extends StatelessWidget {
.isExist(comic.id, ComicType(comic.sourceKey.hashCode))
: false;
var history = appdata.settings['showHistoryStatusOnTile']
? HistoryManager()
.findSync(comic.id, ComicType(comic.sourceKey.hashCode))
? HistoryManager().find(comic.id, ComicType(comic.sourceKey.hashCode))
: null;
if (history?.page == 0) {
history!.page = 1;
@@ -210,63 +214,94 @@ class ComicTile extends StatelessWidget {
Widget _buildDetailedMode(BuildContext context) {
return LayoutBuilder(builder: (context, constrains) {
final height = constrains.maxHeight - 16;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: _onTap,
onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
child: Row(
children: [
Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
width: height * 0.68,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
),
),
SizedBox.fromSize(
size: const Size(16, 5),
),
Expanded(
child: _ComicDescription(
title: comic.maxPage == null
? comic.title.replaceAll("\n", "")
: "[${comic.maxPage}P]${comic.title.replaceAll("\n", "")}",
subtitle: comic.subtitle ?? '',
description: comic.description,
badge: badge ?? comic.language,
tags: comic.tags,
maxLines: 2,
enableTranslate: ComicSource.find(comic.sourceKey)
?.enableTagsTranslate ??
false,
rating: comic.stars,
),
),
],
Widget image = Container(
width: height * 0.68,
height: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
));
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
);
if (heroID != null) {
image = Hero(
tag: "cover$heroID",
child: image,
);
}
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: _onTap,
onLongPress: enableLongPressed ? () => _onLongPressed(context) : null,
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 24, 8),
child: Row(
children: [
image,
SizedBox.fromSize(
size: const Size(16, 5),
),
Expanded(
child: _ComicDescription(
title: comic.maxPage == null
? comic.title.replaceAll("\n", "")
: "[${comic.maxPage}P]${comic.title.replaceAll("\n", "")}",
subtitle: comic.subtitle ?? '',
description: comic.description,
badge: badge ?? comic.language,
tags: comic.tags,
maxLines: 2,
enableTranslate:
ComicSource.find(comic.sourceKey)?.enableTagsTranslate ??
false,
rating: comic.stars,
),
),
],
),
),
);
});
}
Widget _buildBriefMode(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
Widget image = Container(
decoration: BoxDecoration(
color: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.toOpacity(0.2),
blurRadius: 2,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
);
if (heroID != null) {
image = Hero(
tag: "cover$heroID",
child: image,
);
}
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _onTap,
@@ -278,24 +313,7 @@ class ComicTile extends StatelessWidget {
child: Stack(
children: [
Positioned.fill(
child: Hero(
tag: "cover${comic.id}${comic.sourceKey}",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.toOpacity(0.2),
blurRadius: 2,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: buildImage(context),
),
),
child: image,
),
Align(
alignment: Alignment.bottomRight,
@@ -445,7 +463,9 @@ class ComicTile extends StatelessWidget {
children: [
for (var word in all)
OptionChip(
text: word,
text: (comic.tags?.contains(word) ?? false)
? word.translateTagIfNeed
: word,
isSelected: words.contains(word),
onTap: () {
setState(() {
@@ -538,10 +558,8 @@ class _ComicDescription extends StatelessWidget {
softWrap: true,
overflow: TextOverflow.ellipsis,
),
const SizedBox(
height: 4,
),
if (tags != null)
const SizedBox(height: 4),
if (tags != null && tags!.isNotEmpty)
Expanded(
child: LayoutBuilder(builder: (context, constraints) {
if (constraints.maxHeight < 22) {
@@ -606,7 +624,7 @@ class _ComicDescription extends StatelessWidget {
style: const TextStyle(
fontSize: 12.0,
),
maxLines: 1,
maxLines: (tags == null || tags!.isEmpty) ? 3 : 2,
overflow: TextOverflow.ellipsis,
),
],
@@ -739,16 +757,27 @@ class SliverGridComics extends StatefulWidget {
class _SliverGridComicsState extends State<SliverGridComics> {
List<Comic> comics = [];
List<int> heroIDs = [];
static int _nextHeroID = 0;
void generateHeroID() {
heroIDs.clear();
for (var i = 0; i < comics.length; i++) {
heroIDs.add(_nextHeroID++);
}
}
@override
void didUpdateWidget(covariant SliverGridComics oldWidget) {
if (oldWidget.comics.isEqualTo(widget.comics)) {
if (!oldWidget.comics.isEqualTo(widget.comics)) {
comics.clear();
for (var comic in widget.comics) {
if (isBlocked(comic) == null) {
comics.add(comic);
}
}
generateHeroID();
}
super.didUpdateWidget(oldWidget);
}
@@ -760,6 +789,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
comics.add(comic);
}
}
generateHeroID();
HistoryManager().addListener(update);
super.initState();
}
@@ -785,6 +815,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
Widget build(BuildContext context) {
return _SliverGridComics(
comics: comics,
heroIDs: heroIDs,
selection: widget.selections,
onLastItemBuild: widget.onLastItemBuild,
badgeBuilder: widget.badgeBuilder,
@@ -798,6 +829,7 @@ class _SliverGridComicsState extends State<SliverGridComics> {
class _SliverGridComics extends StatelessWidget {
const _SliverGridComics({
required this.comics,
required this.heroIDs,
this.onLastItemBuild,
this.badgeBuilder,
this.menuBuilder,
@@ -808,6 +840,8 @@ class _SliverGridComics extends StatelessWidget {
final List<Comic> comics;
final List<int> heroIDs;
final Map<Comic, bool>? selection;
final void Function()? onLastItemBuild;
@@ -839,6 +873,7 @@ class _SliverGridComics extends StatelessWidget {
onLongPressed: onLongPressed != null
? () => onLongPressed!(comics[index])
: null,
heroID: heroIDs[index],
);
if (selection == null) {
return comic;

View File

@@ -23,7 +23,7 @@ import 'package:venera/foundation/image_provider/local_comic_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/cloudflare.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/tags_translation.dart';

View File

@@ -6,166 +6,99 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:window_manager/window_manager.dart';
const _kTitleBarHeight = 36.0;
class WindowFrameController extends StateController {
bool useDarkTheme = false;
bool isHideWindowFrame = false;
void setDarkTheme() {
useDarkTheme = true;
update();
}
void resetTheme() {
useDarkTheme = false;
update();
}
VoidCallback openSideBar = () {};
void hideWindowFrame() {
isHideWindowFrame = true;
update();
}
void showWindowFrame() {
isHideWindowFrame = false;
update();
}
}
class WindowFrame extends StatelessWidget {
class WindowFrame extends StatefulWidget {
const WindowFrame(this.child, {super.key});
final Widget child;
@override
Widget build(BuildContext context) {
StateController.putIfNotExists<WindowFrameController>(
WindowFrameController());
if (App.isMobile) return child;
return StateBuilder<WindowFrameController>(builder: (controller) {
if (controller.isHideWindowFrame) return child;
var body = Stack(
children: [
Positioned.fill(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.only(top: _kTitleBarHeight)),
child: child,
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: Material(
color: Colors.transparent,
child: Theme(
data: Theme.of(context).copyWith(
brightness: controller.useDarkTheme ? Brightness.dark : null,
),
child: Builder(builder: (context) {
return SizedBox(
height: _kTitleBarHeight,
child: Row(
children: [
if (App.isMacOS)
const DragToMoveArea(
child: SizedBox(
height: double.infinity,
width: 16,
),
).paddingRight(52)
else
const SizedBox(width: 12),
Expanded(
child: DragToMoveArea(
child: Text(
'Venera',
style: TextStyle(
fontSize: 13,
color: (controller.useDarkTheme ||
context.brightness == Brightness.dark)
? Colors.white
: Colors.black,
),
).toAlign(Alignment.centerLeft).paddingLeft(4+(App.isMacOS?25:0)),
),
),
if (kDebugMode)
const TextButton(
onPressed: debug,
child: Text('Debug'),
),
if (!App.isMacOS) const WindowButtons()
],
),
);
}),
),
),
)
],
);
if (App.isLinux) {
return VirtualWindowFrame(child: body);
} else {
return body;
}
});
}
Widget buildMenuButton(
WindowFrameController controller, BuildContext context) {
return InkWell(
onTap: () {
controller.openSideBar();
},
child: SizedBox(
width: 42,
height: double.infinity,
child: Center(
child: CustomPaint(
size: const Size(18, 20),
painter: _MenuPainter(
color: (controller.useDarkTheme ||
Theme.of(context).brightness == Brightness.dark)
? Colors.white
: Colors.black),
),
),
));
}
State<WindowFrame> createState() => _WindowFrameState();
}
class _MenuPainter extends CustomPainter {
final Color color;
_MenuPainter({this.color = Colors.black});
class _WindowFrameState extends State<WindowFrame> {
bool isHideWindowFrame = false;
bool useDarkTheme = false;
@override
void paint(Canvas canvas, Size size) {
final paint = getPaint(color);
final path = Path()
..moveTo(0, size.height / 4)
..lineTo(size.width, size.height / 4)
..moveTo(0, size.height / 4 * 2)
..lineTo(size.width, size.height / 4 * 2)
..moveTo(0, size.height / 4 * 3)
..lineTo(size.width, size.height / 4 * 3);
canvas.drawPath(path, paint);
Widget build(BuildContext context) {
if (App.isMobile) return widget.child;
if (isHideWindowFrame) return widget.child;
var body = Stack(
children: [
Positioned.fill(
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: const EdgeInsets.only(top: _kTitleBarHeight)),
child: widget.child,
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: Material(
color: Colors.transparent,
child: Theme(
data: Theme.of(context).copyWith(
brightness: useDarkTheme ? Brightness.dark : null,
),
child: Builder(builder: (context) {
return SizedBox(
height: _kTitleBarHeight,
child: Row(
children: [
if (App.isMacOS)
const DragToMoveArea(
child: SizedBox(
height: double.infinity,
width: 16,
),
).paddingRight(52)
else
const SizedBox(width: 12),
Expanded(
child: DragToMoveArea(
child: Text(
'Venera',
style: TextStyle(
fontSize: 13,
color: (useDarkTheme ||
context.brightness == Brightness.dark)
? Colors.white
: Colors.black,
),
)
.toAlign(Alignment.centerLeft)
.paddingLeft(4 + (App.isMacOS ? 25 : 0)),
),
),
if (kDebugMode)
const TextButton(
onPressed: debug,
child: Text('Debug'),
),
if (!App.isMacOS) const WindowButtons()
],
),
);
}),
),
),
)
],
);
if (App.isLinux) {
return VirtualWindowFrame(child: body);
} else {
return body;
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class WindowButtons extends StatefulWidget {
@@ -489,7 +422,7 @@ class WindowPlacement {
static Future<WindowPlacement> get current async {
var rect = await windowManager.getBounds();
if(validate(rect)) {
if (validate(rect)) {
lastValidRect = rect;
} else {
rect = lastValidRect ?? defaultPlacement.rect;
@@ -635,4 +568,4 @@ TransitionBuilder VirtualWindowFrameInit() {
void debug() {
ComicSource.reload();
}
}

View File

@@ -10,7 +10,7 @@ export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.2.5";
final version = "1.3.0";
bool get isAndroid => Platform.isAndroid;

View File

@@ -158,6 +158,8 @@ class _Settings with ChangeNotifier {
'sni': true,
'autoAddLanguageFilter': 'none', // none, chinese, english, japanese
'comicSourceListUrl': "https://cdn.jsdelivr.net/gh/venera-app/venera-configs@latest/index.json",
'preloadImageCount': 4,
'followUpdatesFolder': null,
};
operator [](String key) {

View File

@@ -130,6 +130,11 @@ class ComicDetails with HistoryMixin {
/// id-name
final Map<String, String>? chapters;
/// key is group name.
/// When this field is not null, [chapters] will be a merged map of all groups.
/// Only available in some sources.
final Map<String, Map<String, String>>? groupedChapters;
final List<String>? thumbnails;
final List<Comic>? recommend;
@@ -171,15 +176,45 @@ class ComicDetails with HistoryMixin {
return res;
}
static Map<String, String>? _getChapters(dynamic chapters) {
if (chapters == null) return null;
var result = <String, String>{};
if (chapters is Map) {
for (var entry in chapters.entries) {
var value = entry.value;
if (value is Map) {
result.addAll(Map.from(value));
} else {
result[entry.key.toString()] = value.toString();
}
}
}
return result;
}
static Map<String, Map<String, String>>? _getGroupedChapters(dynamic chapters) {
if (chapters == null) return null;
var result = <String, Map<String, String>>{};
if (chapters is Map) {
for (var entry in chapters.entries) {
var value = entry.value;
if (value is Map) {
result[entry.key.toString()] = Map.from(value);
}
}
}
if (result.isEmpty) return null;
return result;
}
ComicDetails.fromJson(Map<String, dynamic> json)
: title = json["title"],
subTitle = json["subtitle"],
cover = json["cover"],
description = json["description"],
tags = _generateMap(json["tags"]),
chapters = json["chapters"] == null
? null
: Map<String, String>.from(json["chapters"]),
chapters = _getChapters(json["chapters"]),
groupedChapters = _getGroupedChapters(json["chapters"]),
sourceKey = json["sourceKey"],
comicId = json["comicId"],
thumbnails = ListOrNull.from(json["thumbnails"]),
@@ -260,6 +295,41 @@ class ComicDetails with HistoryMixin {
}
return null;
}
String? _validateUpdateTime(String time) {
time = time.split(" ").first;
var segments = time.split("-");
if (segments.length != 3) return null;
var year = int.tryParse(segments[0]);
var month = int.tryParse(segments[1]);
var day = int.tryParse(segments[2]);
if (year == null || month == null || day == null) return null;
if (year < 2000 || year > 3000) return null;
if (month < 1 || month > 12) return null;
if (day < 1 || day > 31) return null;
return "$year-$month-$day";
}
String? findUpdateTime() {
if (updateTime != null) {
return _validateUpdateTime(updateTime!);
}
const acceptedNamespaces = [
"更新",
"最後更新",
"最后更新",
"update",
"last update",
];
for (var entry in tags.entries) {
if (acceptedNamespaces.contains(entry.key.toLowerCase()) &&
entry.value.isNotEmpty) {
var value = entry.value.first;
return _validateUpdateTime(value);
}
}
return null;
}
}
class ArchiveInfo {

View File

@@ -6,6 +6,7 @@ import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/image_provider/local_favorite_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/utils/tags_translation.dart';
import 'dart:io';
@@ -154,6 +155,38 @@ class FavoriteItemWithFolderInfo extends FavoriteItem {
);
}
class FavoriteItemWithUpdateInfo extends FavoriteItem {
String? updateTime;
DateTime? lastCheckTime;
bool hasNewUpdate;
FavoriteItemWithUpdateInfo(
FavoriteItem item,
this.updateTime,
this.hasNewUpdate,
int? lastCheckTime,
) : lastCheckTime = lastCheckTime == null
? null
: DateTime.fromMillisecondsSinceEpoch(lastCheckTime),
super(
id: item.id,
name: item.name,
coverPath: item.coverPath,
author: item.author,
type: item.type,
tags: item.tags,
);
@override
String get description {
var updateTime = this.updateTime ?? "Unknown";
var sourceName = type.comicSource?.name ?? "Unknown";
return "$updateTime | $sourceName";
}
}
class LocalFavoritesManager with ChangeNotifier {
factory LocalFavoritesManager() =>
cache ?? (cache = LocalFavoritesManager._create());
@@ -429,7 +462,8 @@ class LocalFavoritesManager with ChangeNotifier {
/// add comic to a folder.
/// return true if success, false if already exists
bool addComic(String folder, FavoriteItem comic, [int? order]) {
bool addComic(String folder, FavoriteItem comic,
[int? order, String? updateTime]) {
_modifiedAfterLastCache = true;
if (!existsFolder(folder)) {
throw Exception("Folder does not exists");
@@ -468,6 +502,18 @@ class LocalFavoritesManager with ChangeNotifier {
values (?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [...params, minValue(folder) - 1]);
}
if (updateTime != null) {
var columns = _db.select("""
pragma table_info("$folder");
""");
if (columns.any((element) => element["name"] == "last_update_time")) {
_db.execute("""
update "$folder"
set last_update_time = ?
where id == ? and type == ?;
""", [updateTime, comic.id, comic.type.value]);
}
}
notifyListeners();
return true;
}
@@ -596,9 +642,11 @@ class LocalFavoritesManager with ChangeNotifier {
void onRead(String id, ComicType type) async {
if (appdata.settings['moveFavoriteAfterRead'] == "none") {
markAsRead(id, type);
return;
}
_modifiedAfterLastCache = true;
var followUpdatesFolder = appdata.settings['followUpdatesFolder'];
for (final folder in folderNames) {
var rows = _db.select("""
select * from "$folder"
@@ -627,9 +675,13 @@ class LocalFavoritesManager with ChangeNotifier {
UPDATE "$folder"
SET
$updateLocationSql
${followUpdatesFolder == folder ? "has_new_update = 0," : ""}
time = ?
WHERE id == ?;
""", [newTime, id]);
WHERE id == ? and type == ?;
""", [newTime, id, type.value]);
if (followUpdatesFolder == folder) {
updateFollowUpdatesUI();
}
}
}
notifyListeners();
@@ -783,6 +835,117 @@ class LocalFavoritesManager with ChangeNotifier {
}
}
void prepareTableForFollowUpdates(String table) {
// check if the table has the column "last_update_time" "has_new_update" "last_check_time"
var columns = _db.select("""
pragma table_info("$table");
""");
if (!columns.any((element) => element["name"] == "last_update_time")) {
_db.execute("""
alter table "$table"
add column last_update_time TEXT;
""");
}
if (!columns.any((element) => element["name"] == "has_new_update")) {
_db.execute("""
alter table "$table"
add column has_new_update int;
""");
}
_db.execute("""
update "$table"
set has_new_update = 0;
""");
if (!columns.any((element) => element["name"] == "last_check_time")) {
_db.execute("""
alter table "$table"
add column last_check_time int;
""");
}
}
void updateUpdateTime(
String folder,
String id,
ComicType type,
String updateTime,
) {
var oldTime = _db.select("""
select last_update_time from "$folder"
where id == ? and type == ?;
""", [id, type.value]).first['last_update_time'];
var hasNewUpdate = oldTime != updateTime;
_db.execute("""
update "$folder"
set last_update_time = ?, has_new_update = ?, last_check_time = ?
where id == ? and type == ?;
""", [
updateTime,
hasNewUpdate ? 1 : 0,
DateTime.now().millisecondsSinceEpoch,
id,
type.value,
]);
}
int countUpdates(String folder) {
return _db.select("""
select count(*) as c from "$folder"
where has_new_update == 1;
""").first['c'];
}
List<FavoriteItemWithUpdateInfo> getUpdates(String folder) {
if (!existsFolder(folder)) {
return [];
}
var res = _db.select("""
select * from "$folder"
where has_new_update == 1;
""");
return res
.map(
(e) => FavoriteItemWithUpdateInfo(
FavoriteItem.fromRow(e),
e['last_update_time'],
e['has_new_update'] == 1,
e['last_check_time'],
),
)
.toList();
}
List<FavoriteItemWithUpdateInfo> getComicsWithUpdatesInfo(String folder) {
if (!existsFolder(folder)) {
return [];
}
var res = _db.select("""
select * from "$folder";
""");
return res
.map(
(e) => FavoriteItemWithUpdateInfo(
FavoriteItem.fromRow(e),
e['last_update_time'],
e['has_new_update'] == 1,
e['last_check_time'],
),
)
.toList();
}
void markAsRead(String id, ComicType type) {
var folder = appdata.settings['followUpdatesFolder'];
if (!existsFolder(folder)) {
return;
}
_db.execute("""
update "$folder"
set has_new_update = 0
where id == ? and type == ?;
""", [id, type.value]);
}
void close() {
_db.dispose();
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/widgets.dart';
abstract class GlobalState {
static final _state = <Pair<Object?, State>>[];
static void register(State state, [Object? key]) {
_state.add(Pair(key, state));
}
static T find<T extends State>([Object? key]) {
for (var pair in _state) {
if ((key == null || pair.left == key) && pair.right is T) {
return pair.right as T;
}
}
throw Exception('State not found');
}
static T? findOrNull<T extends State>([Object? key]) {
for (var pair in _state) {
if ((key == null || pair.left == key) && pair.right is T) {
return pair.right as T;
}
}
return null;
}
static void unregister(State state, [Object? key]) {
_state.removeWhere(
(pair) => (key == null || pair.left == key) && pair.right == state);
}
}
class Pair<K, V> {
K left;
V right;
Pair(this.left, this.right);
}
abstract class AutomaticGlobalState<T extends StatefulWidget>
extends State<T> {
@override
@mustCallSuper
void initState() {
super.initState();
GlobalState.register(this, key);
}
@override
@mustCallSuper
void dispose() {
super.dispose();
GlobalState.unregister(this, key);
}
Object? get key;
void update() {
setState(() {});
}
void refresh() {
update();
}
}

View File

@@ -2,10 +2,12 @@ import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'dart:math';
import 'dart:ffi' as ffi;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:sqlite3/common.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
@@ -124,30 +126,6 @@ class History implements Comic {
.map((e) => int.parse(e))),
maxPage = row["max_page"];
static Future<History> findOrCreate(
HistoryMixin model, {
int ep = 0,
int page = 0,
}) async {
var history = await HistoryManager().find(model.id, model.historyType);
if (history != null) {
return history;
}
history = History.fromModel(model: model, ep: ep, page: page);
HistoryManager().addHistory(history);
return history;
}
static Future<History> createIfNull(
History? history, HistoryMixin model) async {
if (history != null) {
return history;
}
history = History.fromModel(model: model, ep: 0, page: 0);
HistoryManager().addHistory(history);
return history;
}
@override
bool operator ==(Object other) {
return other is History && type == other.type && id == other.id;
@@ -210,7 +188,11 @@ class HistoryManager with ChangeNotifier {
int get length => _db.select("select count(*) from history;").first[0] as int;
Map<String, bool>? _cachedHistory;
/// Cache of history ids. Improve the performance of find operation.
Map<String, bool>? _cachedHistoryIds;
/// Cache records recently modified by the app. Improve the performance of listeners.
final cachedHistories = <String, History>{};
bool isInitialized = false;
@@ -240,14 +222,57 @@ class HistoryManager with ChangeNotifier {
isInitialized = true;
}
static const _insertHistorySql = """
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""";
static Future<void> _addHistoryAsync(int dbAddr, History newItem) {
return Isolate.run(() {
var db = sqlite3.fromPointer(ffi.Pointer.fromAddress(dbAddr));
db.execute(_insertHistorySql, [
newItem.id,
newItem.title,
newItem.subtitle,
newItem.cover,
newItem.time.millisecondsSinceEpoch,
newItem.type.value,
newItem.ep,
newItem.page,
newItem.readEpisode.join(','),
newItem.maxPage
]);
});
}
bool _haveAsyncTask = false;
/// Create a isolate to add history to prevent blocking the UI thread.
Future<void> addHistoryAsync(History newItem) async {
while (_haveAsyncTask) {
await Future.delayed(Duration(milliseconds: 20));
}
_haveAsyncTask = true;
await _addHistoryAsync(_db.handle.address, newItem);
_haveAsyncTask = false;
if (_cachedHistoryIds == null) {
updateCache();
} else {
_cachedHistoryIds![newItem.id] = true;
}
cachedHistories[newItem.id] = newItem;
if (cachedHistories.length > 10) {
cachedHistories.remove(cachedHistories.keys.first);
}
notifyListeners();
}
/// add history. if exists, update time.
///
/// This function would be called when user start reading.
Future<void> addHistory(History newItem) async {
_db.execute("""
insert or replace into history (id, title, subtitle, cover, time, type, ep, page, readEpisode, max_page)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
""", [
void addHistory(History newItem) {
_db.execute(_insertHistorySql, [
newItem.id,
newItem.title,
newItem.subtitle,
@@ -259,7 +284,15 @@ class HistoryManager with ChangeNotifier {
newItem.readEpisode.join(','),
newItem.maxPage
]);
updateCache();
if (_cachedHistoryIds == null) {
updateCache();
} else {
_cachedHistoryIds![newItem.id] = true;
}
cachedHistories[newItem.id] = newItem;
if (cachedHistories.length > 10) {
cachedHistories.remove(cachedHistories.keys.first);
}
notifyListeners();
}
@@ -278,27 +311,31 @@ class HistoryManager with ChangeNotifier {
notifyListeners();
}
Future<History?> find(String id, ComicType type) async {
return findSync(id, type);
}
void updateCache() {
_cachedHistory = {};
_cachedHistoryIds = {};
var res = _db.select("""
select * from history;
select id from history;
""");
for (var element in res) {
_cachedHistory![element["id"] as String] = true;
_cachedHistoryIds![element["id"] as String] = true;
}
for (var key in cachedHistories.keys) {
if (!_cachedHistoryIds!.containsKey(key)) {
cachedHistories.remove(key);
}
}
}
History? findSync(String id, ComicType type) {
if (_cachedHistory == null) {
History? find(String id, ComicType type) {
if (_cachedHistoryIds == null) {
updateCache();
}
if (!_cachedHistory!.containsKey(id)) {
if (!_cachedHistoryIds!.containsKey(id)) {
return null;
}
if (cachedHistories.containsKey(id)) {
return cachedHistories[id];
}
var res = _db.select("""
select * from history

View File

@@ -396,7 +396,7 @@ class ImageFavoriteManager with ChangeNotifier {
var token = ServicesBinding.rootIsolateToken!;
var count = ImageFavoriteManager().length;
if (count == 0) {
return Future.value(ImageFavoritesComputed([], [], []));
return Future.value(ImageFavoritesComputed([], [], [], 0));
} else if (count > 100) {
return Isolate.run(() async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
@@ -436,8 +436,10 @@ class ImageFavoriteManager with ChangeNotifier {
Map<String, int> authorCount = {};
Map<ImageFavoritesComic, int> comicImageCount = {};
Map<ImageFavoritesComic, int> comicMaxPages = {};
int count = 0;
for (var comic in comics) {
count += comic.images.length;
for (var tag in comic.tags) {
String finalTag = tag;
tagCount[finalTag] = (tagCount[finalTag] ?? 0) + 1;
@@ -492,6 +494,7 @@ class ImageFavoriteManager with ChangeNotifier {
.map((comic) => TextWithCount(comic.key.title, comic.value))
.take(maxLength)
.toList(),
count,
);
}
@@ -524,11 +527,14 @@ class ImageFavoritesComputed {
/// 基于喜欢的图片数排序
final List<TextWithCount> comics;
final int count;
/// 计算后的图片收藏数据
const ImageFavoritesComputed(
this.tags,
this.authors,
this.comics,
this.count,
);
bool get isEmpty => tags.isEmpty && authors.isEmpty && comics.isEmpty;

View File

@@ -105,8 +105,8 @@ class LocalComic with HistoryMixin implements Comic {
@override
int? get maxPage => null;
void read() async {
var history = await HistoryManager().find(id, comicType);
void read() {
var history = HistoryManager().find(id, comicType);
App.rootContext.to(
() => Reader(
type: comicType,
@@ -511,7 +511,7 @@ class LocalManager with ChangeNotifier {
}
// Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.
if (c.comicType == ComicType.local) {
if (HistoryManager().findSync(c.id, c.comicType) != null) {
if (HistoryManager().find(c.id, c.comicType) != null) {
HistoryManager().remove(c.id, c.comicType);
}
var folders = LocalFavoritesManager().find(c.id, c.comicType);

View File

@@ -1,238 +0,0 @@
import 'package:flutter/material.dart';
class SimpleController extends StateController {
final void Function()? refreshFunction;
final Map<String, dynamic> Function()? control;
SimpleController({this.refreshFunction, this.control});
@override
void refresh() {
(refreshFunction ?? super.refresh)();
}
Map<String, dynamic> get controlMap => control?.call() ?? {};
}
abstract class StateController {
static final _controllers = <StateControllerWrapped>[];
static T put<T extends StateController>(T controller,
{Object? tag, bool autoRemove = false}) {
_controllers.add(StateControllerWrapped(controller, autoRemove, tag));
return controller;
}
static T putIfNotExists<T extends StateController>(T controller,
{Object? tag, bool autoRemove = false}) {
return findOrNull<T>(tag: tag) ??
put(controller, tag: tag, autoRemove: autoRemove);
}
static T find<T extends StateController>({Object? tag}) {
try {
return _controllers
.lastWhere((element) =>
element.controller is T && (tag == null || tag == element.tag))
.controller as T;
} catch (e) {
throw StateError("$T with tag $tag Not Found");
}
}
static List<T> findAll<T extends StateController>({Object? tag}) {
return _controllers
.where((element) =>
element.controller is T && (tag == null || tag == element.tag))
.map((e) => e.controller as T)
.toList();
}
static T? findOrNull<T extends StateController>({Object? tag}) {
try {
return _controllers
.lastWhere((element) =>
element.controller is T && (tag == null || tag == element.tag))
.controller as T;
} catch (e) {
return null;
}
}
static void remove<T>([Object? tag, bool check = false]) {
for (int i = _controllers.length - 1; i >= 0; i--) {
var element = _controllers[i];
if (element.controller is T && (tag == null || tag == element.tag)) {
if (check && !element.autoRemove) {
continue;
}
_controllers.removeAt(i);
return;
}
}
}
static SimpleController putSimpleController(
void Function() onUpdate, Object? tag,
{void Function()? refresh, Map<String, dynamic> Function()? control}) {
var controller = SimpleController(refreshFunction: refresh, control: control);
controller.stateUpdaters.add(Pair(null, onUpdate));
_controllers.add(StateControllerWrapped(controller, false, tag));
return controller;
}
List<Pair<Object?, void Function()>> stateUpdaters = [];
void update([List<Object>? ids]) {
if (ids == null) {
for (var element in stateUpdaters) {
element.right();
}
} else {
for (var element in stateUpdaters) {
if (ids.contains(element.left)) {
element.right();
}
}
}
}
void dispose() {
_controllers.removeWhere((element) => element.controller == this);
}
void refresh() {
update();
}
}
class StateControllerWrapped {
StateController controller;
bool autoRemove;
Object? tag;
StateControllerWrapped(this.controller, this.autoRemove, this.tag);
}
class StateBuilder<T extends StateController> extends StatefulWidget {
const StateBuilder({
super.key,
this.init,
this.dispose,
this.initState,
this.tag,
required this.builder,
this.id,
});
final T? init;
final void Function(T controller)? dispose;
final void Function(T controller)? initState;
final Object? tag;
final Widget Function(T controller) builder;
Widget builderWrapped(StateController controller) {
return builder(controller as T);
}
void initStateWrapped(StateController controller) {
return initState?.call(controller as T);
}
void disposeWrapped(StateController controller) {
return dispose?.call(controller as T);
}
final Object? id;
@override
State<StateBuilder> createState() => _StateBuilderState<T>();
}
class _StateBuilderState<T extends StateController>
extends State<StateBuilder> {
late T controller;
@override
void initState() {
if (widget.init != null) {
StateController.put(widget.init!, tag: widget.tag, autoRemove: true);
}
try {
controller = StateController.find<T>(tag: widget.tag);
} catch (e) {
throw "Controller Not Found";
}
controller.stateUpdaters.add(Pair(widget.id, () {
if (mounted) {
setState(() {});
}
}));
widget.initStateWrapped(controller);
super.initState();
}
@override
void dispose() {
widget.disposeWrapped(controller);
StateController.remove<T>(widget.tag, true);
super.dispose();
}
@override
Widget build(BuildContext context) => widget.builderWrapped(controller);
}
abstract class StateWithController<T extends StatefulWidget> extends State<T> {
late final SimpleController _controller;
void refresh() {
_controller.update();
}
@override
@mustCallSuper
void initState() {
_controller = StateController.putSimpleController(
() {
if (mounted) {
setState(() {});
}
},
tag,
refresh: refresh,
control: () => control,
);
super.initState();
}
@override
@mustCallSuper
void dispose() {
_controller.dispose();
super.dispose();
}
void update() {
_controller.update();
}
Object? get tag;
Map<String, dynamic> get control => {};
}
class Pair<M, V>{
M left;
V right;
Pair(this.left, this.right);
Pair.fromMap(Map<M, V> map, M key): left = key, right = map[key]
?? (throw Exception("Pair not found"));
}

View File

@@ -10,6 +10,9 @@ import 'package:venera/foundation/js_engine.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/cookie_jar.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
@@ -55,3 +58,23 @@ Future<void> init() async {
Log.error("Unhandled Exception", "${details.exception}\n${details.stack}");
};
}
Future<void> _checkAppUpdates() async {
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0;
var now = DateTime.now().millisecondsSinceEpoch;
if (now - lastCheck < 24 * 60 * 60 * 1000) {
return;
}
appdata.implicitData['lastCheckUpdate'] = now;
appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300));
await checkUpdateUi(false);
}
}
void checkUpdates() {
_checkAppUpdates();
FollowUpdatesService.initChecker();
}

View File

@@ -62,6 +62,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
App.registerForceRebuild(forceRebuild);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addObserver(this);
checkUpdates();
super.initState();
}

View File

@@ -0,0 +1,435 @@
part of 'comic_page.dart';
abstract mixin class _ComicPageActions {
void update();
ComicDetails get comic;
ComicSource get comicSource => ComicSource.find(comic.sourceKey)!;
History? get history;
bool isLiking = false;
bool isLiked = false;
void likeOrUnlike() async {
if (isLiking) return;
isLiking = true;
update();
var res = await comicSource.likeOrUnlikeComic!(comic.id, isLiked);
if (res.error) {
App.rootContext.showMessage(message: res.errorMessage!);
} else {
isLiked = !isLiked;
}
isLiking = false;
update();
}
/// whether the comic is added to local favorite
bool isAddToLocalFav = false;
/// whether the comic is favorite on the server
bool isFavorite = false;
FavoriteItem _toFavoriteItem() {
var tags = <String>[];
for (var e in comic.tags.entries) {
tags.addAll(e.value.map((tag) => '${e.key}:$tag'));
}
return FavoriteItem(
id: comic.id,
name: comic.title,
coverPath: comic.cover,
author: comic.subTitle ?? comic.uploader ?? '',
type: comic.comicType,
tags: tags,
);
}
void openFavPanel() {
showSideBar(
App.rootContext,
_FavoritePanel(
cid: comic.id,
type: comic.comicType,
isFavorite: isFavorite,
onFavorite: (local, network) {
isFavorite = network ?? isFavorite;
isAddToLocalFav = local ?? isAddToLocalFav;
update();
},
favoriteItem: _toFavoriteItem(),
updateTime: comic.findUpdateTime(),
),
);
}
void quickFavorite() {
var folder = appdata.settings['quickFavorite'];
if (folder is! String) {
return;
}
LocalFavoritesManager().addComic(
folder,
_toFavoriteItem(),
null,
comic.findUpdateTime(),
);
isAddToLocalFav = true;
update();
App.rootContext.showMessage(message: "Added".tl);
}
void share() {
var text = comic.title;
if (comic.url != null) {
text += '\n${comic.url}';
}
Share.shareText(text);
}
/// read the comic
///
/// [ep] the episode number, start from 1
///
/// [page] the page number, start from 1
void read([int? ep, int? page]) {
App.rootContext
.to(
() => Reader(
type: comic.comicType,
cid: comic.id,
name: comic.title,
chapters: comic.chapters,
initialChapter: ep,
initialPage: page,
history: history ?? History.fromModel(model: comic, ep: 0, page: 0),
author: comic.findAuthor() ?? '',
tags: comic.plainTags,
),
)
.then((_) {
onReadEnd();
});
}
void continueRead() {
var ep = history?.ep ?? 1;
var page = history?.page ?? 1;
read(ep, page);
}
void onReadEnd();
void download() async {
if (LocalManager().isDownloading(comic.id, comic.comicType)) {
App.rootContext.showMessage(message: "The comic is downloading".tl);
return;
}
if (comic.chapters == null &&
LocalManager().isDownloaded(comic.id, comic.comicType, 0)) {
App.rootContext.showMessage(message: "The comic is downloaded".tl);
return;
}
if (comicSource.archiveDownloader != null) {
bool useNormalDownload = false;
List<ArchiveInfo>? archives;
int selected = -1;
bool isLoading = false;
bool isGettingLink = false;
await showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return ContentDialog(
title: "Download".tl,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<int>(
value: -1,
groupValue: selected,
title: Text("Normal".tl),
onChanged: (v) {
setState(() {
selected = v!;
});
},
),
ExpansionTile(
title: Text("Archive".tl),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
collapsedShape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
onExpansionChanged: (b) {
if (!isLoading && b && archives == null) {
isLoading = true;
comicSource.archiveDownloader!
.getArchives(comic.id)
.then((value) {
if (value.success) {
archives = value.data;
} else {
App.rootContext
.showMessage(message: value.errorMessage!);
}
setState(() {
isLoading = false;
});
});
}
},
children: [
if (archives == null)
const ListLoadingIndicator().toCenter()
else
for (int i = 0; i < archives!.length; i++)
RadioListTile<int>(
value: i,
groupValue: selected,
onChanged: (v) {
setState(() {
selected = v!;
});
},
title: Text(archives![i].title),
subtitle: Text(archives![i].description),
)
],
)
],
),
actions: [
Button.filled(
isLoading: isGettingLink,
onPressed: () async {
if (selected == -1) {
useNormalDownload = true;
context.pop();
return;
}
setState(() {
isGettingLink = true;
});
var res =
await comicSource.archiveDownloader!.getDownloadUrl(
comic.id,
archives![selected].id,
);
if (res.error) {
App.rootContext.showMessage(message: res.errorMessage!);
setState(() {
isGettingLink = false;
});
} else if (context.mounted) {
LocalManager()
.addTask(ArchiveDownloadTask(res.data, comic));
App.rootContext
.showMessage(message: "Download started".tl);
context.pop();
}
},
child: Text("Confirm".tl),
),
],
);
},
);
},
);
if (!useNormalDownload) {
return;
}
}
if (comic.chapters == null) {
LocalManager().addTask(ImagesDownloadTask(
source: comicSource,
comicId: comic.id,
comic: comic,
));
} else {
List<int>? selected;
var downloaded = <int>[];
var localComic = LocalManager().find(comic.id, comic.comicType);
if (localComic != null) {
for (int i = 0; i < comic.chapters!.length; i++) {
if (localComic.downloadedChapters
.contains(comic.chapters!.keys.elementAt(i))) {
downloaded.add(i);
}
}
}
await showSideBar(
App.rootContext,
_SelectDownloadChapter(
comic.chapters!.values.toList(),
(v) => selected = v,
downloaded,
),
);
if (selected == null) return;
LocalManager().addTask(ImagesDownloadTask(
source: comicSource,
comicId: comic.id,
comic: comic,
chapters: selected!.map((i) {
return comic.chapters!.keys.elementAt(i);
}).toList(),
));
}
App.rootContext.showMessage(message: "Download started".tl);
update();
}
void onTapTag(String tag, String namespace) {
var config = comicSource.handleClickTagEvent?.call(namespace, tag) ??
{
'action': 'search',
'keyword': tag,
};
var context = App.mainNavigatorKey!.currentContext!;
if (config['action'] == 'search') {
context.to(() => SearchResultPage(
text: config['keyword'] ?? '',
sourceKey: comicSource.key,
options: const [],
));
} else if (config['action'] == 'category') {
context.to(
() => CategoryComicsPage(
category: config['keyword'] ?? '',
categoryKey: comicSource.categoryData!.key,
param: config['param'],
),
);
}
}
void showMoreActions() {
var context = App.rootContext;
showMenuX(
context,
Offset(
context.width - 16,
context.padding.top,
),
[
MenuEntry(
icon: Icons.copy,
text: "Copy Title".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: comic.title));
context.showMessage(message: "Copied".tl);
},
),
MenuEntry(
icon: Icons.copy_rounded,
text: "Copy ID".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: comic.id));
context.showMessage(message: "Copied".tl);
},
),
if (comic.url != null)
MenuEntry(
icon: Icons.link,
text: "Copy URL".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: comic.url!));
context.showMessage(message: "Copied".tl);
},
),
if (comic.url != null)
MenuEntry(
icon: Icons.open_in_browser,
text: "Open in Browser".tl,
onClick: () {
launchUrlString(comic.url!);
},
),
]);
}
void showComments() {
showSideBar(
App.rootContext,
CommentsPage(
data: comic,
source: comicSource,
),
);
}
void starRating() {
if (!comicSource.isLogged) {
return;
}
var rating = 0.0;
var isLoading = false;
showDialog(
context: App.rootContext,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => SimpleDialog(
title: const Text("Rating"),
alignment: Alignment.center,
children: [
SizedBox(
height: 100,
child: Center(
child: SizedBox(
width: 210,
child: Column(
children: [
const SizedBox(
height: 10,
),
RatingWidget(
padding: 2,
onRatingUpdate: (value) => rating = value,
value: 1,
selectable: true,
size: 40,
),
const Spacer(),
Button.filled(
isLoading: isLoading,
onPressed: () {
setState(() {
isLoading = true;
});
comicSource.starRatingFunc!(comic.id, rating.round())
.then((value) {
if (value.success) {
App.rootContext
.showMessage(message: "Success".tl);
Navigator.of(dialogContext).pop();
} else {
App.rootContext
.showMessage(message: value.errorMessage!);
setState(() {
isLoading = false;
});
}
});
},
child: Text("Submit".tl),
)
],
),
),
),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,348 @@
part of 'comic_page.dart';
class _ComicChapters extends StatelessWidget {
const _ComicChapters({this.history, required this.groupedMode});
final History? history;
final bool groupedMode;
@override
Widget build(BuildContext context) {
return groupedMode
? _GroupedComicChapters(history)
: _NormalComicChapters(history);
}
}
class _NormalComicChapters extends StatefulWidget {
const _NormalComicChapters(this.history);
final History? history;
@override
State<_NormalComicChapters> createState() => _NormalComicChaptersState();
}
class _NormalComicChaptersState extends State<_NormalComicChapters> {
late _ComicPageState state;
bool reverse = false;
bool showAll = false;
late History? history;
late Map<String, String> chapters;
@override
void initState() {
super.initState();
history = widget.history;
}
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
chapters = state.comic.chapters!;
super.didChangeDependencies();
}
@override
void didUpdateWidget(covariant _NormalComicChapters oldWidget) {
super.didUpdateWidget(oldWidget);
setState(() {
history = widget.history;
});
}
@override
Widget build(BuildContext context) {
return SliverLayoutBuilder(
builder: (context, constrains) {
int length = chapters.length;
bool canShowAll = showAll;
if (!showAll) {
var width = constrains.crossAxisExtent - 16;
var crossItems = width ~/ 200;
if (width % 200 != 0) {
crossItems += 1;
}
length = math.min(length, crossItems * 8);
if (length == chapters.length) {
canShowAll = true;
}
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Chapters".tl),
trailing: Tooltip(
message: "Order".tl,
child: IconButton(
icon: Icon(reverse
? Icons.vertical_align_top
: Icons.vertical_align_bottom_outlined),
onPressed: () {
setState(() {
reverse = !reverse;
});
},
),
),
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(
childCount: length,
(context, i) {
if (reverse) {
i = chapters.length - i - 1;
}
var key = chapters.keys.elementAt(i);
var value = chapters[key]!;
bool visited = (history?.readEpisode ?? {}).contains(i + 1);
return Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
child: Material(
color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: () => state.read(i + 1),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Center(
child: Text(
value,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: visited
? context.colorScheme.outline
: null,
),
),
),
),
),
),
);
},
),
gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200,
itemHeight: 48,
),
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
if (!canShowAll)
SliverToBoxAdapter(
child: Align(
alignment: Alignment.center,
child: TextButton.icon(
icon: const Icon(Icons.arrow_drop_down),
onPressed: () {
setState(() {
showAll = true;
});
},
label: Text("${"Show all".tl} (${chapters.length})"),
).paddingTop(12),
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
},
);
}
}
class _GroupedComicChapters extends StatefulWidget {
const _GroupedComicChapters(this.history);
final History? history;
@override
State<_GroupedComicChapters> createState() => _GroupedComicChaptersState();
}
class _GroupedComicChaptersState extends State<_GroupedComicChapters>
with SingleTickerProviderStateMixin {
late _ComicPageState state;
bool reverse = false;
bool showAll = false;
late History? history;
late Map<String, Map<String, String>> chapters;
late TabController tabController;
int index = 0;
@override
void initState() {
super.initState();
history = widget.history;
}
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
chapters = state.comic.groupedChapters!;
tabController = TabController(
length: chapters.keys.length,
vsync: this,
);
tabController.addListener(onTabChange);
super.didChangeDependencies();
}
void onTabChange() {
if (index != tabController.index) {
setState(() {
index = tabController.index;
});
}
}
@override
void didUpdateWidget(covariant _GroupedComicChapters oldWidget) {
super.didUpdateWidget(oldWidget);
setState(() {
history = widget.history;
});
}
@override
Widget build(BuildContext context) {
return SliverLayoutBuilder(
builder: (context, constrains) {
var group = chapters.values.elementAt(index);
int length = group.length;
bool canShowAll = showAll;
if (!showAll) {
var width = constrains.crossAxisExtent - 16;
var crossItems = width ~/ 200;
if (width % 200 != 0) {
crossItems += 1;
}
length = math.min(length, crossItems * 8);
if (length == group.length) {
canShowAll = true;
}
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Chapters".tl),
trailing: Tooltip(
message: "Order".tl,
child: IconButton(
icon: Icon(reverse
? Icons.vertical_align_top
: Icons.vertical_align_bottom_outlined),
onPressed: () {
setState(() {
reverse = !reverse;
});
},
),
),
),
),
SliverToBoxAdapter(
child: AppTabBar(
withUnderLine: false,
controller: tabController,
tabs: chapters.keys.map((e) => Tab(text: e)).toList(),
),
),
SliverPadding(padding: const EdgeInsets.only(top: 8)),
SliverGrid(
delegate: SliverChildBuilderDelegate(
childCount: length,
(context, i) {
if (reverse) {
i = group.length - i - 1;
}
var key = group.keys.elementAt(i);
var value = group[key]!;
var chapterIndex = 0;
for (var j = 0; j < chapters.length; j++) {
if (j == index) {
chapterIndex += i;
break;
}
chapterIndex += chapters.values.elementAt(j).length;
}
bool visited =
(history?.readEpisode ?? {}).contains(chapterIndex + 1);
return Padding(
padding: const EdgeInsets.fromLTRB(6, 4, 6, 4),
child: Material(
color: context.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: () => state.read(chapterIndex + 1),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Center(
child: Text(
value,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: visited
? context.colorScheme.outline
: null,
),
),
),
),
),
),
);
},
),
gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200,
itemHeight: 48,
),
).sliverPadding(const EdgeInsets.symmetric(horizontal: 8)),
if (!canShowAll)
SliverToBoxAdapter(
child: Align(
alignment: Alignment.center,
child: TextButton.icon(
icon: const Icon(Icons.arrow_drop_down),
onPressed: () {
setState(() {
showAll = true;
});
},
label: Text("${"Show all".tl} (${group.length})"),
).paddingTop(12),
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,936 @@
import 'dart:collection';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shimmer_animation/shimmer_animation.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/category_comics_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'dart:math' as math;
part 'comments_page.dart';
part 'chapters.dart';
part 'thumbnails.dart';
part 'favorite.dart';
part 'comments_preview.dart';
part 'actions.dart';
class ComicPage extends StatefulWidget {
const ComicPage({
super.key,
required this.id,
required this.sourceKey,
this.cover,
this.title,
this.heroID,
});
final String id;
final String sourceKey;
final String? cover;
final String? title;
final int? heroID;
@override
State<ComicPage> createState() => _ComicPageState();
}
class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
with _ComicPageActions {
@override
History? history;
bool showAppbarTitle = false;
var scrollController = ScrollController();
bool isDownloaded = false;
@override
void onReadEnd() {
history ??=
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode));
update();
}
@override
Widget buildLoading() {
return _ComicPageLoadingPlaceHolder(
cover: widget.cover,
title: widget.title,
sourceKey: widget.sourceKey,
cid: widget.id,
heroID: widget.heroID,
);
}
@override
void initState() {
scrollController.addListener(onScroll);
super.initState();
}
@override
void dispose() {
scrollController.removeListener(onScroll);
super.dispose();
}
@override
void update() {
setState(() {});
}
@override
ComicDetails get comic => data!;
void onScroll() {
if (scrollController.offset > 100) {
if (!showAppbarTitle) {
setState(() {
showAppbarTitle = true;
});
}
} else {
if (showAppbarTitle) {
setState(() {
showAppbarTitle = false;
});
}
}
}
var isFirst = true;
@override
Widget buildContent(BuildContext context, ComicDetails data) {
return SmoothCustomScrollView(
controller: scrollController,
slivers: [
...buildTitle(),
buildActions(),
buildDescription(),
buildInfo(),
buildChapters(),
buildComments(),
buildThumbnails(),
buildRecommend(),
SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)),
],
);
}
@override
Future<Res<ComicDetails>> loadData() async {
if (widget.sourceKey == 'local') {
var localComic = LocalManager().find(widget.id, ComicType.local);
if (localComic == null) {
return const Res.error('Local comic not found');
}
var history = HistoryManager().find(widget.id, ComicType.local);
if (isFirst) {
Future.microtask(() {
App.rootContext.to(() {
return Reader(
type: ComicType.local,
cid: widget.id,
name: localComic.title,
chapters: localComic.chapters,
history: history ??
History.fromModel(
model: localComic,
ep: 0,
page: 0,
),
author: localComic.subTitle ?? '',
tags: localComic.tags,
);
});
App.mainNavigatorKey!.currentContext!.pop();
});
isFirst = false;
}
await Future.delayed(const Duration(milliseconds: 200));
return const Res.error('Local comic');
}
var comicSource = ComicSource.find(widget.sourceKey);
if (comicSource == null) {
return const Res.error('Comic source not found');
}
isAddToLocalFav = LocalFavoritesManager().isExist(
widget.id,
ComicType(widget.sourceKey.hashCode),
);
history =
HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode));
return comicSource.loadComicInfo!(widget.id);
}
@override
Future<void> onDataLoaded() async {
isLiked = comic.isLiked ?? false;
isFavorite = comic.isFavorite ?? false;
if (comic.chapters == null) {
isDownloaded = LocalManager().isDownloaded(
comic.id,
comic.comicType,
0,
);
}
}
Iterable<Widget> buildTitle() sync* {
yield SliverAppbar(
title: AnimatedOpacity(
opacity: showAppbarTitle ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: Text(comic.title),
),
actions: [
IconButton(
onPressed: showMoreActions, icon: const Icon(Icons.more_horiz))
],
);
yield const SliverPadding(padding: EdgeInsets.only(top: 8));
yield SliverLazyToBoxAdapter(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
Hero(
tag: "cover${widget.heroID}",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
widget.cover ?? comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
),
width: double.infinity,
height: double.infinity,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(comic.title, style: ts.s18),
if (comic.subTitle != null)
SelectableText(comic.subTitle!, style: ts.s14)
.paddingVertical(4),
Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12,
),
],
),
),
],
),
);
}
Widget buildActions() {
bool isMobile = context.width < changePoint;
bool hasHistory = history != null && (history!.ep > 1 || history!.page > 1);
return SliverLazyToBoxAdapter(
child: Column(
children: [
ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
if (hasHistory && !isMobile)
_ActionButton(
icon: const Icon(Icons.menu_book),
text: 'Continue'.tl,
onPressed: continueRead,
iconColor: context.useTextColor(Colors.yellow),
),
if (!isMobile || hasHistory)
_ActionButton(
icon: const Icon(Icons.play_circle_outline),
text: 'Start'.tl,
onPressed: read,
iconColor: context.useTextColor(Colors.orange),
),
if (!isMobile && !isDownloaded)
_ActionButton(
icon: const Icon(Icons.download),
text: 'Download'.tl,
onPressed: download,
iconColor: context.useTextColor(Colors.cyan),
),
if (data!.isLiked != null)
_ActionButton(
icon: const Icon(Icons.favorite_border),
activeIcon: const Icon(Icons.favorite),
isActive: isLiked,
text: ((data!.likesCount != null)
? (data!.likesCount! + (isLiked ? 1 : 0))
: (isLiked ? 'Liked'.tl : 'Like'.tl))
.toString(),
isLoading: isLiking,
onPressed: likeOrUnlike,
iconColor: context.useTextColor(Colors.red),
),
_ActionButton(
icon: const Icon(Icons.bookmark_outline_outlined),
activeIcon: const Icon(Icons.bookmark),
isActive: isFavorite || isAddToLocalFav,
text: 'Favorite'.tl,
onPressed: openFavPanel,
onLongPressed: quickFavorite,
iconColor: context.useTextColor(Colors.purple),
),
if (comicSource.commentsLoader != null)
_ActionButton(
icon: const Icon(Icons.comment),
text: (comic.commentCount ?? 'Comments'.tl).toString(),
onPressed: showComments,
iconColor: context.useTextColor(Colors.green),
),
_ActionButton(
icon: const Icon(Icons.share),
text: 'Share'.tl,
onPressed: share,
iconColor: context.useTextColor(Colors.blue),
),
],
).fixHeight(48),
if (isMobile)
Row(
children: [
Expanded(
child: FilledButton.tonal(
onPressed: download,
child: Text("Download".tl),
),
),
const SizedBox(width: 16),
Expanded(
child: hasHistory
? FilledButton(
onPressed: continueRead, child: Text("Continue".tl))
: FilledButton(onPressed: read, child: Text("Read".tl)),
)
],
).paddingHorizontal(16).paddingVertical(8),
if (history != null)
Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.history, color: context.useTextColor(Colors.teal)),
const SizedBox(width: 8),
Builder(
builder: (context) {
bool haveChapter = comic.chapters != null;
var page = history!.page;
var ep = history!.ep;
String text;
if (haveChapter) {
text = "Last Reading: Chapter @ep Page @page".tlParams({
'ep': ep,
'page': page,
});
} else {
text = "Last Reading: Page @page".tlParams({
'page': page,
});
}
return Text(text);
},
),
const SizedBox(width: 4),
],
),
).toAlign(Alignment.centerLeft),
const Divider(),
],
).paddingTop(16),
);
}
Widget buildDescription() {
if (comic.description == null || comic.description!.trim().isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverLazyToBoxAdapter(
child: Column(
children: [
ListTile(
title: Text("Description".tl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SelectableText(comic.description!).fixWidth(double.infinity),
),
const SizedBox(height: 16),
const Divider(),
],
),
);
}
Widget buildInfo() {
if (comic.tags.isEmpty &&
comic.uploader == null &&
comic.uploadTime == null &&
comic.uploadTime == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
int i = 0;
Widget buildTag({
required String text,
VoidCallback? onTap,
bool isTitle = false,
}) {
Color color;
if (isTitle) {
const colors = [
Colors.blue,
Colors.cyan,
Colors.red,
Colors.pink,
Colors.purple,
Colors.indigo,
Colors.teal,
Colors.green,
Colors.lime,
Colors.yellow,
];
color = context.useBackgroundColor(colors[(i++) % (colors.length)]);
} else {
color = context.colorScheme.surfaceContainerLow;
}
final borderRadius = BorderRadius.circular(12);
const padding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
if (onTap != null) {
return Material(
color: color,
borderRadius: borderRadius,
child: InkWell(
borderRadius: borderRadius,
onTap: onTap,
onLongPress: () {
Clipboard.setData(ClipboardData(text: text));
context.showMessage(message: "Copied".tl);
},
onSecondaryTapDown: (details) {
showMenuX(context, details.globalPosition, [
MenuEntry(
icon: Icons.remove_red_eye,
text: "View".tl,
onClick: onTap,
),
MenuEntry(
icon: Icons.copy,
text: "Copy".tl,
onClick: () {
Clipboard.setData(ClipboardData(text: text));
context.showMessage(message: "Copied".tl);
},
),
]);
},
child: Text(text).padding(padding),
),
);
} else {
return Container(
decoration: BoxDecoration(
color: color,
borderRadius: borderRadius,
),
child: Text(text).padding(padding),
);
}
}
String formatTime(String time) {
if (int.tryParse(time) != null) {
var t = int.tryParse(time);
if (t! > 1000000000000) {
return DateTime.fromMillisecondsSinceEpoch(t)
.toString()
.substring(0, 19);
} else {
return DateTime.fromMillisecondsSinceEpoch(t * 1000)
.toString()
.substring(0, 19);
}
}
if (time.contains('T') || time.contains('Z')) {
var t = DateTime.parse(time);
return t.toString().substring(0, 19);
}
return time;
}
Widget buildWrap({required List<Widget> children}) {
return Wrap(
runSpacing: 8,
spacing: 8,
children: children,
).paddingHorizontal(16).paddingBottom(8);
}
bool enableTranslation =
App.locale.languageCode == 'zh' && comicSource.enableTagsTranslate;
return SliverLazyToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text("Information".tl),
),
if (comic.stars != null)
Row(
children: [
StarRating(
value: comic.stars!,
size: 24,
onTap: starRating,
),
const SizedBox(width: 8),
Text(comic.stars!.toStringAsFixed(2)),
],
).paddingLeft(16).paddingVertical(8),
for (var e in comic.tags.entries)
buildWrap(
children: [
if (e.value.isNotEmpty)
buildTag(text: e.key.ts(comicSource.key), isTitle: true),
for (var tag in e.value)
buildTag(
text: enableTranslation
? TagsTranslation.translationTagWithNamespace(
tag,
e.key.toLowerCase(),
)
: tag,
onTap: () => onTapTag(tag, e.key),
),
],
),
if (comic.uploader != null)
buildWrap(
children: [
buildTag(text: 'Uploader'.tl, isTitle: true),
buildTag(text: comic.uploader!),
],
),
if (comic.uploadTime != null)
buildWrap(
children: [
buildTag(text: 'Upload Time'.tl, isTitle: true),
buildTag(text: formatTime(comic.uploadTime!)),
],
),
if (comic.updateTime != null)
buildWrap(
children: [
buildTag(text: 'Update Time'.tl, isTitle: true),
buildTag(text: formatTime(comic.updateTime!)),
],
),
const SizedBox(height: 12),
const Divider(),
],
),
);
}
Widget buildChapters() {
if (comic.chapters == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return _ComicChapters(
history: history,
groupedMode: comic.groupedChapters != null,
);
}
Widget buildThumbnails() {
if (comic.thumbnails == null && comicSource.loadComicThumbnail == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return const _ComicThumbnails();
}
Widget buildRecommend() {
if (comic.recommend == null || comic.recommend!.isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverMainAxisGroup(slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Related".tl),
),
),
SliverGridComics(comics: comic.recommend!),
]);
}
Widget buildComments() {
if (comic.comments == null || comic.comments!.isEmpty) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return _CommentsPart(
comments: comic.comments!,
showMore: showComments,
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.icon,
required this.text,
required this.onPressed,
this.onLongPressed,
this.activeIcon,
this.isActive,
this.isLoading,
this.iconColor,
});
final Widget icon;
final Widget? activeIcon;
final bool? isActive;
final String text;
final void Function() onPressed;
final bool? isLoading;
final Color? iconColor;
final void Function()? onLongPressed;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
child: InkWell(
onTap: () {
if (!(isLoading ?? false)) {
onPressed();
}
},
onLongPress: onLongPressed,
borderRadius: BorderRadius.circular(18),
child: IconTheme.merge(
data: IconThemeData(size: 20, color: iconColor),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading ?? false)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 1.8),
)
else
(isActive ?? false) ? (activeIcon ?? icon) : icon,
const SizedBox(width: 8),
Text(text),
],
).paddingHorizontal(16),
),
),
);
}
}
class _SelectDownloadChapter extends StatefulWidget {
const _SelectDownloadChapter(this.eps, this.finishSelect, this.downloadedEps);
final List<String> eps;
final void Function(List<int>) finishSelect;
final List<int> downloadedEps;
@override
State<_SelectDownloadChapter> createState() => _SelectDownloadChapterState();
}
class _SelectDownloadChapterState extends State<_SelectDownloadChapter> {
List<int> selected = [];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: Appbar(
title: Text("Download".tl),
backgroundColor: context.colorScheme.surfaceContainerLow,
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: widget.eps.length,
itemBuilder: (context, i) {
return CheckboxListTile(
title: Text(widget.eps[i]),
value: selected.contains(i) ||
widget.downloadedEps.contains(i),
onChanged: widget.downloadedEps.contains(i)
? null
: (v) {
setState(() {
if (selected.contains(i)) {
selected.remove(i);
} else {
selected.add(i);
}
});
});
},
),
),
Container(
height: 50,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
),
),
),
child: Row(
children: [
const SizedBox(width: 16),
Expanded(
child: TextButton(
onPressed: () {
var res = <int>[];
for (int i = 0; i < widget.eps.length; i++) {
if (!widget.downloadedEps.contains(i)) {
res.add(i);
}
}
widget.finishSelect(res);
context.pop();
},
child: Text("Download All".tl),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton(
onPressed: selected.isEmpty
? null
: () {
widget.finishSelect(selected);
context.pop();
},
child: Text("Download Selected".tl),
),
),
const SizedBox(width: 16),
],
),
),
SizedBox(height: MediaQuery.of(context).padding.bottom),
],
),
);
}
}
class _ComicPageLoadingPlaceHolder extends StatelessWidget {
const _ComicPageLoadingPlaceHolder({
this.cover,
this.title,
required this.sourceKey,
required this.cid,
this.heroID,
});
final String? cover;
final String? title;
final String sourceKey;
final String cid;
final int? heroID;
@override
Widget build(BuildContext context) {
Widget buildContainer(double? width, double? height,
{Color? color, double? radius}) {
return Container(
height: height,
width: width,
decoration: BoxDecoration(
color: color ?? context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(radius ?? 4),
),
);
}
return Shimmer(
color: context.isDarkMode ? Colors.grey.shade700 : Colors.white,
child: Column(
children: [
Appbar(title: Text(""), backgroundColor: context.colorScheme.surface),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
buildImage(context),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null)
Text(title ?? "", style: ts.s18)
else
buildContainer(200, 25),
const SizedBox(height: 8),
buildContainer(80, 20),
],
),
),
],
),
const SizedBox(height: 8),
if (context.width < changePoint)
Row(
children: [
Expanded(
child: buildContainer(null, 36, radius: 18),
),
const SizedBox(width: 16),
Expanded(
child: buildContainer(null, 36, radius: 18),
),
],
).paddingHorizontal(16),
const Divider(),
const SizedBox(height: 8),
Center(
child: CircularProgressIndicator(
strokeWidth: 2.4,
).fixHeight(24).fixWidth(24),
)
],
),
);
}
Widget buildImage(BuildContext context) {
Widget child;
if (cover != null) {
child = AnimatedImage(
image: CachedImageProvider(
cover!,
sourceKey: sourceKey,
cid: cid,
),
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
);
} else {
child = const SizedBox();
}
return Hero(
tag: "cover$heroID",
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: context.colorScheme.outlineVariant,
blurRadius: 1,
offset: const Offset(0, 1),
),
],
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: child,
),
);
}
}

View File

@@ -1,25 +1,18 @@
import 'dart:collection';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/translations.dart';
part of 'comic_page.dart';
class CommentsPage extends StatefulWidget {
const CommentsPage(
{super.key, required this.data, required this.source, this.replyId});
const CommentsPage({
super.key,
required this.data,
required this.source,
this.replyComment,
});
final ComicDetails data;
final ComicSource source;
final String? replyId;
final Comment? replyComment;
@override
State<CommentsPage> createState() => _CommentsPageState();
@@ -36,7 +29,7 @@ class _CommentsPageState extends State<CommentsPage> {
void firstLoad() async {
var res = await widget.source.commentsLoader!(
widget.data.comicId, widget.data.subId, 1, widget.replyId);
widget.data.comicId, widget.data.subId, 1, widget.replyComment?.id);
if (res.error) {
setState(() {
_error = res.errorMessage;
@@ -53,7 +46,11 @@ class _CommentsPageState extends State<CommentsPage> {
void loadMore() async {
var res = await widget.source.commentsLoader!(
widget.data.comicId, widget.data.subId, _page + 1, widget.replyId);
widget.data.comicId,
widget.data.subId,
_page + 1,
widget.replyComment?.id,
);
if (res.error) {
context.showMessage(message: res.errorMessage ?? "Unknown Error");
} else {
@@ -105,8 +102,44 @@ class _CommentsPageState extends State<CommentsPage> {
child: ListView.builder(
primary: false,
padding: EdgeInsets.zero,
itemCount: _comments!.length + 1,
itemCount: _comments!.length + 2,
itemBuilder: (context, index) {
if (index == 0) {
if (widget.replyComment != null) {
return Column(
children: [
_CommentTile(
comment: widget.replyComment!,
source: widget.source,
comic: widget.data,
showAvatar: showAvatar,
showActions: false,
),
const SizedBox(height: 8),
Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Text(
"Replies".tl,
style: ts.s18,
),
),
],
);
} else {
return const SizedBox();
}
}
index--;
if (index == _comments!.length) {
if (_page < (maxPage ?? _page + 1)) {
loadMore();
@@ -141,6 +174,12 @@ class _CommentsPageState extends State<CommentsPage> {
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Material(
color: context.colorScheme.surfaceContainer,
@@ -160,7 +199,7 @@ class _CommentsPageState extends State<CommentsPage> {
),
if (sending)
const Padding(
padding: EdgeInsets.all(8.5),
padding: EdgeInsets.all(8),
child: SizedBox(
width: 24,
height: 24,
@@ -182,7 +221,7 @@ class _CommentsPageState extends State<CommentsPage> {
widget.data.comicId,
widget.data.subId,
controller.text,
widget.replyId);
widget.replyComment?.id);
if (!b.error) {
controller.text = "";
setState(() {
@@ -205,7 +244,7 @@ class _CommentsPageState extends State<CommentsPage> {
),
)
],
).paddingVertical(2).paddingLeft(16).paddingRight(4),
).paddingLeft(16).paddingRight(4),
),
);
}
@@ -217,6 +256,7 @@ class _CommentTile extends StatefulWidget {
required this.source,
required this.comic,
required this.showAvatar,
this.showActions = true,
});
final Comment comment;
@@ -227,6 +267,8 @@ class _CommentTile extends StatefulWidget {
final bool showAvatar;
final bool showActions;
@override
State<_CommentTile> createState() => _CommentTileState();
}
@@ -243,24 +285,17 @@ class _CommentTileState extends State<_CommentTile> {
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.showAvatar)
Container(
width: 40,
height: 40,
width: 36,
height: 36,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(18),
color: Theme.of(context).colorScheme.secondaryContainer),
child: widget.comment.avatar == null
? null
@@ -270,7 +305,7 @@ class _CommentTileState extends State<_CommentTile> {
sourceKey: widget.source.key,
),
),
).paddingRight(12),
).paddingRight(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -288,11 +323,14 @@ class _CommentTileState extends State<_CommentTile> {
),
)
],
).paddingAll(16),
),
);
}
Widget buildActions() {
if (!widget.showActions) {
return const SizedBox();
}
if (widget.comment.score == null && widget.comment.replyCount == null) {
return const SizedBox();
}
@@ -331,7 +369,7 @@ class _CommentTileState extends State<_CommentTile> {
CommentsPage(
data: widget.comic,
source: widget.source,
replyId: widget.comment.id,
replyComment: widget.comment,
),
showBarrier: false,
);
@@ -676,7 +714,17 @@ class _RichCommentContentState extends State<RichCommentContent> {
attributes[attrSplits[0]] = attrSplits[1].replaceAll('"', '');
}
}
const acceptedTags = ['img', 'a', 'b', 'i', 'u', 's', 'br', 'span', 'strong'];
const acceptedTags = [
'img',
'a',
'b',
'i',
'u',
's',
'br',
'span',
'strong'
];
if (acceptedTags.contains(tagName)) {
writeBuffer();
if (tagName == 'img') {

View File

@@ -0,0 +1,150 @@
part of 'comic_page.dart';
class _CommentsPart extends StatefulWidget {
const _CommentsPart({
required this.comments,
required this.showMore,
});
final List<Comment> comments;
final void Function() showMore;
@override
State<_CommentsPart> createState() => _CommentsPartState();
}
class _CommentsPartState extends State<_CommentsPart> {
final scrollController = ScrollController();
late List<Comment> comments;
@override
void initState() {
comments = widget.comments;
super.initState();
}
@override
Widget build(BuildContext context) {
return MultiSliver(
children: [
SliverLazyToBoxAdapter(
child: ListTile(
title: Text("Comments".tl),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
scrollController.animateTo(
scrollController.position.pixels - 340,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
},
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
scrollController.animateTo(
scrollController.position.pixels + 340,
duration: const Duration(milliseconds: 200),
curve: Curves.ease,
);
},
),
],
),
),
),
SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 184,
child: MediaQuery.removePadding(
removeTop: true,
context: context,
child: ListView.builder(
controller: scrollController,
scrollDirection: Axis.horizontal,
itemCount: comments.length,
itemBuilder: (context, index) {
return _CommentWidget(comment: comments[index]);
},
),
),
),
const SizedBox(height: 8),
_ActionButton(
icon: const Icon(Icons.comment),
text: "View more".tl,
onPressed: widget.showMore,
iconColor: context.useTextColor(Colors.green),
).fixHeight(48).paddingRight(8).toAlign(Alignment.centerRight),
const SizedBox(height: 8),
],
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}
class _CommentWidget extends StatelessWidget {
const _CommentWidget({required this.comment});
final Comment comment;
@override
Widget build(BuildContext context) {
return Container(
height: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 8, 0, 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
width: 324,
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Row(
children: [
if (comment.avatar != null)
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
color: context.colorScheme.surfaceContainer,
),
clipBehavior: Clip.antiAlias,
child: Image(
image: CachedImageProvider(comment.avatar!),
width: 36,
height: 36,
fit: BoxFit.cover,
),
).paddingRight(8),
Text(comment.userName, style: ts.bold),
],
),
const SizedBox(height: 4),
Expanded(
child: RichCommentContent(text: comment.content).fixWidth(324),
),
const SizedBox(height: 4),
if (comment.time != null)
Text(comment.time!, style: ts.s12).toAlign(Alignment.centerLeft),
],
),
);
}
}

View File

@@ -0,0 +1,432 @@
part of 'comic_page.dart';
class _FavoritePanel extends StatefulWidget {
const _FavoritePanel({
required this.cid,
required this.type,
required this.isFavorite,
required this.onFavorite,
required this.favoriteItem,
this.updateTime,
});
final String cid;
final ComicType type;
/// whether the comic is in the network favorite list
///
/// if null, the comic source does not support favorite or support multiple favorite lists
final bool? isFavorite;
final void Function(bool?, bool?) onFavorite;
final FavoriteItem favoriteItem;
final String? updateTime;
@override
State<_FavoritePanel> createState() => _FavoritePanelState();
}
class _FavoritePanelState extends State<_FavoritePanel>
with SingleTickerProviderStateMixin {
late ComicSource comicSource;
late TabController tabController;
late bool hasNetwork;
@override
void initState() {
comicSource = widget.type.comicSource!;
localFolders = LocalFavoritesManager().folderNames;
added = LocalFavoritesManager().find(widget.cid, widget.type);
hasNetwork = comicSource.favoriteData != null && comicSource.isLogged;
var initIndex = 0;
if (appdata.implicitData['favoritePanelIndex'] is int) {
initIndex = appdata.implicitData['favoritePanelIndex'];
}
initIndex = initIndex.clamp(0, hasNetwork ? 1 : 0);
tabController = TabController(
initialIndex: initIndex,
length: hasNetwork ? 2 : 1,
vsync: this,
);
super.initState();
}
@override
void dispose() {
var currentIndex = tabController.index;
appdata.implicitData['favoritePanelIndex'] = currentIndex;
appdata.writeImplicitData();
tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: Appbar(
title: Text("Favorite".tl),
),
body: Column(
children: [
TabBar(
controller: tabController,
tabs: [
Tab(text: "Local".tl),
if (hasNetwork) Tab(text: "Network".tl),
],
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
buildLocal(),
if (hasNetwork) buildNetwork(),
],
),
),
],
),
);
}
late List<String> localFolders;
late List<String> added;
var selectedLocalFolders = <String>{};
Widget buildLocal() {
var isRemove = selectedLocalFolders.isNotEmpty &&
added.contains(selectedLocalFolders.first);
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: localFolders.length + 1,
itemBuilder: (context, index) {
if (index == localFolders.length) {
return SizedBox(
height: 36,
child: Center(
child: TextButton(
onPressed: () {
newFolder().then((v) {
setState(() {
localFolders = LocalFavoritesManager().folderNames;
});
});
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 20),
const SizedBox(width: 4),
Text("New Folder".tl)
],
),
),
),
);
}
var folder = localFolders[index];
var disabled = false;
if (selectedLocalFolders.isNotEmpty) {
if (added.contains(folder) &&
!added.contains(selectedLocalFolders.first)) {
disabled = true;
} else if (!added.contains(folder) &&
added.contains(selectedLocalFolders.first)) {
disabled = true;
}
}
return CheckboxListTile(
title: Row(
children: [
Text(folder),
const SizedBox(width: 8),
if (added.contains(folder))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
value: selectedLocalFolders.contains(folder),
onChanged: disabled
? null
: (v) {
setState(() {
if (v!) {
selectedLocalFolders.add(folder);
} else {
selectedLocalFolders.remove(folder);
}
});
},
);
},
),
),
Center(
child: FilledButton(
onPressed: () {
if (selectedLocalFolders.isEmpty) {
return;
}
if (isRemove) {
for (var folder in selectedLocalFolders) {
LocalFavoritesManager()
.deleteComicWithId(folder, widget.cid, widget.type);
}
widget.onFavorite(false, null);
} else {
for (var folder in selectedLocalFolders) {
LocalFavoritesManager().addComic(
folder,
widget.favoriteItem,
null,
widget.updateTime,
);
}
widget.onFavorite(true, null);
}
context.pop();
},
child: isRemove ? Text("Remove".tl) : Text("Add".tl),
).paddingVertical(8),
),
],
);
}
Widget buildNetwork() {
return _NetworkFavorites(
cid: widget.cid,
comicSource: comicSource,
isFavorite: widget.isFavorite,
onFavorite: (network) {
widget.onFavorite(null, network);
},
);
}
}
class _NetworkFavorites extends StatefulWidget {
const _NetworkFavorites({
required this.cid,
required this.comicSource,
required this.isFavorite,
required this.onFavorite,
});
final String cid;
final ComicSource comicSource;
final bool? isFavorite;
final void Function(bool) onFavorite;
@override
State<_NetworkFavorites> createState() => _NetworkFavoritesState();
}
class _NetworkFavoritesState extends State<_NetworkFavorites> {
@override
Widget build(BuildContext context) {
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
return isMultiFolder ? buildMultiFolder() : buildSingleFolder();
}
bool isLoading = false;
Widget buildSingleFolder() {
var isFavorite = widget.isFavorite ?? false;
return Column(
children: [
Expanded(
child: Center(
child: Text(isFavorite ? "Added to favorites".tl : "Not added".tl),
),
),
Center(
child: Button.filled(
isLoading: isLoading,
onPressed: () async {
setState(() {
isLoading = true;
});
var res = await widget.comicSource.favoriteData!
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
if (res.success) {
widget.onFavorite(!isFavorite);
context.pop();
App.rootContext.showMessage(
message: isFavorite ? "Removed".tl : "Added".tl);
} else {
setState(() {
isLoading = false;
});
context.showMessage(message: res.errorMessage!);
}
},
child: isFavorite ? Text("Remove".tl) : Text("Add".tl),
).paddingVertical(8),
),
],
);
}
Map<String, String>? folders;
var addedFolders = <String>{};
var isLoadingFolders = true;
// for network favorites, only one selection is allowed
String? selected;
void loadFolders() async {
var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid);
if (res.error) {
context.showMessage(message: res.errorMessage!);
} else {
folders = res.data;
if (res.subData is List) {
addedFolders = List<String>.from(res.subData).toSet();
}
setState(() {
isLoadingFolders = false;
});
}
}
Widget buildMultiFolder() {
if (widget.isFavorite == true &&
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
return Column(
children: [
Expanded(
child: Center(
child: Text("Added to favorites".tl),
),
),
Center(
child: Button.filled(
isLoading: isLoading,
onPressed: () async {
setState(() {
isLoading = true;
});
var res = await widget.comicSource.favoriteData!
.addOrDelFavorite!(widget.cid, '', false, null);
if (res.success) {
widget.onFavorite(false);
context.pop();
App.rootContext.showMessage(message: "Removed".tl);
} else {
setState(() {
isLoading = false;
});
context.showMessage(message: res.errorMessage!);
}
},
child: Text("Remove".tl),
).paddingVertical(8),
),
],
);
}
if (isLoadingFolders) {
loadFolders();
return const Center(child: CircularProgressIndicator());
} else {
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: folders!.length,
itemBuilder: (context, index) {
var name = folders!.values.elementAt(index);
var id = folders!.keys.elementAt(index);
return CheckboxListTile(
title: Row(
children: [
Text(name),
const SizedBox(width: 8),
if (addedFolders.contains(id))
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: context.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text("Added".tl, style: ts.s12),
),
],
),
value: selected == id,
onChanged: (v) {
setState(() {
selected = id;
});
},
);
},
),
),
Center(
child: Button.filled(
isLoading: isLoading,
onPressed: () async {
if (selected == null) {
return;
}
setState(() {
isLoading = true;
});
var res =
await widget.comicSource.favoriteData!.addOrDelFavorite!(
widget.cid,
selected!,
!addedFolders.contains(selected!),
null,
);
if (res.success) {
context.showMessage(message: "Success".tl);
context.pop();
} else {
context.showMessage(message: res.errorMessage!);
setState(() {
isLoading = false;
});
}
},
child: selected != null && addedFolders.contains(selected!)
? Text("Remove".tl)
: Text("Add".tl),
).paddingVertical(8),
),
],
);
}
}
}

View File

@@ -0,0 +1,169 @@
part of 'comic_page.dart';
class _ComicThumbnails extends StatefulWidget {
const _ComicThumbnails();
@override
State<_ComicThumbnails> createState() => _ComicThumbnailsState();
}
class _ComicThumbnailsState extends State<_ComicThumbnails> {
late _ComicPageState state;
late List<String> thumbnails;
bool isInitialLoading = true;
String? next;
String? error;
bool isLoading = false;
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
loadNext();
thumbnails = List.from(state.comic.thumbnails ?? []);
super.didChangeDependencies();
}
void loadNext() async {
if (state.comicSource.loadComicThumbnail == null) return;
if (!isInitialLoading && next == null) {
return;
}
if (isLoading) return;
Future.microtask(() {
setState(() {
isLoading = true;
});
});
var res = await state.comicSource.loadComicThumbnail!(state.comic.id, next);
if (res.success) {
thumbnails.addAll(res.data);
next = res.subData;
isInitialLoading = false;
} else {
error = res.errorMessage;
}
if (mounted) {
setState(() {
isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return MultiSliver(
children: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Preview".tl),
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(
childCount: thumbnails.length,
(context, index) {
if (index == thumbnails.length - 1 && error == null) {
loadNext();
}
var url = thumbnails[index];
ImagePart? part;
if (url.contains('@')) {
var params = url.split('@')[1].split('&');
url = url.split('@')[0];
double? x1, y1, x2, y2;
try {
for (var p in params) {
if (p.startsWith('x')) {
var r = p.split('=')[1];
x1 = double.parse(r.split('-')[0]);
x2 = double.parse(r.split('-')[1]);
}
if (p.startsWith('y')) {
var r = p.split('=')[1];
y1 = double.parse(r.split('-')[0]);
y2 = double.parse(r.split('-')[1]);
}
}
} catch (_) {
// ignore
}
part = ImagePart(x1: x1, y1: y1, x2: x2, y2: y2);
}
return Padding(
padding: context.width < changePoint
? const EdgeInsets.all(4)
: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: InkWell(
onTap: () => state.read(null, index + 1),
borderRadius:
const BorderRadius.all(Radius.circular(8)),
child: Container(
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
width: double.infinity,
height: double.infinity,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
url,
sourceKey: state.widget.sourceKey,
),
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
part: part,
),
),
),
),
const SizedBox(
height: 4,
),
Text((index + 1).toString()),
],
),
);
},
),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 0.68,
),
),
if (error != null)
SliverToBoxAdapter(
child: Column(
children: [
Text(error!),
Button.outlined(
onPressed: loadNext,
child: Text("Retry".tl),
)
],
),
)
else if (isLoading)
const SliverListLoadingIndicator(),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -156,23 +156,27 @@ class _BodyState extends State<_Body> {
);
}
static Future<void> update(ComicSource source) async {
static Future<void> update(ComicSource source,
[bool showLoading = true]) async {
if (!source.url.isURL) {
App.rootContext.showMessage(message: "Invalid url config");
return;
}
ComicSource.remove(source.key);
bool cancel = false;
var controller = showLoadingDialog(
App.rootContext,
onCancel: () => cancel = true,
barrierDismissible: false,
);
LoadingDialogController? controller;
if (showLoading) {
controller = showLoadingDialog(
App.rootContext,
onCancel: () => cancel = true,
barrierDismissible: false,
);
}
try {
var res = await AppDio().get<String>(source.url,
options: Options(responseType: ResponseType.plain));
if (cancel) return;
controller.close();
controller?.close();
await ComicSourceParser().parse(res.data!, source.filePath);
await File(source.filePath).writeAsString(res.data!);
if (ComicSource.availableUpdates.containsKey(source.key)) {
@@ -551,13 +555,59 @@ class _CheckUpdatesButtonState extends State<_CheckUpdatesButton> {
} else if (count == 0) {
context.showMessage(message: "No updates".tl);
} else {
context.showMessage(message: "@c updates".tlParams({"c": count}));
showUpdateDialog();
}
setState(() {
isLoading = false;
});
}
void showUpdateDialog() async {
var text = ComicSource.availableUpdates.entries.map((e) {
return "${ComicSource.find(e.key)!.name}: ${e.value}";
}).join("\n");
bool doUpdate = false;
await showDialog(
context: App.rootContext,
builder: (context) {
return ContentDialog(
title: "Updates".tl,
content: Text(text).paddingHorizontal(16),
actions: [
FilledButton(
onPressed: () {
doUpdate = true;
context.pop();
},
child: Text("Update".tl),
),
],
);
},
);
if (doUpdate) {
var loadingController = showLoadingDialog(
context,
message: "Updating".tl,
withProgress: true,
);
int current = 0;
int total = ComicSource.availableUpdates.length;
try {
var shouldUpdate = ComicSource.availableUpdates.keys.toList();
for (var key in shouldUpdate) {
var source = ComicSource.find(key)!;
await _BodyState.update(source, false);
current++;
loadingController.setProgress(current / total);
}
} catch (e) {
context.showMessage(message: e.toString());
}
loadingController.close();
}
}
@override
Widget build(BuildContext context) {
return Button.normal(

View File

@@ -3,8 +3,8 @@ import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/global_state.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
@@ -52,9 +52,7 @@ class _ExplorePageState extends State<ExplorePage>
if (index == 2) {
int page = controller.index;
String currentPageId = pages[page];
StateController.find<SimpleController>(tag: currentPageId)
.control!()['toTop']
?.call();
GlobalState.find<_SingleExplorePageState>(currentPageId).toTop();
}
}
@@ -98,7 +96,7 @@ class _ExplorePageState extends State<ExplorePage>
void refresh() {
int page = controller.index;
String currentPageId = pages[page];
StateController.find<SimpleController>(tag: currentPageId).refresh();
GlobalState.find<_SingleExplorePageState>(currentPageId).refresh();
}
Widget buildFAB() => Material(
@@ -244,7 +242,7 @@ class _SingleExplorePage extends StatefulWidget {
State<_SingleExplorePage> createState() => _SingleExplorePageState();
}
class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
class _SingleExplorePageState extends AutomaticGlobalState<_SingleExplorePage>
with AutomaticKeepAliveClientMixin<_SingleExplorePage> {
late final ExplorePageData data;
@@ -328,7 +326,7 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
}
@override
Object? get tag => widget.title;
Object? get key => widget.title;
@override
void refresh() {
@@ -347,9 +345,6 @@ class _SingleExplorePageState extends StateWithController<_SingleExplorePage>
);
}
}
@override
Map<String, dynamic> get control => {"toTop": toTop};
}
class _MixedExplorePage extends StatefulWidget {

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
@@ -14,7 +15,7 @@ import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/pages/settings/settings_page.dart';
import 'package:venera/utils/io.dart';

View File

@@ -351,6 +351,21 @@ class _LocalFavoritesPageState extends State<_LocalFavoritesPage> {
text: "Download".tl,
onClick: downloadSelected,
),
if (selectedComics.length == 1)
MenuEntry(
icon: Icons.copy,
text: "Copy Title".tl,
onClick: () {
Clipboard.setData(
ClipboardData(
text: selectedComics.keys.first.title,
),
);
context.showMessage(
message: "Copied".tl,
);
},
),
]),
],
)

View File

@@ -0,0 +1,665 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/translations.dart';
import '../foundation/global_state.dart';
class FollowUpdatesWidget extends StatefulWidget {
const FollowUpdatesWidget({super.key});
@override
State<FollowUpdatesWidget> createState() => _FollowUpdatesWidgetState();
}
class _FollowUpdatesWidgetState
extends AutomaticGlobalState<FollowUpdatesWidget> {
int _count = 0;
String? get folder => appdata.settings["followUpdatesFolder"];
void getCount() {
if (folder == null) {
_count = 0;
return;
}
if (!LocalFavoritesManager().folderNames.contains(folder)) {
_count = 0;
appdata.settings["followUpdatesFolder"] = null;
Future.microtask(() {
appdata.saveData();
});
} else {
_count = LocalFavoritesManager().countUpdates(folder!);
}
}
void updateCount() {
setState(() {
getCount();
});
}
@override
void initState() {
super.initState();
getCount();
}
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(8),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
context.to(() => FollowUpdatesPage());
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 56,
child: Row(
children: [
Center(
child: Text('Follow Updates'.tl, style: ts.s18),
),
const Spacer(),
const Icon(Icons.arrow_right),
],
),
).paddingHorizontal(16),
if (_count > 0)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
margin: const EdgeInsets.only(bottom: 16, left: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).colorScheme.primaryContainer,
),
child: Text(
'@c updates'.tlParams({
'c': _count,
}),
style: ts.s16,
),
),
],
),
),
),
);
}
@override
Object? get key => 'FollowUpdatesWidget';
}
class FollowUpdatesPage extends StatefulWidget {
const FollowUpdatesPage({super.key});
@override
State<FollowUpdatesPage> createState() => _FollowUpdatesPageState();
}
class _FollowUpdatesPageState extends AutomaticGlobalState<FollowUpdatesPage> {
String? get folder => appdata.settings["followUpdatesFolder"];
var updatedComics = <FavoriteItemWithUpdateInfo>[];
var allComics = <FavoriteItemWithUpdateInfo>[];
/// Sort comics by update time in descending order with nulls at the end.
void sortComics() {
allComics.sort((a, b) {
if (a.updateTime == null && b.updateTime == null) {
return 0;
} else if (a.updateTime == null) {
return -1;
} else if (b.updateTime == null) {
return 1;
}
return b.updateTime!.compareTo(a.updateTime!);
});
}
@override
void initState() {
super.initState();
if (folder != null) {
allComics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder!);
sortComics();
updatedComics = allComics.where((c) => c.hasNewUpdate).toList();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text('Follow Updates'.tl)),
if (folder == null)
buildNotConfigured(context)
else
buildConfigured(context),
SliverPadding(padding: const EdgeInsets.only(top: 8)),
buildUpdatedComics(),
buildAllComics(),
],
),
);
}
Widget buildNotConfigured(BuildContext context) {
return SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: Icon(Icons.info_outline),
title: Text("Not Configured".tl),
),
Text(
"Choose a folder to follow updates.".tl,
style: ts.s16,
).paddingHorizontal(16),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: showSelector,
child: Text("Choose Folder".tl),
).paddingHorizontal(16).toAlign(Alignment.centerRight),
const SizedBox(height: 16),
],
),
),
);
}
Widget buildConfigured(BuildContext context) {
return SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: Icon(Icons.stars_outlined),
title: Text(folder!),
),
Text(
"Automatic update checking enabled.".tl,
style: ts.s14,
).paddingHorizontal(16),
Text(
"The app will check for updates at most once a day.".tl,
style: ts.s14,
).paddingHorizontal(16),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: showSelector,
child: Text("Change Folder".tl),
),
FilledButton.tonal(
onPressed: checkNow,
child: Text("Check Now".tl),
),
const SizedBox(width: 16),
],
),
const SizedBox(height: 16),
],
),
),
);
}
Widget buildUpdatedComics() {
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(Icons.update),
const SizedBox(width: 8),
Text(
"Updates".tl,
style: ts.s18,
),
],
),
),
),
if (updatedComics.isNotEmpty)
SliverToBoxAdapter(
child: Text(
"The comic will be marked as no updates as soon as you read it."
.tl)
.paddingHorizontal(16)
.paddingVertical(4),
),
if (updatedComics.isNotEmpty)
SliverGridComics(comics: updatedComics)
else
SliverToBoxAdapter(
child: Row(
children: [
Container(
margin:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"No updates found".tl,
style: ts.s16,
),
],
),
)
],
),
),
],
);
}
Widget buildAllComics() {
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 0.6,
),
),
),
child: Row(
children: [
Icon(Icons.list),
const SizedBox(width: 8),
Text(
"All Comics".tl,
style: ts.s18,
),
],
),
),
),
SliverGridComics(comics: allComics),
],
);
}
void showSelector() {
var folders = LocalFavoritesManager().folderNames;
if (folders.isEmpty) {
context.showMessage(message: "No folders available".tl);
return;
}
String? selectedFolder;
showDialog(
context: App.rootContext,
builder: (context) {
return StatefulBuilder(builder: (context, setState) {
return ContentDialog(
title: "Choose Folder".tl,
content: Column(
children: [
ListTile(
title: Text("Folder".tl),
trailing: Select(
minWidth: 120,
current: selectedFolder,
values: folders,
onTap: (i) {
setState(() {
selectedFolder = folders[i];
});
},
),
),
],
),
actions: [
if (appdata.settings["followUpdatesFolder"] != null)
TextButton(
onPressed: () {
disable();
context.pop();
},
child: Text("Disable".tl),
),
FilledButton(
onPressed: selectedFolder == null
? null
: () {
context.pop();
setFolder(selectedFolder!);
},
child: Text("Confirm".tl),
),
],
);
});
},
);
}
void disable() {
appdata.settings["followUpdatesFolder"] = null;
appdata.saveData();
updateFollowUpdatesUI();
}
void setFolder(String folder) async {
FollowUpdatesService.cancelChecking?.call();
LocalFavoritesManager().prepareTableForFollowUpdates(folder);
var count = LocalFavoritesManager().count(folder);
if (count > 0) {
bool isCanceled = false;
void onCancel() {
isCanceled = true;
}
var loadingController = showLoadingDialog(
App.rootContext,
withProgress: true,
cancelButtonText: "Cancel".tl,
onCancel: onCancel,
message: "Updating comics...".tl,
);
await for (var progress in _updateFolder(folder, true)) {
if (isCanceled) {
return;
}
loadingController.setProgress(progress.current / progress.total);
}
loadingController.close();
}
setState(() {
appdata.settings["followUpdatesFolder"] = folder;
updatedComics = [];
allComics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
sortComics();
});
appdata.saveData();
}
void checkNow() async {
FollowUpdatesService.cancelChecking?.call();
bool isCanceled = false;
void onCancel() {
isCanceled = true;
}
var loadingController = showLoadingDialog(
App.rootContext,
withProgress: true,
cancelButtonText: "Cancel".tl,
onCancel: onCancel,
message: "Updating comics...".tl,
);
int updated = 0;
await for (var progress in _updateFolder(folder!, true)) {
if (isCanceled) {
return;
}
loadingController.setProgress(progress.current / progress.total);
updated = progress.updated;
}
loadingController.close();
if (updated > 0) {
GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount();
updateComics();
}
}
void updateComics() {
if (folder == null) {
setState(() {
allComics = [];
updatedComics = [];
});
return;
}
setState(() {
allComics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder!);
sortComics();
updatedComics = allComics.where((c) => c.hasNewUpdate).toList();
});
}
@override
Object? get key => 'FollowUpdatesPage';
}
class _UpdateProgress {
final int total;
final int current;
final int errors;
final int updated;
_UpdateProgress(this.total, this.current, this.errors, this.updated);
}
void _updateFolderBase(
String folder,
StreamController<_UpdateProgress> stream,
bool ignoreCheckTime,
) async {
var comics = LocalFavoritesManager().getComicsWithUpdatesInfo(folder);
int current = 0;
int errors = 0;
int updated = 0;
var futures = <Future>[];
const maxConcurrent = 5;
for (int i = 0; i < comics.length; i++) {
if (stream.isClosed) {
return;
}
if (!ignoreCheckTime) {
var lastCheckTime = comics[i].lastCheckTime;
if (lastCheckTime != null &&
DateTime.now().difference(lastCheckTime).inDays < 1) {
current++;
stream.add(_UpdateProgress(comics.length, current, errors, updated));
continue;
}
}
if (futures.length >= maxConcurrent) {
await Future.any(futures);
}
var future = () async {
int retries = 3;
while (true) {
try {
var c = comics[i];
var comicSource = c.type.comicSource;
if (comicSource == null) return;
var newInfo = (await comicSource.loadComicInfo!(c.id)).data;
var newTags = <String>[];
for (var entry in newInfo.tags.entries) {
const shouldIgnore = ['author', 'artist', 'time'];
var namespace = entry.key;
if (shouldIgnore.contains(namespace.toLowerCase())) {
continue;
}
for (var tag in entry.value) {
newTags.add("$namespace:$tag");
}
}
var item = FavoriteItem(
id: c.id,
name: newInfo.title,
coverPath: newInfo.cover,
author: newInfo.subTitle ??
newInfo.tags['author']?.firstOrNull ??
c.author,
type: c.type,
tags: newTags,
);
LocalFavoritesManager().updateInfo(folder, item);
var updateTime = newInfo.findUpdateTime();
if (updateTime != null && updateTime != c.updateTime) {
LocalFavoritesManager().updateUpdateTime(
folder,
c.id,
c.type,
updateTime,
);
}
updated++;
return;
} catch (e, s) {
Log.error("Check Updates", e, s);
retries--;
if (retries == 0) {
errors++;
return;
}
} finally {
current++;
stream.add(_UpdateProgress(comics.length, current, errors, updated));
}
}
}();
future.then((_) {
futures.remove(future);
});
futures.add(future);
}
await Future.wait(futures);
stream.close();
}
Stream<_UpdateProgress> _updateFolder(String folder, bool ignoreCheckTime) {
var stream = StreamController<_UpdateProgress>();
_updateFolderBase(folder, stream, ignoreCheckTime);
return stream.stream;
}
/// Background service for checking updates
abstract class FollowUpdatesService {
static bool isChecking = false;
static void Function()? cancelChecking;
static void check() async {
if (isChecking) {
return;
}
var folder = appdata.settings["followUpdatesFolder"];
if (folder == null) {
return;
}
bool isCanceled = false;
cancelChecking = () {
isCanceled = true;
};
isChecking = true;
int updated = 0;
try {
await for (var progress in _updateFolder(folder, false)) {
if (isCanceled) {
return;
}
updated = progress.updated;
}
} finally {
cancelChecking = null;
isChecking = false;
if (updated > 0) {
updateFollowUpdatesUI();
}
}
}
static void initChecker() {
Timer.periodic(const Duration(hours: 1), (timer) {
check();
});
}
}
void updateFollowUpdatesUI() {
GlobalState.findOrNull<_FollowUpdatesWidgetState>()?.updateCount();
GlobalState.findOrNull<_FollowUpdatesPageState>()?.updateComics();
}

View File

@@ -9,9 +9,10 @@ import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/comic_source_page.dart';
import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/follow_updates_page.dart';
import 'package:venera/pages/history_page.dart';
import 'package:venera/pages/image_favorites_page/image_favorites_page.dart';
import 'package:venera/pages/search_page.dart';
@@ -34,6 +35,7 @@ class HomePage extends StatelessWidget {
const _SyncDataWidget(),
const _History(),
const _Local(),
const FollowUpdatesWidget(),
const _ComicSourceWidget(),
const ImageFavorites(),
SliverPadding(padding: EdgeInsets.only(top: context.padding.bottom)),
@@ -821,6 +823,20 @@ class _ImageFavoritesState extends State<ImageFavorites> {
Center(
child: Text('Image Favorites'.tl, style: ts.s18),
),
if (hasData)
Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
imageFavoritesCompute!.count.toString(),
style: ts.s12,
),
),
const Spacer(),
const Icon(Icons.arrow_right),
],

View File

@@ -11,7 +11,7 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/image_favorites_provider.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/image_favorites_page/type.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/ext.dart';

View File

@@ -4,7 +4,7 @@ import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
import 'package:venera/pages/downloading_page.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/utils/cbz.dart';

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/pages/categories_page.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
@@ -7,7 +6,6 @@ import 'package:venera/utils/translations.dart';
import '../components/components.dart';
import '../foundation/app.dart';
import 'comic_source_page.dart';
import 'explore_page.dart';
import 'favorites/favorites_page.dart';
import 'home_page.dart';
@@ -36,24 +34,8 @@ class _MainPageState extends State<MainPage> {
_navigatorKey!.currentContext!.pop();
}
void checkUpdates() async {
var lastCheck = appdata.implicitData['lastCheckUpdate'] ?? 0;
var now = DateTime.now().millisecondsSinceEpoch;
if (now - lastCheck < 24 * 60 * 60 * 1000) {
return;
}
appdata.implicitData['lastCheckUpdate'] = now;
appdata.writeImplicitData();
ComicSourcePage.checkComicSourceUpdate();
if (appdata.settings['checkUpdateOnStart']) {
await Future.delayed(const Duration(milliseconds: 300));
await checkUpdateUi(false);
}
}
@override
void initState() {
checkUpdates();
_observer = NaviObserver();
_navigatorKey = GlobalKey();
App.mainNavigatorKey = _navigatorKey;

View File

@@ -3,31 +3,30 @@ part of 'reader.dart';
class ComicImage extends StatefulWidget {
/// Modified from flutter Image
ComicImage({
required ImageProvider image,
super.key,
double scale = 1.0,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false,
Map<String, String>? headers,
int? cacheWidth,
int? cacheHeight,
}
): image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
required ImageProvider image,
super.key,
double scale = 1.0,
this.semanticLabel,
this.excludeFromSemantics = false,
this.width,
this.height,
this.color,
this.opacity,
this.colorBlendMode,
this.fit,
this.alignment = Alignment.center,
this.repeat = ImageRepeat.noRepeat,
this.centerSlice,
this.matchTextDirection = false,
this.gaplessPlayback = false,
this.filterQuality = FilterQuality.medium,
this.isAntiAlias = false,
Map<String, String>? headers,
int? cacheWidth,
int? cacheHeight,
}) : image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
assert(cacheWidth == null || cacheWidth > 0),
assert(cacheHeight == null || cacheHeight > 0);
final ImageProvider image;
@@ -138,8 +137,8 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
}
void _updateInvertColors() {
_invertColors = MediaQuery.maybeInvertColorsOf(context)
?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
_invertColors = MediaQuery.maybeInvertColorsOf(context) ??
SemanticsBinding.instance.accessibilityFeatures.invertColors;
}
void _resolveImage() {
@@ -148,16 +147,19 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
imageProvider: widget.image,
);
final ImageStream newStream =
provider.resolve(createLocalImageConfiguration(
provider.resolve(createLocalImageConfiguration(
context,
size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
size: widget.width != null && widget.height != null
? Size(widget.width!, widget.height!)
: null,
));
_updateSourceStream(newStream);
}
ImageStreamListener? _imageStreamListener;
ImageStreamListener _getListener({bool recreateListener = false}) {
if(_imageStreamListener == null || recreateListener) {
if (_imageStreamListener == null || recreateListener) {
_lastException = null;
_imageStreamListener = ImageStreamListener(
_handleImageFrame,
@@ -191,7 +193,8 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
void _replaceImage({required ImageInfo? info}) {
final ImageInfo? oldImageInfo = _imageInfo;
SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose());
SchedulerBinding.instance
.addPostFrameCallback((_) => oldImageInfo?.dispose());
_imageInfo = info;
}
@@ -208,7 +211,9 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
}
if (!widget.gaplessPlayback) {
setState(() { _replaceImage(info: null); });
setState(() {
_replaceImage(info: null);
});
}
setState(() {
@@ -247,7 +252,9 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
return;
}
if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) {
if (keepStreamAlive &&
_completerHandle == null &&
_imageStream?.completer != null) {
_completerHandle = _imageStream!.completer!.keepAlive();
}
@@ -269,26 +276,41 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
children: [
Expanded(
child: Center(
child: Text(_lastException.toString(), maxLines: 3,),
child: Text(
_lastException.toString(),
maxLines: 3,
),
),
),
const SizedBox(height: 4,),
const SizedBox(
height: 4,
),
MouseRegion(
cursor: SystemMouseCursors.click,
child: Listener(
onPointerDown: (details){
onPointerDown: (details) {
GlobalState.find<_ReaderGestureDetectorState>().ignoreNextTap();
setState(() {
_loadingProgress = null;
_lastException = null;
});
_resolveImage();
},
child: const SizedBox(
child: SizedBox(
width: 84,
height: 36,
child: Center(
child: Text("Retry", style: TextStyle(color: Colors.blue),),
child: Text(
"Retry".tl,
style: TextStyle(color: Colors.blue),
),
),
),
),
),
const SizedBox(height: 16,),
const SizedBox(
height: 16,
),
],
),
),
@@ -300,34 +322,32 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
var width = widget.width;
var height = widget.height;
if(_imageInfo != null) {
if (_imageInfo != null) {
// Record the height and the width of the image
_cache[widget.image.hashCode] = Size(
_imageInfo!.image.width.toDouble(),
_imageInfo!.image.height.toDouble()
);
_cache[widget.image.hashCode] = Size(_imageInfo!.image.width.toDouble(),
_imageInfo!.image.height.toDouble());
}
Size? cacheSize = _cache[widget.image.hashCode];
if(cacheSize != null){
if(width == double.infinity) {
if (cacheSize != null) {
if (width == double.infinity) {
width = constrains.maxWidth;
height = width * cacheSize.height / cacheSize.width;
} else if(height == double.infinity) {
} else if (height == double.infinity) {
height = constrains.maxHeight;
width = height * cacheSize.width / cacheSize.height;
}
} else {
if(width == double.infinity) {
if (width == double.infinity) {
width = constrains.maxWidth;
height = 300;
} else if(height == double.infinity) {
} else if (height == double.infinity) {
height = constrains.maxHeight;
width = 300;
}
}
if(_imageInfo != null){
if (_imageInfo != null) {
// build image
Widget result = RawImage(
// Do not clone the image, because RawImage is a stateless wrapper.
@@ -379,12 +399,13 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
height: 24,
child: CircularProgressIndicator(
strokeWidth: 3,
backgroundColor: context.colorScheme.surfaceContainerLow,
backgroundColor: context.colorScheme.surfaceContainer,
value: (_loadingProgress != null &&
_loadingProgress!.expectedTotalBytes!=null &&
_loadingProgress!.expectedTotalBytes! != 0)
?_loadingProgress!.cumulativeBytesLoaded / _loadingProgress!.expectedTotalBytes!
:0,
_loadingProgress!.expectedTotalBytes != null &&
_loadingProgress!.expectedTotalBytes! != 0)
? _loadingProgress!.cumulativeBytesLoaded /
_loadingProgress!.expectedTotalBytes!
: 0,
),
),
),
@@ -398,8 +419,10 @@ class _ComicImageState extends State<ComicImage> with WidgetsBindingObserver {
super.debugFillProperties(description);
description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream));
description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo));
description.add(DiagnosticsProperty<ImageChunkEvent>('loadingProgress', _loadingProgress));
description.add(DiagnosticsProperty<ImageChunkEvent>(
'loadingProgress', _loadingProgress));
description.add(DiagnosticsProperty<int>('frameNumber', _frameNumber));
description.add(DiagnosticsProperty<bool>('wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
description.add(DiagnosticsProperty<bool>(
'wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
}
}

View File

@@ -9,7 +9,7 @@ class _ReaderGestureDetector extends StatefulWidget {
State<_ReaderGestureDetector> createState() => _ReaderGestureDetectorState();
}
class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
class _ReaderGestureDetectorState extends AutomaticGlobalState<_ReaderGestureDetector> {
late TapGestureRecognizer _tapGestureRecognizer;
static const _kDoubleTapMaxTime = Duration(milliseconds: 200);
@@ -26,6 +26,12 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
late _ReaderState reader;
bool ignoreNextTag = false;
void ignoreNextTap() {
ignoreNextTag = true;
}
@override
void initState() {
_tapGestureRecognizer = TapGestureRecognizer()
@@ -44,6 +50,10 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
behavior: HitTestBehavior.translucent,
onPointerDown: (event) {
fingers++;
if (ignoreNextTag) {
ignoreNextTag = false;
return;
}
_lastTapPointer = event.pointer;
_lastTapMoveDistance = Offset.zero;
_tapGestureRecognizer.addPointer(event);
@@ -290,6 +300,9 @@ class _ReaderGestureDetectorState extends State<_ReaderGestureDetector> {
void removeDragListener(_DragListener listener) {
_dragListeners.remove(listener);
}
@override
Object? get key => "reader_gesture";
}
class _DragListener {

View File

@@ -105,7 +105,7 @@ class _GalleryModeState extends State<_GalleryMode>
late List<bool> cached;
int get preCacheCount => 4;
int get preCacheCount => appdata.settings["preloadImageCount"];
var photoViewControllers = <int, PhotoViewController>{};
@@ -371,6 +371,9 @@ class _ContinuousModeState extends State<_ContinuousMode>
var fingers = 0;
bool disableScroll = false;
late List<bool> cached;
int get preCacheCount => appdata.settings["preloadImageCount"];
/// Whether the user was scrolling the page.
/// The gesture detector has a delay to detect tap event.
/// To handle the tap event, we need to know if the user was scrolling before the delay.
@@ -388,6 +391,11 @@ class _ContinuousModeState extends State<_ContinuousMode>
reader = context.reader;
reader._imageViewController = this;
itemPositionsListener.itemPositions.addListener(onPositionChanged);
cached = List.filled(reader.maxPage + 2, false);
Future.delayed(
const Duration(milliseconds: 100),
() => cacheImages(reader.page),
);
super.initState();
}
@@ -404,6 +412,7 @@ class _ContinuousModeState extends State<_ContinuousMode>
reader.setPage(page);
context.readerScaffold.update();
}
cacheImages(page);
}
double? futurePosition;
@@ -443,6 +452,15 @@ class _ContinuousModeState extends State<_ContinuousMode>
}
}
void cacheImages(int current) {
for (int i = current + 1; i <= current + preCacheCount; i++) {
if (i <= reader.maxPage && !cached[i]) {
_precacheImage(i, context);
cached[i] = true;
}
}
}
@override
Widget build(BuildContext context) {
Widget widget = ScrollablePositionedList.builder(
@@ -473,8 +491,6 @@ class _ContinuousModeState extends State<_ContinuousMode>
width = double.infinity;
}
_precacheImage(index, context);
ImageProvider image = _createImageProvider(index, context);
return ComicImage(
@@ -555,18 +571,20 @@ class _ContinuousModeState extends State<_ContinuousMode>
delayedSetIsScrolling(false);
}
var length = reader.maxChapter;
if (!scrollController.hasClients) return false;
if (scrollController.position.pixels <=
scrollController.position.minScrollExtent &&
reader.chapter != 1) {
context.readerScaffold.setFloatingButton(-1);
} else if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent &&
reader.chapter < length) {
context.readerScaffold.setFloatingButton(1);
} else {
context.readerScaffold.setFloatingButton(0);
if (notification is ScrollUpdateNotification) {
var length = reader.maxChapter;
if (!scrollController.hasClients) return false;
if (scrollController.position.pixels <=
scrollController.position.minScrollExtent &&
reader.chapter != 1) {
context.readerScaffold.setFloatingButton(-1);
} else if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent &&
reader.chapter < length) {
context.readerScaffold.setFloatingButton(1);
} else {
context.readerScaffold.setFloatingButton(0);
}
}
return true;

View File

@@ -41,7 +41,7 @@ class _ReaderWithLoadingState
@override
Future<Res<ReaderProps>> loadData() async {
var comicSource = ComicSource.find(widget.sourceKey);
var history = HistoryManager().findSync(
var history = HistoryManager().find(
widget.id,
ComicType.fromKey(widget.sourceKey),
);

View File

@@ -22,6 +22,7 @@ import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/global_state.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/reader_image.dart';
import 'package:venera/foundation/local.dart';
@@ -229,6 +230,10 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
updateHistory();
}
/// Prevent multiple history updates in a short time.
/// `HistoryManager().addHistoryAsync` is a high-cost operation because it creates a new isolate.
Timer? _updateHistoryTimer;
void updateHistory() {
if (history != null) {
history!.page = page;
@@ -237,9 +242,12 @@ class _ReaderState extends State<Reader> with _ReaderLocation, _ReaderWindow {
history!.maxPage = maxPage;
}
history!.readEpisode.add(chapter);
print(history!.readEpisode);
history!.time = DateTime.now();
HistoryManager().addHistory(history!);
_updateHistoryTimer?.cancel();
_updateHistoryTimer = Timer(const Duration(seconds: 1), () {
HistoryManager().addHistoryAsync(history!);
_updateHistoryTimer = null;
});
}
}

View File

@@ -7,7 +7,7 @@ import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/foundation/global_state.dart';
import 'package:venera/pages/aggregated_search_page.dart';
import 'package:venera/pages/search_result_page.dart';
import 'package:venera/pages/settings/settings_page.dart';
@@ -16,7 +16,7 @@ import 'package:venera/utils/ext.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
import 'comic_page.dart';
import 'comic_details_page/comic_page.dart';
import 'comic_source_page.dart';
class SearchPage extends StatefulWidget {

View File

@@ -3,7 +3,7 @@ import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/appdata.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/foundation/global_state.dart';
import 'package:venera/pages/search_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/tags_translation.dart';

View File

@@ -85,8 +85,8 @@ class _AboutSettingsState extends State<AboutSettings> {
}
Future<bool> checkUpdate() async {
var res = await AppDio().get(
"https://cdn.jsdelivr.net/gh/venera-app/venera@latest/pubspec.yaml");
var res = await AppDio()
.get("https://cdn.jsdelivr.net/gh/venera-app/venera@master/pubspec.yaml");
if (res.statusCode == 200) {
var data = loadYaml(res.data);
if (data["version"] != null) {
@@ -108,7 +108,7 @@ Future<void> checkUpdateUi([bool showMessageIfNoUpdate = true]) async {
content: Text(
"A new version is available. Do you want to update now?"
.tl)
.paddingHorizontal(8),
.paddingHorizontal(16),
actions: [
Button.text(
onPressed: () {
@@ -137,6 +137,9 @@ bool _compareVersion(String version1, String version2) {
if (int.parse(v1[i]) > int.parse(v2[i])) {
return true;
}
if (int.parse(v1[i]) < int.parse(v2[i])) {
return false;
}
}
return false;
}

View File

@@ -143,6 +143,13 @@ class _ReaderSettingsState extends State<ReaderSettings> {
callback: () => context.to(() => _CustomImageProcessing()),
actionTitle: "Edit".tl,
).toSliver(),
_SliderSetting(
title: "Number of images preloaded".tl,
settingsIndex: "preloadImageCount",
interval: 1,
min: 1,
max: 16,
).toSliver(),
],
);
}

View File

@@ -1,7 +1,7 @@
import 'package:app_links/app_links.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/pages/comic_details_page/comic_page.dart';
void handleLinks() {
final appLinks = AppLinks();

View File

@@ -52,6 +52,15 @@ extension TagsTranslation on String{
/// translate tag's text to chinese
String get translateTagsToCN => _translateTags(this);
String get translateTagIfNeed {
var locale = App.locale;
if (locale.languageCode == "zh") {
return translateTagsToCN;
} else {
return this;
}
}
static String translateTag(String tag) {
if(tag.contains(':') && tag.indexOf(':') == tag.lastIndexOf(':')) {
var [namespace, text] = tag.split(':');

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
url: "https://pub.dev"
source: hosted
version: "2.11.0"
version: "2.12.0"
battery_plus:
dependency: "direct main"
description:
@@ -69,10 +69,10 @@ packages:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
build_cli_annotations:
dependency: transitive
description:
@@ -85,26 +85,26 @@ packages:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
version: "1.19.1"
convert:
dependency: transitive
description:
@@ -182,10 +182,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "1.3.2"
ffi:
dependency: transitive
description:
@@ -548,18 +548,18 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev"
source: hosted
version: "10.0.7"
version: "10.0.8"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev"
source: hosted
version: "3.0.8"
version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
@@ -629,10 +629,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
@@ -645,10 +645,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.16.0"
mime:
dependency: "direct main"
description:
@@ -669,10 +669,10 @@ packages:
dependency: "direct main"
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
@@ -860,10 +860,10 @@ packages:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
version: "1.10.1"
sprintf:
dependency: transitive
description:
@@ -892,26 +892,26 @@ packages:
dependency: transitive
description:
name: stack_trace
sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.0"
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.1"
syntax_highlight:
dependency: "direct main"
description:
@@ -924,18 +924,18 @@ packages:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
version: "0.7.3"
version: "0.7.4"
typed_data:
dependency: transitive
description:
@@ -1036,10 +1036,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.dev"
source: hosted
version: "14.3.0"
version: "14.3.1"
web:
dependency: transitive
description:
@@ -1106,5 +1106,5 @@ packages:
source: hosted
version: "0.0.10"
sdks:
dart: ">=3.6.0 <4.0.0"
flutter: ">=3.27.4"
dart: ">=3.7.0-0 <4.0.0"
flutter: ">=3.29.0"

View File

@@ -2,17 +2,17 @@ name: venera
description: "A comic app."
publish_to: 'none'
version: 1.2.5+125
version: 1.3.0+130
environment:
sdk: '>=3.6.0 <4.0.0'
flutter: 3.27.4
flutter: 3.29.0
dependencies:
flutter:
sdk: flutter
path_provider: any
intl: ^0.19.0
intl: any
window_manager: ^0.4.3
sqlite3: ^2.4.7
sqlite3_flutter_libs: ^0.5.28