Compare commits

...

11 Commits

Author SHA1 Message Date
e1fbdfbd50 Update version code 2025-10-07 16:10:14 +08:00
0a5b70b161 Add font patching script for linux arm64. Close #468 2025-10-07 16:09:30 +08:00
9173665afe Fix invalid total comics count. Close #524 2025-10-07 09:47:25 +08:00
e0ea449c17 Improve updating following and fix potential crash. 2025-10-06 10:17:01 +08:00
c438a84537 flutter 3.35.5 2025-10-05 17:31:57 +08:00
8c625e212a fix downloading issue when chapter name contains special characters. Close #533 2025-10-05 17:31:24 +08:00
ab786ed2ab fix padding check. Close #527 2025-10-05 16:58:41 +08:00
d9303aab2e fix activity name. Close #528 2025-10-05 16:56:32 +08:00
nyne
b7f79476c8 Merge pull request #534 from lings03/v1.5.1-dev
favorite page && cover page
2025-10-05 16:17:14 +08:00
角砂糖
44bcce4385 Add a page to view cover 2025-10-03 02:32:36 +08:00
角砂糖
6ce6066de2 Update comic details favorite page style 2025-10-03 02:32:31 +08:00
21 changed files with 1220 additions and 438 deletions

View File

@@ -170,6 +170,9 @@ jobs:
sudo apt-get update -y
sudo apt-get install -y ninja-build libgtk-3-dev webkit2gtk-4.1
dart pub global activate flutter_to_debian
- name: "Patch font"
run: |
dart run patch/font.dart
- run: python3 debian/build.py arm64
- uses: actions/upload-artifact@v4
with:

View File

@@ -83,7 +83,10 @@
"New Folder": "新建文件夹",
"Reading": "阅读中",
"Appearance": "外观",
"Network Favorites": "网络收藏",
"Local Favorites": "本地收藏",
"Show local favorites before network favorites": "在网络收藏之前显示本地收藏",
"Auto close favorite panel after operation": "自动关闭收藏面板",
"APP": "应用",
"About": "关于",
"Display mode of comic tile": "漫画缩略图的显示模式",
@@ -497,7 +500,10 @@
"New Folder": "建立資料夾",
"Reading": "閱讀中",
"Appearance": "外觀",
"Network Favorites": "網路收藏",
"Local Favorites": "本機收藏",
"Show local favorites before network favorites": "在網路收藏之前顯示本機收藏",
"Auto close favorite panel after operation": "自動關閉收藏面板",
"APP": "應用",
"About": "關於",
"Display mode of comic tile": "漫畫縮圖的顯示模式",

View File

@@ -13,7 +13,7 @@ export "widget_utils.dart";
export "context.dart";
class _App {
final version = "1.5.1";
final version = "1.5.2";
bool get isAndroid => Platform.isAndroid;

View File

@@ -192,6 +192,8 @@ class Settings with ChangeNotifier {
'comicSpecificSettings': <String, Map<String, dynamic>>{},
'ignoreBadCertificate': false,
'readerScrollSpeed': 1.0, // 0.5 - 3.0
'localFavoritesFirst': true,
'autoCloseFavoritePanel': false,
};
operator [](String key) {

View File

@@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:convert';
import 'dart:ffi';
import 'dart:isolate';
@@ -213,12 +214,10 @@ class LocalFavoritesManager with ChangeNotifier {
late Map<String, int> counts;
var _hashedIds = <int, int>{};
int get totalComics {
int total = 0;
for (var t in counts.values) {
total += t;
}
return total;
return _hashedIds.length;
}
int folderComics(String folder) {
@@ -280,6 +279,48 @@ class LocalFavoritesManager with ChangeNotifier {
for (var folder in folderNames) {
counts[folder] = count(folder);
}
_initHashedIds(folderNames, _db.handle).then((value) {
_hashedIds = value;
notifyListeners();
});
}
void refreshHashedIds() {
_initHashedIds(folderNames, _db.handle).then((value) {
_hashedIds = value;
notifyListeners();
});
}
void reduceHashedId(String id, int type) {
var hash = id.hashCode ^ type;
if (_hashedIds.containsKey(hash)) {
if (_hashedIds[hash]! > 1) {
_hashedIds[hash] = _hashedIds[hash]! - 1;
} else {
_hashedIds.remove(hash);
}
}
}
static Future<Map<int, int>> _initHashedIds(
List<String> folders, Pointer<void> p) {
return Isolate.run(() {
var db = sqlite3.fromPointer(p);
var hashedIds = <int, int>{};
for (var folder in folders) {
var rows = db.select("""
select id, type from "$folder";
""");
for (var row in rows) {
var id = row["id"] as String;
var type = row["type"] as int;
var hash = id.hashCode ^ type;
hashedIds[hash] = (hashedIds[hash] ?? 0) + 1;
}
}
return hashedIds;
});
}
List<String> find(String id, ComicType type) {
@@ -559,7 +600,6 @@ class LocalFavoritesManager with ChangeNotifier {
/// return true if success, false if already exists
bool addComic(String folder, FavoriteItem comic,
[int? order, String? updateTime]) {
_modifiedAfterLastCache = true;
if (!existsFolder(folder)) {
throw Exception("Folder does not exists");
}
@@ -614,14 +654,14 @@ class LocalFavoritesManager with ChangeNotifier {
} else {
counts[folder] = counts[folder]! + 1;
}
var hash = comic.id.hashCode ^ comic.type.value;
_hashedIds[hash] = (_hashedIds[hash] ?? 0) + 1;
notifyListeners();
return true;
}
void moveFavorite(
String sourceFolder, String targetFolder, String id, ComicType type) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
@@ -655,8 +695,6 @@ class LocalFavoritesManager with ChangeNotifier {
void batchMoveFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
@@ -691,25 +729,15 @@ class LocalFavoritesManager with ChangeNotifier {
_db.execute("COMMIT");
// Update counts
if (counts[targetFolder] == null) {
counts[targetFolder] = count(targetFolder);
} else {
counts[targetFolder] = counts[targetFolder]! + items.length;
}
if (counts[sourceFolder] != null) {
counts[sourceFolder] = counts[sourceFolder]! - items.length;
} else {
counts[sourceFolder] = count(sourceFolder);
}
refreshHashedIds();
notifyListeners();
}
void batchCopyFavorites(
String sourceFolder, String targetFolder, List<FavoriteItem> items) {
_modifiedAfterLastCache = true;
if (!existsFolder(sourceFolder)) {
throw Exception("Source folder does not exist");
}
@@ -740,18 +768,14 @@ class LocalFavoritesManager with ChangeNotifier {
_db.execute("COMMIT");
// Update counts
if (counts[targetFolder] == null) {
counts[targetFolder] = count(targetFolder);
} else {
counts[targetFolder] = counts[targetFolder]! + items.length;
}
refreshHashedIds();
notifyListeners();
}
/// delete a folder
void deleteFolder(String name) {
_modifiedAfterLastCache = true;
_db.execute("""
drop table "$name";
""");
@@ -760,11 +784,11 @@ class LocalFavoritesManager with ChangeNotifier {
where folder_name == ?;
""", [name]);
counts.remove(name);
refreshHashedIds();
notifyListeners();
}
void deleteComicWithId(String folder, String id, ComicType type) {
_modifiedAfterLastCache = true;
LocalFavoriteImageProvider.delete(id, type.value);
_db.execute("""
delete from "$folder"
@@ -775,11 +799,11 @@ class LocalFavoritesManager with ChangeNotifier {
} else {
counts[folder] = count(folder);
}
reduceHashedId(id, type.value);
notifyListeners();
}
void batchDeleteComics(String folder, List<FavoriteItem> comics) {
_modifiedAfterLastCache = true;
_db.execute("BEGIN TRANSACTION");
try {
for (var comic in comics) {
@@ -800,11 +824,13 @@ class LocalFavoritesManager with ChangeNotifier {
return;
}
_db.execute("COMMIT");
for (var comic in comics) {
reduceHashedId(comic.id, comic.type.value);
}
notifyListeners();
}
void batchDeleteComicsInAllFolders(List<ComicID> comics) {
_modifiedAfterLastCache = true;
_db.execute("BEGIN TRANSACTION");
var folderNames = _getFolderNamesWithDB();
try {
@@ -824,6 +850,10 @@ class LocalFavoritesManager with ChangeNotifier {
}
initCounts();
_db.execute("COMMIT");
for (var comic in comics) {
var hash = comic.id.hashCode ^ comic.type.value;
_hashedIds.remove(hash);
}
notifyListeners();
}
@@ -908,7 +938,6 @@ class LocalFavoritesManager with ChangeNotifier {
markAsRead(id, type);
return;
}
_modifiedAfterLastCache = true;
var followUpdatesFolder = appdata.settings['followUpdatesFolder'];
for (final folder in folderNames) {
var rows = _db.select("""
@@ -1029,28 +1058,9 @@ class LocalFavoritesManager with ChangeNotifier {
notifyListeners();
}
final _cachedFavoritedIds = <String, bool>{};
bool isExist(String id, ComicType type) {
if (_modifiedAfterLastCache) {
_cacheFavoritedIds();
}
return _cachedFavoritedIds.containsKey("$id@${type.value}");
}
bool _modifiedAfterLastCache = true;
void _cacheFavoritedIds() {
_modifiedAfterLastCache = false;
_cachedFavoritedIds.clear();
for (var folder in folderNames) {
var rows = _db.select("""
select id, type from "$folder";
""");
for (var row in rows) {
_cachedFavoritedIds["${row["id"]}@${row["type"]}"] = true;
}
}
var hash = id.hashCode ^ type.value;
return _hashedIds.containsKey(hash);
}
void updateInfo(String folder, FavoriteItem comic, [bool notify = true]) {

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/utils/channel.dart';
class ComicUpdateResult {
final bool updated;
@@ -62,6 +63,7 @@ Future<ComicUpdateResult> updateComic(
return ComicUpdateResult(updated, null);
} catch (e, s) {
Log.error("Check Updates", e, s);
await Future.delayed(const Duration(seconds: 2));
retries--;
if (retries == 0) {
return ComicUpdateResult(false, e.toString());
@@ -114,9 +116,36 @@ void updateFolderBase(
current = 0;
stream.add(UpdateProgress(total, current, errors, updated));
var futures = <Future>[];
var channel = Channel<FavoriteItemWithUpdateInfo>(10);
// Producer
() async {
var c = 0;
for (var comic in comicsToUpdate) {
var future = updateComic(comic, folder).then((result) {
await channel.push(comic);
c++;
// Throttle
if (c % 5 == 0) {
var delay = c % 100 + 1;
if (delay > 10) {
delay = 10;
}
await Future.delayed(Duration(seconds: delay));
}
}
channel.close();
}();
// Consumers
var updateFutures = <Future>[];
for (var i = 0; i < 5; i++) {
var f = () async {
while (true) {
var comic = await channel.pop();
if (comic == null) {
break;
}
var result = await updateComic(comic, folder);
current++;
if (result.updated) {
updated++;
@@ -124,13 +153,13 @@ void updateFolderBase(
if (result.errorMessage != null) {
errors++;
}
stream.add(
UpdateProgress(total, current, errors, updated, comic, result.errorMessage));
});
futures.add(future);
stream.add(UpdateProgress(total, current, errors, updated, comic, result.errorMessage));
}
}();
updateFutures.add(f);
}
await Future.wait(futures);
await Future.wait(updateFutures);
if (updated > 0) {
LocalFavoritesManager().notifyChanges();

View File

@@ -423,6 +423,7 @@ class LocalManager with ChangeNotifier {
if (comic.hasChapters) {
var cid =
ep is int ? comic.chapters!.ids.elementAt(ep - 1) : (ep as String);
cid = getChapterDirectoryName(cid);
directory = Directory(FilePath.join(directory.path, cid));
}
var files = <File>[];
@@ -600,7 +601,10 @@ class LocalManager with ChangeNotifier {
}
var shouldRemovedDirs = <Directory>[];
for (var chapter in chapters) {
var dir = Directory(FilePath.join(c.baseDir, chapter));
var dir = Directory(FilePath.join(
c.baseDir,
getChapterDirectoryName(chapter),
));
if (dir.existsSync()) {
shouldRemovedDirs.add(dir);
}
@@ -668,6 +672,21 @@ class LocalManager with ChangeNotifier {
}
});
}
static String getChapterDirectoryName(String name) {
var builder = StringBuffer();
for (var i = 0; i < name.length; i++) {
var char = name[i];
if (char == '/' || char == '\\' || char == ':' || char == '*' ||
char == '?'
|| char == '"' || char == '<' || char == '>' || char == '|') {
builder.write('_');
} else {
builder.write(char);
}
}
return builder.toString();
}
}
enum LocalSortType {

View File

@@ -199,6 +199,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
tertiary = light.tertiary;
}
return MaterialApp(
title: "venera",
home: home,
debugShowCheckedModeBanner: false,
theme: getTheme(primary, secondary, tertiary, Brightness.light),
@@ -246,7 +247,7 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
/// https://github.com/flutter/flutter/issues/161086
var isPaddingCheckError =
MediaQuery.of(context).viewPadding.top <= 0 ||
MediaQuery.of(context).viewPadding.top > 50;
MediaQuery.of(context).viewPadding.top > 200;
if (isPaddingCheckError && Platform.isAndroid) {
widget = MediaQuery(

View File

@@ -107,7 +107,21 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
var local = LocalManager().find(id, comicType);
if (path != null) {
if (local == null) {
Directory(path!).deleteIgnoreError(recursive: true);
Future.sync(() async {
var tasks = this.tasks.values.toList();
for (var i = 0; i < tasks.length; i++) {
if (!tasks[i].isComplete) {
tasks[i].cancel();
await tasks[i].wait();
}
}
try {
await Directory(path!).delete(recursive: true);
}
catch(e) {
Log.error("Download", "Failed to delete directory: $e");
}
});
} else if (chapters != null) {
for (var c in chapters!) {
var dir = Directory(FilePath.join(path!, c));
@@ -197,7 +211,9 @@ class ImagesDownloadTask extends DownloadTask with _TransferSpeedMixin {
if (comic!.chapters != null) {
saveTo = Directory(FilePath.join(
path!,
LocalManager.getChapterDirectoryName(
_images!.keys.elementAt(_chapter),
),
));
if (!saveTo.existsSync()) {
saveTo.createSync(recursive: true);

View File

@@ -56,8 +56,12 @@ abstract mixin class _ComicPageActions {
type: comic.comicType,
isFavorite: isFavorite,
onFavorite: (local, network) {
isFavorite = network ?? isFavorite;
isAddToLocalFav = local ?? isAddToLocalFav;
if (network != null) {
isFavorite = network;
}
if (local != null) {
isAddToLocalFav = local;
}
update();
},
favoriteItem: _toFavoriteItem(),

View File

@@ -1,7 +1,10 @@
import 'dart:async';
import 'dart:collection';
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:photo_view/photo_view.dart';
import 'package:shimmer_animation/shimmer_animation.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:url_launcher/url_launcher_string.dart';
@@ -17,10 +20,12 @@ 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/network/cache.dart';
import 'package:venera/pages/favorites/favorites_page.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/app_links.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/file_type.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/tags_translation.dart';
import 'package:venera/utils/translations.dart';
@@ -38,6 +43,8 @@ part 'comments_preview.dart';
part 'actions.dart';
part 'cover_viewer.dart';
class ComicPage extends StatefulWidget {
const ComicPage({
super.key,
@@ -256,6 +263,18 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
Future<void> onDataLoaded() async {
isLiked = comic.isLiked ?? false;
isFavorite = comic.isFavorite ?? false;
// For sources with multi-folder favorites, prefer querying folders to get accurate favorite status
// Some sources may not set isFavorite reliably when multi-folder is enabled
if (comicSource.favoriteData?.loadFolders != null && comicSource.isLogged) {
var res = await comicSource.favoriteData!.loadFolders!(comic.id);
if (!res.error) {
if (res.subData is List) {
var list = List<String>.from(res.subData);
isFavorite = list.isNotEmpty;
update();
}
}
}
if (comic.chapters == null) {
isDownloaded = LocalManager().isDownloaded(comic.id, comic.comicType, 0);
}
@@ -283,7 +302,10 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
Hero(
GestureDetector(
onTap: () => _viewCover(context),
onLongPress: () => _saveCover(context),
child: Hero(
tag: "cover${widget.heroID}",
child: Container(
decoration: BoxDecoration(
@@ -311,6 +333,7 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
@@ -710,6 +733,54 @@ class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
}
return _CommentsPart(comments: comic.comments!, showMore: showComments);
}
void _viewCover(BuildContext context) {
final imageProvider = CachedImageProvider(
widget.cover ?? comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
);
context.to(
() => _CoverViewer(
imageProvider: imageProvider,
title: comic.title,
heroTag: "cover${widget.heroID}",
),
);
}
void _saveCover(BuildContext context) async {
try {
final imageProvider = CachedImageProvider(
widget.cover ?? comic.cover,
sourceKey: comic.sourceKey,
cid: comic.id,
);
final imageStream = imageProvider.resolve(const ImageConfiguration());
final completer = Completer<Uint8List>();
imageStream.addListener(
ImageStreamListener((ImageInfo info, bool _) async {
final byteData = await info.image.toByteData(
format: ImageByteFormat.png,
);
if (byteData != null) {
completer.complete(byteData.buffer.asUint8List());
}
}),
);
final data = await completer.future;
final fileType = detectFileType(data);
await saveFile(filename: "cover${fileType.ext}", data: data);
} catch (e) {
if (context.mounted) {
context.showMessage(message: "Error".tl);
}
}
}
}
class _ActionButton extends StatelessWidget {

View File

@@ -0,0 +1,140 @@
part of 'comic_page.dart';
class _CoverViewer extends StatefulWidget {
const _CoverViewer({
required this.imageProvider,
required this.title,
required this.heroTag,
});
final ImageProvider imageProvider;
final String title;
final String heroTag;
@override
State<_CoverViewer> createState() => _CoverViewerState();
}
class _CoverViewerState extends State<_CoverViewer> {
bool isAppBarShow = true;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: true,
child: Scaffold(
backgroundColor: context.colorScheme.surface,
body: Stack(
children: [
Positioned.fill(
child: PhotoView(
imageProvider: widget.imageProvider,
minScale: PhotoViewComputedScale.contained * 1.0,
maxScale: PhotoViewComputedScale.covered * 3.0,
backgroundDecoration: BoxDecoration(
color: context.colorScheme.surface,
),
loadingBuilder: (context, event) => Center(
child: SizedBox(
width: 24.0,
height: 24.0,
child: CircularProgressIndicator(
value: event == null || event.expectedTotalBytes == null
? null
: event.cumulativeBytesLoaded /
event.expectedTotalBytes!,
),
),
),
onTapUp: (context, details, controllerValue) {
setState(() {
isAppBarShow = !isAppBarShow;
});
},
heroAttributes: PhotoViewHeroAttributes(tag: widget.heroTag),
),
),
AnimatedPositioned(
top: isAppBarShow ? 0 : -(context.padding.top + 52),
left: 0,
right: 0,
duration: const Duration(milliseconds: 180),
child: _buildAppBar(),
),
],
),
),
);
}
Widget _buildAppBar() {
return Material(
color: context.colorScheme.surface.toOpacity(0.72),
child: BlurEffect(
child: Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: context.colorScheme.outlineVariant,
width: 0.5,
),
),
),
height: 52,
child: Row(
children: [
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
Navigator.of(context).pop();
},
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.title,
style: const TextStyle(fontSize: 18),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.save_alt),
onPressed: _saveCover,
),
const SizedBox(width: 8),
],
),
).paddingTop(context.padding.top),
),
);
}
void _saveCover() async {
try {
final imageStream = widget.imageProvider.resolve(
const ImageConfiguration(),
);
final completer = Completer<Uint8List>();
imageStream.addListener(
ImageStreamListener((ImageInfo info, bool _) async {
final byteData = await info.image.toByteData(
format: ImageByteFormat.png,
);
if (byteData != null) {
completer.complete(byteData.buffer.asUint8List());
}
}),
);
final data = await completer.future;
final fileType = detectFileType(data);
await saveFile(filename: "cover_${widget.title}${fileType.ext}", data: data);
} catch (e) {
if (mounted) {
context.showMessage(message: "Error".tl);
}
}
}
}

View File

@@ -33,198 +33,122 @@ class _FavoritePanelState extends State<_FavoritePanel>
with SingleTickerProviderStateMixin {
late ComicSource comicSource;
late TabController tabController;
late bool hasNetwork;
late List<String> localFolders;
late List<String> added;
@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(
appBar: Appbar(title: Text("Favorite".tl)),
body: _FavoriteList(
cid: widget.cid,
type: widget.type,
isFavorite: widget.isFavorite,
onFavorite: widget.onFavorite,
favoriteItem: widget.favoriteItem,
updateTime: widget.updateTime,
comicSource: comicSource,
hasNetwork: hasNetwork,
localFolders: localFolders,
added: added,
),
);
}
}
class _FavoriteList extends StatefulWidget {
const _FavoriteList({
required this.cid,
required this.type,
required this.isFavorite,
required this.onFavorite,
required this.favoriteItem,
this.updateTime,
required this.comicSource,
required this.hasNetwork,
required this.localFolders,
required this.added,
});
final String cid;
final ComicType type;
final bool? isFavorite;
final void Function(bool?, bool?) onFavorite;
final FavoriteItem favoriteItem;
final String? updateTime;
final ComicSource comicSource;
final bool hasNetwork;
final List<String> localFolders;
final List<String> added;
@override
State<_FavoriteList> createState() => _FavoriteListState();
}
class _FavoriteListState extends State<_FavoriteList> {
@override
Widget build(BuildContext context) {
final localFavoritesFirst = appdata.settings['localFavoritesFirst'] ?? true;
final localSection = _LocalSection(
cid: widget.cid,
type: widget.type,
favoriteItem: widget.favoriteItem,
updateTime: widget.updateTime,
localFolders: widget.localFolders,
added: widget.added,
onFavorite: (local) {
widget.onFavorite(local, null);
},
);
final networkSection = widget.hasNetwork
? _NetworkSection(
cid: widget.cid,
comicSource: widget.comicSource,
isFavorite: widget.isFavorite,
onFavorite: (network) {
widget.onFavorite(null, network);
},
)
: null;
final divider = widget.hasNetwork
? Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: context.colorScheme.outlineVariant.withValues(alpha: 0.3),
)
: null;
return ListView(
children: [
if (localFavoritesFirst) ...[
localSection,
if (widget.hasNetwork) ...[divider!, networkSection!],
] else ...[
if (widget.hasNetwork) ...[networkSection!, divider!],
localSection,
],
],
);
}
}
class _NetworkFavorites extends StatefulWidget {
const _NetworkFavorites({
class _NetworkSection extends StatefulWidget {
const _NetworkSection({
required this.cid,
required this.comicSource,
required this.isFavorite,
@@ -232,82 +156,55 @@ class _NetworkFavorites extends StatefulWidget {
});
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();
State<_NetworkSection> createState() => _NetworkSectionState();
}
class _NetworkSectionState extends State<_NetworkSection> {
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;
bool? localIsFavorite;
final Map<String, bool> _itemLoading = {};
late List<double> _skeletonWidths;
// for network favorites, only one selection is allowed
String? selected;
@override
void initState() {
super.initState();
localIsFavorite = widget.isFavorite;
_skeletonWidths = List.generate(3, (_) => 0.3 + math.Random().nextDouble() * 0.5);
if (widget.comicSource.favoriteData!.loadFolders != null) {
loadFolders();
} else {
isLoadingFolders = false;
}
}
void loadFolders() async {
var res = await widget.comicSource.favoriteData!.loadFolders!(widget.cid);
if (res.error) {
context.showMessage(message: res.errorMessage!);
setState(() {
isLoadingFolders = false;
});
} else {
folders = res.data;
if (res.subData is List) {
addedFolders = List<String>.from(res.subData).toSet();
final list = List<String>.from(res.subData);
if (list.isNotEmpty) {
addedFolders = {list.first};
} else {
addedFolders.clear();
}
localIsFavorite = addedFolders.isNotEmpty;
} else {
addedFolders.clear();
localIsFavorite = false;
}
setState(() {
isLoadingFolders = false;
@@ -315,61 +212,91 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
}
}
Widget buildMultiFolder() {
if (widget.isFavorite == true &&
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
Widget _buildLoadingSkeleton() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Center(
child: Text("Added to favorites".tl),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"Network Favorites".tl,
style: ts.s14.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.primary,
),
),
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),
),
Shimmer(
child: Column(
children: List.generate(3, (index) {
return ListTile(
title: Container(
height: 20,
width: double.infinity,
margin: const EdgeInsets.only(right: 16),
child: FractionallySizedBox(
widthFactor: _skeletonWidths[index],
alignment: Alignment.centerLeft,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(4),
),
),
),
),
trailing: Container(
height: 28,
width: 60 + (index * 2),
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
),
),
);
}),
),
),
],
);
}
@override
Widget build(BuildContext context) {
if (isLoadingFolders) {
loadFolders();
return const Center(child: CircularProgressIndicator());
return _buildLoadingSkeleton();
}
bool isMultiFolder = widget.comicSource.favoriteData!.loadFolders != null;
if (isMultiFolder) {
return _buildMultiFolder();
} else {
return _buildSingleFolder();
}
}
Widget _buildSingleFolder() {
var isFavorite = localIsFavorite ?? false;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
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(
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"Network Favorites".tl,
style: ts.s14.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.primary,
),
),
),
ListTile(
title: Row(
children: [
Text(name),
Text("Network Favorites".tl),
const SizedBox(width: 8),
if (addedFolders.contains(id))
if (isFavorite)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
@@ -383,50 +310,372 @@ class _NetworkFavoritesState extends State<_NetworkFavorites> {
),
],
),
value: selected == id,
onChanged: (v) {
setState(() {
selected = id;
});
},
);
},
),
),
Center(
child: Button.filled(
isLoading: isLoading,
onPressed: () async {
if (selected == null) {
return;
}
trailing: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _HoverButton(
isFavorite: isFavorite,
onTap: () async {
setState(() {
isLoading = true;
});
var res =
await widget.comicSource.favoriteData!.addOrDelFavorite!(
widget.cid,
selected!,
!addedFolders.contains(selected!),
null,
);
var res = await widget
.comicSource
.favoriteData!
.addOrDelFavorite!(widget.cid, '', !isFavorite, null);
if (res.success) {
context.showMessage(message: "Success".tl);
setState(() {
localIsFavorite = !isFavorite;
});
widget.onFavorite(!isFavorite);
App.rootContext.showMessage(
message: isFavorite ? "Removed".tl : "Added".tl,
);
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop();
}
} else {
context.showMessage(message: res.errorMessage!);
}
setState(() {
isLoading = false;
});
},
),
),
],
);
}
Widget _buildMultiFolder() {
if (localIsFavorite == true &&
widget.comicSource.favoriteData!.singleFolderForSingleComic) {
return ListTile(
title: Row(
children: [
Text("Network Favorites".tl),
const SizedBox(width: 8),
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),
),
],
),
trailing: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _HoverButton(
isFavorite: true,
onTap: () async {
setState(() {
isLoading = true;
});
var res = await widget
.comicSource
.favoriteData!
.addOrDelFavorite!(widget.cid, '', false, null);
if (res.success) {
// Invalidate network cache so subsequent loads see latest
NetworkCacheManager().clear();
setState(() {
localIsFavorite = false;
});
widget.onFavorite(false);
App.rootContext.showMessage(message: "Removed".tl);
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop();
}
} else {
context.showMessage(message: res.errorMessage!);
}
setState(() {
isLoading = false;
});
},
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"Network Favorites".tl,
style: ts.s14.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.primary,
),
),
),
...folders!.entries.map((entry) {
var name = entry.value;
var id = entry.key;
var isAdded = addedFolders.contains(id);
var hasSelection = addedFolders.isNotEmpty;
var enabled = !hasSelection || isAdded;
return ListTile(
title: Row(
children: [
Text(name),
const SizedBox(width: 8),
if (isAdded)
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),
),
],
),
trailing: (_itemLoading[id] ?? false)
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _HoverButton(
isFavorite: isAdded,
enabled: enabled,
onTap: () async {
setState(() {
_itemLoading[id] = true;
});
var res = await widget
.comicSource
.favoriteData!
.addOrDelFavorite!(widget.cid, id, !isAdded, null);
if (res.success) {
// Invalidate network cache so folders/pages reload with fresh data
NetworkCacheManager().clear();
setState(() {
if (isAdded) {
addedFolders.clear();
} else {
addedFolders
..clear()
..add(id);
}
// sync local flag for single-folder-per-comic logic and parent
localIsFavorite = addedFolders.isNotEmpty;
});
// notify parent so page state updates when closing and reopening panel
widget.onFavorite(addedFolders.isNotEmpty);
context.showMessage(message: "Success".tl);
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop();
}
} else {
context.showMessage(message: res.errorMessage!);
}
setState(() {
_itemLoading[id] = false;
});
},
),
);
}),
],
);
}
}
class _LocalSection extends StatefulWidget {
const _LocalSection({
required this.cid,
required this.type,
required this.favoriteItem,
this.updateTime,
required this.localFolders,
required this.added,
required this.onFavorite,
});
final String cid;
final ComicType type;
final FavoriteItem favoriteItem;
final String? updateTime;
final List<String> localFolders;
final List<String> added;
final void Function(bool) onFavorite;
@override
State<_LocalSection> createState() => _LocalSectionState();
}
class _LocalSectionState extends State<_LocalSection> {
late List<String> localFolders;
late Set<String> localAdded;
@override
void initState() {
super.initState();
localFolders = widget.localFolders;
localAdded = widget.added.toSet();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"Local Favorites".tl,
style: ts.s14.copyWith(
fontWeight: FontWeight.w600,
color: context.colorScheme.primary,
),
),
),
...localFolders.map((folder) {
var isAdded = localAdded.contains(folder);
return ListTile(
title: Row(
children: [
Text(folder),
const SizedBox(width: 8),
if (isAdded)
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),
),
],
),
trailing: _HoverButton(
isFavorite: isAdded,
onTap: () {
if (isAdded) {
LocalFavoritesManager().deleteComicWithId(
folder,
widget.cid,
widget.type,
);
setState(() {
localAdded.remove(folder);
});
widget.onFavorite(false);
} else {
LocalFavoritesManager().addComic(
folder,
widget.favoriteItem,
null,
widget.updateTime,
);
setState(() {
localAdded.add(folder);
});
widget.onFavorite(true);
}
if (appdata.settings['autoCloseFavoritePanel'] ?? false) {
context.pop();
}
},
child: selected != null && addedFolders.contains(selected!)
? Text("Remove".tl)
: Text("Add".tl),
).paddingVertical(8),
),
);
}),
// New folder button
ListTile(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add, size: 20),
const SizedBox(width: 4),
Text("New Folder".tl),
],
),
onTap: () {
newFolder().then((v) {
setState(() {
localFolders = LocalFavoritesManager().folderNames;
});
});
},
),
],
);
}
}
class _HoverButton extends StatefulWidget {
const _HoverButton({
required this.isFavorite,
required this.onTap,
this.enabled = true,
});
final bool isFavorite;
final VoidCallback onTap;
final bool enabled;
@override
State<_HoverButton> createState() => _HoverButtonState();
}
class _HoverButtonState extends State<_HoverButton> {
bool isHovered = false;
@override
Widget build(BuildContext context) {
final removeColor = context.colorScheme.error;
final removeHoverColor = Color.lerp(removeColor, Colors.black, 0.2)!;
final addColor = context.colorScheme.primary;
final addHoverColor = Color.lerp(addColor, Colors.black, 0.2)!;
return MouseRegion(
onEnter: widget.enabled ? (_) => setState(() => isHovered = true) : null,
onExit: widget.enabled ? (_) => setState(() => isHovered = false) : null,
child: GestureDetector(
onTap: widget.enabled ? widget.onTap : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: widget.enabled
? (widget.isFavorite
? (isHovered ? removeHoverColor : removeColor)
: (isHovered ? addHoverColor : addColor))
: context.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(12),
),
child: Text(
widget.isFavorite ? "Remove".tl : "Add".tl,
style: ts.s12.copyWith(
color: widget.enabled
? context.colorScheme.onPrimary
: context.colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}

View File

@@ -15,6 +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/network/cache.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';

View File

@@ -36,6 +36,8 @@ Future<bool> _deleteComic(
favId,
);
if (res.success) {
// Invalidate network cache so next loads fetch fresh data
NetworkCacheManager().clear();
context.showMessage(message: "Deleted".tl);
result = true;
context.pop();
@@ -115,6 +117,8 @@ class _NormalFavoritePageState extends State<_NormalFavoritePage> {
child: IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
// Force refresh bypassing cache
NetworkCacheManager().clear();
comicListKey.currentState!.refresh();
},
),

View File

@@ -13,6 +13,14 @@ class _LocalFavoritesSettingsState extends State<LocalFavoritesSettings> {
return SmoothCustomScrollView(
slivers: [
SliverAppbar(title: Text("Local Favorites".tl)),
_SwitchSetting(
title: "Show local favorites before network favorites".tl,
settingKey: "localFavoritesFirst",
).toSliver(),
_SwitchSetting(
title: "Auto close favorite panel after operation".tl,
settingKey: "autoCloseFavoritePanel",
).toSliver(),
SelectSetting(
title: "Add new favorite to".tl,
settingKey: "newFavoriteAddTo",

58
lib/utils/channel.dart Normal file
View File

@@ -0,0 +1,58 @@
import 'dart:async';
import 'dart:collection';
class Channel<T> {
final Queue<T> _queue;
final int size;
Channel(this.size) : _queue = Queue<T>();
Completer? _releaseCompleter;
Completer? _pushCompleter;
var currentSize = 0;
var isClosed = false;
Future<void> push(T item) async {
if (currentSize >= size) {
_releaseCompleter ??= Completer();
return _releaseCompleter!.future.then((_) {
if (isClosed) {
return;
}
_queue.addLast(item);
currentSize++;
});
}
_queue.addLast(item);
currentSize++;
_pushCompleter?.complete();
_pushCompleter = null;
}
Future<T?> pop() async {
while (_queue.isEmpty) {
if (isClosed) {
return null;
}
_pushCompleter ??= Completer();
await _pushCompleter!.future;
}
var item = _queue.removeFirst();
currentSize--;
if (_releaseCompleter != null && currentSize < size) {
_releaseCompleter!.complete();
_releaseCompleter = null;
}
return item;
}
void close() {
isClosed = true;
_pushCompleter?.complete();
_releaseCompleter?.complete();
}
}

28
patch/font.dart Normal file
View File

@@ -0,0 +1,28 @@
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:dio/dio.dart';
void main() async {
const harmonySansLink = "https://developer.huawei.com/images/download/general/HarmonyOS-Sans.zip";
var dio = Dio();
await dio.download(harmonySansLink, "HarmonyOS-Sans.zip");
await extractFileToDisk("HarmonyOS-Sans.zip", "./assets/");
File("HarmonyOS-Sans.zip").deleteSync();
var pubspec = await File("pubspec.yaml").readAsString();
pubspec = pubspec.replaceFirst("# fonts:",
""" fonts:
- family: HarmonyOS Sans
fonts:
- asset: assets/HarmonyOS Sans/HarmonyOS_Sans_SC/HarmonyOS_Sans_SC_Regular.ttf
""");
await File("pubspec.yaml").writeAsString(pubspec);
var mainDart = await File("lib/main.dart").readAsString();
mainDart = mainDart.replaceFirst("Noto Sans CJK", "HarmonyOS Sans");
await File("lib/main.dart").writeAsString(mainDart);
print("Successfully patched font.");
}

View File

@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
archive:
dependency: "direct dev"
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
args:
dependency: transitive
description:
@@ -770,6 +778,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
posix:
dependency: transitive
description:
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
rhttp:
dependency: "direct main"
description:
@@ -1116,4 +1132,4 @@ packages:
version: "0.0.12"
sdks:
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.35.2"
flutter: ">=3.35.5"

View File

@@ -2,11 +2,11 @@ name: venera
description: "A comic app."
publish_to: 'none'
version: 1.5.1+151
version: 1.5.2+152
environment:
sdk: '>=3.8.0 <4.0.0'
flutter: 3.35.3
flutter: 3.35.5
dependencies:
flutter:
@@ -94,6 +94,7 @@ dev_dependencies:
flutter_lints: ^5.0.0
flutter_to_arch: ^1.0.1
flutter_to_debian: ^2.0.2
archive: any
flutter:
uses-material-design: true
@@ -104,6 +105,7 @@ flutter:
- assets/tags.json
- assets/tags_tw.json
- assets/opencc.txt
# fonts:
flutter_to_arch:
name: Venera

115
test/channel_test.dart Normal file
View File

@@ -0,0 +1,115 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:venera/utils/channel.dart';
void main() {
test("1-1-1", () async {
var channel = Channel<int>(1);
await channel.push(1);
var item = await channel.pop();
expect(item, 1);
});
test("1-3-1", () async {
var channel = Channel<int>(1);
// producer
() async {
await channel.push(1);
}();
() async {
await channel.push(2);
}();
() async {
await channel.push(3);
}();
// consumer
var results = <int>[];
for (var i = 0; i < 3; i++) {
var item = await channel.pop();
if (item != null) {
results.add(item);
}
}
expect(results.length, 3);
});
test("2-3-1", () async {
var channel = Channel<int>(2);
// producer
() async {
await channel.push(1);
}();
() async {
await channel.push(2);
}();
() async {
await channel.push(3);
}();
// consumer
var results = <int>[];
for (var i = 0; i < 3; i++) {
var item = await channel.pop();
if (item != null) {
results.add(item);
}
}
expect(results.length, 3);
});
test("1-1-3", () async {
var channel = Channel<int>(1);
// producer
() async {
print("push 1");
await channel.push(1);
print("push 2");
await channel.push(2);
print("push 3");
await channel.push(3);
print("push done");
channel.close();
}();
// consumer
var consumers = <Future>[];
var results = <int>[];
for (var i = 0; i < 3; i++) {
consumers.add(() async {
while (true) {
var item = await channel.pop();
if (item == null) {
break;
}
print("pop $item");
results.add(item);
}
}());
}
await Future.wait(consumers);
expect(results.length, 3);
});
test("close", () async {
var channel = Channel<int>(2);
// producer
() async {
await channel.push(1);
await channel.push(2);
await channel.push(3);
channel.close();
}();
// consumer
await channel.pop();
await channel.pop();
await channel.pop();
var item4 = await channel.pop();
expect(item4, null);
});
}