mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 07:47:24 +00:00
download & view local comics
This commit is contained in:
@@ -165,7 +165,11 @@ class _ButtonState extends State<Button> {
|
|||||||
if (height != null) {
|
if (height != null) {
|
||||||
height = height - padding.vertical;
|
height = height - padding.vertical;
|
||||||
}
|
}
|
||||||
Widget child = DefaultTextStyle(
|
Widget child = IconTheme(
|
||||||
|
data: IconThemeData(
|
||||||
|
color: textColor,
|
||||||
|
),
|
||||||
|
child: DefaultTextStyle(
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: textColor,
|
color: textColor,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@@ -178,6 +182,7 @@ class _ButtonState extends State<Button> {
|
|||||||
strokeWidth: 1.8,
|
strokeWidth: 1.8,
|
||||||
).fixWidth(16).fixHeight(16)
|
).fixWidth(16).fixHeight(16)
|
||||||
: widget.child,
|
: widget.child,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (width != null || height != null) {
|
if (width != null || height != null) {
|
||||||
child = child.toCenter();
|
child = child.toCenter();
|
||||||
@@ -255,7 +260,7 @@ class _ButtonState extends State<Button> {
|
|||||||
|
|
||||||
Color get textColor {
|
Color get textColor {
|
||||||
if (widget.type == ButtonType.outlined) {
|
if (widget.type == ButtonType.outlined) {
|
||||||
return widget.color ?? context.colorScheme.onSurface;
|
return widget.color ?? context.colorScheme.primary;
|
||||||
}
|
}
|
||||||
return widget.type == ButtonType.filled
|
return widget.type == ButtonType.filled
|
||||||
? context.colorScheme.onPrimary
|
? context.colorScheme.onPrimary
|
||||||
|
@@ -7,6 +7,7 @@ class ComicTile extends StatelessWidget {
|
|||||||
this.enableLongPressed = true,
|
this.enableLongPressed = true,
|
||||||
this.badge,
|
this.badge,
|
||||||
this.menuOptions,
|
this.menuOptions,
|
||||||
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Comic comic;
|
final Comic comic;
|
||||||
@@ -17,7 +18,13 @@ class ComicTile extends StatelessWidget {
|
|||||||
|
|
||||||
final List<MenuEntry>? menuOptions;
|
final List<MenuEntry>? menuOptions;
|
||||||
|
|
||||||
void onTap() {
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
void _onTap() {
|
||||||
|
if(onTap != null) {
|
||||||
|
onTap!();
|
||||||
|
return;
|
||||||
|
}
|
||||||
App.mainNavigatorKey?.currentContext
|
App.mainNavigatorKey?.currentContext
|
||||||
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey));
|
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey));
|
||||||
}
|
}
|
||||||
@@ -43,7 +50,7 @@ class ComicTile extends StatelessWidget {
|
|||||||
MenuEntry(
|
MenuEntry(
|
||||||
icon: Icons.chrome_reader_mode_outlined,
|
icon: Icons.chrome_reader_mode_outlined,
|
||||||
text: 'Details'.tl,
|
text: 'Details'.tl,
|
||||||
onClick: onTap,
|
onClick: _onTap,
|
||||||
),
|
),
|
||||||
MenuEntry(
|
MenuEntry(
|
||||||
icon: Icons.copy,
|
icon: Icons.copy,
|
||||||
@@ -155,7 +162,7 @@ class ComicTile extends StatelessWidget {
|
|||||||
final height = constrains.maxHeight - 16;
|
final height = constrains.maxHeight - 16;
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
onTap: onTap,
|
onTap: _onTap,
|
||||||
onLongPress: enableLongPressed ? () => onLongPress(context) : null,
|
onLongPress: enableLongPressed ? () => onLongPress(context) : null,
|
||||||
onSecondaryTapDown: onSecondaryTap,
|
onSecondaryTapDown: onSecondaryTap,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -250,7 +257,7 @@ class ComicTile extends StatelessWidget {
|
|||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: _onTap,
|
||||||
onLongPress:
|
onLongPress:
|
||||||
enableLongPressed ? () => onLongPress(context) : null,
|
enableLongPressed ? () => onLongPress(context) : null,
|
||||||
onSecondaryTapDown: onSecondaryTap,
|
onSecondaryTapDown: onSecondaryTap,
|
||||||
@@ -464,6 +471,7 @@ class SliverGridComics extends StatelessWidget {
|
|||||||
this.onLastItemBuild,
|
this.onLastItemBuild,
|
||||||
this.badgeBuilder,
|
this.badgeBuilder,
|
||||||
this.menuBuilder,
|
this.menuBuilder,
|
||||||
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<Comic> comics;
|
final List<Comic> comics;
|
||||||
@@ -474,6 +482,8 @@ class SliverGridComics extends StatelessWidget {
|
|||||||
|
|
||||||
final List<MenuEntry> Function(Comic)? menuBuilder;
|
final List<MenuEntry> Function(Comic)? menuBuilder;
|
||||||
|
|
||||||
|
final void Function(Comic)? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return StateBuilder<SliverGridComicsController>(
|
return StateBuilder<SliverGridComicsController>(
|
||||||
@@ -490,6 +500,7 @@ class SliverGridComics extends StatelessWidget {
|
|||||||
onLastItemBuild: onLastItemBuild,
|
onLastItemBuild: onLastItemBuild,
|
||||||
badgeBuilder: badgeBuilder,
|
badgeBuilder: badgeBuilder,
|
||||||
menuBuilder: menuBuilder,
|
menuBuilder: menuBuilder,
|
||||||
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -502,6 +513,7 @@ class _SliverGridComics extends StatelessWidget {
|
|||||||
this.onLastItemBuild,
|
this.onLastItemBuild,
|
||||||
this.badgeBuilder,
|
this.badgeBuilder,
|
||||||
this.menuBuilder,
|
this.menuBuilder,
|
||||||
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<Comic> comics;
|
final List<Comic> comics;
|
||||||
@@ -512,6 +524,8 @@ class _SliverGridComics extends StatelessWidget {
|
|||||||
|
|
||||||
final List<MenuEntry> Function(Comic)? menuBuilder;
|
final List<MenuEntry> Function(Comic)? menuBuilder;
|
||||||
|
|
||||||
|
final void Function(Comic)? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SliverGrid(
|
return SliverGrid(
|
||||||
@@ -525,6 +539,7 @@ class _SliverGridComics extends StatelessWidget {
|
|||||||
comic: comics[index],
|
comic: comics[index],
|
||||||
badge: badge,
|
badge: badge,
|
||||||
menuOptions: menuBuilder?.call(comics[index]),
|
menuOptions: menuBuilder?.call(comics[index]),
|
||||||
|
onTap: onTap != null ? () => onTap!(comics[index]) : null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childCount: comics.length,
|
childCount: comics.length,
|
||||||
|
@@ -95,7 +95,7 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
|
|||||||
|
|
||||||
Future<Res<S>> loadData();
|
Future<Res<S>> loadData();
|
||||||
|
|
||||||
void onDataLoaded() {}
|
FutureOr<void> onDataLoaded() {}
|
||||||
|
|
||||||
Widget buildContent(BuildContext context, S data);
|
Widget buildContent(BuildContext context, S data);
|
||||||
|
|
||||||
@@ -114,13 +114,13 @@ abstract class LoadingState<T extends StatefulWidget, S extends Object>
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
error = null;
|
error = null;
|
||||||
});
|
});
|
||||||
loadData().then((value) {
|
loadData().then((value) async {
|
||||||
if (value.success) {
|
if (value.success) {
|
||||||
|
await onDataLoaded();
|
||||||
setState(() {
|
setState(() {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
data = value.data;
|
data = value.data;
|
||||||
});
|
});
|
||||||
onDataLoaded();
|
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
|
@@ -54,8 +54,8 @@ class _MenuRoute<T> extends PopupRoute<T> {
|
|||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: Material(
|
child: Material(
|
||||||
color: context.brightness == Brightness.light
|
color: context.brightness == Brightness.light
|
||||||
? const Color(0xFFFAFAFA).withOpacity(0.72)
|
? const Color(0xFFFAFAFA).withOpacity(0.82)
|
||||||
: const Color(0xFF090909).withOpacity(0.72),
|
: const Color(0xFF090909).withOpacity(0.82),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: width,
|
width: width,
|
||||||
|
@@ -86,7 +86,7 @@ class PopupIndicatorWidget extends InheritedWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<T> showPopUpWidget<T>(BuildContext context, Widget widget) async {
|
Future<T> showPopUpWidget<T>(BuildContext context, Widget widget) async {
|
||||||
return await Navigator.of(context).push(PopUpWidget(widget));
|
return await Navigator.of(context, rootNavigator: true).push(PopUpWidget(widget));
|
||||||
}
|
}
|
||||||
|
|
||||||
class PopUpWidgetScaffold extends StatefulWidget {
|
class PopUpWidgetScaffold extends StatefulWidget {
|
||||||
@@ -124,7 +124,7 @@ class _PopUpWidgetScaffoldState extends State<PopUpWidgetScaffold> {
|
|||||||
width: 8,
|
width: 8,
|
||||||
),
|
),
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: "返回".tl,
|
message: "Back".tl,
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_sharp),
|
icon: const Icon(Icons.arrow_back_sharp),
|
||||||
onPressed: () => context.canPop()
|
onPressed: () => context.canPop()
|
||||||
|
@@ -203,13 +203,13 @@ class _SidebarBodyState extends State<SidebarBody> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void showSideBar(BuildContext context, Widget widget,
|
Future<void> showSideBar(BuildContext context, Widget widget,
|
||||||
{String? title,
|
{String? title,
|
||||||
bool showBarrier = true,
|
bool showBarrier = true,
|
||||||
bool useSurfaceTintColor = false,
|
bool useSurfaceTintColor = false,
|
||||||
double width = 500,
|
double width = 500,
|
||||||
bool addTopPadding = false}) {
|
bool addTopPadding = false}) {
|
||||||
Navigator.of(context).push(
|
return Navigator.of(context).push(
|
||||||
SideBarRoute(
|
SideBarRoute(
|
||||||
title,
|
title,
|
||||||
widget,
|
widget,
|
||||||
|
@@ -139,6 +139,29 @@ class ComicDetails with HistoryMixin {
|
|||||||
uploadTime = json["uploadTime"],
|
uploadTime = json["uploadTime"],
|
||||||
updateTime = json["updateTime"];
|
updateTime = json["updateTime"];
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"title": title,
|
||||||
|
"subTitle": subTitle,
|
||||||
|
"cover": cover,
|
||||||
|
"description": description,
|
||||||
|
"tags": tags,
|
||||||
|
"chapters": chapters,
|
||||||
|
"thumbnails": thumbnails,
|
||||||
|
"recommend": null,
|
||||||
|
"sourceKey": sourceKey,
|
||||||
|
"comicId": comicId,
|
||||||
|
"isFavorite": isFavorite,
|
||||||
|
"subId": subId,
|
||||||
|
"isLiked": isLiked,
|
||||||
|
"likesCount": likesCount,
|
||||||
|
"commentsCount": commentsCount,
|
||||||
|
"uploader": uploader,
|
||||||
|
"uploadTime": uploadTime,
|
||||||
|
"updateTime": updateTime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
HistoryType get historyType => HistoryType(sourceKey.hashCode);
|
HistoryType get historyType => HistoryType(sourceKey.hashCode);
|
||||||
|
|
||||||
|
@@ -1,10 +1,7 @@
|
|||||||
import 'dart:async' show Future, StreamController;
|
import 'dart:async' show Future, StreamController;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:venera/foundation/cache_manager.dart';
|
import 'package:venera/network/images.dart';
|
||||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
|
||||||
import 'package:venera/foundation/consts.dart';
|
|
||||||
import 'package:venera/network/app_dio.dart';
|
|
||||||
import 'base_image_provider.dart';
|
import 'base_image_provider.dart';
|
||||||
import 'cached_image.dart' as image_provider;
|
import 'cached_image.dart' as image_provider;
|
||||||
|
|
||||||
@@ -21,52 +18,16 @@ class CachedImageProvider
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
||||||
final cacheKey = "$url@$sourceKey";
|
await for (var progress in ImageDownloader.loadThumbnail(url, sourceKey)) {
|
||||||
final cache = await CacheManager().findCache(cacheKey);
|
|
||||||
|
|
||||||
if (cache != null) {
|
|
||||||
return await cache.readAsBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
var configs = <String, dynamic>{};
|
|
||||||
if (sourceKey != null) {
|
|
||||||
var comicSource = ComicSource.find(sourceKey!);
|
|
||||||
configs = comicSource!.getThumbnailLoadingConfig?.call(url) ?? {};
|
|
||||||
}
|
|
||||||
configs['headers'] ??= {
|
|
||||||
'user-agent': webUA,
|
|
||||||
};
|
|
||||||
|
|
||||||
var dio = AppDio(BaseOptions(
|
|
||||||
headers: configs['headers'],
|
|
||||||
method: configs['method'] ?? 'GET',
|
|
||||||
responseType: ResponseType.stream,
|
|
||||||
));
|
|
||||||
|
|
||||||
var req = await dio.request<ResponseBody>(configs['url'] ?? url,
|
|
||||||
data: configs['data']);
|
|
||||||
var stream = req.data?.stream ?? (throw "Error: Empty response body.");
|
|
||||||
int? expectedBytes = req.data!.contentLength;
|
|
||||||
if (expectedBytes == -1) {
|
|
||||||
expectedBytes = null;
|
|
||||||
}
|
|
||||||
var buffer = <int>[];
|
|
||||||
await for (var data in stream) {
|
|
||||||
buffer.addAll(data);
|
|
||||||
if (expectedBytes != null) {
|
|
||||||
chunkEvents.add(ImageChunkEvent(
|
chunkEvents.add(ImageChunkEvent(
|
||||||
cumulativeBytesLoaded: buffer.length,
|
cumulativeBytesLoaded: progress.currentBytes,
|
||||||
expectedTotalBytes: expectedBytes,
|
expectedTotalBytes: progress.totalBytes,
|
||||||
));
|
));
|
||||||
|
if(progress.imageBytes != null) {
|
||||||
|
return progress.imageBytes!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
throw "Error: Empty response body.";
|
||||||
if(configs['onResponse'] != null) {
|
|
||||||
buffer = configs['onResponse'](buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
await CacheManager().writeCache(cacheKey, buffer);
|
|
||||||
return Uint8List.fromList(buffer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@@ -1,10 +1,7 @@
|
|||||||
import 'dart:async' show Future, StreamController;
|
import 'dart:async' show Future, StreamController;
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:venera/foundation/cache_manager.dart';
|
import 'package:venera/network/images.dart';
|
||||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
|
||||||
import 'package:venera/foundation/consts.dart';
|
|
||||||
import 'package:venera/network/app_dio.dart';
|
|
||||||
import 'base_image_provider.dart';
|
import 'base_image_provider.dart';
|
||||||
import 'reader_image.dart' as image_provider;
|
import 'reader_image.dart' as image_provider;
|
||||||
|
|
||||||
@@ -23,52 +20,17 @@ class ReaderImageProvider
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async {
|
||||||
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
|
await for (var event
|
||||||
final cache = await CacheManager().findCache(cacheKey);
|
in ImageDownloader.loadComicImage(imageKey, sourceKey, cid, eid)) {
|
||||||
|
|
||||||
if (cache != null) {
|
|
||||||
return await cache.readAsBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
var configs = <String, dynamic>{};
|
|
||||||
if (sourceKey != null) {
|
|
||||||
var comicSource = ComicSource.find(sourceKey!);
|
|
||||||
configs = comicSource!.getImageLoadingConfig?.call(imageKey, cid, eid) ?? {};
|
|
||||||
}
|
|
||||||
configs['headers'] ??= {
|
|
||||||
'user-agent': webUA,
|
|
||||||
};
|
|
||||||
|
|
||||||
var dio = AppDio(BaseOptions(
|
|
||||||
headers: configs['headers'],
|
|
||||||
method: configs['method'] ?? 'GET',
|
|
||||||
responseType: ResponseType.stream,
|
|
||||||
));
|
|
||||||
|
|
||||||
var req = await dio.request<ResponseBody>(configs['url'] ?? imageKey,
|
|
||||||
data: configs['data']);
|
|
||||||
var stream = req.data?.stream ?? (throw "Error: Empty response body.");
|
|
||||||
int? expectedBytes = req.data!.contentLength;
|
|
||||||
if (expectedBytes == -1) {
|
|
||||||
expectedBytes = null;
|
|
||||||
}
|
|
||||||
var buffer = <int>[];
|
|
||||||
await for (var data in stream) {
|
|
||||||
buffer.addAll(data);
|
|
||||||
if (expectedBytes != null) {
|
|
||||||
chunkEvents.add(ImageChunkEvent(
|
chunkEvents.add(ImageChunkEvent(
|
||||||
cumulativeBytesLoaded: buffer.length,
|
cumulativeBytesLoaded: event.currentBytes,
|
||||||
expectedTotalBytes: expectedBytes,
|
expectedTotalBytes: event.totalBytes,
|
||||||
));
|
));
|
||||||
|
if (event.imageBytes != null) {
|
||||||
|
return event.imageBytes!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
throw "Error: Empty response body.";
|
||||||
if(configs['onResponse'] != null) {
|
|
||||||
buffer = configs['onResponse'](buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
await CacheManager().writeCache(cacheKey, buffer);
|
|
||||||
return Uint8List.fromList(buffer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@@ -1,16 +1,18 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:sqlite3/sqlite3.dart';
|
import 'package:sqlite3/sqlite3.dart';
|
||||||
import 'package:venera/foundation/comic_source/comic_source.dart';
|
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||||
import 'package:venera/foundation/comic_type.dart';
|
import 'package:venera/foundation/comic_type.dart';
|
||||||
|
import 'package:venera/network/download.dart';
|
||||||
|
import 'package:venera/pages/reader/reader.dart';
|
||||||
import 'package:venera/utils/io.dart';
|
import 'package:venera/utils/io.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'history.dart';
|
||||||
|
|
||||||
class LocalComic implements Comic {
|
class LocalComic with HistoryMixin implements Comic {
|
||||||
@override
|
@override
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
@@ -37,6 +39,8 @@ class LocalComic implements Comic {
|
|||||||
|
|
||||||
final ComicType comicType;
|
final ComicType comicType;
|
||||||
|
|
||||||
|
final List<String> downloadedChapters;
|
||||||
|
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
|
|
||||||
const LocalComic({
|
const LocalComic({
|
||||||
@@ -48,6 +52,7 @@ class LocalComic implements Comic {
|
|||||||
required this.chapters,
|
required this.chapters,
|
||||||
required this.cover,
|
required this.cover,
|
||||||
required this.comicType,
|
required this.comicType,
|
||||||
|
required this.downloadedChapters,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,9 +65,14 @@ class LocalComic implements Comic {
|
|||||||
chapters = Map.from(jsonDecode(row[5] as String)),
|
chapters = Map.from(jsonDecode(row[5] as String)),
|
||||||
cover = row[6] as String,
|
cover = row[6] as String,
|
||||||
comicType = ComicType(row[7] as int),
|
comicType = ComicType(row[7] as int),
|
||||||
createdAt = DateTime.fromMillisecondsSinceEpoch(row[8] as int);
|
downloadedChapters = List.from(jsonDecode(row[8] as String)),
|
||||||
|
createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int);
|
||||||
|
|
||||||
File get coverFile => File('${LocalManager().path}/$directory/$cover');
|
File get coverFile => File(FilePath.join(
|
||||||
|
LocalManager().path,
|
||||||
|
directory,
|
||||||
|
cover,
|
||||||
|
));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get description => "";
|
String get description => "";
|
||||||
@@ -85,6 +95,31 @@ class LocalComic implements Comic {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
int? get maxPage => null;
|
int? get maxPage => null;
|
||||||
|
|
||||||
|
void read() async {
|
||||||
|
var history = await HistoryManager().find(id, comicType);
|
||||||
|
App.rootContext.to(
|
||||||
|
() => Reader(
|
||||||
|
type: comicType,
|
||||||
|
cid: id,
|
||||||
|
name: title,
|
||||||
|
chapters: chapters,
|
||||||
|
initialChapter: history?.ep,
|
||||||
|
initialPage: history?.page,
|
||||||
|
history: history ?? History.fromModel(
|
||||||
|
model: this,
|
||||||
|
ep: 0,
|
||||||
|
page: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
HistoryType get historyType => comicType;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get subTitle => subtitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
class LocalManager with ChangeNotifier {
|
class LocalManager with ChangeNotifier {
|
||||||
@@ -103,10 +138,10 @@ class LocalManager with ChangeNotifier {
|
|||||||
// return error message if failed
|
// return error message if failed
|
||||||
Future<String?> setNewPath(String newPath) async {
|
Future<String?> setNewPath(String newPath) async {
|
||||||
var newDir = Directory(newPath);
|
var newDir = Directory(newPath);
|
||||||
if(!await newDir.exists()) {
|
if (!await newDir.exists()) {
|
||||||
return "Directory does not exist";
|
return "Directory does not exist";
|
||||||
}
|
}
|
||||||
if(!await newDir.list().isEmpty) {
|
if (!await newDir.list().isEmpty) {
|
||||||
return "Directory is not empty";
|
return "Directory is not empty";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -137,6 +172,7 @@ class LocalManager with ChangeNotifier {
|
|||||||
chapters TEXT NOT NULL,
|
chapters TEXT NOT NULL,
|
||||||
cover TEXT NOT NULL,
|
cover TEXT NOT NULL,
|
||||||
comic_type INTEGER NOT NULL,
|
comic_type INTEGER NOT NULL,
|
||||||
|
downloadedChapters TEXT NOT NULL,
|
||||||
created_at INTEGER,
|
created_at INTEGER,
|
||||||
PRIMARY KEY (id, comic_type)
|
PRIMARY KEY (id, comic_type)
|
||||||
);
|
);
|
||||||
@@ -147,17 +183,18 @@ class LocalManager with ChangeNotifier {
|
|||||||
if (App.isAndroid) {
|
if (App.isAndroid) {
|
||||||
var external = await getExternalStorageDirectories();
|
var external = await getExternalStorageDirectories();
|
||||||
if (external != null && external.isNotEmpty) {
|
if (external != null && external.isNotEmpty) {
|
||||||
path = FilePath.join(external.first.path, 'local_path');
|
path = FilePath.join(external.first.path, 'local');
|
||||||
} else {
|
} else {
|
||||||
path = FilePath.join(App.dataPath, 'local_path');
|
path = FilePath.join(App.dataPath, 'local');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
path = FilePath.join(App.dataPath, 'local_path');
|
path = FilePath.join(App.dataPath, 'local');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!Directory(path).existsSync()) {
|
if (!Directory(path).existsSync()) {
|
||||||
await Directory(path).create();
|
await Directory(path).create();
|
||||||
}
|
}
|
||||||
|
restoreDownloadingTasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
String findValidId(ComicType type) {
|
String findValidId(ComicType type) {
|
||||||
@@ -175,8 +212,13 @@ class LocalManager with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> add(LocalComic comic, [String? id]) async {
|
Future<void> add(LocalComic comic, [String? id]) async {
|
||||||
|
var old = find(id ?? comic.id, comic.comicType);
|
||||||
|
var downloaded = comic.downloadedChapters;
|
||||||
|
if (old != null) {
|
||||||
|
downloaded.addAll(old.downloadedChapters);
|
||||||
|
}
|
||||||
_db.execute(
|
_db.execute(
|
||||||
'INSERT INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);',
|
'INSERT OR REPLACE INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
|
||||||
[
|
[
|
||||||
id ?? comic.id,
|
id ?? comic.id,
|
||||||
comic.title,
|
comic.title,
|
||||||
@@ -186,6 +228,7 @@ class LocalManager with ChangeNotifier {
|
|||||||
jsonEncode(comic.chapters),
|
jsonEncode(comic.chapters),
|
||||||
comic.cover,
|
comic.cover,
|
||||||
comic.comicType.value,
|
comic.comicType.value,
|
||||||
|
jsonEncode(downloaded),
|
||||||
comic.createdAt.millisecondsSinceEpoch,
|
comic.createdAt.millisecondsSinceEpoch,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -274,7 +317,7 @@ class LocalManager with ChangeNotifier {
|
|||||||
files.sort((a, b) {
|
files.sort((a, b) {
|
||||||
var ai = int.tryParse(a.name.split('.').first);
|
var ai = int.tryParse(a.name.split('.').first);
|
||||||
var bi = int.tryParse(b.name.split('.').first);
|
var bi = int.tryParse(b.name.split('.').first);
|
||||||
if(ai != null && bi != null) {
|
if (ai != null && bi != null) {
|
||||||
return ai.compareTo(bi);
|
return ai.compareTo(bi);
|
||||||
}
|
}
|
||||||
return a.name.compareTo(b.name);
|
return a.name.compareTo(b.name);
|
||||||
@@ -284,9 +327,80 @@ class LocalManager with ChangeNotifier {
|
|||||||
|
|
||||||
Future<bool> isDownloaded(String id, ComicType type, int ep) async {
|
Future<bool> isDownloaded(String id, ComicType type, int ep) async {
|
||||||
var comic = find(id, type);
|
var comic = find(id, type);
|
||||||
if(comic == null) return false;
|
if (comic == null) return false;
|
||||||
if(comic.chapters == null) return true;
|
if (comic.chapters == null) return true;
|
||||||
var eid = comic.chapters!.keys.elementAt(ep);
|
return comic.downloadedChapters
|
||||||
return Directory(FilePath.join(path, comic.directory, eid)).exists();
|
.contains(comic.chapters!.keys.elementAt(ep));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DownloadTask> downloadingTasks = [];
|
||||||
|
|
||||||
|
bool isDownloading(String id, ComicType type) {
|
||||||
|
return downloadingTasks
|
||||||
|
.any((element) => element.id == id && element.comicType == type);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Directory> findValidDirectory(
|
||||||
|
String id, ComicType type, String name) async {
|
||||||
|
var comic = find(id, type);
|
||||||
|
if (comic != null) {
|
||||||
|
return Directory(FilePath.join(path, comic.directory));
|
||||||
|
}
|
||||||
|
var dir = findValidDirectoryName(path, name);
|
||||||
|
return Directory(FilePath.join(path, dir)).create().then((value) => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void completeTask(DownloadTask task) {
|
||||||
|
add(task.toLocalComic());
|
||||||
|
downloadingTasks.remove(task);
|
||||||
|
notifyListeners();
|
||||||
|
saveCurrentDownloadingTasks();
|
||||||
|
downloadingTasks.firstOrNull?.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeTask(DownloadTask task) {
|
||||||
|
downloadingTasks.remove(task);
|
||||||
|
notifyListeners();
|
||||||
|
saveCurrentDownloadingTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
void moveToFirst(DownloadTask task) {
|
||||||
|
if (downloadingTasks.first != task) {
|
||||||
|
var shouldResume = !downloadingTasks.first.isPaused;
|
||||||
|
downloadingTasks.first.pause();
|
||||||
|
downloadingTasks.remove(task);
|
||||||
|
downloadingTasks.insert(0, task);
|
||||||
|
notifyListeners();
|
||||||
|
saveCurrentDownloadingTasks();
|
||||||
|
if (shouldResume) {
|
||||||
|
downloadingTasks.first.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveCurrentDownloadingTasks() async {
|
||||||
|
var tasks = downloadingTasks.map((e) => e.toJson()).toList();
|
||||||
|
await File(FilePath.join(App.dataPath, 'downloading_tasks.json'))
|
||||||
|
.writeAsString(jsonEncode(tasks));
|
||||||
|
}
|
||||||
|
|
||||||
|
void restoreDownloadingTasks() {
|
||||||
|
var file = File(FilePath.join(App.dataPath, 'downloading_tasks.json'));
|
||||||
|
if (file.existsSync()) {
|
||||||
|
var tasks = jsonDecode(file.readAsStringSync());
|
||||||
|
for (var e in tasks) {
|
||||||
|
var task = DownloadTask.fromJson(e);
|
||||||
|
if (task != null) {
|
||||||
|
downloadingTasks.add(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void addTask(DownloadTask task) {
|
||||||
|
downloadingTasks.add(task);
|
||||||
|
notifyListeners();
|
||||||
|
saveCurrentDownloadingTasks();
|
||||||
|
downloadingTasks.first.resume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -82,7 +82,10 @@ class Log {
|
|||||||
addLog(LogLevel.warning, title, content);
|
addLog(LogLevel.warning, title, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
static error(String title, String content) {
|
static error(String title, String content, [Object? stackTrace]) {
|
||||||
|
if(stackTrace != null) {
|
||||||
|
content += "\n${stackTrace.toString()}";
|
||||||
|
}
|
||||||
addLog(LogLevel.error, title, content);
|
addLog(LogLevel.error, title, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -15,10 +15,10 @@ Future<void> init() async {
|
|||||||
await appdata.init();
|
await appdata.init();
|
||||||
await App.init();
|
await App.init();
|
||||||
await HistoryManager().init();
|
await HistoryManager().init();
|
||||||
await LocalManager().init();
|
|
||||||
await LocalFavoritesManager().init();
|
await LocalFavoritesManager().init();
|
||||||
SingleInstanceCookieJar("${App.dataPath}/cookie.db");
|
SingleInstanceCookieJar("${App.dataPath}/cookie.db");
|
||||||
await JsEngine().init();
|
await JsEngine().init();
|
||||||
await ComicSource.init();
|
await ComicSource.init();
|
||||||
|
await LocalManager().init();
|
||||||
CacheManager();
|
CacheManager();
|
||||||
}
|
}
|
@@ -1,14 +1,27 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
import 'package:flutter/widgets.dart' show ChangeNotifier;
|
||||||
|
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||||
|
import 'package:venera/foundation/comic_type.dart';
|
||||||
|
import 'package:venera/foundation/local.dart';
|
||||||
|
import 'package:venera/foundation/log.dart';
|
||||||
|
import 'package:venera/foundation/res.dart';
|
||||||
|
import 'package:venera/network/images.dart';
|
||||||
|
import 'package:venera/utils/ext.dart';
|
||||||
|
import 'package:venera/utils/file_type.dart';
|
||||||
|
import 'package:venera/utils/io.dart';
|
||||||
|
|
||||||
abstract class DownloadTask with ChangeNotifier {
|
abstract class DownloadTask with ChangeNotifier {
|
||||||
int get current;
|
/// 0-1
|
||||||
|
double get progress;
|
||||||
|
|
||||||
int get total;
|
bool get isComplete;
|
||||||
|
|
||||||
double get progress => current / total;
|
bool get isError;
|
||||||
|
|
||||||
bool get isComplete => current == total;
|
bool get isPaused;
|
||||||
|
|
||||||
|
/// bytes per second
|
||||||
int get speed;
|
int get speed;
|
||||||
|
|
||||||
void cancel();
|
void cancel();
|
||||||
@@ -20,4 +33,549 @@ abstract class DownloadTask with ChangeNotifier {
|
|||||||
String get title;
|
String get title;
|
||||||
|
|
||||||
String? get cover;
|
String? get cover;
|
||||||
|
|
||||||
|
String get message;
|
||||||
|
|
||||||
|
/// root path for the comic. If null, the task is not scheduled.
|
||||||
|
String? path;
|
||||||
|
|
||||||
|
/// convert current state to json, which can be used to restore the task
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
LocalComic toLocalComic();
|
||||||
|
|
||||||
|
String get id;
|
||||||
|
|
||||||
|
ComicType get comicType;
|
||||||
|
|
||||||
|
static DownloadTask? fromJson(Map<String, dynamic> json) {
|
||||||
|
switch (json["type"]) {
|
||||||
|
case "ImagesDownloadTask":
|
||||||
|
return ImagesDownloadTask.fromJson(json);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
|
||||||
|
final ComicSource source;
|
||||||
|
|
||||||
|
final String comicId;
|
||||||
|
|
||||||
|
/// comic details. If null, the comic details will be fetched from the source.
|
||||||
|
ComicDetails? comic;
|
||||||
|
|
||||||
|
/// chapters to download. If null, all chapters will be downloaded.
|
||||||
|
final List<String>? chapters;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get id => comicId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ComicType get comicType => ComicType(source.key.hashCode);
|
||||||
|
|
||||||
|
ImagesDownloadTask({
|
||||||
|
required this.source,
|
||||||
|
required this.comicId,
|
||||||
|
this.comic,
|
||||||
|
this.chapters,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void cancel() {
|
||||||
|
_isRunning = false;
|
||||||
|
LocalManager().removeTask(this);
|
||||||
|
var local = LocalManager().find(id, comicType);
|
||||||
|
if (path != null) {
|
||||||
|
if (local == null) {
|
||||||
|
Directory(path!).deleteIgnoreError();
|
||||||
|
} else if (chapters != null) {
|
||||||
|
for (var c in chapters!) {
|
||||||
|
var dir = Directory(FilePath.join(path!, c));
|
||||||
|
if (dir.existsSync()) {
|
||||||
|
dir.deleteSync(recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get cover => _cover;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isComplete => _totalCount == _downloadedCount;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get message => _message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pause() {
|
||||||
|
if (isPaused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_isRunning = false;
|
||||||
|
_message = "Paused";
|
||||||
|
_currentSpeed = 0;
|
||||||
|
var shouldMove = <int>[];
|
||||||
|
for (var entry in tasks.entries) {
|
||||||
|
if (!entry.value.isComplete) {
|
||||||
|
entry.value.cancel();
|
||||||
|
shouldMove.add(entry.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var i in shouldMove) {
|
||||||
|
tasks.remove(i);
|
||||||
|
}
|
||||||
|
stopRecorder();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
double get progress => _totalCount == 0 ? 0 : _downloadedCount / _totalCount;
|
||||||
|
|
||||||
|
bool _isRunning = false;
|
||||||
|
|
||||||
|
bool _isError = false;
|
||||||
|
|
||||||
|
String _message = "Fetching comic info...";
|
||||||
|
|
||||||
|
String? _cover;
|
||||||
|
|
||||||
|
Map<String, List<String>>? _images;
|
||||||
|
|
||||||
|
int _downloadedCount = 0;
|
||||||
|
|
||||||
|
int _totalCount = 0;
|
||||||
|
|
||||||
|
int _index = 0;
|
||||||
|
|
||||||
|
int _chapter = 0;
|
||||||
|
|
||||||
|
var tasks = <int, _ImageDownloadWrapper>{};
|
||||||
|
|
||||||
|
int get _maxConcurrentTasks => 5;
|
||||||
|
|
||||||
|
void _scheduleTasks() {
|
||||||
|
var images = _images![_images!.keys.elementAt(_chapter)]!;
|
||||||
|
var downloading = 0;
|
||||||
|
for (var i = _index; i < images.length; i++) {
|
||||||
|
if (downloading >= _maxConcurrentTasks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (tasks[i] != null) {
|
||||||
|
if (!tasks[i]!.isComplete) {
|
||||||
|
downloading++;
|
||||||
|
}
|
||||||
|
if (tasks[i]!.error == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Directory saveTo;
|
||||||
|
if (comic!.chapters != null) {
|
||||||
|
saveTo = Directory(FilePath.join(
|
||||||
|
path!,
|
||||||
|
comic!.chapters!.keys.elementAt(_chapter),
|
||||||
|
));
|
||||||
|
if (!saveTo.existsSync()) {
|
||||||
|
saveTo.createSync();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
saveTo = Directory(path!);
|
||||||
|
}
|
||||||
|
var task = _ImageDownloadWrapper(
|
||||||
|
this,
|
||||||
|
_images!.keys.elementAt(_chapter),
|
||||||
|
images[i],
|
||||||
|
saveTo,
|
||||||
|
i,
|
||||||
|
);
|
||||||
|
tasks[i] = task;
|
||||||
|
task.wait().then((task) {
|
||||||
|
if (task.isComplete) {
|
||||||
|
_scheduleTasks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void resume() async {
|
||||||
|
if (_isRunning) return;
|
||||||
|
_isRunning = true;
|
||||||
|
notifyListeners();
|
||||||
|
runRecorder();
|
||||||
|
|
||||||
|
if (comic == null) {
|
||||||
|
var res = await runWithRetry(() async {
|
||||||
|
var r = await source.loadComicInfo!(comicId);
|
||||||
|
if (r.error) {
|
||||||
|
throw r.errorMessage!;
|
||||||
|
} else {
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!_isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.error) {
|
||||||
|
_setError("Error: ${res.errorMessage}");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
comic = res.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path == null) {
|
||||||
|
var dir = await LocalManager().findValidDirectory(
|
||||||
|
comicId,
|
||||||
|
comicType,
|
||||||
|
comic!.title,
|
||||||
|
);
|
||||||
|
if (!(await dir.exists())) {
|
||||||
|
try {
|
||||||
|
await dir.create();
|
||||||
|
} catch (e) {
|
||||||
|
_setError("Error: $e");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path = dir.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LocalManager().saveCurrentDownloadingTasks();
|
||||||
|
|
||||||
|
if (cover == null) {
|
||||||
|
var res = await runWithRetry(() async {
|
||||||
|
Uint8List? data;
|
||||||
|
await for (var progress
|
||||||
|
in ImageDownloader.loadThumbnail(comic!.cover, source.key)) {
|
||||||
|
if (progress.imageBytes != null) {
|
||||||
|
data = progress.imageBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data == null) {
|
||||||
|
throw "Failed to download cover";
|
||||||
|
}
|
||||||
|
var fileType = detectFileType(data);
|
||||||
|
var file = File(FilePath.join(path!, "cover${fileType.ext}"));
|
||||||
|
file.writeAsBytesSync(data);
|
||||||
|
return file.path;
|
||||||
|
});
|
||||||
|
if (res.error) {
|
||||||
|
_setError("Error: ${res.errorMessage}");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
_cover = res.data;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
await LocalManager().saveCurrentDownloadingTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_images == null) {
|
||||||
|
if (comic!.chapters == null) {
|
||||||
|
var res = await runWithRetry(() async {
|
||||||
|
var r = await source.loadComicPages!(comicId, null);
|
||||||
|
if (r.error) {
|
||||||
|
throw r.errorMessage!;
|
||||||
|
} else {
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!_isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.error) {
|
||||||
|
_setError("Error: ${res.errorMessage}");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
_images = {'': res.data};
|
||||||
|
_totalCount = _images!['']!.length;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_images = {};
|
||||||
|
_totalCount = 0;
|
||||||
|
for (var i in comic!.chapters!.keys) {
|
||||||
|
if (chapters != null && !chapters!.contains(i)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (_images![i] != null) {
|
||||||
|
_totalCount += _images![i]!.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var res = await runWithRetry(() async {
|
||||||
|
var r = await source.loadComicPages!(comicId, i);
|
||||||
|
if (r.error) {
|
||||||
|
throw r.errorMessage!;
|
||||||
|
} else {
|
||||||
|
return r.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!_isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.error) {
|
||||||
|
_setError("Error: ${res.errorMessage}");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
_images![i] = res.data;
|
||||||
|
_totalCount += _images![i]!.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_message = "$_downloadedCount/$_totalCount";
|
||||||
|
notifyListeners();
|
||||||
|
await LocalManager().saveCurrentDownloadingTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
while (_chapter < _images!.length) {
|
||||||
|
var images = _images![_images!.keys.elementAt(_chapter)]!;
|
||||||
|
tasks.clear();
|
||||||
|
while (_index < images.length) {
|
||||||
|
_scheduleTasks();
|
||||||
|
var task = tasks[_index]!;
|
||||||
|
await task.wait();
|
||||||
|
if (isPaused) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (task.error != null) {
|
||||||
|
_setError("Error: ${task.error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_index++;
|
||||||
|
_downloadedCount++;
|
||||||
|
_message = "$_downloadedCount/$_totalCount";
|
||||||
|
await LocalManager().saveCurrentDownloadingTasks();
|
||||||
|
}
|
||||||
|
_index = 0;
|
||||||
|
_chapter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalManager().completeTask(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onNextSecond(Timer t) {
|
||||||
|
notifyListeners();
|
||||||
|
super.onNextSecond(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setError(String message) {
|
||||||
|
_isRunning = false;
|
||||||
|
_isError = true;
|
||||||
|
_message = message;
|
||||||
|
notifyListeners();
|
||||||
|
stopRecorder();
|
||||||
|
Log.error("Download", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get speed => currentSpeed;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get title => comic?.title ?? "Loading...";
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
"type": "ImagesDownloadTask",
|
||||||
|
"source": source.key,
|
||||||
|
"comicId": comicId,
|
||||||
|
"comic": comic?.toJson(),
|
||||||
|
"chapters": chapters,
|
||||||
|
"path": path,
|
||||||
|
"cover": cover,
|
||||||
|
"images": _images,
|
||||||
|
"downloadedCount": _downloadedCount,
|
||||||
|
"totalCount": _totalCount,
|
||||||
|
"index": _index,
|
||||||
|
"chapter": _chapter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static ImagesDownloadTask? fromJson(Map<String, dynamic> json) {
|
||||||
|
if (json["type"] != "ImagesDownloadTask") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<String>>? images;
|
||||||
|
if (json["images"] != null) {
|
||||||
|
images = {};
|
||||||
|
for (var entry in json["images"].entries) {
|
||||||
|
images[entry.key] = List<String>.from(entry.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImagesDownloadTask(
|
||||||
|
source: ComicSource.find(json["source"])!,
|
||||||
|
comicId: json["comicId"],
|
||||||
|
comic:
|
||||||
|
json["comic"] == null ? null : ComicDetails.fromJson(json["comic"]),
|
||||||
|
chapters: ListOrNull.from(json["chapters"]),
|
||||||
|
)
|
||||||
|
..path = json["path"]
|
||||||
|
.._cover = json["cover"]
|
||||||
|
.._images = images
|
||||||
|
.._downloadedCount = json["downloadedCount"]
|
||||||
|
.._totalCount = json["totalCount"]
|
||||||
|
.._index = json["index"]
|
||||||
|
.._chapter = json["chapter"];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isError => _isError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isPaused => !_isRunning;
|
||||||
|
|
||||||
|
@override
|
||||||
|
LocalComic toLocalComic() {
|
||||||
|
return LocalComic(
|
||||||
|
id: comic!.id,
|
||||||
|
title: title,
|
||||||
|
subtitle: comic!.subTitle ?? '',
|
||||||
|
tags: comic!.tags.entries.expand((e) {
|
||||||
|
return e.value.map((v) => "${e.key}:$v");
|
||||||
|
}).toList(),
|
||||||
|
directory: Directory(path!).name,
|
||||||
|
chapters: comic!.chapters,
|
||||||
|
cover: File(_cover!).uri.pathSegments.last,
|
||||||
|
comicType: ComicType(source.key.hashCode),
|
||||||
|
downloadedChapters: chapters ?? [],
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (other is ImagesDownloadTask) {
|
||||||
|
return other.comicId == comicId && other.source.key == source.key;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(comicId, source.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Res<T>> runWithRetry<T>(Future<T> Function() task,
|
||||||
|
{int retry = 3}) async {
|
||||||
|
for (var i = 0; i < retry; i++) {
|
||||||
|
try {
|
||||||
|
return Res(await task());
|
||||||
|
} catch (e) {
|
||||||
|
if (i == retry - 1) {
|
||||||
|
return Res.error(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw UnimplementedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageDownloadWrapper {
|
||||||
|
final ImagesDownloadTask task;
|
||||||
|
|
||||||
|
final String chapter;
|
||||||
|
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
final String image;
|
||||||
|
|
||||||
|
final Directory saveTo;
|
||||||
|
|
||||||
|
_ImageDownloadWrapper(
|
||||||
|
this.task,
|
||||||
|
this.chapter,
|
||||||
|
this.image,
|
||||||
|
this.saveTo,
|
||||||
|
this.index,
|
||||||
|
) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isComplete = false;
|
||||||
|
|
||||||
|
String? error;
|
||||||
|
|
||||||
|
bool isCancelled = false;
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
isCancelled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var completers = <Completer<_ImageDownloadWrapper>>[];
|
||||||
|
|
||||||
|
void start() async {
|
||||||
|
int lastBytes = 0;
|
||||||
|
try {
|
||||||
|
await for (var p in ImageDownloader.loadComicImage(
|
||||||
|
image, task.source.key, task.comicId, chapter)) {
|
||||||
|
if (isCancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
task.onData(p.currentBytes - lastBytes);
|
||||||
|
lastBytes = p.currentBytes;
|
||||||
|
if (p.imageBytes != null) {
|
||||||
|
var fileType = detectFileType(p.imageBytes!);
|
||||||
|
var file = saveTo.joinFile("$index${fileType.ext}");
|
||||||
|
await file.writeAsBytes(p.imageBytes!);
|
||||||
|
isComplete = true;
|
||||||
|
for (var c in completers) {
|
||||||
|
c.complete(this);
|
||||||
|
}
|
||||||
|
completers.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e, s) {
|
||||||
|
Log.error("Download", e.toString(), s);
|
||||||
|
error = e.toString();
|
||||||
|
for (var c in completers) {
|
||||||
|
if (!c.isCompleted) {
|
||||||
|
c.complete(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_ImageDownloadWrapper> wait() {
|
||||||
|
if (isComplete) {
|
||||||
|
return Future.value(this);
|
||||||
|
}
|
||||||
|
var c = Completer<_ImageDownloadWrapper>();
|
||||||
|
completers.add(c);
|
||||||
|
return c.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract mixin class _TransferSpeedMixin {
|
||||||
|
int _bytesSinceLastSecond = 0;
|
||||||
|
|
||||||
|
int _currentSpeed = 0;
|
||||||
|
|
||||||
|
int get currentSpeed => _currentSpeed;
|
||||||
|
|
||||||
|
Timer? timer;
|
||||||
|
|
||||||
|
void onData(int length) {
|
||||||
|
if (timer == null) return;
|
||||||
|
_bytesSinceLastSecond += length;
|
||||||
|
}
|
||||||
|
|
||||||
|
void onNextSecond(Timer t) {
|
||||||
|
_currentSpeed = _bytesSinceLastSecond;
|
||||||
|
_bytesSinceLastSecond = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void runRecorder() {
|
||||||
|
if (timer != null) {
|
||||||
|
timer!.cancel();
|
||||||
|
}
|
||||||
|
timer = Timer.periodic(const Duration(seconds: 1), onNextSecond);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopRecorder() {
|
||||||
|
timer?.cancel();
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
}
|
}
|
139
lib/network/images.dart
Normal file
139
lib/network/images.dart
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:venera/foundation/cache_manager.dart';
|
||||||
|
import 'package:venera/foundation/comic_source/comic_source.dart';
|
||||||
|
import 'package:venera/foundation/consts.dart';
|
||||||
|
|
||||||
|
import 'app_dio.dart';
|
||||||
|
|
||||||
|
class ImageDownloader {
|
||||||
|
static Stream<ImageDownloadProgress> loadThumbnail(String url, String? sourceKey) async* {
|
||||||
|
final cacheKey = "$url@$sourceKey";
|
||||||
|
final cache = await CacheManager().findCache(cacheKey);
|
||||||
|
|
||||||
|
if (cache != null) {
|
||||||
|
var data = await cache.readAsBytes();
|
||||||
|
yield ImageDownloadProgress(
|
||||||
|
currentBytes: data.length,
|
||||||
|
totalBytes: data.length,
|
||||||
|
imageBytes: data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var configs = <String, dynamic>{};
|
||||||
|
if (sourceKey != null) {
|
||||||
|
var comicSource = ComicSource.find(sourceKey);
|
||||||
|
configs = comicSource!.getThumbnailLoadingConfig?.call(url) ?? {};
|
||||||
|
}
|
||||||
|
configs['headers'] ??= {
|
||||||
|
'user-agent': webUA,
|
||||||
|
};
|
||||||
|
|
||||||
|
var dio = AppDio(BaseOptions(
|
||||||
|
headers: configs['headers'],
|
||||||
|
method: configs['method'] ?? 'GET',
|
||||||
|
responseType: ResponseType.stream,
|
||||||
|
));
|
||||||
|
|
||||||
|
var req = await dio.request<ResponseBody>(configs['url'] ?? url,
|
||||||
|
data: configs['data']);
|
||||||
|
var stream = req.data?.stream ?? (throw "Error: Empty response body.");
|
||||||
|
int? expectedBytes = req.data!.contentLength;
|
||||||
|
if (expectedBytes == -1) {
|
||||||
|
expectedBytes = null;
|
||||||
|
}
|
||||||
|
var buffer = <int>[];
|
||||||
|
await for (var data in stream) {
|
||||||
|
buffer.addAll(data);
|
||||||
|
if (expectedBytes != null) {
|
||||||
|
yield ImageDownloadProgress(
|
||||||
|
currentBytes: buffer.length,
|
||||||
|
totalBytes: expectedBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(configs['onResponse'] != null) {
|
||||||
|
buffer = configs['onResponse'](buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
await CacheManager().writeCache(cacheKey, buffer);
|
||||||
|
yield ImageDownloadProgress(
|
||||||
|
currentBytes: buffer.length,
|
||||||
|
totalBytes: buffer.length,
|
||||||
|
imageBytes: Uint8List.fromList(buffer),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Stream<ImageDownloadProgress> loadComicImage(String imageKey, String? sourceKey, String cid, String eid) async* {
|
||||||
|
final cacheKey = "$imageKey@$sourceKey@$cid@$eid";
|
||||||
|
final cache = await CacheManager().findCache(cacheKey);
|
||||||
|
|
||||||
|
if (cache != null) {
|
||||||
|
var data = await cache.readAsBytes();
|
||||||
|
yield ImageDownloadProgress(
|
||||||
|
currentBytes: data.length,
|
||||||
|
totalBytes: data.length,
|
||||||
|
imageBytes: data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var configs = <String, dynamic>{};
|
||||||
|
if (sourceKey != null) {
|
||||||
|
var comicSource = ComicSource.find(sourceKey);
|
||||||
|
configs = comicSource!.getImageLoadingConfig?.call(imageKey, cid, eid) ?? {};
|
||||||
|
}
|
||||||
|
configs['headers'] ??= {
|
||||||
|
'user-agent': webUA,
|
||||||
|
};
|
||||||
|
|
||||||
|
var dio = AppDio(BaseOptions(
|
||||||
|
headers: configs['headers'],
|
||||||
|
method: configs['method'] ?? 'GET',
|
||||||
|
responseType: ResponseType.stream,
|
||||||
|
));
|
||||||
|
|
||||||
|
var req = await dio.request<ResponseBody>(configs['url'] ?? imageKey,
|
||||||
|
data: configs['data']);
|
||||||
|
var stream = req.data?.stream ?? (throw "Error: Empty response body.");
|
||||||
|
int? expectedBytes = req.data!.contentLength;
|
||||||
|
if (expectedBytes == -1) {
|
||||||
|
expectedBytes = null;
|
||||||
|
}
|
||||||
|
var buffer = <int>[];
|
||||||
|
await for (var data in stream) {
|
||||||
|
buffer.addAll(data);
|
||||||
|
if (expectedBytes != null) {
|
||||||
|
yield ImageDownloadProgress(
|
||||||
|
currentBytes: buffer.length,
|
||||||
|
totalBytes: expectedBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(configs['onResponse'] != null) {
|
||||||
|
buffer = configs['onResponse'](buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
await CacheManager().writeCache(cacheKey, buffer);
|
||||||
|
yield ImageDownloadProgress(
|
||||||
|
currentBytes: buffer.length,
|
||||||
|
totalBytes: buffer.length,
|
||||||
|
imageBytes: Uint8List.fromList(buffer),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImageDownloadProgress {
|
||||||
|
final int currentBytes;
|
||||||
|
|
||||||
|
final int totalBytes;
|
||||||
|
|
||||||
|
final Uint8List? imageBytes;
|
||||||
|
|
||||||
|
const ImageDownloadProgress({
|
||||||
|
required this.currentBytes,
|
||||||
|
required this.totalBytes,
|
||||||
|
this.imageBytes,
|
||||||
|
});
|
||||||
|
}
|
@@ -7,9 +7,12 @@ import 'package:venera/foundation/consts.dart';
|
|||||||
import 'package:venera/foundation/favorites.dart';
|
import 'package:venera/foundation/favorites.dart';
|
||||||
import 'package:venera/foundation/history.dart';
|
import 'package:venera/foundation/history.dart';
|
||||||
import 'package:venera/foundation/image_provider/cached_image.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/foundation/res.dart';
|
||||||
|
import 'package:venera/network/download.dart';
|
||||||
import 'package:venera/pages/favorites/favorites_page.dart';
|
import 'package:venera/pages/favorites/favorites_page.dart';
|
||||||
import 'package:venera/pages/reader/reader.dart';
|
import 'package:venera/pages/reader/reader.dart';
|
||||||
|
import 'package:venera/utils/io.dart';
|
||||||
import 'package:venera/utils/translations.dart';
|
import 'package:venera/utils/translations.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
@@ -32,6 +35,8 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
|||||||
|
|
||||||
var scrollController = ScrollController();
|
var scrollController = ScrollController();
|
||||||
|
|
||||||
|
bool isDownloaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
scrollController.addListener(onScroll);
|
scrollController.addListener(onScroll);
|
||||||
@@ -92,9 +97,16 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
onDataLoaded() {
|
Future<void> onDataLoaded() async {
|
||||||
isLiked = comic.isLiked ?? false;
|
isLiked = comic.isLiked ?? false;
|
||||||
isFavorite = comic.isFavorite ?? false;
|
isFavorite = comic.isFavorite ?? false;
|
||||||
|
if (comic.chapters == null) {
|
||||||
|
isDownloaded = await LocalManager().isDownloaded(
|
||||||
|
comic.id,
|
||||||
|
comic.comicType,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Iterable<Widget> buildTitle() sync* {
|
Iterable<Widget> buildTitle() sync* {
|
||||||
@@ -173,7 +185,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
|||||||
onPressed: read,
|
onPressed: read,
|
||||||
iconColor: context.useTextColor(Colors.orange),
|
iconColor: context.useTextColor(Colors.orange),
|
||||||
),
|
),
|
||||||
if (!isMobile)
|
if (!isMobile && !isDownloaded)
|
||||||
_ActionButton(
|
_ActionButton(
|
||||||
icon: const Icon(Icons.download),
|
icon: const Icon(Icons.download),
|
||||||
text: 'Download'.tl,
|
text: 'Download'.tl,
|
||||||
@@ -219,7 +231,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FilledButton.tonal(
|
child: FilledButton.tonal(
|
||||||
onPressed: () {},
|
onPressed: download,
|
||||||
child: Text("Download".tl),
|
child: Text("Download".tl),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -335,7 +347,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
|
|||||||
children: [
|
children: [
|
||||||
buildTag(text: e.key, isTitle: true),
|
buildTag(text: e.key, isTitle: true),
|
||||||
for (var tag in e.value)
|
for (var tag in e.value)
|
||||||
buildTag(text: tag, onTap: () => onTagTap(tag, e.key)),
|
buildTag(text: tag, onTap: () => onTapTag(tag, e.key)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (comic.uploader != null)
|
if (comic.uploader != null)
|
||||||
@@ -455,7 +467,9 @@ abstract mixin class _ComicPageActions {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void share() {}
|
void share() {
|
||||||
|
Share.shareText(comic.title);
|
||||||
|
}
|
||||||
|
|
||||||
/// read the comic
|
/// read the comic
|
||||||
///
|
///
|
||||||
@@ -482,9 +496,57 @@ abstract mixin class _ComicPageActions {
|
|||||||
read(ep, page);
|
read(ep, page);
|
||||||
}
|
}
|
||||||
|
|
||||||
void download() {}
|
void download() async {
|
||||||
|
if (LocalManager().isDownloading(comic.id, comic.comicType)) {
|
||||||
|
App.rootContext.showMessage(message: "The comic is downloading".tl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (comic.chapters == null &&
|
||||||
|
await LocalManager().isDownloaded(comic.id, comic.comicType, 0)) {
|
||||||
|
App.rootContext.showMessage(message: "The comic is downloaded".tl);
|
||||||
|
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 onTagTap(String tag, String namespace) {}
|
void onTapTag(String tag, String namespace) {}
|
||||||
|
|
||||||
void showMoreActions() {}
|
void showMoreActions() {}
|
||||||
|
|
||||||
@@ -1137,3 +1199,95 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: () {
|
||||||
|
widget.finishSelect(selected);
|
||||||
|
context.pop();
|
||||||
|
},
|
||||||
|
child: Text("Download Selected".tl),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: MediaQuery.of(context).padding.bottom + 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
218
lib/pages/downloading_page.dart
Normal file
218
lib/pages/downloading_page.dart
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:venera/components/components.dart';
|
||||||
|
import 'package:venera/foundation/app.dart';
|
||||||
|
import 'package:venera/foundation/local.dart';
|
||||||
|
import 'package:venera/network/download.dart';
|
||||||
|
import 'package:venera/utils/io.dart';
|
||||||
|
import 'package:venera/utils/translations.dart';
|
||||||
|
|
||||||
|
class DownloadingPage extends StatefulWidget {
|
||||||
|
const DownloadingPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DownloadingPage> createState() => _DownloadingPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadingPageState extends State<DownloadingPage> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
LocalManager().addListener(update);
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
LocalManager().removeListener(update);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void update() {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopUpWidgetScaffold(
|
||||||
|
title: "",
|
||||||
|
body: ListView.builder(
|
||||||
|
itemCount: LocalManager().downloadingTasks.length + 1,
|
||||||
|
itemBuilder: (BuildContext context, int i) {
|
||||||
|
if (i == 0) {
|
||||||
|
return buildTop();
|
||||||
|
}
|
||||||
|
i--;
|
||||||
|
|
||||||
|
return _DownloadTaskTile(
|
||||||
|
task: LocalManager().downloadingTasks[i],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildTop() {
|
||||||
|
int speed = 0;
|
||||||
|
if (LocalManager().downloadingTasks.isNotEmpty) {
|
||||||
|
speed = LocalManager().downloadingTasks.first.speed;
|
||||||
|
}
|
||||||
|
var first = LocalManager().downloadingTasks.firstOrNull;
|
||||||
|
return Container(
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: context.colorScheme.outlineVariant,
|
||||||
|
width: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (first?.isPaused == true)
|
||||||
|
Text(
|
||||||
|
"Paused".tl,
|
||||||
|
style: ts.s18.bold,
|
||||||
|
)
|
||||||
|
else if (first?.isError == true)
|
||||||
|
Text(
|
||||||
|
"Error".tl,
|
||||||
|
style: ts.s18.bold,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
"${bytesToReadableString(speed)}/s",
|
||||||
|
style: ts.s18.bold,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (first?.isPaused == true || first?.isError == true)
|
||||||
|
OutlinedButton(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.play_arrow, size: 18),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text("Start".tl),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
first!.resume();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else if (first != null)
|
||||||
|
OutlinedButton(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.pause, size: 18),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text("Pause".tl),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
first.pause();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).paddingHorizontal(16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadTaskTile extends StatefulWidget {
|
||||||
|
const _DownloadTaskTile({required this.task});
|
||||||
|
|
||||||
|
final DownloadTask task;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DownloadTaskTile> createState() => _DownloadTaskTileState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadTaskTileState extends State<_DownloadTaskTile> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
widget.task.addListener(update);
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.task.removeListener(update);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void update() {
|
||||||
|
context.findAncestorStateOfType<_DownloadingPageState>()?.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
height: 136,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 82,
|
||||||
|
height: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: context.colorScheme.primaryContainer,
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: widget.task.cover == null
|
||||||
|
? null
|
||||||
|
: Image.file(
|
||||||
|
File(widget.task.cover!),
|
||||||
|
filterQuality: FilterQuality.medium,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.task.title,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
MenuButton(
|
||||||
|
entries: [
|
||||||
|
MenuEntry(
|
||||||
|
icon: Icons.close,
|
||||||
|
text: "Cancel".tl,
|
||||||
|
onClick: () {
|
||||||
|
widget.task.cancel();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
icon: Icons.vertical_align_top,
|
||||||
|
text: "Move To First".tl,
|
||||||
|
onClick: () {
|
||||||
|
LocalManager().moveToFirst(widget.task);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (!widget.task.isPaused || widget.task.isError)
|
||||||
|
Text(
|
||||||
|
widget.task.message,
|
||||||
|
style: ts.s12,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: widget.task.progress,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -12,11 +12,14 @@ import 'package:venera/foundation/log.dart';
|
|||||||
import 'package:venera/pages/accounts_page.dart';
|
import 'package:venera/pages/accounts_page.dart';
|
||||||
import 'package:venera/pages/comic_page.dart';
|
import 'package:venera/pages/comic_page.dart';
|
||||||
import 'package:venera/pages/comic_source_page.dart';
|
import 'package:venera/pages/comic_source_page.dart';
|
||||||
|
import 'package:venera/pages/downloading_page.dart';
|
||||||
import 'package:venera/pages/history_page.dart';
|
import 'package:venera/pages/history_page.dart';
|
||||||
import 'package:venera/pages/search_page.dart';
|
import 'package:venera/pages/search_page.dart';
|
||||||
import 'package:venera/utils/io.dart';
|
import 'package:venera/utils/io.dart';
|
||||||
import 'package:venera/utils/translations.dart';
|
import 'package:venera/utils/translations.dart';
|
||||||
|
|
||||||
|
import 'local_comics_page.dart';
|
||||||
|
|
||||||
class HomePage extends StatelessWidget {
|
class HomePage extends StatelessWidget {
|
||||||
const HomePage({super.key});
|
const HomePage({super.key});
|
||||||
|
|
||||||
@@ -163,8 +166,8 @@ class _HistoryState extends State<_History> {
|
|||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 96,
|
width: 92,
|
||||||
height: 128,
|
height: 114,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -242,7 +245,9 @@ class _LocalState extends State<_Local> {
|
|||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
onTap: () {},
|
onTap: () {
|
||||||
|
context.to(() => const LocalComicsPage());
|
||||||
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -277,12 +282,12 @@ class _LocalState extends State<_Local> {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// TODO: view local comic
|
local[index].read();
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 96,
|
width: 92,
|
||||||
height: 128,
|
height: 114,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -307,6 +312,24 @@ class _LocalState extends State<_Local> {
|
|||||||
).paddingHorizontal(8),
|
).paddingHorizontal(8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
if (LocalManager().downloadingTasks.isNotEmpty)
|
||||||
|
Button.outlined(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if(LocalManager().downloadingTasks.first.isPaused)
|
||||||
|
const Icon(Icons.pause_circle_outline, size: 18)
|
||||||
|
else
|
||||||
|
const _AnimatedDownloadingIcon(),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text("@a Tasks".tlParams({
|
||||||
|
'a': LocalManager().downloadingTasks.length,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
showPopUpWidget(context, const DownloadingPage());
|
||||||
|
},
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Button.filled(
|
Button.filled(
|
||||||
onPressed: import,
|
onPressed: import,
|
||||||
@@ -601,6 +624,7 @@ class _ImportComicsWidgetState extends State<_ImportComicsWidget> {
|
|||||||
chapters: Map.fromIterables(chapters, chapters),
|
chapters: Map.fromIterables(chapters, chapters),
|
||||||
cover: coverPath,
|
cover: coverPath,
|
||||||
comicType: ComicType.local,
|
comicType: ComicType.local,
|
||||||
|
downloadedChapters: chapters,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -810,3 +834,62 @@ class _AccountsWidgetState extends State<_AccountsWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AnimatedDownloadingIcon extends StatefulWidget {
|
||||||
|
const _AnimatedDownloadingIcon();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AnimatedDownloadingIcon> createState() =>
|
||||||
|
__AnimatedDownloadingIconState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class __AnimatedDownloadingIconState extends State<_AnimatedDownloadingIcon>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
lowerBound: -1,
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 2000),
|
||||||
|
)..repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Container(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, 18 * _controller.value),
|
||||||
|
child: Icon(
|
||||||
|
Icons.arrow_downward,
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
66
lib/pages/local_comics_page.dart
Normal file
66
lib/pages/local_comics_page.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:venera/components/components.dart';
|
||||||
|
import 'package:venera/foundation/app.dart';
|
||||||
|
import 'package:venera/foundation/local.dart';
|
||||||
|
import 'package:venera/pages/downloading_page.dart';
|
||||||
|
import 'package:venera/utils/translations.dart';
|
||||||
|
|
||||||
|
class LocalComicsPage extends StatefulWidget {
|
||||||
|
const LocalComicsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LocalComicsPage> createState() => _LocalComicsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LocalComicsPageState extends State<LocalComicsPage> {
|
||||||
|
late List<LocalComic> comics;
|
||||||
|
|
||||||
|
void update() {
|
||||||
|
setState(() {
|
||||||
|
comics = LocalManager().getComics();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
comics = LocalManager().getComics();
|
||||||
|
LocalManager().addListener(update);
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
LocalManager().removeListener(update);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: SmoothCustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
SliverAppbar(
|
||||||
|
title: Text("Local".tl),
|
||||||
|
actions: [
|
||||||
|
Tooltip(
|
||||||
|
message: "Downloading".tl,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
onPressed: () {
|
||||||
|
showPopUpWidget(context, const DownloadingPage());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SliverGridComics(
|
||||||
|
comics: comics,
|
||||||
|
onTap: (c) {
|
||||||
|
(c as LocalComic).read();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -510,6 +510,9 @@ class _ContinuousModeState extends State<_ContinuousMode>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if(event is KeyUpEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
bool? forward;
|
bool? forward;
|
||||||
if (reader.mode == ReaderMode.continuousLeftToRight &&
|
if (reader.mode == ReaderMode.continuousLeftToRight &&
|
||||||
event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||||
|
@@ -9,6 +9,7 @@ import 'package:path/path.dart' as p;
|
|||||||
import 'package:share_plus/share_plus.dart' as s;
|
import 'package:share_plus/share_plus.dart' as s;
|
||||||
|
|
||||||
export 'dart:io';
|
export 'dart:io';
|
||||||
|
export 'dart:typed_data';
|
||||||
|
|
||||||
class FilePath {
|
class FilePath {
|
||||||
const FilePath._();
|
const FilePath._();
|
||||||
@@ -64,6 +65,10 @@ extension DirectoryExtension on Directory {
|
|||||||
newName = sanitizeFileName(newName);
|
newName = sanitizeFileName(newName);
|
||||||
return renameSync(path.replaceLast(name, newName));
|
return renameSync(path.replaceLast(name, newName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
File joinFile(String name) {
|
||||||
|
return File(FilePath.join(path, name));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String sanitizeFileName(String fileName) {
|
String sanitizeFileName(String fileName) {
|
||||||
@@ -108,7 +113,7 @@ String findValidDirectoryName(String path, String directory) {
|
|||||||
var name = sanitizeFileName(directory);
|
var name = sanitizeFileName(directory);
|
||||||
var dir = Directory("$path/$name");
|
var dir = Directory("$path/$name");
|
||||||
var i = 1;
|
var i = 1;
|
||||||
while (dir.existsSync()) {
|
while (dir.existsSync() && dir.listSync().isNotEmpty) {
|
||||||
name = sanitizeFileName("$directory($i)");
|
name = sanitizeFileName("$directory($i)");
|
||||||
dir = Directory("$path/$name");
|
dir = Directory("$path/$name");
|
||||||
i++;
|
i++;
|
||||||
|
Reference in New Issue
Block a user