Add cxyz Android project structure and initial configuration

- Introduced a new Flutter project for the cxyz Android manga/comic reader, including essential files such as pubspec.yaml, AndroidManifest.xml, and Gradle configuration.
- Added .gitignore files to exclude unnecessary files and directories for both the main project and Android-specific files.
- Implemented initial README.md with project description, features, setup instructions, and API details for ComicLand, MangaRead, and MangaTown.
- Configured analysis options and dependencies in pubspec.yaml to support Flutter development.
- Established a pnpm workspace and lockfile for dependency management.
- Included various Android resources such as icons, styles, and launch backgrounds for a complete application setup.
This commit is contained in:
2026-08-29 17:50:31 +07:00
parent 6e7f1ced35
commit a7c6058540
105 changed files with 16721 additions and 275 deletions
+86
View File
@@ -0,0 +1,86 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ / Android Studio
*.iml
*.ipr
*.iws
.idea/
# VS Code (uncomment to ignore local workspace settings)
#.vscode/
# Flutter / Dart / Pub
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
.fvm/
build/
coverage/
.widget_preview/
# Local analysis / temp HOME used by scripts and tooling
.tmp-home/
.tmp/
# Symbolication / obfuscation
app.*.symbols
app.*.map.json
# Android build outputs (also covered under android/)
/android/app/debug
/android/app/profile
/android/app/release
**/android/**/gradle-wrapper.jar
**/android/.gradle/
**/android/captures/
**/android/local.properties
**/android/**/GeneratedPluginRegistrant.java
**/android/.cxx/
# Signing — never commit keystores or local key config
**/android/key.properties
**/*.keystore
**/*.jks
*.apk
*.aab
*.ap_
# Secrets / local env
.env
.env.*
!.env.example
# Test / coverage extras
coverage/
.test_coverage/
*.lcov
# Platform ephemeral (if added later)
**/ios/Pods/
**/ios/.symlinks/
**/ios/Flutter/Flutter.framework
**/ios/Flutter/Flutter.podspec
**/ios/Flutter/Generated.xcconfig
**/ios/Flutter/ephemeral/
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
**/macos/Flutter/ephemeral/
**/linux/flutter/ephemeral/
**/windows/flutter/ephemeral/
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "6655482ec06e547f90abf8ae7590466f4415978d"
channel: "[user-branch]"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 6655482ec06e547f90abf8ae7590466f4415978d
base_revision: 6655482ec06e547f90abf8ae7590466f4415978d
- platform: android
create_revision: 6655482ec06e547f90abf8ae7590466f4415978d
base_revision: 6655482ec06e547f90abf8ae7590466f4415978d
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+91
View File
@@ -0,0 +1,91 @@
# cxyz Android (Flutter)
On-device manga/comic reader/downloader — Flutter reimplementation of [cxyz](../) adapters for Android.
## Features (v1)
- **Catalog**: source picker → per-source menus (not one shared tab bar)
- **Sources** (build-time): ComicLand, MangaRead, and/or MangaTown via `--dart-define=SOURCES=…` (or `SOURCES=all`)
- **Series**: chapter list, sync, multi-select / download-all (unread filter)
- **Reader**: gapless vertical webtoon + horizontal paged mode; progress + star
- **Library**: starred series only; **History**: recently read
- **Downloads**: running job + queue link; CBZ under public `Download/Comics/`
- **Settings**: RTL + WebDAV sync (progress, library, history)
## Stack
Flutter 3 · Riverpod · go_router · Dio · Drift (SQLite) · archive (CBZ) · cached_network_image · html
## Setup
Use a local Flutter SDK (this repo may use `../.tools/flutter-sdk` on the machine that cloned it):
```bash
export PATH="/Users/Shared/LocalData/Projects/cxyz/.tools/bin:/Users/Shared/LocalData/Projects/cxyz/.tools/flutter-sdk/bin:$PATH"
export CI=true BOT=true
cd cxyz-android
flutter pub get
dart run build_runner build --delete-conflicting-outputs
flutter run
```
Sideload APK only (not Play Store–friendly).
## Build-time sources (`SOURCES`)
Compile with Flutter `--dart-define`. Unset / invalid → **ComicLand only** (not all sources).
Prefer the helper (copies a tagged filename and prints the path):
```bash
./scripts/build_apk.sh # → …/cxyz-comicland-release.apk
./scripts/build_apk.sh mangaread # → …/cxyz-mangaread-release.apk
./scripts/build_apk.sh comicland,mangaread # → …/cxyz-comicland-mangaread-release.apk
./scripts/build_apk.sh all # → …/cxyz-all-release.apk
./scripts/build_apk.sh mangaread --debug # → …/cxyz-mangaread-debug.apk
```
Or call Flutter directly (always writes `app-release.apk` / `app-debug.apk`):
```bash
flutter build apk
flutter build apk --dart-define=SOURCES=mangaread
flutter build apk --dart-define=SOURCES=comicland,mangaread,mangatown
flutter build apk --dart-define=SOURCES=all
flutter run --dart-define=SOURCES=all
```
With two or more sources enabled, Catalog opens a **source picker**; each source shows only its own menus (e.g. ComicLand: Newest / Popular / …; MangaRead: Latest / Search; MangaTown: Latest / Hot / Directory / …). A single-source build skips the picker and opens that source’s home.
## ComicLand API
Base: `https://api.comicland.org/api`
| Endpoint | Use |
|---|---|
| `GET /comics/popular` | Popular shelf |
| `GET /comics?offset&limit` | Latest / browse |
| `GET /comic/search?q&offset&limit` | Search |
| `GET /comic/detail?slug=` | Series + chapters |
| `GET /chapter/pages_by_index?slug&index` | Page image URLs |
CDN images need Referer/Origin `https://comicland.org/`.
## MangaRead (HTML / Madara)
Origin: `https://www.mangaread.org/`
- Series: listing HTML `li.wp-manga-chapter`, fallback `POST …/ajax/chapters/`
- Pages: chapter HTML with mobile UA → `img.wp-manga-chapter-img`
- Catalog: Latest (`/manga/?m_orderby=latest`) + Search (`?s=&post_type=wp-manga`)
- Cloudflare: direct Dio only; HTTP 403 surfaces a clear error (no FlareSolverr on phone)
## MangaTown (HTML / mobile)
Origin: `https://m.mangatown.com`
- Catalog: Latest (`/latest`), Hot (`/hot`, `?tviews.za` / `?wviews.za` / `?mviews.za`), Directory (`/directory/{demo}-{genre}-0-{status}-{letter}-0`), Search (`/search?name=`)
- Series: `/manga/{slug}` → `ul.detail-ch-list`
- Pages: one image per reader page (`#viewer img#image`); page N at `/manga/{slug}/c005/N.html`
- CDN images need Referer `https://m.mangatown.com/`
+19
View File
@@ -0,0 +1,19 @@
# Extra analysis options for Drift / Flutter.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
errors:
invalid_annotation_target: ignore
linter:
rules:
prefer_const_constructors: false
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+49
View File
@@ -0,0 +1,49 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "org.cxyz.cxyz_android"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "org.cxyz.cxyz_android"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 26
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,52 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28"/>
<application
android:label="cxyz"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,153 @@
package org.cxyz.cxyz_android
import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
class MainActivity : FlutterActivity() {
private val channelName = "org.cxyz.cxyz_android/cbz_store"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"saveCbzFromPath" -> {
val relativeDir = call.argument<String>("relativeDir")
val displayName = call.argument<String>("displayName")
val sourcePath = call.argument<String>("sourcePath")
if (relativeDir == null || displayName == null || sourcePath == null) {
result.error(
"bad_args",
"relativeDir, displayName, sourcePath required",
null,
)
return@setMethodCallHandler
}
try {
result.success(saveCbzFromPath(relativeDir, displayName, sourcePath))
} catch (e: Exception) {
result.error("save_failed", e.message, null)
}
}
"readCbzToPath" -> {
val uriOrPath = call.argument<String>("uri")
val destPath = call.argument<String>("destPath")
if (uriOrPath == null || destPath == null) {
result.error("bad_args", "uri and destPath required", null)
return@setMethodCallHandler
}
try {
result.success(readCbzToPath(uriOrPath, destPath))
} catch (e: Exception) {
result.error("read_failed", e.message, null)
}
}
"deleteCbz" -> {
val uriOrPath = call.argument<String>("uri")
if (uriOrPath == null) {
result.error("bad_args", "uri required", null)
return@setMethodCallHandler
}
try {
result.success(deleteCbz(uriOrPath))
} catch (e: Exception) {
result.error("delete_failed", e.message, null)
}
}
"sdkInt" -> result.success(Build.VERSION.SDK_INT)
else -> result.notImplemented()
}
}
}
private fun saveCbzFromPath(
relativeDir: String,
displayName: String,
sourcePath: String,
): String {
val source = File(sourcePath)
if (!source.exists()) {
throw IllegalStateException("Source file missing: $sourcePath")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = applicationContext.contentResolver
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, "application/vnd.comicbook+zip")
put(
MediaStore.MediaColumns.RELATIVE_PATH,
"${Environment.DIRECTORY_DOWNLOADS}/$relativeDir",
)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val uri = resolver.insert(collection, values)
?: throw IllegalStateException("MediaStore insert returned null")
resolver.openOutputStream(uri)?.use { out ->
FileInputStream(source).use { input -> input.copyTo(out) }
out.flush()
} ?: throw IllegalStateException("Could not open output stream for $uri")
values.clear()
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(uri, values, null, null)
return uri.toString()
}
val downloads =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val dir = File(downloads, relativeDir)
if (!dir.exists() && !dir.mkdirs()) {
throw IllegalStateException("Could not create ${dir.absolutePath}")
}
val file = File(dir, displayName)
FileInputStream(source).use { input ->
FileOutputStream(file).use { out ->
input.copyTo(out)
out.flush()
}
}
return file.absolutePath
}
private fun readCbzToPath(uriOrPath: String, destPath: String): String {
val dest = File(destPath)
dest.parentFile?.mkdirs()
if (uriOrPath.startsWith("content://")) {
val uri = Uri.parse(uriOrPath)
applicationContext.contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(dest).use { out ->
input.copyTo(out)
out.flush()
}
} ?: throw IllegalStateException("Could not open $uriOrPath")
} else {
File(uriOrPath).inputStream().use { input ->
FileOutputStream(dest).use { out ->
input.copyTo(out)
out.flush()
}
}
}
return dest.absolutePath
}
private fun deleteCbz(uriOrPath: String): Boolean {
return if (uriOrPath.startsWith("content://")) {
val uri = Uri.parse(uriOrPath)
applicationContext.contentResolver.delete(uri, null, null) > 0
} else {
val file = File(uriOrPath)
!file.exists() || file.delete()
}
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+59
View File
@@ -0,0 +1,59 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
// flutter_secure_storage 11 requests compileSdk 37; some SDKs install that
// platform as android-37.0, which AGP cannot resolve. Cap plugins at 36.
subprojects {
afterEvaluate {
val android = project.extensions.findByName("android") ?: return@afterEvaluate
try {
val method = android.javaClass.methods.firstOrNull {
it.name == "setCompileSdkVersion" || it.name == "setCompileSdk"
}
// Prefer the Int setter used by AGP 8+
val setCompileSdk = android.javaClass.methods.firstOrNull { m ->
m.name == "setCompileSdk" && m.parameterTypes.size == 1
}
if (setCompileSdk != null) {
val current = android.javaClass.methods
.firstOrNull { it.name == "getCompileSdk" && it.parameterCount == 0 }
?.invoke(android)
val api = when (current) {
is Int -> current
is String -> current.toIntOrNull() ?: 0
else -> 0
}
if (api > 36) {
setCompileSdk.invoke(android, 36)
}
} else if (method != null) {
method.invoke(android, 36)
}
} catch (_: Exception) {
// Non-Android subprojects — ignore.
}
}
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 975 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 B

+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers/providers.dart';
import 'router.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const ProviderScope(child: CxyzApp()));
}
class CxyzApp extends ConsumerStatefulWidget {
const CxyzApp({super.key});
@override
ConsumerState<CxyzApp> createState() => _CxyzAppState();
}
class _CxyzAppState extends ConsumerState<CxyzApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.read(webDavSyncProvider).scheduleSync();
}
}
@override
Widget build(BuildContext context) {
final router = ref.watch(routerProvider);
return MaterialApp.router(
title: 'cxyz',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1B6CA8),
brightness: Brightness.light,
),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1B6CA8),
brightness: Brightness.dark,
),
useMaterial3: true,
),
routerConfig: router,
);
}
}
+129
View File
@@ -0,0 +1,129 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/database.dart';
import '../services/cbz_store.dart';
import '../services/download_queue.dart';
import '../services/image_headers.dart';
import '../services/ntfy_notifier.dart';
import '../services/peek_mode.dart';
import '../services/reader_direction.dart';
import '../services/webdav_sync.dart';
import '../sources/registry.dart';
final sourceRegistryProvider = Provider<SourceRegistry>((ref) {
return SourceRegistry();
});
final databaseProvider = Provider<AppDatabase>((ref) {
final db = AppDatabase();
ref.onDispose(db.close);
return db;
});
final cbzStoreProvider = Provider<CbzStore>((ref) => CbzStore());
final ntfyNotifierProvider = Provider<NtfyNotifier>((ref) {
return NtfyNotifier(store: ref.watch(cbzStoreProvider));
});
final downloadQueueProvider = ChangeNotifierProvider<DownloadQueue>((ref) {
final queue = DownloadQueue(
db: ref.watch(databaseProvider),
sources: ref.watch(sourceRegistryProvider),
store: ref.watch(cbzStoreProvider),
ntfy: ref.watch(ntfyNotifierProvider),
);
// Clear leftover queue rows for chapters already on disk (e.g. older builds).
unawaited(queue.reconcileStaleJobs());
return queue;
});
/// Per-request headers come from [ComicCover] / reader via `httpHeaders`.
final imageCacheManagerProvider = Provider<HeaderCacheManager>((ref) {
return HeaderCacheManager(const {});
});
final libraryTitlesProvider = StreamProvider<List<Title>>((ref) {
final db = ref.watch(databaseProvider);
return (db.select(db.titles)
..where((t) => t.inLibrary.equals(true))
..orderBy([(t) => OrderingTerm.asc(t.title)]))
.watch();
});
final historyTitlesProvider = StreamProvider<List<Title>>((ref) {
final db = ref.watch(databaseProvider);
return (db.select(db.titles)
..where((t) => t.lastReadAt.isNotNull())
..orderBy([(t) => OrderingTerm.desc(t.lastReadAt)]))
.watch();
});
final queueJobsProvider = StreamProvider<List<QueueJobView>>((ref) {
final db = ref.watch(databaseProvider);
return db.watchQueueJobs();
});
final downloadedSeriesProvider =
StreamProvider<List<DownloadedSeriesGroup>>((ref) {
final db = ref.watch(databaseProvider);
return db.watchDownloadedSeries();
});
class PeekModeNotifier extends StateNotifier<bool> {
PeekModeNotifier() : super(false) {
_load();
}
Future<void> _load() async {
state = await PeekMode.load();
}
Future<void> toggle() async {
state = !state;
await PeekMode.save(state);
}
Future<void> setEnabled(bool enabled) async {
state = enabled;
await PeekMode.save(enabled);
}
}
final peekModeProvider =
StateNotifierProvider<PeekModeNotifier, bool>((ref) => PeekModeNotifier());
class ReadingDirectionNotifier extends StateNotifier<ReadingDirection> {
ReadingDirectionNotifier() : super(ReadingDirection.ltr) {
_load();
}
Future<void> _load() async {
state = await ReaderDirection.load();
}
Future<void> setDirection(ReadingDirection direction) async {
state = direction;
await ReaderDirection.save(direction);
}
Future<void> toggle() async {
await setDirection(
state == ReadingDirection.ltr
? ReadingDirection.rtl
: ReadingDirection.ltr,
);
}
}
final readingDirectionProvider =
StateNotifierProvider<ReadingDirectionNotifier, ReadingDirection>(
(ref) => ReadingDirectionNotifier(),
);
final webDavSyncProvider = Provider<WebDavSyncService>((ref) {
return WebDavSyncService(ref.watch(databaseProvider));
});
+155
View File
@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../services/reader_direction.dart';
import '../sources/source.dart';
import '../sources/source_flags.dart';
import '../ui/catalog/source_picker_page.dart';
import '../ui/catalog_page.dart';
import '../ui/downloads_page.dart';
import '../ui/history_page.dart';
import '../ui/library_page.dart';
import '../ui/reader_page.dart';
import '../ui/settings_page.dart';
import '../ui/shell.dart';
import '../ui/title_page.dart';
final _rootKey = GlobalKey<NavigatorState>();
SourceId? _parseSourceId(String? raw) {
if (raw == null) return null;
return SourceId.tryParse(raw);
}
CatalogMenuKind? _parseMenu(String? raw) {
if (raw == null) return null;
for (final k in CatalogMenuKind.values) {
if (k.pathSegment == raw) return k;
}
return null;
}
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
navigatorKey: _rootKey,
initialLocation: '/catalog',
routes: [
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
return AppShell(navigationShell: navigationShell);
},
branches: [
StatefulShellBranch(
routes: [
// Sibling routes (not nested under picker) so back stack is clean.
GoRoute(
path: '/catalog',
redirect: (context, state) {
final enabled = enabledSourceIds();
if (enabled.length == 1) {
return '/catalog/${enabled.first.name}';
}
return null;
},
builder: (context, state) => const SourcePickerPage(),
),
GoRoute(
path: '/catalog/:sourceId',
builder: (context, state) {
final id = _parseSourceId(state.pathParameters['sourceId']);
if (id == null) {
return const Scaffold(
body: Center(child: Text('Unknown source')),
);
}
final single = enabledSourceIds().length == 1;
return SourceHomePage(
sourceId: id,
embeddedAsRoot: single,
);
},
routes: [
GoRoute(
path: ':menu',
builder: (context, state) {
final id =
_parseSourceId(state.pathParameters['sourceId']);
final menu = _parseMenu(state.pathParameters['menu']);
if (id == null || menu == null) {
return const Scaffold(
body: Center(child: Text('Unknown catalog feed')),
);
}
return CatalogFeedPage(sourceId: id, menuKind: menu);
},
),
],
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/library',
builder: (context, state) => const LibraryPage(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/history',
builder: (context, state) => const HistoryPage(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/downloads',
builder: (context, state) => const DownloadsPage(),
routes: [
GoRoute(
path: 'queue',
builder: (context, state) => const DownloadsQueuePage(),
),
],
),
],
),
],
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
),
GoRoute(
path: '/title/:id',
builder: (context, state) {
final id = int.parse(state.pathParameters['id']!);
return TitlePage(titleId: id);
},
),
GoRoute(
path: '/read/:titleId/:chapterId',
builder: (context, state) {
final titleId = int.parse(state.pathParameters['titleId']!);
final chapterId = int.parse(state.pathParameters['chapterId']!);
final modeParam = state.uri.queryParameters['mode'];
ReaderMode? initialMode;
if (modeParam == 'paged') {
initialMode = ReaderMode.paged;
} else if (modeParam == 'webtoon') {
initialMode = ReaderMode.webtoon;
}
return ReaderPage(
titleId: titleId,
chapterId: chapterId,
initialMode: initialMode,
);
},
),
],
);
});
+388
View File
@@ -0,0 +1,388 @@
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:dio/dio.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import '../sources/comicland/urls.dart';
import '../sources/source.dart';
/// Saves CBZs to public Download/Comics via a native MediaStore channel.
///
/// Transfers use temp file paths (not byte arrays) to avoid binder size limits.
class PublicCbzStore {
PublicCbzStore({MethodChannel? channel})
: _channel = channel ??
const MethodChannel('org.cxyz.cxyz_android/cbz_store');
final MethodChannel _channel;
int? _sdkInt;
Future<int> sdkInt() async {
if (_sdkInt != null) return _sdkInt!;
try {
final v = await _channel.invokeMethod<int>('sdkInt');
_sdkInt = v ?? 30;
} catch (_) {
_sdkInt = 30;
}
return _sdkInt!;
}
/// Request WRITE_EXTERNAL_STORAGE on API 26–28 only.
Future<void> ensureWritePermission() async {
if (!Platform.isAndroid) return;
final sdk = await sdkInt();
if (sdk >= 29) return;
final status = await Permission.storage.request();
if (!status.isGranted) {
throw StateError('Storage permission is required to save CBZ files');
}
}
Future<String> saveCbzFromPath({
required String seriesDirName,
required String displayName,
required String sourcePath,
}) async {
await ensureWritePermission();
final relativeDir = 'Comics/$seriesDirName';
final uri = await _channel.invokeMethod<String>('saveCbzFromPath', {
'relativeDir': relativeDir,
'displayName': displayName,
'sourcePath': sourcePath,
});
if (uri == null || uri.isEmpty) {
throw StateError('Native saveCbzFromPath returned empty uri');
}
return uri;
}
Future<Uint8List> readCbz(String uriOrPath) async {
if (!uriOrPath.startsWith('content://')) {
return File(uriOrPath).readAsBytes();
}
final tmp = await File(
p.join(
Directory.systemTemp.path,
'cxyz-read-${DateTime.now().millisecondsSinceEpoch}.cbz',
),
).create();
try {
await _channel.invokeMethod<String>('readCbzToPath', {
'uri': uriOrPath,
'destPath': tmp.path,
});
return await tmp.readAsBytes();
} finally {
try {
await tmp.delete();
} catch (_) {}
}
}
Future<void> deleteCbz(String uriOrPath) async {
if (uriOrPath.startsWith('content://')) {
await _channel.invokeMethod<bool>('deleteCbz', {'uri': uriOrPath});
return;
}
final file = File(uriOrPath);
if (await file.exists()) {
await file.delete();
}
}
}
class CbzStore {
CbzStore({Dio? dio, PublicCbzStore? publicStore})
: _dio = dio ?? Dio(),
_public = publicStore ?? PublicCbzStore();
final Dio _dio;
final PublicCbzStore _public;
Future<Directory> comicsRoot() async {
final docs = await getApplicationDocumentsDirectory();
final dir = Directory(p.join(docs.path, 'Comics'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
Future<Directory> cacheRoot() async {
final cache = await getTemporaryDirectory();
final dir = Directory(p.join(cache.path, 'chapter_cache'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
String cbzDisplayName({
required String chapterUrl,
String? chapterNumber,
String? label,
}) {
return transferLinkBasename(
chapterNumber,
label,
'${sanitizeFsStem(stemForComicLandChapterCbz(chapterUrl))}.cbz',
);
}
Future<String> resolveCacheDir({
required String comicTitle,
required String chapterKey,
}) async {
final root = await cacheRoot();
final dir = Directory(
p.join(root.path, sanitizeComicDirName(comicTitle), sanitizeFsStem(chapterKey)),
);
return dir.path;
}
String extensionFromContentType(String? contentType) {
if (contentType == null || contentType.isEmpty) return '';
final ct = contentType.split(';').first.trim().toLowerCase();
const map = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/pjpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
'image/avif': '.avif',
};
return map[ct] ?? '';
}
String extensionFromImageUrl(String imageUrl) {
try {
final ext = p.extension(Uri.parse(imageUrl).path).toLowerCase();
if (ext.isNotEmpty &&
ext.length <= 8 &&
RegExp(r'^\.[a-z0-9]+$').hasMatch(ext)) {
return ext;
}
} catch (_) {}
return '';
}
/// Download images → pack CBZ → extract to cache. Returns paths and sizes.
/// [cbzPath] may be a content:// URI (MediaStore) or a filesystem path.
Future<
({
String cbzPath,
String cacheDir,
int imageCount,
int rawBytes,
int cbzBytes,
String? firstImagePath,
int firstImageBytes,
})> downloadChapter({
required ComicSource source,
required String comicTitle,
required ChapterLink chapter,
void Function(double progress, String message)? onProgress,
bool Function()? isCancelled,
}) async {
onProgress?.call(0.02, 'Fetching…');
final urls = await source.fetchPageUrls(chapter.url);
if (urls.isEmpty) {
throw StateError('No chapter images were found for ${chapter.url}');
}
final tmp = await Directory.systemTemp.createTemp('cxyz-chapter-');
try {
final saved = <File>[];
var rawBytes = 0;
for (var i = 0; i < urls.length; i++) {
if (isCancelled?.call() == true) {
throw StateError('Download cancelled');
}
final imageUrl = urls[i];
onProgress?.call(
0.05 + (0.7 * (i / urls.length)),
'Downloading…',
);
final res = await _dio.get<List<int>>(
imageUrl,
options: Options(
responseType: ResponseType.bytes,
headers: source.imageHeaders,
receiveTimeout: const Duration(seconds: 120),
validateStatus: (s) => s != null && s >= 200 && s < 300,
),
);
var ext = extensionFromImageUrl(imageUrl);
if (ext.isEmpty) {
ext = extensionFromContentType(res.headers.value('content-type'));
if (ext.isEmpty) ext = '.bin';
}
final filename = 'page-${(i + 1).toString().padLeft(4, '0')}$ext';
final file = File(p.join(tmp.path, filename));
final data = Uint8List.fromList(res.data ?? const []);
await file.writeAsBytes(data, flush: true);
rawBytes += data.length;
saved.add(file);
}
onProgress?.call(0.8, 'Packing…');
final archive = Archive();
for (final file in saved) {
final bytes = await file.readAsBytes();
archive.addFile(
ArchiveFile(p.basename(file.path), bytes.length, bytes),
);
}
final encoded = ZipEncoder().encode(archive);
final zipBytes = Uint8List.fromList(encoded);
final cbzBytes = zipBytes.length;
final packed = File(p.join(tmp.path, 'chapter.cbz'));
await packed.writeAsBytes(zipBytes, flush: true);
final seriesDir = sanitizeComicDirName(comicTitle);
final displayName = cbzDisplayName(
chapterUrl: chapter.url,
chapterNumber: chapter.chapterNumber,
label: chapter.title,
);
String cbzPath;
if (Platform.isAndroid) {
cbzPath = await _public.saveCbzFromPath(
seriesDirName: seriesDir,
displayName: displayName,
sourcePath: packed.path,
);
} else {
// Desktop / tests: keep writing under app documents.
final root = await comicsRoot();
final titleDir = Directory(p.join(root.path, seriesDir));
if (!await titleDir.exists()) {
await titleDir.create(recursive: true);
}
cbzPath = p.join(titleDir.path, displayName);
await packed.copy(cbzPath);
}
onProgress?.call(0.9, 'Extracting…');
final cacheDir = await resolveCacheDir(
comicTitle: comicTitle,
chapterKey: chapter.chapterKey,
);
final extracted = await extractBytesToCache(zipBytes, cacheDir);
extracted.sort();
final firstImagePath = extracted.isEmpty ? null : extracted.first;
final firstImageBytes = firstImagePath == null
? 0
: await File(firstImagePath).length();
onProgress?.call(1.0, 'Done');
return (
cbzPath: cbzPath,
cacheDir: cacheDir,
imageCount: saved.length,
rawBytes: rawBytes,
cbzBytes: cbzBytes,
firstImagePath: firstImagePath,
firstImageBytes: firstImageBytes,
);
} finally {
try {
await tmp.delete(recursive: true);
} catch (_) {}
}
}
Future<List<String>> extractBytesToCache(
Uint8List bytes,
String cacheDirPath,
) async {
final dir = Directory(cacheDirPath);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
await dir.create(recursive: true);
final archive = ZipDecoder().decodeBytes(bytes);
final out = <String>[];
for (final file in archive.files) {
if (!file.isFile) continue;
final name = p.basename(file.name);
if (name.isEmpty || name.startsWith('.')) continue;
final outPath = p.join(cacheDirPath, name);
final outFile = File(outPath);
await outFile.writeAsBytes(file.content as List<int>, flush: true);
out.add(outPath);
}
out.sort();
return out;
}
Future<List<String>> extractCbzToCache(String cbzPath, String cacheDirPath) async {
final bytes = await readCbzBytes(cbzPath);
return extractBytesToCache(bytes, cacheDirPath);
}
Future<Uint8List> readCbzBytes(String cbzPath) async {
if (cbzPath.startsWith('content://')) {
return _public.readCbz(cbzPath);
}
return File(cbzPath).readAsBytes();
}
Future<List<String>> pagesFromCacheOrCbz({
required String? cacheDir,
required String? cbzPath,
required String comicTitle,
required String chapterKey,
}) async {
if (cacheDir != null) {
final dir = Directory(cacheDir);
if (await dir.exists()) {
final files = await dir
.list()
.where((e) => e is File)
.map((e) => e.path)
.toList();
files.sort();
if (files.isNotEmpty) return files;
}
}
if (cbzPath != null && cbzPath.isNotEmpty) {
final exists = cbzPath.startsWith('content://') ||
await File(cbzPath).exists();
if (exists) {
final target = cacheDir ??
await resolveCacheDir(comicTitle: comicTitle, chapterKey: chapterKey);
return extractCbzToCache(cbzPath, target);
}
}
return const [];
}
Future<void> deleteDownload({
String? cbzPath,
String? cacheDir,
}) async {
if (cacheDir != null) {
try {
final dir = Directory(cacheDir);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
} catch (_) {}
}
if (cbzPath != null && cbzPath.isNotEmpty) {
try {
await _public.deleteCbz(cbzPath);
} catch (_) {}
}
}
}
@@ -0,0 +1,443 @@
import 'dart:async';
import 'dart:collection';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import '../data/database.dart';
import '../sources/registry.dart';
import '../sources/source.dart';
import 'cbz_store.dart';
import 'ntfy_notifier.dart';
class DownloadQueue extends ChangeNotifier {
DownloadQueue({
required AppDatabase db,
required SourceRegistry sources,
required this._store,
NtfyNotifier? ntfy,
}) : _db = db,
_sources = sources,
_ntfy = ntfy ?? NtfyNotifier(store: _store);
final AppDatabase _db;
final SourceRegistry _sources;
final CbzStore _store;
final NtfyNotifier _ntfy;
final Queue<int> _chapterIds = Queue<int>();
final Set<int> _cancelled = {};
/// chapterId → jobId for open jobs created at enqueue.
final Map<int, int> _jobByChapter = {};
bool _running = false;
int? _activeChapterId;
double _progress = 0;
String _message = '';
int _sessionOk = 0;
int _sessionFailed = 0;
bool _sessionHadWork = false;
DateTime? _sessionStartedAt;
final List<Future<void>> _pendingNtfy = [];
int? get activeChapterId => _activeChapterId;
double get progress => _progress;
String get message => _message;
bool get isRunning => _running;
int get queuedCount => _chapterIds.length;
String _label(String seriesTitle, String chapterLabel) =>
'$seriesTitle — $chapterLabel';
Future<void> enqueueChapters(Iterable<int> chapterIds) async {
for (final id in chapterIds) {
if (_chapterIds.contains(id) || id == _activeChapterId) continue;
_cancelled.remove(id);
final chapter = await _db.getChapter(id);
if (chapter == null) continue;
final title = await _db.getTitle(chapter.titleId);
if (title == null) continue;
_chapterIds.add(id);
await (_db.update(_db.chapters)..where((c) => c.id.equals(id))).write(
const ChaptersCompanion(
status: Value('pending'),
error: Value(null),
),
);
final existing = await _db.findOpenJobForChapter(id);
late final int jobId;
if (existing != null) {
jobId = existing.id;
await (_db.update(_db.jobs)..where((j) => j.id.equals(jobId))).write(
JobsCompanion(
titleId: Value(title.id),
chapterId: Value(id),
status: const Value('queued'),
progress: const Value(0),
message: Value(_label(title.title, chapter.label)),
finishedAt: const Value(null),
),
);
} else {
jobId = await _db.into(_db.jobs).insert(
JobsCompanion.insert(
type: 'download',
titleId: Value(title.id),
chapterId: Value(id),
status: const Value('queued'),
message: Value(_label(title.title, chapter.label)),
),
);
}
_jobByChapter[id] = jobId;
}
notifyListeners();
unawaited(_pump());
}
/// Cancel / dismiss a queue row for [chapterId].
///
/// Always closes open jobs. Only resets chapter status when it was still
/// pending/downloading/failed — never wipes an already-downloaded chapter.
Future<void> cancelChapter(int chapterId) async {
_cancelled.add(chapterId);
_chapterIds.removeWhere((id) => id == chapterId);
_jobByChapter.remove(chapterId);
await (_db.update(_db.jobs)
..where(
(j) =>
j.chapterId.equals(chapterId) &
j.status.isIn(['queued', 'running', 'failed']),
))
.write(
JobsCompanion(
status: const Value('done'),
message: const Value('Cancelled'),
finishedAt: Value(DateTime.now()),
),
);
final chapter = await _db.getChapter(chapterId);
if (chapter != null && chapter.status != 'downloaded') {
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId)))
.write(
const ChaptersCompanion(status: Value('pending'), error: Value(null)),
);
}
notifyListeners();
}
Future<void> retryChapter(int chapterId) async {
await enqueueChapters([chapterId]);
}
/// Remove offline download: delete CBZ + cache, reset chapter to pending.
Future<void> deleteDownload(int chapterId) async {
final chapter = await _db.getChapter(chapterId);
if (chapter == null) return;
await _store.deleteDownload(
cbzPath: chapter.cbzPath,
cacheDir: chapter.cacheDir,
);
await _closeOpenJobs(chapterId, message: 'Deleted');
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write(
const ChaptersCompanion(
status: Value('pending'),
cbzPath: Value(null),
cacheDir: Value(null),
downloadedAt: Value(null),
error: Value(null),
),
);
}
/// Mark all open jobs for [chapterId] as done (dismisses Queue rows).
Future<void> _closeOpenJobs(
int chapterId, {
required String message,
double? progress,
}) async {
await (_db.update(_db.jobs)
..where(
(j) =>
j.chapterId.equals(chapterId) &
j.status.isIn(['queued', 'running', 'failed']),
))
.write(
JobsCompanion(
status: const Value('done'),
progress: progress != null ? Value(progress) : const Value.absent(),
message: Value(message),
finishedAt: Value(DateTime.now()),
),
);
}
/// Drop stale queue rows for chapters that are already downloaded.
Future<void> reconcileStaleJobs() async {
final open = await (_db.select(_db.jobs)
..where((j) => j.status.isIn(['queued', 'running', 'failed'])))
.get();
for (final job in open) {
final chapterId = job.chapterId;
if (chapterId == null) continue;
final chapter = await _db.getChapter(chapterId);
if (chapter?.status == 'downloaded') {
await _closeOpenJobs(chapterId, message: chapter!.label, progress: 1);
}
}
}
Future<void> _pump() async {
if (_running) return;
_running = true;
_sessionOk = 0;
_sessionFailed = 0;
_sessionHadWork = false;
_sessionStartedAt = DateTime.now();
_pendingNtfy.clear();
notifyListeners();
try {
while (_chapterIds.isNotEmpty) {
final chapterId = _chapterIds.removeFirst();
if (_cancelled.contains(chapterId)) {
await _closeOpenJobs(chapterId, message: 'Cancelled');
final chapter = await _db.getChapter(chapterId);
if (chapter != null && chapter.status != 'downloaded') {
await (_db.update(_db.chapters)
..where((c) => c.id.equals(chapterId)))
.write(
const ChaptersCompanion(
status: Value('pending'),
error: Value(null),
),
);
}
_jobByChapter.remove(chapterId);
continue;
}
_sessionHadWork = true;
await _downloadOne(chapterId);
}
} finally {
final hadWork = _sessionHadWork;
final ok = _sessionOk;
final failed = _sessionFailed;
final started = _sessionStartedAt;
final pending = List<Future<void>>.from(_pendingNtfy);
_pendingNtfy.clear();
_running = false;
_activeChapterId = null;
_progress = 0;
_message = '';
_sessionOk = 0;
_sessionFailed = 0;
_sessionHadWork = false;
_sessionStartedAt = null;
notifyListeners();
if (hadWork) {
// Per-chapter completed/failed webhooks first, then queue-empty.
await Future.wait(pending);
final duration =
started == null ? null : DateTime.now().difference(started);
await _ntfy.queueDrained(ok: ok, failed: failed, duration: duration);
}
}
}
Future<void> _downloadOne(int chapterId) async {
_activeChapterId = chapterId;
_progress = 0;
final chapter = await _db.getChapter(chapterId);
if (chapter == null) {
await _closeOpenJobs(chapterId, message: 'Chapter missing');
return;
}
final title = await _db.getTitle(chapter.titleId);
if (title == null) {
await _closeOpenJobs(chapterId, message: 'Series missing');
return;
}
final label = _label(title.title, chapter.label);
_message = label;
notifyListeners();
var jobId = _jobByChapter[chapterId];
if (jobId == null) {
final existing = await _db.findOpenJobForChapter(chapterId);
if (existing != null) {
jobId = existing.id;
} else {
jobId = await _db.into(_db.jobs).insert(
JobsCompanion.insert(
type: 'download',
titleId: Value(title.id),
chapterId: Value(chapterId),
status: const Value('running'),
message: Value(label),
),
);
}
_jobByChapter[chapterId] = jobId;
}
await (_db.update(_db.jobs)..where((j) => j.id.equals(jobId!))).write(
JobsCompanion(
titleId: Value(title.id),
status: const Value('running'),
progress: const Value(0),
message: Value(label),
finishedAt: const Value(null),
),
);
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write(
const ChaptersCompanion(status: Value('downloading'), error: Value(null)),
);
try {
final source = _sources.get(SourceId.values.byName(title.source));
final link = ChapterLink(
url: chapter.url,
title: chapter.label,
chapterKey: chapter.chapterKey,
chapterNumber: chapter.chapterNumber,
);
final result = await _store.downloadChapter(
source: source,
comicTitle: title.title,
chapter: link,
onProgress: (p, _) {
_progress = p;
_message = label;
notifyListeners();
unawaited(
(_db.update(_db.jobs)..where((j) => j.id.equals(jobId!))).write(
JobsCompanion(progress: Value(p), message: Value(label)),
),
);
},
isCancelled: () => _cancelled.contains(chapterId),
);
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write(
ChaptersCompanion(
status: const Value('downloaded'),
cbzPath: Value(result.cbzPath),
cacheDir: Value(result.cacheDir),
downloadedAt: Value(DateTime.now()),
error: const Value(null),
),
);
// Close every open job for this chapter (covers stale duplicates).
await _closeOpenJobs(chapterId, message: label, progress: 1);
_sessionOk++;
_pendingNtfy.add(
_ntfy.chapterCompleted(
ChapterCompletedPayload(
seriesTitle: title.title,
chapterLabel: chapter.label,
imageCount: result.imageCount,
rawBytes: result.rawBytes,
cbzBytes: result.cbzBytes,
cbzPath: result.cbzPath,
cacheDir: result.cacheDir,
coverUrl: title.coverUrl,
),
),
);
} catch (e) {
final cancelled = _cancelled.contains(chapterId);
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write(
ChaptersCompanion(
status: Value(cancelled ? 'pending' : 'failed'),
error: Value(cancelled ? null : e.toString()),
),
);
await (_db.update(_db.jobs)..where((j) => j.id.equals(jobId!))).write(
JobsCompanion(
status: Value(cancelled ? 'done' : 'failed'),
message: Value(cancelled ? 'Cancelled' : label),
finishedAt: Value(DateTime.now()),
),
);
if (!cancelled) {
_sessionFailed++;
_pendingNtfy.add(
_ntfy.jobFailed(
seriesTitle: title.title,
chapterLabel: chapter.label,
error: e.toString(),
),
);
}
} finally {
_cancelled.remove(chapterId);
_jobByChapter.remove(chapterId);
}
}
/// Upsert series into library from a remote [SeriesDetail].
Future<int> upsertSeries(SeriesDetail series) async {
final existing = await _db.findTitleByUrl(series.listingUrl);
late final int titleId;
if (existing == null) {
titleId = await _db.into(_db.titles).insert(
TitlesCompanion.insert(
source: series.source.id,
url: series.listingUrl,
slug: series.slug,
title: series.title,
coverUrl: Value(series.coverUrl),
description: Value(series.description),
lastCheckedAt: Value(DateTime.now()),
),
);
} else {
titleId = existing.id;
await (_db.update(_db.titles)..where((t) => t.id.equals(titleId))).write(
TitlesCompanion(
title: Value(series.title),
coverUrl: Value(series.coverUrl),
description: Value(series.description),
lastCheckedAt: Value(DateTime.now()),
),
);
}
for (final ch in series.chapters) {
final rows = await (_db.select(_db.chapters)
..where(
(c) =>
c.titleId.equals(titleId) & c.chapterKey.equals(ch.chapterKey),
))
.get();
if (rows.isEmpty) {
await _db.into(_db.chapters).insert(
ChaptersCompanion.insert(
titleId: titleId,
chapterKey: ch.chapterKey,
chapterNumber: Value(ch.chapterNumber),
label: ch.title,
url: ch.url,
),
);
} else {
await (_db.update(_db.chapters)..where((c) => c.id.equals(rows.first.id)))
.write(
ChaptersCompanion(
label: Value(ch.title),
url: Value(ch.url),
chapterNumber: Value(ch.chapterNumber),
),
);
}
}
return titleId;
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/painting.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
/// Cache manager that attaches ComicLand CDN Referer/Origin headers.
class HeaderCacheManager extends CacheManager {
HeaderCacheManager(this.headers)
: super(
Config(
'cxyzHeaderCache',
fileService: _HeaderHttpFileService(headers),
),
);
final Map<String, String> headers;
}
class _HeaderHttpFileService extends HttpFileService {
_HeaderHttpFileService(this.headers);
final Map<String, String> headers;
@override
Future<FileServiceResponse> get(String url, {Map<String, String>? headers}) {
final merged = <String, String>{...this.headers, ...?headers};
return super.get(url, headers: merged);
}
}
ImageProvider headerNetworkImage(String url, Map<String, String> headers) {
return NetworkImage(url, headers: headers);
}
@@ -0,0 +1,419 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p;
import 'cbz_store.dart';
import 'ntfy_settings.dart';
const int _kMaxCbzAttachBytes = 15 * 1024 * 1024;
const int _kPreviewMaxEdge = 720;
const int _kPreviewMaxBytes = 250 * 1024;
/// Encode for HTTP headers (Dart rejects non-ASCII / CR/LF).
/// ntfy decodes RFC 2047 (`=?UTF-8?B?…?=`).
String encodeNtfyHeader(String value) {
final flat = value.replaceAll(RegExp(r'[\r\n]+'), '\n').trim();
if (flat.isEmpty) return flat;
final needsEncode =
flat.codeUnits.any((c) => c < 0x20 || c > 0x7e);
if (!needsEncode) return flat;
final b64 = base64.encode(utf8.encode(flat));
return '=?UTF-8?B?$b64?=';
}
class ChapterCompletedPayload {
const ChapterCompletedPayload({
required this.seriesTitle,
required this.chapterLabel,
required this.imageCount,
required this.rawBytes,
required this.cbzBytes,
this.cbzPath,
this.cacheDir,
this.coverUrl,
});
final String seriesTitle;
final String chapterLabel;
final int imageCount;
final int rawBytes;
final int cbzBytes;
final String? cbzPath;
final String? cacheDir;
final String? coverUrl;
}
/// Fire-and-forget ntfy publisher for download queue events.
class NtfyNotifier {
NtfyNotifier({Dio? dio, CbzStore? store})
: _dio = dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 60),
sendTimeout: const Duration(seconds: 120),
validateStatus: (s) => s != null && s >= 200 && s < 300,
),
),
_store = store ?? CbzStore();
final Dio _dio;
final CbzStore _store;
static String formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
final kb = bytes / 1024;
if (kb < 1024) return '${kb.toStringAsFixed(kb < 10 ? 1 : 0)} KB';
final mb = kb / 1024;
if (mb < 1024) return '${mb.toStringAsFixed(mb < 10 ? 1 : 0)} MB';
final gb = mb / 1024;
return '${gb.toStringAsFixed(2)} GB';
}
static String completedTitle(String series, String chapter) =>
'Downloaded · $series · $chapter';
static String failedTitle(String series, String chapter) =>
'Download failed · $series · $chapter';
static String drainedTitle({required int ok, required int failed}) =>
'Queue finished · $ok downloaded · $failed failed';
static String completedBody({
required int imageCount,
required int rawBytes,
required int cbzBytes,
String? note,
}) {
final saved = rawBytes > 0
? (((rawBytes - cbzBytes) / rawBytes) * 100).clamp(0, 100).round()
: 0;
final lines = <String>[
'Pages: $imageCount',
'Images: ${formatBytes(rawBytes)}',
'CBZ: ${formatBytes(cbzBytes)}${rawBytes > 0 ? ' (saved $saved%)' : ''}',
];
if (note != null && note.isNotEmpty) lines.add(note);
return lines.join('\n');
}
Future<void> chapterCompleted(ChapterCompletedPayload payload) async {
final settings = await NtfySettings.load();
if (!settings.enabled) return;
final url = settings.urlForCompleted;
if (url == null) return;
final title = completedTitle(payload.seriesTitle, payload.chapterLabel);
var note = '';
Uint8List? attachBytes;
String? filename;
String? attachUrl;
switch (settings.attachmentMode) {
case NtfyAttachmentMode.none:
break;
case NtfyAttachmentMode.cover:
final cover = payload.coverUrl?.trim();
if (cover == null || cover.isEmpty) {
note = 'Attachment skipped (no cover URL)';
} else {
attachUrl = cover;
}
break;
case NtfyAttachmentMode.firstImage:
final preview = await _buildFirstPagePreview(payload.cacheDir);
if (preview == null) {
note = 'Preview resize failed';
} else {
attachBytes = preview;
filename = 'preview.jpg';
}
break;
case NtfyAttachmentMode.cbz:
final path = payload.cbzPath;
if (path == null || path.isEmpty) {
note = 'Attachment skipped (no CBZ path)';
} else if (payload.cbzBytes > _kMaxCbzAttachBytes) {
note = 'Attachment skipped (15 MB limit)';
} else {
try {
attachBytes = await _store.readCbzBytes(path);
if (attachBytes.length > _kMaxCbzAttachBytes) {
attachBytes = null;
note = 'Attachment skipped (15 MB limit)';
} else {
filename = _cbzFilename(payload.chapterLabel);
}
} catch (_) {
note = 'Attachment skipped (read failed)';
}
}
}
final body = completedBody(
imageCount: payload.imageCount,
rawBytes: payload.rawBytes,
cbzBytes: payload.cbzBytes,
note: note.isEmpty ? null : note,
);
try {
if (attachBytes != null && filename != null) {
await _putAttachment(
url: url,
title: title,
message: body,
tags: 'white_check_mark',
priority: '3',
filename: filename,
bytes: attachBytes,
token: settings.authToken,
);
} else if (attachUrl != null) {
await _postText(
url: url,
title: title,
message: body,
tags: 'white_check_mark',
priority: '3',
token: settings.authToken,
attachUrl: attachUrl,
);
} else {
await _postText(
url: url,
title: title,
message: body,
tags: 'white_check_mark',
priority: '3',
token: settings.authToken,
);
}
} catch (e, st) {
debugPrint('ntfy chapterCompleted failed: $e\n$st');
}
}
Future<void> jobFailed({
required String seriesTitle,
required String chapterLabel,
required String error,
}) async {
final settings = await NtfySettings.load();
if (!settings.enabled) return;
final url = settings.urlForFailed;
if (url == null) return;
final trimmed = error.trim();
final snippet =
trimmed.length > 500 ? '${trimmed.substring(0, 500)}…' : trimmed;
try {
await _postText(
url: url,
title: failedTitle(seriesTitle, chapterLabel),
message: snippet.isEmpty ? 'Unknown error' : snippet,
tags: 'x',
priority: '4',
token: settings.authToken,
);
} catch (e, st) {
debugPrint('ntfy jobFailed failed: $e\n$st');
}
}
Future<void> queueDrained({
required int ok,
required int failed,
Duration? duration,
}) async {
final settings = await NtfySettings.load();
if (!settings.enabled) return;
final url = settings.urlForDrained;
if (url == null) return;
final lines = <String>[];
if (duration != null) {
lines.add('Finished in ${_formatDuration(duration)}');
}
lines.add('$ok downloaded · $failed failed');
try {
await _postText(
url: url,
title: drainedTitle(ok: ok, failed: failed),
message: lines.join('\n'),
tags: 'inbox_tray',
priority: '3',
token: settings.authToken,
);
} catch (e, st) {
debugPrint('ntfy queueDrained failed: $e\n$st');
}
}
/// Send a sample notification to [url] (for Settings Test buttons).
Future<void> testTopic(String url, {required String kind}) async {
final settings = await NtfySettings.load();
final trimmed = url.trim();
if (trimmed.isEmpty) {
throw StateError('URL is empty');
}
switch (kind) {
case 'completed':
await _postText(
url: trimmed,
title: completedTitle('Sample Series', 'Chapter 1'),
message: completedBody(
imageCount: 12,
rawBytes: 5 * 1024 * 1024,
cbzBytes: 4 * 1024 * 1024,
note: 'Test notification (${settings.attachmentMode.label})',
),
tags: 'white_check_mark',
priority: '3',
token: settings.authToken,
);
case 'failed':
await _postText(
url: trimmed,
title: failedTitle('Sample Series', 'Chapter 2'),
message: 'Sample error for testing',
tags: 'x',
priority: '4',
token: settings.authToken,
);
case 'drained':
await _postText(
url: trimmed,
title: drainedTitle(ok: 3, failed: 1),
message: 'Finished in 1m 5s\n3 downloaded · 1 failed',
tags: 'inbox_tray',
priority: '3',
token: settings.authToken,
);
default:
throw StateError('Unknown test kind: $kind');
}
}
Future<void> _postText({
required String url,
required String title,
required String message,
required String tags,
required String priority,
required String token,
String? attachUrl,
}) async {
await _dio.post<void>(
url,
data: message,
options: Options(
headers: {
'Title': encodeNtfyHeader(title),
'Tags': tags,
'Priority': priority,
if (attachUrl != null && attachUrl.isNotEmpty)
'Attach': encodeNtfyHeader(attachUrl),
if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}',
},
contentType: 'text/plain; charset=utf-8',
),
);
}
Future<void> _putAttachment({
required String url,
required String title,
required String message,
required String tags,
required String priority,
required String filename,
required Uint8List bytes,
required String token,
}) async {
await _dio.put<void>(
url,
data: bytes,
options: Options(
headers: {
'Title': encodeNtfyHeader(title),
// Multiline body must be header-safe (RFC 2047), not raw newlines.
'Message': encodeNtfyHeader(message),
'Tags': tags,
'Priority': priority,
'Filename': encodeNtfyHeader(filename),
if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}',
},
contentType: 'application/octet-stream',
),
);
}
Future<Uint8List?> _buildFirstPagePreview(String? cacheDir) async {
if (cacheDir == null || cacheDir.isEmpty) return null;
final dir = Directory(cacheDir);
if (!await dir.exists()) return null;
final files = await dir
.list()
.where((e) => e is File)
.cast<File>()
.toList();
files.sort((a, b) => p.basename(a.path).compareTo(p.basename(b.path)));
if (files.isEmpty) return null;
final bytes = await files.first.readAsBytes();
return compute(_resizePreviewIsolate, bytes);
}
static String _cbzFilename(String chapterLabel) {
final stem = chapterLabel
.trim()
.replaceAll(RegExp(r'[^a-zA-Z0-9._-]+'), '_')
.replaceAll(RegExp(r'^_+|_+$'), '');
final name = stem.isEmpty ? 'chapter' : stem;
return name.toLowerCase().endsWith('.cbz') ? name : '$name.cbz';
}
static String _formatDuration(Duration d) {
final s = d.inSeconds;
if (s < 60) return '${s}s';
final m = s ~/ 60;
final rem = s % 60;
if (m < 60) return rem == 0 ? '${m}m' : '${m}m ${rem}s';
final h = m ~/ 60;
final rm = m % 60;
return rm == 0 ? '${h}h' : '${h}h ${rm}m';
}
}
/// Isolate entry: decode → max edge 720 → JPEG ≤ 250 KB.
Uint8List? _resizePreviewIsolate(Uint8List input) {
return resizePreviewForTest(input);
}
/// Visible for unit tests.
Uint8List? resizePreviewForTest(Uint8List input) {
final decoded = img.decodeImage(input);
if (decoded == null) return null;
var image = decoded;
final longest = image.width > image.height ? image.width : image.height;
if (longest > _kPreviewMaxEdge) {
if (image.width >= image.height) {
image = img.copyResize(image, width: _kPreviewMaxEdge);
} else {
image = img.copyResize(image, height: _kPreviewMaxEdge);
}
}
var quality = 75;
Uint8List? best;
while (quality >= 40) {
final encoded = Uint8List.fromList(img.encodeJpg(image, quality: quality));
best = encoded;
if (encoded.length <= _kPreviewMaxBytes) return encoded;
quality -= 15;
}
return best;
}
@@ -0,0 +1,100 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// What to attach on chapter-completed ntfy notifications.
enum NtfyAttachmentMode {
none,
cover,
firstImage,
cbz;
String get storageValue => name;
static NtfyAttachmentMode fromStorage(String? raw) {
for (final m in NtfyAttachmentMode.values) {
if (m.name == raw) return m;
}
return NtfyAttachmentMode.none;
}
String get label {
switch (this) {
case NtfyAttachmentMode.none:
return 'None';
case NtfyAttachmentMode.cover:
return 'Series cover';
case NtfyAttachmentMode.firstImage:
return 'First page';
case NtfyAttachmentMode.cbz:
return 'CBZ file';
}
}
}
class NtfySettings {
static const _kEnabled = 'ntfy_enabled';
static const _kCompletedUrl = 'ntfy_completed_url';
static const _kFailedUrl = 'ntfy_failed_url';
static const _kDrainedUrl = 'ntfy_drained_url';
static const _kAttachment = 'ntfy_attachment_mode';
static const _tokenKey = 'ntfy_auth_token';
static const _secure = FlutterSecureStorage();
final bool enabled;
final String completedUrl;
final String failedUrl;
final String drainedUrl;
final NtfyAttachmentMode attachmentMode;
final String authToken;
const NtfySettings({
required this.enabled,
required this.completedUrl,
required this.failedUrl,
required this.drainedUrl,
required this.attachmentMode,
required this.authToken,
});
String? get urlForCompleted => _nonEmpty(completedUrl);
String? get urlForFailed => _nonEmpty(failedUrl);
String? get urlForDrained => _nonEmpty(drainedUrl);
static String? _nonEmpty(String raw) {
final t = raw.trim();
return t.isEmpty ? null : t;
}
static Future<NtfySettings> load() async {
final prefs = await SharedPreferences.getInstance();
final token = await _secure.read(key: _tokenKey) ?? '';
return NtfySettings(
enabled: prefs.getBool(_kEnabled) ?? false,
completedUrl: prefs.getString(_kCompletedUrl) ?? '',
failedUrl: prefs.getString(_kFailedUrl) ?? '',
drainedUrl: prefs.getString(_kDrainedUrl) ?? '',
attachmentMode: NtfyAttachmentMode.fromStorage(
prefs.getString(_kAttachment),
),
authToken: token,
);
}
static Future<void> save({
required bool enabled,
required String completedUrl,
required String failedUrl,
required String drainedUrl,
required NtfyAttachmentMode attachmentMode,
required String authToken,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kEnabled, enabled);
await prefs.setString(_kCompletedUrl, completedUrl.trim());
await prefs.setString(_kFailedUrl, failedUrl.trim());
await prefs.setString(_kDrainedUrl, drainedUrl.trim());
await prefs.setString(_kAttachment, attachmentMode.storageValue);
await _secure.write(key: _tokenKey, value: authToken);
}
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Privacy mode: hide/blur comic covers in lists.
class PeekMode {
static const prefKey = 'peek_mode_enabled';
static Future<bool> load() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(prefKey) ?? false;
}
static Future<void> save(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(prefKey, enabled);
}
}
@@ -0,0 +1,39 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Vertical continuous scroll vs horizontal page turns.
enum ReaderMode {
webtoon,
paged;
String get storageValue => this == ReaderMode.paged ? 'paged' : 'webtoon';
static ReaderMode fromStorage(String? raw) {
return raw == 'paged' ? ReaderMode.paged : ReaderMode.webtoon;
}
}
/// Global / per-title paged-reader tap/swipe direction.
enum ReadingDirection {
ltr,
rtl;
String get storageValue => name;
static ReadingDirection fromStorage(String? raw) {
return raw == 'rtl' ? ReadingDirection.rtl : ReadingDirection.ltr;
}
}
class ReaderDirection {
static const prefKey = 'reader_reading_direction';
static Future<ReadingDirection> load() async {
final prefs = await SharedPreferences.getInstance();
return ReadingDirection.fromStorage(prefs.getString(prefKey));
}
static Future<void> save(ReadingDirection direction) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(prefKey, direction.storageValue);
}
}
@@ -0,0 +1,32 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Layout zoom for the in-app reader (`pageWidth = screenWidth * scale`).
class ReaderZoom {
static const prefKey = 'reader_zoom_scale';
static const double min = 0.4;
static const double max = 1.5;
static const double step = 0.1;
static const double defaultScale = 1.0;
static double clamp(double value) {
final stepped = (value / step).round() * step;
return stepped.clamp(min, max);
}
static Future<double> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getDouble(prefKey);
if (raw == null) return defaultScale;
return clamp(raw);
}
static Future<void> save(double scale) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(prefKey, clamp(scale));
}
static String percentLabel(double scale) {
final pct = (clamp(scale) * 100).round();
return '$pct%';
}
}
@@ -0,0 +1,53 @@
import '../sources/source.dart';
import 'reader_direction.dart';
/// Built-in reader prefs per catalog source (used when a title has no override).
class SourceReaderDefaults {
const SourceReaderDefaults._();
/// ComicLand → vertical webtoon; MangaRead / MangaTown → horizontal paged.
static ReaderMode modeFor(String sourceId) {
switch (SourceId.tryParse(sourceId)) {
case SourceId.mangaread:
case SourceId.mangatown:
return ReaderMode.paged;
case SourceId.comicland:
case null:
return ReaderMode.webtoon;
}
}
/// MangaRead / MangaTown → RTL; ComicLand falls back to global Settings.
static ReadingDirection? directionFor(String sourceId) {
switch (SourceId.tryParse(sourceId)) {
case SourceId.mangaread:
case SourceId.mangatown:
return ReadingDirection.rtl;
case SourceId.comicland:
case null:
return null;
}
}
/// Resolve stored title mode, else [modeFor].
static ReaderMode resolveMode(String sourceId, String? storedMode) {
final raw = storedMode?.trim() ?? '';
if (raw == 'paged' || raw == 'webtoon') {
return ReaderMode.fromStorage(raw);
}
return modeFor(sourceId);
}
/// Resolve stored title direction, else [directionFor], else [globalFallback].
static ReadingDirection resolveDirection(
String sourceId,
String? storedDirection, {
required ReadingDirection globalFallback,
}) {
final raw = storedDirection?.trim() ?? '';
if (raw == 'ltr' || raw == 'rtl') {
return ReadingDirection.fromStorage(raw);
}
return directionFor(sourceId) ?? globalFallback;
}
}
@@ -0,0 +1,92 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
class WebDavSettings {
static const _kEnabled = 'webdav_enabled';
static const _kUrl = 'webdav_url';
static const _kUsername = 'webdav_username';
static const _kPath = 'webdav_path';
static const _kLastSync = 'webdav_last_sync';
static const _kLastError = 'webdav_last_error';
static const _kLastStatus = 'webdav_last_status';
static const _passwordKey = 'webdav_password';
static const defaultPath = '/cxyz/progress.json';
static const _secure = FlutterSecureStorage();
final bool enabled;
final String url;
final String username;
final String password;
final String path;
final DateTime? lastSyncAt;
final String? lastError;
final String? lastStatus;
const WebDavSettings({
required this.enabled,
required this.url,
required this.username,
required this.password,
required this.path,
this.lastSyncAt,
this.lastError,
this.lastStatus,
});
bool get isConfigured =>
url.trim().isNotEmpty && username.trim().isNotEmpty;
static Future<WebDavSettings> load() async {
final prefs = await SharedPreferences.getInstance();
final password = await _secure.read(key: _passwordKey) ?? '';
final lastSyncRaw = prefs.getString(_kLastSync);
return WebDavSettings(
enabled: prefs.getBool(_kEnabled) ?? false,
url: prefs.getString(_kUrl) ?? '',
username: prefs.getString(_kUsername) ?? '',
password: password,
path: prefs.getString(_kPath) ?? defaultPath,
lastSyncAt:
lastSyncRaw == null ? null : DateTime.tryParse(lastSyncRaw),
lastError: prefs.getString(_kLastError),
lastStatus: prefs.getString(_kLastStatus),
);
}
static Future<void> save({
required bool enabled,
required String url,
required String username,
required String password,
required String path,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kEnabled, enabled);
await prefs.setString(_kUrl, url.trim());
await prefs.setString(_kUsername, username.trim());
await prefs.setString(
_kPath,
path.trim().isEmpty ? defaultPath : path.trim(),
);
await _secure.write(key: _passwordKey, value: password);
}
static Future<void> setSyncResult({
String? status,
String? error,
DateTime? syncedAt,
}) async {
final prefs = await SharedPreferences.getInstance();
if (status != null) await prefs.setString(_kLastStatus, status);
if (error != null) {
await prefs.setString(_kLastError, error);
} else {
await prefs.remove(_kLastError);
}
if (syncedAt != null) {
await prefs.setString(_kLastSync, syncedAt.toUtc().toIso8601String());
}
}
}
+474
View File
@@ -0,0 +1,474 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import '../data/database.dart';
import 'webdav_settings.dart';
class WebDavSyncService {
WebDavSyncService(this._db);
final AppDatabase _db;
bool _busy = false;
Timer? _debounce;
void scheduleSync() {
_debounce?.cancel();
_debounce = Timer(const Duration(seconds: 10), () {
unawaited(sync());
});
}
Future<String> testConnection() async {
final settings = await WebDavSettings.load();
if (!settings.isConfigured) {
throw StateError('Enter WebDAV URL and username first');
}
final dio = _dio(settings);
final url = _fileUrl(settings);
try {
final res = await dio.request(
url,
options: Options(
method: 'PROPFIND',
headers: {'Depth': '0'},
validateStatus: (s) => s != null && s < 500,
),
);
// Some hosts reject PROPFIND; try GET as fallback.
if (res.statusCode == 404 || res.statusCode == 405 || res.statusCode == 501) {
final get = await dio.get<List<int>>(
url,
options: Options(
responseType: ResponseType.bytes,
validateStatus: (s) => s != null && (s == 200 || s == 404),
),
);
if (get.statusCode == 200 || get.statusCode == 404) {
return 'Connection OK';
}
throw StateError('HTTP ${get.statusCode}');
}
if (res.statusCode != null && res.statusCode! >= 200 && res.statusCode! < 300) {
return 'Connection OK';
}
if (res.statusCode == 401 || res.statusCode == 403) {
throw StateError('Auth failed (HTTP ${res.statusCode})');
}
// Parent exists even if file missing
if (res.statusCode == 404) return 'Connection OK (file not created yet)';
throw StateError('HTTP ${res.statusCode}');
} on DioException catch (e) {
throw StateError(e.message ?? e.toString());
}
}
Future<void> sync() async {
if (_busy) return;
final settings = await WebDavSettings.load();
if (!settings.enabled || !settings.isConfigured) return;
_busy = true;
try {
await _syncOnce(settings);
await WebDavSettings.setSyncResult(
status: 'Synced',
error: null,
syncedAt: DateTime.now(),
);
} catch (e) {
await WebDavSettings.setSyncResult(
status: 'Failed',
error: e.toString(),
);
rethrow;
} finally {
_busy = false;
}
}
Future<void> _syncOnce(WebDavSettings settings, {int attempt = 0}) async {
final dio = _dio(settings);
final url = _fileUrl(settings);
String? etag;
Map<String, dynamic> remote = {
'updatedAt': null,
'titles': <dynamic>[],
'chapters': <dynamic>[],
};
final get = await dio.get<List<int>>(
url,
options: Options(
responseType: ResponseType.bytes,
validateStatus: (s) => s != null && (s == 200 || s == 404),
),
);
if (get.statusCode == 200 && get.data != null) {
etag = get.headers.value('etag');
final text = utf8.decode(get.data!);
if (text.trim().isNotEmpty) {
remote = jsonDecode(text) as Map<String, dynamic>;
}
}
final localBundle = await _exportLocal();
final merged = _merge(remote, localBundle);
await _applyRemote(merged);
final body = utf8.encode(const JsonEncoder.withIndent(' ').convert(merged));
final headers = <String, dynamic>{
'Content-Type': 'application/json',
};
if (etag != null && etag.isNotEmpty) {
headers['If-Match'] = etag;
} else if (get.statusCode == 404) {
// Create only if absent
headers['If-None-Match'] = '*';
}
try {
final put = await dio.put<List<int>>(
url,
data: body,
options: Options(
headers: headers,
validateStatus: (s) =>
s != null && ((s >= 200 && s < 300) || s == 412),
),
);
if (put.statusCode == 412) {
if (attempt >= 2) {
throw StateError('Sync conflict: too many retries');
}
await _syncOnce(settings, attempt: attempt + 1);
}
} on DioException catch (e) {
// Weak hosts may ignore precondition; retry once without If-Match.
if (attempt == 0 && etag != null) {
await dio.put(
url,
data: body,
options: Options(headers: {'Content-Type': 'application/json'}),
);
} else {
throw StateError(e.message ?? e.toString());
}
}
}
Dio _dio(WebDavSettings settings) {
return Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Authorization':
'Basic ${base64Encode(utf8.encode('${settings.username}:${settings.password}'))}',
},
),
);
}
String _fileUrl(WebDavSettings settings) {
final base = settings.url.trim().replaceAll(RegExp(r'/+$'), '');
var path = settings.path.trim().isEmpty
? WebDavSettings.defaultPath
: settings.path.trim();
if (!path.startsWith('/')) path = '/$path';
return '$base$path';
}
Future<Map<String, dynamic>> _exportLocal() async {
final titles = await _db.select(_db.titles).get();
final chapters = await _db.select(_db.chapters).get();
final titleById = {for (final t in titles) t.id: t};
final titleMaps = <Map<String, dynamic>>[];
for (final t in titles) {
String? chapterKey;
if (t.lastReadChapterId != null) {
final ch = await _db.getChapter(t.lastReadChapterId!);
chapterKey = ch?.chapterKey;
}
titleMaps.add({
'source': t.source,
'url': t.url,
'slug': t.slug,
'title': t.title,
'coverUrl': t.coverUrl,
'inLibrary': t.inLibrary,
'readerMode': t.readerMode,
'readingDirection': t.readingDirection,
'lastReadChapterKey': chapterKey,
'lastReadAt': t.lastReadAt?.toUtc().toIso8601String(),
'libraryUpdatedAt': t.libraryUpdatedAt?.toUtc().toIso8601String(),
});
}
final chapterMaps = <Map<String, dynamic>>[];
for (final c in chapters) {
final t = titleById[c.titleId];
if (t == null) continue;
if (c.lastReadAt == null && c.lastPage == 0 && !c.completed) continue;
chapterMaps.add({
'source': t.source,
'seriesUrl': t.url,
'chapterKey': c.chapterKey,
'label': c.label,
'url': c.url,
'chapterNumber': c.chapterNumber,
'lastPage': c.lastPage,
'pageCount': c.pageCount,
'completed': c.completed,
'lastReadAt': c.lastReadAt?.toUtc().toIso8601String(),
});
}
return {
'updatedAt': DateTime.now().toUtc().toIso8601String(),
'titles': titleMaps,
'chapters': chapterMaps,
};
}
Map<String, dynamic> _merge(
Map<String, dynamic> remote,
Map<String, dynamic> local,
) {
final remoteTitles = _asMapList(remote['titles']);
final localTitles = _asMapList(local['titles']);
final titles = <String, Map<String, dynamic>>{};
for (final t in [...remoteTitles, ...localTitles]) {
final key = '${t['source']}|${t['url']}';
final existing = titles[key];
if (existing == null) {
titles[key] = Map<String, dynamic>.from(t);
continue;
}
titles[key] = _mergeTitle(existing, t);
}
final remoteChapters = _asMapList(remote['chapters']);
final localChapters = _asMapList(local['chapters']);
final chapters = <String, Map<String, dynamic>>{};
for (final c in [...remoteChapters, ...localChapters]) {
final key = '${c['source']}|${c['seriesUrl']}|${c['chapterKey']}';
final existing = chapters[key];
if (existing == null) {
chapters[key] = Map<String, dynamic>.from(c);
continue;
}
chapters[key] = _newerBy(existing, c, 'lastReadAt');
}
return {
'updatedAt': DateTime.now().toUtc().toIso8601String(),
'titles': titles.values.toList(),
'chapters': chapters.values.toList(),
};
}
Map<String, dynamic> _mergeTitle(
Map<String, dynamic> a,
Map<String, dynamic> b,
) {
final out = Map<String, dynamic>.from(a);
// Metadata: prefer non-empty
for (final field in ['title', 'coverUrl', 'slug']) {
final bv = b[field];
if ((out[field] == null || (out[field] as String?)?.isEmpty == true) &&
bv is String &&
bv.isNotEmpty) {
out[field] = bv;
}
}
// History / continue by lastReadAt
final newerRead = _newerBy(a, b, 'lastReadAt');
out['lastReadAt'] = newerRead['lastReadAt'];
out['lastReadChapterKey'] = newerRead['lastReadChapterKey'];
out['readerMode'] = newerRead['readerMode'] ?? out['readerMode'];
out['readingDirection'] =
newerRead['readingDirection'] ?? out['readingDirection'];
// Library star by libraryUpdatedAt
final newerLib = _newerBy(a, b, 'libraryUpdatedAt');
out['inLibrary'] = newerLib['inLibrary'] ?? false;
out['libraryUpdatedAt'] = newerLib['libraryUpdatedAt'];
return out;
}
Map<String, dynamic> _newerBy(
Map<String, dynamic> a,
Map<String, dynamic> b,
String field,
) {
final at = _parseTime(a[field]);
final bt = _parseTime(b[field]);
if (bt == null) return a;
if (at == null) return b;
return bt.isAfter(at) ? b : a;
}
DateTime? _parseTime(dynamic v) {
if (v is! String || v.isEmpty) return null;
return DateTime.tryParse(v)?.toUtc();
}
List<Map<String, dynamic>> _asMapList(dynamic v) {
if (v is! List) return const [];
return v
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
Future<void> _applyRemote(Map<String, dynamic> merged) async {
final titleMaps = _asMapList(merged['titles']);
final chapterMaps = _asMapList(merged['chapters']);
for (final t in titleMaps) {
final source = t['source'] as String? ?? '';
final url = t['url'] as String? ?? '';
if (source.isEmpty || url.isEmpty) continue;
final existing = await _db.findTitleBySourceUrl(source, url);
final lastReadAt = _parseTime(t['lastReadAt']);
final libraryUpdatedAt = _parseTime(t['libraryUpdatedAt']);
final inLibrary = t['inLibrary'] == true;
late final int titleId;
if (existing == null) {
titleId = await _db.into(_db.titles).insert(
TitlesCompanion.insert(
source: source,
url: url,
slug: (t['slug'] as String?) ?? '',
title: (t['title'] as String?) ?? url,
coverUrl: Value(t['coverUrl'] as String?),
readerMode: Value(
(t['readerMode'] as String?) ?? '',
),
readingDirection: Value(t['readingDirection'] as String?),
lastReadAt: Value(lastReadAt),
inLibrary: Value(inLibrary),
libraryUpdatedAt: Value(libraryUpdatedAt),
),
);
} else {
titleId = existing.id;
await (_db.update(_db.titles)..where((x) => x.id.equals(titleId)))
.write(
TitlesCompanion(
title: Value(
(t['title'] as String?)?.isNotEmpty == true
? t['title'] as String
: existing.title,
),
coverUrl: Value(
(t['coverUrl'] as String?) ?? existing.coverUrl,
),
slug: Value(
(t['slug'] as String?)?.isNotEmpty == true
? t['slug'] as String
: existing.slug,
),
readerMode: Value(
(t['readerMode'] as String?) ?? existing.readerMode,
),
readingDirection: Value(
(t['readingDirection'] as String?) ?? existing.readingDirection,
),
lastReadAt: Value(lastReadAt ?? existing.lastReadAt),
inLibrary: Value(inLibrary),
libraryUpdatedAt: Value(libraryUpdatedAt ?? existing.libraryUpdatedAt),
),
);
}
final chapterKey = t['lastReadChapterKey'] as String?;
if (chapterKey != null && chapterKey.isNotEmpty) {
final rows = await (_db.select(_db.chapters)
..where(
(c) =>
c.titleId.equals(titleId) & c.chapterKey.equals(chapterKey),
))
.get();
if (rows.isNotEmpty) {
await (_db.update(_db.titles)..where((x) => x.id.equals(titleId)))
.write(
TitlesCompanion(lastReadChapterId: Value(rows.first.id)),
);
}
}
}
for (final c in chapterMaps) {
final source = c['source'] as String? ?? '';
final seriesUrl = c['seriesUrl'] as String? ?? '';
final chapterKey = c['chapterKey'] as String? ?? '';
if (source.isEmpty || seriesUrl.isEmpty || chapterKey.isEmpty) continue;
var title = await _db.findTitleBySourceUrl(source, seriesUrl);
if (title == null) {
final titleId = await _db.into(_db.titles).insert(
TitlesCompanion.insert(
source: source,
url: seriesUrl,
slug: '',
title: seriesUrl,
),
);
title = await _db.getTitle(titleId);
}
if (title == null) continue;
final rows = await (_db.select(_db.chapters)
..where(
(ch) =>
ch.titleId.equals(title!.id) &
ch.chapterKey.equals(chapterKey),
))
.get();
final lastReadAt = _parseTime(c['lastReadAt']);
final lastPage = (c['lastPage'] as num?)?.toInt() ?? 0;
final pageCount = (c['pageCount'] as num?)?.toInt() ?? 0;
final completed = c['completed'] == true;
if (rows.isEmpty) {
await _db.into(_db.chapters).insert(
ChaptersCompanion.insert(
titleId: title.id,
chapterKey: chapterKey,
label: (c['label'] as String?) ?? chapterKey,
url: (c['url'] as String?) ?? seriesUrl,
chapterNumber: Value(c['chapterNumber'] as String?),
lastPage: Value(lastPage),
pageCount: Value(pageCount),
completed: Value(completed),
lastReadAt: Value(lastReadAt),
),
);
} else {
final local = rows.first;
final remoteTime = lastReadAt;
final localTime = local.lastReadAt;
final takeRemote = remoteTime != null &&
(localTime == null || remoteTime.isAfter(localTime));
if (takeRemote) {
await (_db.update(_db.chapters)..where((x) => x.id.equals(local.id)))
.write(
ChaptersCompanion(
lastPage: Value(lastPage),
pageCount: Value(pageCount > 0 ? pageCount : local.pageCount),
completed: Value(completed),
lastReadAt: Value(lastReadAt),
label: Value(
(c['label'] as String?)?.isNotEmpty == true
? c['label'] as String
: local.label,
),
),
);
}
}
}
}
}
@@ -0,0 +1,235 @@
import 'package:dio/dio.dart';
import '../source.dart';
import 'constants.dart';
import 'urls.dart';
/// ComicLand JSON adapter (port of cxyz `src/comicland/*`).
class ComicLandSource implements ComicSource {
ComicLandSource({Dio? dio})
: _dio = dio ??
Dio(
BaseOptions(
baseUrl: comicLandApiBase,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 60),
headers: comicLandApiHeaders,
responseType: ResponseType.json,
validateStatus: (s) => s != null && s >= 200 && s < 300,
),
);
final Dio _dio;
@override
SourceId get id => SourceId.comicland;
@override
String get displayName => 'ComicLand';
@override
String get blurb =>
'Newest, popular, official, uncensored, recommended, and search via ComicLand API';
@override
List<CatalogMenu> get catalogMenus => const [
CatalogMenu(kind: CatalogMenuKind.latest, title: 'Newest'),
CatalogMenu(kind: CatalogMenuKind.popular, title: 'Popular'),
CatalogMenu(kind: CatalogMenuKind.official, title: 'Official'),
CatalogMenu(kind: CatalogMenuKind.uncensored, title: 'Uncensored'),
CatalogMenu(kind: CatalogMenuKind.recommended, title: 'Recommended'),
CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'),
];
@override
Map<String, String> get imageHeaders => Map.unmodifiable(comicLandCdnHeaders);
Map<String, dynamic> _unwrap(dynamic payload, String label) {
if (payload is! Map) {
throw StateError('ComicLand $label: empty response');
}
final map = Map<String, dynamic>.from(payload);
final code = map['code'];
if (code != 0) {
final msg = (map['message'] as String?)?.trim();
throw StateError(
'ComicLand $label failed: ${msg?.isNotEmpty == true ? msg : 'code $code'}',
);
}
final data = map['data'];
if (data is! Map) {
throw StateError('ComicLand $label: missing data');
}
return Map<String, dynamic>.from(data);
}
Future<Map<String, dynamic>> _get(
String path, {
Map<String, dynamic>? query,
String label = 'request',
}) async {
final res = await _dio.get<dynamic>(path, queryParameters: query);
return _unwrap(res.data, label);
}
List<CatalogComic> _listFrom(Map<String, dynamic> data, {String listKey = 'list'}) {
final raw = data[listKey];
if (raw is! List) return const [];
return raw
.whereType<Map>()
.map((e) => catalogFromJson(Map<String, dynamic>.from(e)))
.toList();
}
@override
Future<List<CatalogComic>> loadCatalog(
CatalogMenuKind kind, {
String query = '',
int offset = 0,
int limit = 30,
CatalogBrowseParams? browse,
}) async {
switch (kind) {
case CatalogMenuKind.hotToday:
case CatalogMenuKind.hotWeek:
case CatalogMenuKind.hotMonth:
case CatalogMenuKind.directory:
throw UnsupportedError('ComicLand has no ${kind.name} catalog feed');
case CatalogMenuKind.popular:
final data = await _get('/comics/popular', label: 'comics/popular');
final all = _listFrom(data);
if (offset == 0 && all.length <= limit) return all;
final end = (offset + limit).clamp(0, all.length);
if (offset >= all.length) return const [];
return all.sublist(offset, end);
case CatalogMenuKind.latest:
final data = await _get(
'/comics',
query: {'offset': offset, 'limit': limit, 'status': 'ongoing'},
label: 'comics/newest',
);
return _listFrom(data);
case CatalogMenuKind.official:
final data = await _get(
'/comics/official',
query: {'offset': offset, 'limit': limit},
label: 'comics/official',
);
return _listFrom(data);
case CatalogMenuKind.uncensored:
final data = await _get(
'/comic/search',
query: {'q': 'uncensored', 'offset': offset, 'limit': limit},
label: 'comic/search/uncensored',
);
return _listFrom(data, listKey: 'items');
case CatalogMenuKind.recommended:
final data = await _get(
'/comics',
query: {'offset': offset, 'limit': limit},
label: 'comics/recommended',
);
return _listFrom(data);
case CatalogMenuKind.search:
final q = query.trim();
if (q.length < 2) return const [];
final data = await _get(
'/comic/search',
query: {'q': q, 'offset': offset, 'limit': limit},
label: 'comic/search',
);
return _listFrom(data, listKey: 'items');
}
}
@override
Future<SeriesDetail> fetchSeries(String listingUrl) async {
final parts = parseComicLandUrl(listingUrl);
final data = await _get(
'/comic/detail',
query: {'slug': parts.slug},
label: 'comic/detail',
);
final title = (data['title'] as String?)?.trim().isNotEmpty == true
? (data['title'] as String).trim()
: parts.slug;
final rawChapters = data['chapters'];
final chapters = <ChapterLink>[];
if (rawChapters is List) {
for (final row in rawChapters) {
if (row is! Map) continue;
final map = Map<String, dynamic>.from(row);
final index = _chapterIndexString(map['chapter_index']);
if (index == null) continue;
final label = (map['title'] as String?)?.trim().replaceAll(RegExp(r'\s+'), ' ');
final chapterTitle =
(label != null && label.isNotEmpty) ? label : 'Chapter $index';
final url = chapterUrlFromParts(parts.origin, parts.slug, index);
chapters.add(
ChapterLink(
url: url,
title: chapterTitle,
chapterKey: chapterKeyFromUrl(url),
chapterNumber: chapterNumberFromTitle(chapterTitle) ?? index,
),
);
}
}
chapters.sort((a, b) {
final na = double.tryParse(Uri.parse(a.url).pathSegments.last);
final nb = double.tryParse(Uri.parse(b.url).pathSegments.last);
if (na != null && nb != null && na != nb) return na.compareTo(nb);
return a.url.compareTo(b.url);
});
final listing = listingUrlForSlug(parts.slug);
return SeriesDetail(
source: SourceId.comicland,
listingUrl: listing,
slug: parts.slug,
title: title,
coverUrl: data['cover_url'] as String?,
description: data['description'] as String?,
chapters: chapters,
);
}
@override
Future<List<String>> fetchPageUrls(String chapterUrl) async {
final parts = parseComicLandUrl(chapterUrl);
if (parts.chapterIndex == null) {
throw StateError('ComicLand chapter URL is missing an index: $chapterUrl');
}
final data = await _get(
'/chapter/pages_by_index',
query: {'slug': parts.slug, 'index': parts.chapterIndex},
label: 'chapter/pages_by_index',
);
final pages = data['pages'];
if (pages is! List) return const [];
final urls = <String>[];
for (final raw in pages) {
if (raw is! String || raw.trim().isEmpty) continue;
try {
urls.add(Uri.parse(raw.trim()).toString());
} catch (_) {}
}
return urls;
}
String? _chapterIndexString(dynamic rawIndex) {
if (rawIndex is num && rawIndex.isFinite) {
if (rawIndex == rawIndex.roundToDouble()) {
return rawIndex.toInt().toString();
}
return rawIndex.toString();
}
if (rawIndex is String && rawIndex.trim().isNotEmpty) {
return rawIndex.trim();
}
return null;
}
}
@@ -0,0 +1,33 @@
// ComicLand API / CDN constants (port of cxyz `src/comicland/constants.js`).
const comicLandHosts = <String>['comicland.org', 'www.comicland.org'];
const comicLandOrigin = 'https://comicland.org/';
const comicLandApiOrigin = 'https://api.comicland.org';
/// Axios-style base used by the SPA: `https://api.comicland.org/api`.
const comicLandApiBase = '$comicLandApiOrigin/api';
const comicLandDefaultListing =
'https://comicland.org/comic/the-delinquent-girl';
const comicLandUserAgent =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const comicLandApiHeaders = <String, String>{
'Accept': 'application/json,text/plain,*/*',
'Accept-Language': 'en-US,en;q=0.9',
'Origin': 'https://comicland.org',
'Referer': comicLandOrigin,
'User-Agent': comicLandUserAgent,
};
const comicLandCdnHeaders = <String, String>{
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': comicLandOrigin,
'Origin': 'https://comicland.org',
'User-Agent': comicLandUserAgent,
};
@@ -0,0 +1,149 @@
import '../source.dart';
import 'constants.dart';
class ComicLandUrlParts {
const ComicLandUrlParts({
required this.origin,
required this.slug,
this.chapterIndex,
});
final String origin;
final String slug;
final String? chapterIndex;
}
bool isLikelyComicLandSite(String url) {
try {
final host = Uri.parse(url).host.toLowerCase();
return comicLandHosts.contains(host) || host.contains('comicland');
} catch (_) {
return false;
}
}
String seriesSlugFromListingUrl(String pageUrl) {
try {
final parts = Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
final comicIdx = parts.indexOf('comic');
if (comicIdx != -1 && comicIdx + 1 < parts.length) {
return parts[comicIdx + 1];
}
} catch (_) {}
return 'comic';
}
ComicLandUrlParts parseComicLandUrl(String pageUrl) {
final u = Uri.parse(pageUrl);
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
final comicIdx = parts.indexOf('comic');
final slug =
comicIdx != -1 && comicIdx + 1 < parts.length ? parts[comicIdx + 1] : 'comic';
String? chapterIndex;
final chapterIdx = parts.indexOf('chapter');
if (chapterIdx != -1 && chapterIdx + 1 < parts.length) {
chapterIndex = parts[chapterIdx + 1];
}
return ComicLandUrlParts(
origin: '${u.scheme}://${u.host}',
slug: slug,
chapterIndex: chapterIndex,
);
}
String chapterUrlFromParts(String siteOrigin, String slug, Object chapterIndex) {
final origin = siteOrigin.replaceAll(RegExp(r'/+$'), '');
return '$origin/comic/$slug/chapter/$chapterIndex';
}
String listingUrlForSlug(String slug) =>
'${comicLandOrigin.replaceAll(RegExp(r'/+$'), '')}/comic/$slug';
String chapterKeyFromUrl(String chapterUrl) {
try {
final segs = Uri.parse(chapterUrl).pathSegments.where((s) => s.isNotEmpty).toList();
return segs.isEmpty ? 'chapter' : segs.last;
} catch (_) {
return 'chapter';
}
}
String? chapterNumberFromTitle(String title) {
final m = RegExp(r'^Chapter\s+([\d.]+)', caseSensitive: false)
.firstMatch(title.trim().replaceAll(RegExp(r'\s+'), ' '));
return m?.group(1);
}
String sanitizeFsStem(String stem) {
final cleaned = stem
.replaceAll(RegExp(r'[^a-z0-9._-]+', caseSensitive: false), '_')
.replaceAll(RegExp(r'^_+|_+$'), '');
if (cleaned.isEmpty) return 'chapter';
return cleaned.length > 180 ? cleaned.substring(0, 180) : cleaned;
}
String sanitizeComicDirName(String name) {
final collapsed = name.trim().replaceAll(RegExp(r'\s+'), ' ');
final cleaned = String.fromCharCodes(
collapsed.runes.where((code) {
if (code < 32 || code == 127) return false;
final ch = String.fromCharCode(code);
return !RegExp(r'[<>:"/\\|?*]').hasMatch(ch);
}),
).replaceAll(RegExp(r'\.+$'), '').trim();
if (cleaned.isEmpty) return 'comic';
return cleaned.length > 180 ? cleaned.substring(0, 180) : cleaned;
}
String stemForComicLandChapterCbz(String chapterPageUrl) {
try {
final segs =
Uri.parse(chapterPageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
final comicIdx = segs.indexOf('comic');
if (comicIdx != -1 &&
segs.length >= comicIdx + 4 &&
segs[comicIdx + 2] == 'chapter') {
return '${segs[comicIdx + 1]}-chapter-${segs[comicIdx + 3]}';
}
if (segs.length >= 2) {
return '${segs[segs.length - 2]}-${segs.last}';
}
return segs.isEmpty ? 'chapter' : segs.last;
} catch (_) {
return 'chapter';
}
}
String transferLinkBasename(String? chapterNumber, String? label, String? cbzPath) {
final candidates = [chapterNumber, label]
.whereType<String>()
.where((s) => s.trim().isNotEmpty);
for (final raw in candidates) {
final text = raw.trim();
final m = RegExp(r'(\d+)((?:\.\d+)*)([a-zA-Z]*)').firstMatch(text);
if (m != null) {
final padded = m.group(1)!.padLeft(3, '0');
return 'chapter $padded${m.group(2) ?? ''}${m.group(3) ?? ''}.cbz';
}
}
if (cbzPath != null && cbzPath.isNotEmpty) {
final base = cbzPath.split('/').last;
if (base.isNotEmpty) return base;
}
return 'chapter unknown.cbz';
}
CatalogComic catalogFromJson(Map<String, dynamic> row) {
final slug = (row['slug'] as String?)?.trim() ?? 'comic';
return CatalogComic(
source: SourceId.comicland,
slug: slug,
title: (row['title'] as String?)?.trim().isNotEmpty == true
? (row['title'] as String).trim()
: slug,
listingUrl: listingUrlForSlug(slug),
coverUrl: row['cover_url'] as String?,
description: row['description'] as String?,
rating: (row['rating'] is num) ? (row['rating'] as num).toDouble() : null,
);
}
@@ -0,0 +1,32 @@
/// MangaRead / Madara constants (port of cxyz mangaread fetch helpers).
library;
const mangaReadHosts = <String>['www.mangaread.org', 'mangaread.org'];
const mangaReadOrigin = 'https://www.mangaread.org/';
const mangaReadDesktopUa =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const mangaReadMobileUa =
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 '
'(KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1';
const mangaReadHtmlHeaders = <String, String>{
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'User-Agent': mangaReadDesktopUa,
};
const mangaReadReaderHeaders = <String, String>{
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'User-Agent': mangaReadMobileUa,
};
const mangaReadCdnHeaders = <String, String>{
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': mangaReadOrigin,
'User-Agent': mangaReadDesktopUa,
};
@@ -0,0 +1,328 @@
import 'package:dio/dio.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
import '../source.dart';
import 'constants.dart';
import 'urls.dart';
/// MangaRead / Madara HTML adapter (port of cxyz `mangareadFetchChapters.js`).
class MangaReadSource implements ComicSource {
MangaReadSource({Dio? dio})
: _dio = dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 60),
headers: mangaReadHtmlHeaders,
responseType: ResponseType.plain,
validateStatus: (s) => s != null && s >= 200 && s < 300,
),
);
final Dio _dio;
@override
SourceId get id => SourceId.mangaread;
@override
String get displayName => 'MangaRead';
@override
String get blurb => 'Latest and search via mangaread.org (Madara HTML)';
@override
List<CatalogMenu> get catalogMenus => const [
CatalogMenu(kind: CatalogMenuKind.latest, title: 'Latest'),
CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'),
];
@override
Map<String, String> get imageHeaders => Map.unmodifiable(mangaReadCdnHeaders);
Never _cloudflareOrRethrow(DioException e, String label) {
final code = e.response?.statusCode;
if (code == 403) {
throw StateError(
'MangaRead $label blocked (HTTP 403 / Cloudflare). '
'Direct fetch is not available right now.',
);
}
throw StateError('MangaRead $label failed: ${e.message ?? e}');
}
Future<String> _getHtml(
String url, {
Map<String, String>? headers,
String label = 'request',
}) async {
try {
final res = await _dio.get<String>(
url,
options: Options(headers: headers ?? mangaReadHtmlHeaders),
);
return res.data ?? '';
} on DioException catch (e) {
_cloudflareOrRethrow(e, label);
}
}
Future<String> _postAjaxChapters(String mangaBaseUrl, String refererUrl) async {
final base = mangaBaseUrl.endsWith('/') ? mangaBaseUrl : '$mangaBaseUrl/';
final ajaxUrl = Uri.parse(base).resolve('ajax/chapters/').toString();
final origin = Uri.parse(refererUrl).origin;
try {
final res = await _dio.post<String>(
ajaxUrl,
data: '',
options: Options(
headers: {
...mangaReadHtmlHeaders,
'Accept': '*/*',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Referer': refererUrl,
'Origin': origin,
'X-Requested-With': 'XMLHttpRequest',
},
responseType: ResponseType.plain,
),
);
return res.data ?? '';
} on DioException catch (e) {
_cloudflareOrRethrow(e, 'ajax/chapters');
}
}
@override
Future<List<CatalogComic>> loadCatalog(
CatalogMenuKind kind, {
String query = '',
int offset = 0,
int limit = 30,
CatalogBrowseParams? browse,
}) async {
switch (kind) {
case CatalogMenuKind.popular:
throw UnsupportedError('MangaRead has no popular catalog feed');
case CatalogMenuKind.official:
throw UnsupportedError('MangaRead has no official catalog feed');
case CatalogMenuKind.uncensored:
throw UnsupportedError('MangaRead has no uncensored catalog feed');
case CatalogMenuKind.recommended:
throw UnsupportedError('MangaRead has no recommended catalog feed');
case CatalogMenuKind.hotToday:
case CatalogMenuKind.hotWeek:
case CatalogMenuKind.hotMonth:
case CatalogMenuKind.directory:
throw UnsupportedError('MangaRead has no ${kind.name} catalog feed');
case CatalogMenuKind.latest:
// Madara HTML pages are ~12 titles each. Callers pass offset as
// (pageIndex * theirLimit); map that to a 1-based site page.
final page = (offset ~/ (limit > 0 ? limit : 12)) + 1;
final url = page <= 1
? '${mangaReadOrigin}manga/?m_orderby=latest'
: '${mangaReadOrigin}manga/page/$page/?m_orderby=latest';
final html = await _getHtml(url, label: 'latest');
return _parseCatalogCards(html, baseHref: url);
case CatalogMenuKind.search:
final q = query.trim();
if (q.length < 2) return const [];
// Same page mapping as latest; Madara search paginates via /page/N/.
final page = (offset ~/ (limit > 0 ? limit : 12)) + 1;
final uri = page <= 1
? Uri.parse(mangaReadOrigin).replace(
queryParameters: {
's': q,
'post_type': 'wp-manga',
},
)
: Uri.parse(
'${mangaReadOrigin}page/$page/',
).replace(
queryParameters: {
's': q,
'post_type': 'wp-manga',
},
);
final html = await _getHtml(uri.toString(), label: 'search');
return _parseCatalogCards(html, baseHref: uri.toString());
}
}
List<CatalogComic> _parseCatalogCards(String html, {required String baseHref}) {
final doc = html_parser.parse(html);
final cards = <dom.Element>[
...doc.querySelectorAll('.page-item-detail'),
...doc.querySelectorAll('.c-tabs-item__content'),
];
final seen = <String>{};
final out = <CatalogComic>[];
for (final card in cards) {
final titleA = card.querySelector('.post-title a') ??
card.querySelector('h3 a') ??
card.querySelector('h5 a') ??
card.querySelector('a');
if (titleA == null) continue;
final href = titleA.attributes['href']?.trim();
if (href == null || href.isEmpty) continue;
final listingUrl = absoluteUrl(href, baseHref);
if (!listingUrl.contains('/manga/')) continue;
final slug = mangaSlugFromUrl(listingUrl);
if (slug == 'comic' || !seen.add(slug)) continue;
final title = titleA.text.trim().replaceAll(RegExp(r'\s+'), ' ');
if (title.isEmpty) continue;
String? coverUrl;
final img = card.querySelector('img');
if (img != null) {
coverUrl = _pickImageSrc(img);
if (coverUrl != null) {
coverUrl = absoluteUrl(coverUrl, baseHref);
}
}
out.add(
CatalogComic(
source: SourceId.mangaread,
slug: slug,
title: title,
listingUrl: listingUrlForSlug(slug),
coverUrl: coverUrl,
),
);
}
return out;
}
String? _pickImageSrc(dom.Element img) {
for (final attr in [
'data-src',
'data-lazy-src',
'data-original',
'data-full-url',
'src',
]) {
final raw = img.attributes[attr]?.trim();
if (raw != null && raw.isNotEmpty && !raw.startsWith('data:')) {
return raw;
}
}
return null;
}
List<ChapterLink> _extractChapters(String html, String baseHref) {
final doc = html_parser.parse(html);
final items = doc.querySelectorAll('li.wp-manga-chapter');
final chapters = <ChapterLink>[];
for (final li in items) {
final a = li.querySelector('a');
if (a == null) continue;
final href = a.attributes['href']?.trim();
if (href == null || href.isEmpty) continue;
final title = a.text.trim().replaceAll(RegExp(r'\s+'), ' ');
final url = absoluteUrl(href, baseHref);
chapters.add(
ChapterLink(
url: url,
title: title.isNotEmpty ? title : 'Chapter',
chapterKey: chapterKeyFromUrl(url),
chapterNumber: chapterNumberFromTitle(title),
),
);
}
return chapters;
}
String? _extractTitle(String html) {
final doc = html_parser.parse(html);
final og = doc.querySelector('meta[property="og:title"]');
final ogContent = og?.attributes['content']?.trim();
if (ogContent != null && ogContent.isNotEmpty) {
return ogContent.replaceAll(RegExp(r'\s+'), ' ');
}
final h1 = doc.querySelector('.profile-manga .post-title h1');
final h1Text = h1?.text.trim().replaceAll(RegExp(r'\s+'), ' ');
if (h1Text != null && h1Text.isNotEmpty) return h1Text;
final titleEl = doc.querySelector('title');
final titleText = titleEl?.text.trim() ?? '';
final m = RegExp(r'^Read\s+(.+?)\s+-\s+manga', caseSensitive: false)
.firstMatch(titleText);
if (m != null) {
return m.group(1)!.trim().replaceAll(RegExp(r'\s+'), ' ');
}
return null;
}
String? _extractCover(String html, String baseHref) {
final doc = html_parser.parse(html);
final og = doc.querySelector('meta[property="og:image"]');
final ogUrl = og?.attributes['content']?.trim();
if (ogUrl != null && ogUrl.isNotEmpty) {
return absoluteUrl(ogUrl, baseHref);
}
final img = doc.querySelector('.summary_image img') ??
doc.querySelector('.tab-summary img');
if (img == null) return null;
final raw = _pickImageSrc(img);
if (raw == null) return null;
return absoluteUrl(raw, baseHref);
}
@override
Future<SeriesDetail> fetchSeries(String listingUrl) async {
final base = mangaBaseUrlFromListingUrl(listingUrl);
final html = await _getHtml(base, label: 'series');
var chapters = _extractChapters(html, base);
if (chapters.isEmpty) {
final ajaxHtml = await _postAjaxChapters(base, listingUrl);
chapters = _extractChapters(ajaxHtml, base);
}
// Madara lists newest first; store oldest → newest for consistent numbering.
if (chapters.length > 1) {
chapters = chapters.reversed.toList(growable: false);
}
final slug = mangaSlugFromUrl(base);
final title = _extractTitle(html) ?? slug;
return SeriesDetail(
source: SourceId.mangaread,
listingUrl: base,
slug: slug,
title: title,
coverUrl: _extractCover(html, base),
chapters: chapters,
);
}
List<String> _collectPageUrls(String html, String baseHref) {
final doc = html_parser.parse(html);
final imgs = doc.querySelectorAll('img.wp-manga-chapter-img');
final urls = <String>[];
for (final img in imgs) {
final raw = _pickImageSrc(img);
if (raw == null) continue;
try {
urls.add(absoluteUrl(raw, baseHref));
} catch (_) {}
}
return urls;
}
@override
Future<List<String>> fetchPageUrls(String chapterUrl) async {
var html = await _getHtml(
chapterUrl,
headers: mangaReadReaderHeaders,
label: 'chapter (mobile)',
);
var urls = _collectPageUrls(html, chapterUrl);
if (urls.isEmpty) {
html = await _getHtml(chapterUrl, label: 'chapter (desktop)');
urls = _collectPageUrls(html, chapterUrl);
}
return urls;
}
}
@@ -0,0 +1,47 @@
String mangaSlugFromUrl(String pageUrl) {
try {
final parts = Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
final mi = parts.indexOf('manga');
if (mi != -1 && mi + 1 < parts.length) {
return parts[mi + 1];
}
} catch (_) {}
return 'comic';
}
String mangaBaseUrlFromListingUrl(String pageUrl) {
final u = Uri.parse(pageUrl);
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
final mi = parts.indexOf('manga');
if (mi == -1 || mi + 1 >= parts.length) {
throw StateError(
'Could not find /manga/{slug}/ in URL; use a manga or chapter link from MangaRead.',
);
}
final slug = parts[mi + 1];
return Uri(scheme: u.scheme, host: u.host, path: '/manga/$slug/').toString();
}
String listingUrlForSlug(String slug, {String origin = 'https://www.mangaread.org'}) {
final base = origin.replaceAll(RegExp(r'/+$'), '');
return '$base/manga/$slug/';
}
String chapterKeyFromUrl(String chapterUrl) {
try {
final segs = Uri.parse(chapterUrl).pathSegments.where((s) => s.isNotEmpty).toList();
return segs.isEmpty ? 'chapter' : segs.last;
} catch (_) {
return 'chapter';
}
}
String? chapterNumberFromTitle(String title) {
final m = RegExp(r'(?:Chapter|Ch\.?)\s*([\d.]+)', caseSensitive: false)
.firstMatch(title.trim().replaceAll(RegExp(r'\s+'), ' '));
return m?.group(1);
}
String absoluteUrl(String href, String baseHref) {
return Uri.parse(baseHref).resolve(href.trim()).toString();
}
@@ -0,0 +1,24 @@
/// MangaTown mobile (`m.mangatown.com`) constants.
library;
const mangaTownOrigin = 'https://m.mangatown.com';
const mangaTownMobileUa =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 '
'(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
const mangaTownHtmlHeaders = <String, String>{
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'User-Agent': mangaTownMobileUa,
};
const mangaTownImageHeaders = <String, String>{
'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': '$mangaTownOrigin/',
'User-Agent': mangaTownMobileUa,
};
/// Site catalog page size (Latest / Hot / Directory).
const mangaTownCatalogPageSize = 20;
@@ -0,0 +1,307 @@
import 'package:dio/dio.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html_parser;
import '../source.dart';
import 'constants.dart';
import 'urls.dart';
/// MangaTown mobile HTML adapter (`m.mangatown.com`).
class MangaTownSource implements ComicSource {
MangaTownSource({Dio? dio})
: _dio = dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 60),
headers: mangaTownHtmlHeaders,
responseType: ResponseType.plain,
validateStatus: (s) => s != null && s >= 200 && s < 300,
),
);
final Dio _dio;
static const _pageFetchConcurrency = 4;
@override
SourceId get id => SourceId.mangatown;
@override
String get displayName => 'MangaTown';
@override
String get blurb =>
'Latest, hot rankings, directory filters, and search via m.mangatown.com';
@override
List<CatalogMenu> get catalogMenus => const [
CatalogMenu(kind: CatalogMenuKind.latest, title: 'Latest'),
CatalogMenu(kind: CatalogMenuKind.popular, title: 'Hot'),
CatalogMenu(kind: CatalogMenuKind.hotToday, title: 'Hot Today'),
CatalogMenu(kind: CatalogMenuKind.hotWeek, title: 'Hot Week'),
CatalogMenu(kind: CatalogMenuKind.hotMonth, title: 'Hot Month'),
CatalogMenu(kind: CatalogMenuKind.directory, title: 'Directory'),
CatalogMenu(kind: CatalogMenuKind.search, title: 'Search'),
];
@override
Map<String, String> get imageHeaders =>
Map.unmodifiable(mangaTownImageHeaders);
Future<String> _getHtml(
String url, {
Map<String, String>? headers,
String label = 'request',
}) async {
try {
final res = await _dio.get<String>(
url,
options: Options(headers: headers ?? mangaTownHtmlHeaders),
);
return res.data ?? '';
} on DioException catch (e) {
throw StateError('MangaTown $label failed: ${e.message ?? e}');
}
}
/// CatalogFeedPage uses caller `limit` as page stride (like MangaRead).
int _sitePage(int offset, int limit) {
final stride = limit > 0 ? limit : mangaTownCatalogPageSize;
return (offset ~/ stride) + 1;
}
@override
Future<List<CatalogComic>> loadCatalog(
CatalogMenuKind kind, {
String query = '',
int offset = 0,
int limit = 30,
CatalogBrowseParams? browse,
}) async {
final page = _sitePage(offset, limit);
late final String url;
switch (kind) {
case CatalogMenuKind.official:
case CatalogMenuKind.uncensored:
case CatalogMenuKind.recommended:
throw UnsupportedError('MangaTown has no ${kind.name} catalog feed');
case CatalogMenuKind.latest:
url = catalogLatestUrl(page: page);
case CatalogMenuKind.popular:
case CatalogMenuKind.hotToday:
case CatalogMenuKind.hotWeek:
case CatalogMenuKind.hotMonth:
final period = hotPeriodForMenu(kind)!;
url = catalogHotUrl(period: period, page: page);
case CatalogMenuKind.directory:
url = directoryUrl(browse ?? const CatalogBrowseParams(), page: page);
case CatalogMenuKind.search:
final q = query.trim();
if (q.length < 2) return const [];
url = catalogSearchUrl(query: q, page: page);
}
final html = await _getHtml(url, label: kind.name);
return _parseCatalogCards(html, baseHref: url);
}
List<CatalogComic> _parseCatalogCards(String html, {required String baseHref}) {
final doc = html_parser.parse(html);
final posts = doc.querySelectorAll('ul.post-list li div.post');
final seen = <String>{};
final out = <CatalogComic>[];
for (final post in posts) {
final coverA = post.querySelector('a.manga-cover');
final href = coverA?.attributes['href']?.trim() ??
post.querySelector('a[href*="/manga/"]')?.attributes['href']?.trim();
if (href == null || href.isEmpty) continue;
final listingUrl = absoluteUrl(href, baseHref);
if (!listingUrl.contains('/manga/')) continue;
final slug = mangaSlugFromUrl(listingUrl);
if (slug == 'comic' || !seen.add(slug)) continue;
var title = coverA?.attributes['rel']?.trim() ?? '';
if (title.isEmpty) {
title = post
.querySelector('.post-info .title')
?.text
.trim()
.replaceAll(RegExp(r'\s+'), ' ') ??
'';
}
if (title.isEmpty) title = slug;
String? coverUrl;
final img = post.querySelector('img');
final rawSrc = img?.attributes['src']?.trim();
if (rawSrc != null && rawSrc.isNotEmpty && !rawSrc.startsWith('data:')) {
coverUrl = absoluteUrl(rawSrc, baseHref);
}
out.add(
CatalogComic(
source: SourceId.mangatown,
slug: slug,
title: title,
listingUrl: listingUrlForSlug(slug),
coverUrl: coverUrl,
),
);
}
return out;
}
String? _extractTitle(String html) {
final doc = html_parser.parse(html);
final t = doc
.querySelector('.manga-detail-top .title')
?.text
.trim()
.replaceAll(RegExp(r'\s+'), ' ');
if (t != null && t.isNotEmpty) return t;
final titleEl = doc.querySelector('title')?.text.trim() ?? '';
final m = RegExp(
r'Read\s+(.+?)\s+Manga',
caseSensitive: false,
).firstMatch(titleEl);
return m?.group(1)?.trim();
}
String? _extractCover(String html, String baseHref) {
final doc = html_parser.parse(html);
final img = doc.querySelector('img.detail-cover');
final raw = img?.attributes['src']?.trim();
if (raw == null || raw.isEmpty) return null;
return absoluteUrl(raw, baseHref);
}
String? _extractDescription(String html) {
final doc = html_parser.parse(html);
final show = doc.querySelector('#show');
if (show != null) {
final clone = show.clone(true);
clone.querySelectorAll('a').forEach((a) => a.remove());
final text = clone.text.trim().replaceAll(RegExp(r'\s+'), ' ');
if (text.isNotEmpty) return text;
}
final hide = doc.querySelector('#hide');
if (hide != null) {
final clone = hide.clone(true);
clone.querySelectorAll('a').forEach((a) => a.remove());
final text = clone.text.trim().replaceAll(RegExp(r'\s+'), ' ');
if (text.isNotEmpty) return text;
}
return null;
}
List<ChapterLink> _extractChapters(String html, String baseHref) {
final doc = html_parser.parse(html);
final items = doc.querySelectorAll('ul.detail-ch-list li a');
final chapters = <ChapterLink>[];
for (final a in items) {
final href = a.attributes['href']?.trim();
if (href == null || href.isEmpty) continue;
final clone = a.clone(true);
clone.querySelectorAll('span').forEach((s) => s.remove());
final title = clone.text.trim().replaceAll(RegExp(r'\s+'), ' ');
final url = absoluteUrl(href, baseHref);
final key = chapterKeyFromUrl(url);
chapters.add(
ChapterLink(
url: chapterBaseUrl(url),
title: title.isNotEmpty ? title : key,
chapterKey: key,
chapterNumber: chapterNumberFromTitle(title),
),
);
}
return chapters;
}
@override
Future<SeriesDetail> fetchSeries(String listingUrl) async {
final base = mangaBaseUrlFromListingUrl(listingUrl);
final html = await _getHtml(base, label: 'series');
var chapters = _extractChapters(html, base);
if (chapters.length > 1) {
chapters = chapters.reversed.toList(growable: false);
}
final slug = mangaSlugFromUrl(base);
return SeriesDetail(
source: SourceId.mangatown,
listingUrl: listingUrlForSlug(slug),
slug: slug,
title: _extractTitle(html) ?? slug,
coverUrl: _extractCover(html, base),
description: _extractDescription(html),
chapters: chapters,
);
}
int _pageCountFromHtml(String html) {
final doc = html_parser.parse(html);
final options = doc.querySelectorAll('select.index-page option');
if (options.isNotEmpty) return options.length;
final selects = doc.querySelectorAll('.ch-select select option');
if (selects.isNotEmpty) return selects.length;
final pager = doc.querySelector('#pager')?.text ?? '';
final m = RegExp(r'/\s*(\d+)').firstMatch(pager);
if (m != null) {
return int.tryParse(m.group(1)!) ?? 1;
}
return 1;
}
String? _imageFromReaderHtml(String html, String baseHref) {
final doc = html_parser.parse(html);
dom.Element? img =
doc.querySelector('#viewer img#image') ?? doc.querySelector('img#image');
final raw = img?.attributes['src']?.trim();
if (raw == null || raw.isEmpty || raw.startsWith('data:')) return null;
return absoluteUrl(raw, baseHref);
}
Future<List<String>> _fetchPagesParallel(
String chapterBase,
int fromPage,
int total,
) async {
if (fromPage > total) return const [];
final count = total - fromPage + 1;
final urls = List<String?>.filled(count, null);
var next = fromPage;
Future<void> worker() async {
while (true) {
final i = next;
next++;
if (i > total) return;
final pageUrl = chapterPageUrl(chapterBase, i);
final html = await _getHtml(pageUrl, label: 'chapter page $i');
urls[i - fromPage] = _imageFromReaderHtml(html, pageUrl);
}
}
final workers = List.generate(
_pageFetchConcurrency.clamp(1, count),
(_) => worker(),
);
await Future.wait(workers);
return urls.whereType<String>().toList(growable: false);
}
@override
Future<List<String>> fetchPageUrls(String chapterUrl) async {
final base = chapterBaseUrl(chapterUrl);
final firstHtml = await _getHtml(base, label: 'chapter');
final total = _pageCountFromHtml(firstHtml);
final first = _imageFromReaderHtml(firstHtml, base);
if (total <= 1) {
return first == null ? const [] : [first];
}
final rest = await _fetchPagesParallel(base, 2, total);
if (first == null) return rest;
return [first, ...rest];
}
}
@@ -0,0 +1,245 @@
import '../source.dart';
import 'constants.dart';
/// Hot ranking period for `/hot` feeds.
enum MangaTownHotPeriod {
total,
today,
week,
month,
}
String? hotPeriodQuery(MangaTownHotPeriod period) {
switch (period) {
case MangaTownHotPeriod.total:
return null;
case MangaTownHotPeriod.today:
return 'tviews.za';
case MangaTownHotPeriod.week:
return 'wviews.za';
case MangaTownHotPeriod.month:
return 'mviews.za';
}
}
MangaTownHotPeriod? hotPeriodForMenu(CatalogMenuKind kind) {
switch (kind) {
case CatalogMenuKind.popular:
return MangaTownHotPeriod.total;
case CatalogMenuKind.hotToday:
return MangaTownHotPeriod.today;
case CatalogMenuKind.hotWeek:
return MangaTownHotPeriod.week;
case CatalogMenuKind.hotMonth:
return MangaTownHotPeriod.month;
default:
return null;
}
}
/// Page 1 uses the bare path; page 2+ appends `/{n}.html`.
String _pagedPath(String path, int page) {
final base = path.replaceAll(RegExp(r'/+$'), '');
if (page <= 1) return base;
return '$base/$page.html';
}
Uri _withOptionalQuery(String path, String? queryToken) {
final uri = Uri.parse('$mangaTownOrigin$path');
if (queryToken == null || queryToken.isEmpty) return uri;
// Site uses bare tokens like `?wviews.za` (no `=` value).
return Uri.parse('${uri.toString()}?$queryToken');
}
String catalogLatestUrl({int page = 1}) {
return _withOptionalQuery(_pagedPath('/latest', page), null).toString();
}
String catalogHotUrl({
MangaTownHotPeriod period = MangaTownHotPeriod.total,
int page = 1,
}) {
return _withOptionalQuery(
_pagedPath('/hot', page),
hotPeriodQuery(period),
).toString();
}
/// `/directory/{demographic}-{genre}-0-{status}-{initial}-0`
String directoryUrl(
CatalogBrowseParams browse, {
int page = 1,
}) {
final demo = _seg(browse.demographic);
final genre = _seg(browse.genre);
final status = _seg(browse.status);
final initial = _seg(browse.initial);
final path = _pagedPath(
'/directory/$demo-$genre-0-$status-$initial-0',
page,
);
return '$mangaTownOrigin$path';
}
String catalogSearchUrl({required String query, int page = 1}) {
final path = _pagedPath('/search', page);
return Uri.parse('$mangaTownOrigin$path').replace(
queryParameters: {'name': query},
).toString();
}
String _seg(String raw) {
final t = raw.trim();
if (t.isEmpty) return '0';
return t;
}
String mangaSlugFromUrl(String pageUrl) {
try {
final parts =
Uri.parse(pageUrl).pathSegments.where((s) => s.isNotEmpty).toList();
final mi = parts.indexOf('manga');
if (mi != -1 && mi + 1 < parts.length) {
return parts[mi + 1];
}
} catch (_) {}
return 'comic';
}
String listingUrlForSlug(String slug, {String origin = mangaTownOrigin}) {
final base = origin.replaceAll(RegExp(r'/+$'), '');
return '$base/manga/$slug';
}
String mangaBaseUrlFromListingUrl(String pageUrl) {
final u = Uri.parse(pageUrl);
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
final mi = parts.indexOf('manga');
if (mi == -1 || mi + 1 >= parts.length) {
throw StateError(
'Could not find /manga/{slug}/ in URL; use a manga or chapter link from MangaTown.',
);
}
final slug = parts[mi + 1];
return Uri(scheme: u.scheme, host: u.host, path: '/manga/$slug').toString();
}
/// Strip reader page suffixes like `/2.html` so we keep `/manga/{slug}/c005/`.
String chapterBaseUrl(String chapterUrl) {
final u = Uri.parse(chapterUrl);
final parts = u.pathSegments.where((s) => s.isNotEmpty).toList();
if (parts.isEmpty) return chapterUrl;
final last = parts.last;
if (RegExp(r'^\d+\.html$', caseSensitive: false).hasMatch(last)) {
parts.removeLast();
}
final path = '/${parts.join('/')}/';
return Uri(scheme: u.scheme, host: u.host, path: path).toString();
}
/// 1-based reader page URL under a chapter base.
String chapterPageUrl(String chapterBase, int pageIndex) {
final base = chapterBaseUrl(chapterBase);
if (pageIndex <= 1) return base;
final trimmed = base.replaceAll(RegExp(r'/+$'), '');
return '$trimmed/$pageIndex.html';
}
String chapterKeyFromUrl(String chapterUrl) {
try {
final segs = Uri.parse(chapterBaseUrl(chapterUrl))
.pathSegments
.where((s) => s.isNotEmpty)
.toList();
return segs.isEmpty ? 'chapter' : segs.last;
} catch (_) {
return 'chapter';
}
}
String? chapterNumberFromTitle(String title) {
final cleaned = title.trim().replaceAll(RegExp(r'\s+'), ' ');
final m = RegExp(
r'(?:Chapter|Ch\.?|C\.?)\s*([\d.]+)',
caseSensitive: false,
).firstMatch(cleaned);
return m?.group(1);
}
String absoluteUrl(String href, String baseHref) {
final resolved = Uri.parse(baseHref).resolve(href.trim()).toString();
if (resolved.startsWith('//')) {
return 'https:$resolved';
}
return resolved;
}
/// Demographics offered by MangaTown directory filters.
const mangaTownDemographics = <MapEntry<String, String>>[
MapEntry('0', 'All'),
MapEntry('josei', 'Josei'),
MapEntry('seinen', 'Seinen'),
MapEntry('shoujo', 'Shoujo'),
MapEntry('shoujo_ai', 'Shoujo Ai'),
MapEntry('shounen', 'Shounen'),
MapEntry('shounen_ai', 'Shounen Ai'),
MapEntry('yaoi', 'Yaoi'),
MapEntry('yuri', 'Yuri'),
];
/// Genre slug → label for directory filters.
const mangaTownGenres = <MapEntry<String, String>>[
MapEntry('0', 'All'),
MapEntry('4_koma', '4 Koma'),
MapEntry('action', 'Action'),
MapEntry('adventure', 'Adventure'),
MapEntry('comedy', 'Comedy'),
MapEntry('cooking', 'Cooking'),
MapEntry('doujinshi', 'Doujinshi'),
MapEntry('drama', 'Drama'),
MapEntry('ecchi', 'Ecchi'),
MapEntry('fantasy', 'Fantasy'),
MapEntry('gender_bender', 'Gender Bender'),
MapEntry('harem', 'Harem'),
MapEntry('historical', 'Historical'),
MapEntry('horror', 'Horror'),
MapEntry('martial_arts', 'Martial Arts'),
MapEntry('mature', 'Mature'),
MapEntry('mecha', 'Mecha'),
MapEntry('music', 'Music'),
MapEntry('mystery', 'Mystery'),
MapEntry('one_shot', 'One Shot'),
MapEntry('psychological', 'Psychological'),
MapEntry('reverse_harem', 'Reverse Harem'),
MapEntry('romance', 'Romance'),
MapEntry('school_life', 'School Life'),
MapEntry('sci_fi', 'Sci Fi'),
MapEntry('shotacon', 'Shotacon'),
MapEntry('slice_of_life', 'Slice Of Life'),
MapEntry('smut', 'Smut'),
MapEntry('sports', 'Sports'),
MapEntry('supernatural', 'Supernatural'),
MapEntry('suspense', 'Suspense'),
MapEntry('tragedy', 'Tragedy'),
MapEntry('vampire', 'Vampire'),
MapEntry('webtoons', 'Webtoons'),
MapEntry('youkai', 'Youkai'),
];
const mangaTownStatuses = <MapEntry<String, String>>[
MapEntry('0', 'All'),
MapEntry('ongoing', 'Ongoing'),
MapEntry('new', 'New'),
MapEntry('completed', 'Completed'),
];
/// A–Z plus `0-9`.
List<MapEntry<String, String>> mangaTownInitials() {
final out = <MapEntry<String, String>>[const MapEntry('0', 'All')];
for (var c = 0; c < 26; c++) {
final letter = String.fromCharCode('a'.codeUnitAt(0) + c);
out.add(MapEntry(letter, letter.toUpperCase()));
}
out.add(const MapEntry('0-9', '0-9'));
return out;
}
+66
View File
@@ -0,0 +1,66 @@
import 'comicland/comicland_source.dart';
import 'mangaread/mangaread_source.dart';
import 'mangatown/mangatown_source.dart';
import 'source.dart';
import 'source_flags.dart';
class SourceRegistry {
SourceRegistry({
Set<SourceId>? enabled,
ComicSource? comicLand,
ComicSource? mangaRead,
ComicSource? mangaTown,
}) : _sources = _build(
enabled ?? enabledSourceIds(),
comicLand: comicLand,
mangaRead: mangaRead,
mangaTown: mangaTown,
);
final Map<SourceId, ComicSource> _sources;
static Map<SourceId, ComicSource> _build(
Set<SourceId> enabled, {
ComicSource? comicLand,
ComicSource? mangaRead,
ComicSource? mangaTown,
}) {
final map = <SourceId, ComicSource>{};
for (final id in enabled) {
switch (id) {
case SourceId.comicland:
map[id] = comicLand ?? ComicLandSource();
case SourceId.mangaread:
map[id] = mangaRead ?? MangaReadSource();
case SourceId.mangatown:
map[id] = mangaTown ?? MangaTownSource();
}
}
if (map.isEmpty) {
map[SourceId.comicland] = comicLand ?? ComicLandSource();
}
return map;
}
List<ComicSource> get all {
// Stable order matching SourceId declaration.
return SourceId.values
.where(_sources.containsKey)
.map((id) => _sources[id]!)
.toList(growable: false);
}
bool get hasMultiple => _sources.length > 1;
ComicSource get(SourceId id) {
final s = _sources[id];
if (s == null) {
throw StateError('Source not enabled in this build: $id');
}
return s;
}
bool isEnabled(SourceId id) => _sources.containsKey(id);
ComicSource get defaultSource => all.first;
}
+176
View File
@@ -0,0 +1,176 @@
// Shared models and source-adapter contract (port of cxyz `sources.js`).
enum SourceId {
comicland,
mangaread,
mangatown;
String get id => name;
String get label {
switch (this) {
case SourceId.comicland:
return 'ComicLand';
case SourceId.mangaread:
return 'MangaRead';
case SourceId.mangatown:
return 'MangaTown';
}
}
/// Bundled favicon asset (not fetched at runtime).
String get logoAsset => 'assets/sources/$name.png';
static SourceId? tryParse(String raw) {
final key = raw.trim().toLowerCase();
for (final id in SourceId.values) {
if (id.name == key) return id;
}
return null;
}
}
/// Browse menus a source may expose on its Catalog home.
enum CatalogMenuKind {
popular,
latest,
official,
uncensored,
recommended,
search,
hotToday,
hotWeek,
hotMonth,
directory;
String get pathSegment => name;
}
/// Optional directory filters (MangaTown). Segment `0` means All.
class CatalogBrowseParams {
const CatalogBrowseParams({
this.demographic = '0',
this.genre = '0',
this.status = '0',
this.initial = '0',
});
final String demographic;
final String genre;
final String status;
final String initial;
CatalogBrowseParams copyWith({
String? demographic,
String? genre,
String? status,
String? initial,
}) {
return CatalogBrowseParams(
demographic: demographic ?? this.demographic,
genre: genre ?? this.genre,
status: status ?? this.status,
initial: initial ?? this.initial,
);
}
@override
bool operator ==(Object other) {
return other is CatalogBrowseParams &&
other.demographic == demographic &&
other.genre == genre &&
other.status == status &&
other.initial == initial;
}
@override
int get hashCode => Object.hash(demographic, genre, status, initial);
}
class CatalogMenu {
const CatalogMenu({required this.kind, required this.title});
final CatalogMenuKind kind;
final String title;
}
class CatalogComic {
const CatalogComic({
required this.source,
required this.slug,
required this.title,
required this.listingUrl,
this.coverUrl,
this.description,
this.rating,
});
final SourceId source;
final String slug;
final String title;
final String listingUrl;
final String? coverUrl;
final String? description;
final double? rating;
}
class ChapterLink {
const ChapterLink({
required this.url,
required this.title,
required this.chapterKey,
this.chapterNumber,
});
final String url;
final String title;
final String chapterKey;
final String? chapterNumber;
}
class SeriesDetail {
const SeriesDetail({
required this.source,
required this.listingUrl,
required this.slug,
required this.title,
required this.chapters,
this.coverUrl,
this.description,
});
final SourceId source;
final String listingUrl;
final String slug;
final String title;
final String? coverUrl;
final String? description;
final List<ChapterLink> chapters;
}
/// Site adapter: per-source catalog menus + series + page URLs.
abstract class ComicSource {
SourceId get id;
String get displayName;
String get blurb;
/// Menus on this source’s Catalog home. Omit unsupported feeds (e.g. no search).
List<CatalogMenu> get catalogMenus;
/// Headers for page/CDN image requests.
Map<String, String> get imageHeaders;
Future<List<CatalogComic>> loadCatalog(
CatalogMenuKind kind, {
String query = '',
int offset = 0,
int limit = 30,
CatalogBrowseParams? browse,
});
Future<SeriesDetail> fetchSeries(String listingUrl);
Future<List<String>> fetchPageUrls(String chapterUrl);
}
@@ -0,0 +1,37 @@
import 'source.dart';
/// Compile-time source gate: `--dart-define=SOURCES=comicland,mangaread,mangatown`
/// or `--dart-define=SOURCES=all`.
///
/// Unset / invalid → ComicLand only. Default is **not** all sources.
const String kSourcesDefine = String.fromEnvironment(
'SOURCES',
defaultValue: 'comicland',
);
/// Parses [raw] into enabled [SourceId]s.
///
/// - Token `all` (case-insensitive) enables every [SourceId].
/// - Unknown tokens ignored.
/// - Empty result falls back to `{SourceId.comicland}`.
Set<SourceId> parseEnabledSourceIds([String raw = kSourcesDefine]) {
final parts = raw.split(',').map((p) => p.trim().toLowerCase()).where(
(p) => p.isNotEmpty,
);
for (final part in parts) {
if (part == 'all') {
return SourceId.values.toSet();
}
}
final out = <SourceId>{};
for (final part in parts) {
final id = SourceId.tryParse(part);
if (id != null) out.add(id);
}
if (out.isEmpty) {
return {SourceId.comicland};
}
return out;
}
Set<SourceId> enabledSourceIds() => parseEnabledSourceIds();
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import '../../sources/mangatown/urls.dart';
import '../../sources/source.dart';
/// MangaTown Directory filter bar (demographic / genre / status / initial).
class MangaTownDirectoryFilters extends StatelessWidget {
const MangaTownDirectoryFilters({
super.key,
required this.value,
required this.onChanged,
});
final CatalogBrowseParams value;
final ValueChanged<CatalogBrowseParams> onChanged;
@override
Widget build(BuildContext context) {
final initials = mangaTownInitials();
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Column(
children: [
Row(
children: [
Expanded(
child: _FilterDropdown(
label: 'Demographic',
value: value.demographic,
entries: mangaTownDemographics,
onChanged: (v) => onChanged(value.copyWith(demographic: v)),
),
),
const SizedBox(width: 8),
Expanded(
child: _FilterDropdown(
label: 'Status',
value: value.status,
entries: mangaTownStatuses,
onChanged: (v) => onChanged(value.copyWith(status: v)),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _FilterDropdown(
label: 'Genre',
value: value.genre,
entries: mangaTownGenres,
onChanged: (v) => onChanged(value.copyWith(genre: v)),
),
),
const SizedBox(width: 8),
Expanded(
child: _FilterDropdown(
label: 'Letter',
value: value.initial,
entries: initials,
onChanged: (v) => onChanged(value.copyWith(initial: v)),
),
),
],
),
],
),
);
}
}
class _FilterDropdown extends StatelessWidget {
const _FilterDropdown({
required this.label,
required this.value,
required this.entries,
required this.onChanged,
});
final String label;
final String value;
final List<MapEntry<String, String>> entries;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final known = entries.any((e) => e.key == value) ? value : '0';
return InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
isDense: true,
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
isExpanded: true,
value: known,
items: [
for (final e in entries)
DropdownMenuItem(value: e.key, child: Text(e.value)),
],
onChanged: (v) {
if (v != null) onChanged(v);
},
),
),
);
}
}
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../providers/providers.dart';
import '../../sources/source.dart';
import '../widgets/comic_cover.dart';
import '../widgets/source_logo.dart';
/// Catalog tab root helpers live in the router redirect; picker is [SourcePickerPage].
const _kLastCatalogSource = 'catalog_last_source_id';
Future<void> saveLastCatalogSource(SourceId id) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kLastCatalogSource, id.name);
}
Future<SourceId?> loadLastCatalogSource() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_kLastCatalogSource);
if (raw == null) return null;
return SourceId.tryParse(raw);
}
class SourcePickerPage extends ConsumerStatefulWidget {
const SourcePickerPage({super.key});
@override
ConsumerState<SourcePickerPage> createState() => _SourcePickerPageState();
}
class _SourcePickerPageState extends ConsumerState<SourcePickerPage> {
SourceId? _last;
@override
void initState() {
super.initState();
loadLastCatalogSource().then((id) {
if (!mounted) return;
setState(() => _last = id);
});
}
@override
Widget build(BuildContext context) {
final registry = ref.watch(sourceRegistryProvider);
final sources = registry.all;
final last = _last;
final continueSource =
last != null && registry.isEnabled(last) ? registry.get(last) : null;
return Scaffold(
appBar: AppBar(
title: const Text('Catalog'),
actions: [
const PeekModeToggleButton(),
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
if (continueSource != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Align(
alignment: Alignment.centerLeft,
child: ActionChip(
avatar: SourceLogo(sourceId: continueSource.id, size: 18),
label: Text('Continue · ${continueSource.displayName}'),
onPressed: () => _open(continueSource.id),
),
),
),
for (final s in sources)
ListTile(
leading: SourceLogo(sourceId: s.id),
title: Text(s.displayName),
subtitle: Text(s.blurb),
trailing: const Icon(Icons.chevron_right),
onTap: () => _open(s.id),
),
],
),
);
}
void _open(SourceId id) {
saveLastCatalogSource(id);
context.push('/catalog/${id.name}');
}
}
/// Lists [ComicSource.catalogMenus] for one enabled source.
class SourceHomePage extends ConsumerWidget {
const SourceHomePage({
super.key,
required this.sourceId,
this.embeddedAsRoot = false,
});
final SourceId sourceId;
final bool embeddedAsRoot;
@override
Widget build(BuildContext context, WidgetRef ref) {
final registry = ref.watch(sourceRegistryProvider);
if (!registry.isEnabled(sourceId)) {
return Scaffold(
appBar: AppBar(title: const Text('Catalog')),
body: const Center(child: Text('Source not enabled in this build.')),
);
}
final source = registry.get(sourceId);
final menus = source.catalogMenus;
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
SourceLogo(sourceId: sourceId, size: 24),
const SizedBox(width: 8),
Text(source.displayName),
],
),
leading: embeddedAsRoot
? null
: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go('/catalog');
}
},
),
actions: [
const PeekModeToggleButton(),
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: ListView.separated(
itemCount: menus.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) {
final menu = menus[i];
return ListTile(
leading: Icon(_iconFor(menu.kind)),
title: Text(menu.title),
trailing: const Icon(Icons.chevron_right),
onTap: () {
saveLastCatalogSource(sourceId);
context.push('/catalog/${sourceId.name}/${menu.kind.pathSegment}');
},
);
},
),
);
}
IconData _iconFor(CatalogMenuKind kind) {
switch (kind) {
case CatalogMenuKind.popular:
return Icons.local_fire_department_outlined;
case CatalogMenuKind.latest:
return Icons.schedule_outlined;
case CatalogMenuKind.official:
return Icons.verified_outlined;
case CatalogMenuKind.uncensored:
return Icons.visibility_outlined;
case CatalogMenuKind.recommended:
return Icons.thumb_up_outlined;
case CatalogMenuKind.search:
return Icons.search;
case CatalogMenuKind.hotToday:
return Icons.wb_sunny_outlined;
case CatalogMenuKind.hotWeek:
return Icons.date_range_outlined;
case CatalogMenuKind.hotMonth:
return Icons.calendar_month_outlined;
case CatalogMenuKind.directory:
return Icons.grid_view_outlined;
}
}
}
+272
View File
@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/providers.dart';
import '../sources/source.dart';
import 'catalog/mangatown_directory_filters.dart';
import 'widgets/comic_cover.dart';
/// Catalog feed for one `(source, menuKind)` — grid + search field when needed.
class CatalogFeedPage extends ConsumerStatefulWidget {
const CatalogFeedPage({
super.key,
required this.sourceId,
required this.menuKind,
});
final SourceId sourceId;
final CatalogMenuKind menuKind;
@override
ConsumerState<CatalogFeedPage> createState() => _CatalogFeedPageState();
}
class _CatalogFeedPageState extends ConsumerState<CatalogFeedPage> {
final _searchCtrl = TextEditingController();
List<CatalogComic> _items = const [];
bool _loading = true;
String? _error;
String _lastQuery = '';
/// Next 0-based page index (mapped to `offset: page * _pageSize` for sources).
int _page = 0;
static const _pageSize = 30;
bool _hasMore = true;
bool _loadingMore = false;
CatalogBrowseParams _browse = const CatalogBrowseParams();
ComicSource get _source =>
ref.read(sourceRegistryProvider).get(widget.sourceId);
bool get _showDirectoryFilters =>
widget.sourceId == SourceId.mangatown &&
widget.menuKind == CatalogMenuKind.directory;
String get _menuTitle {
final menus = _source.catalogMenus;
for (final m in menus) {
if (m.kind == widget.menuKind) return m.title;
}
return widget.menuKind.name;
}
@override
void initState() {
super.initState();
if (widget.menuKind != CatalogMenuKind.search) {
_load(reset: true);
} else {
_loading = false;
}
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
Future<void> _load({required bool reset}) async {
if (reset) {
setState(() {
_loading = true;
_error = null;
_page = 0;
_hasMore = true;
});
} else {
if (!_hasMore || _loadingMore) return;
setState(() => _loadingMore = true);
}
final pageIndex = reset ? 0 : _page;
try {
final list = await _source.loadCatalog(
widget.menuKind,
query: _lastQuery,
offset: pageIndex * _pageSize,
limit: _pageSize,
browse: _showDirectoryFilters ? _browse : null,
);
if (!mounted) return;
setState(() {
if (reset) {
_items = list;
} else {
// Drop accidental overlaps if a source re-served the same page.
final seen = _items.map((c) => c.listingUrl).toSet();
_items = [
..._items,
...list.where((c) => seen.add(c.listingUrl)),
];
}
_page = pageIndex + 1;
// Madara (and similar) may return < limit per HTML page; keep going
// until a page comes back empty.
_hasMore = list.isNotEmpty;
_loading = false;
_loadingMore = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loading = false;
_loadingMore = false;
});
}
}
Future<void> _runSearch(String q) async {
final query = q.trim();
if (query.length < 2) {
setState(() {
_items = const [];
_lastQuery = '';
_error = null;
});
return;
}
_lastQuery = query;
await _load(reset: true);
}
void _onBrowseChanged(CatalogBrowseParams next) {
if (next == _browse) return;
setState(() => _browse = next);
_load(reset: true);
}
Future<void> _openComic(CatalogComic comic) async {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(content: Text('Loading ${comic.title}…')),
);
try {
final source = ref.read(sourceRegistryProvider).get(comic.source);
final queue = ref.read(downloadQueueProvider);
final series = await source.fetchSeries(comic.listingUrl);
final titleId = await queue.upsertSeries(series);
if (!mounted) return;
messenger.hideCurrentSnackBar();
context.push('/title/$titleId');
} catch (e) {
if (!mounted) return;
messenger.hideCurrentSnackBar();
messenger.showSnackBar(SnackBar(content: Text('Failed: $e')));
}
}
Widget _grid() {
if (_loading && _items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_items.isEmpty) {
final empty = widget.menuKind == CatalogMenuKind.search
? 'Type at least 2 characters to search'
: 'No comics found';
return Center(child: Text(empty));
}
return RefreshIndicator(
onRefresh: () => _load(reset: true),
child: NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.pixels > n.metrics.maxScrollExtent - 400) {
_load(reset: false);
}
return false;
},
child: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: (() {
final tablet =
MediaQuery.sizeOf(context).shortestSide >= 600;
return SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: tablet ? 5 : 3,
childAspectRatio: tablet ? 0.58 : 0.55,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
);
})(),
itemCount: _items.length + (_loadingMore ? 1 : 0),
itemBuilder: (context, i) {
if (i >= _items.length) {
return const Center(child: CircularProgressIndicator());
}
final c = _items[i];
final tablet =
MediaQuery.sizeOf(context).shortestSide >= 600;
return ComicGridTile(
comic: c,
onTap: () => _openComic(c),
titleMaxLines: tablet ? 1 : 2,
);
},
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('${_source.displayName} · $_menuTitle'),
actions: [
const PeekModeToggleButton(),
IconButton(
tooltip: 'Settings',
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: Column(
children: [
if (_error != null)
MaterialBanner(
content: Text(_error!, maxLines: 3, overflow: TextOverflow.ellipsis),
actions: [
TextButton(
onPressed: () => setState(() => _error = null),
child: const Text('Dismiss'),
),
],
),
if (widget.menuKind == CatalogMenuKind.search)
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: TextField(
controller: _searchCtrl,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: 'Search comics…',
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchCtrl.clear();
_runSearch('');
},
),
),
onChanged: (v) {
Future<void>.delayed(const Duration(milliseconds: 250), () {
if (_searchCtrl.text == v) _runSearch(v);
});
},
onSubmitted: _runSearch,
),
),
if (_showDirectoryFilters)
MangaTownDirectoryFilters(
value: _browse,
onChanged: _onBrowseChanged,
),
Expanded(child: _grid()),
],
),
);
}
}
+320
View File
@@ -0,0 +1,320 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../data/database.dart';
import '../providers/providers.dart';
import '../sources/source.dart';
import 'widgets/comic_cover.dart';
class DownloadsPage extends ConsumerWidget {
const DownloadsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final queue = ref.watch(downloadQueueProvider);
final queueAsync = ref.watch(queueJobsProvider);
final downloadedAsync = ref.watch(downloadedSeriesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Downloads')),
body: queueAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (queueJobs) {
return downloadedAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (groups) {
final queued =
queueJobs.where((j) => j.status == 'queued').length;
final failed =
queueJobs.where((j) => j.status == 'failed').length;
final pendingTotal = queued + failed;
final hasDownloads = groups.isNotEmpty;
final showRunning = queue.isRunning;
if (!showRunning && !hasDownloads && pendingTotal == 0) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'No downloads yet.\nSelect chapters on a series and tap Download.',
textAlign: TextAlign.center,
),
),
);
}
final queueLabel = failed > 0
? 'Queue ($pendingTotal · $failed failed)'
: 'Queue ($pendingTotal)';
return CustomScrollView(
slivers: [
if (showRunning)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
queue.message.isEmpty
? 'Downloading…'
: queue.message,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: queue.progress.clamp(0.0, 1.0),
),
],
),
),
),
if (pendingTotal > 0)
SliverToBoxAdapter(
child: ListTile(
leading: const Icon(Icons.queue),
title: Text(queueLabel),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.push('/downloads/queue'),
),
),
if (groups.isNotEmpty) ...[
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Downloaded',
style: Theme.of(context).textTheme.titleMedium,
),
),
),
SliverList.builder(
itemCount: groups.length,
itemBuilder: (context, i) {
return _DownloadedSeriesTile(group: groups[i]);
},
),
],
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
},
);
},
),
);
}
}
class DownloadsQueuePage extends ConsumerWidget {
const DownloadsQueuePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final queueAsync = ref.watch(queueJobsProvider);
return Scaffold(
appBar: AppBar(title: const Text('Queue')),
body: queueAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('$e')),
data: (jobs) {
final list = jobs
.where((j) => j.status == 'queued' || j.status == 'failed')
.toList();
if (list.isEmpty) {
return const Center(child: Text('Queue is empty.'));
}
return ListView.builder(
itemCount: list.length,
itemBuilder: (context, i) => _QueueJobTile(job: list[i]),
);
},
),
);
}
}
class _QueueJobTile extends ConsumerWidget {
const _QueueJobTile({required this.job});
final QueueJobView job;
@override
Widget build(BuildContext context, WidgetRef ref) {
final statusLabel = switch (job.status) {
'running' => 'Downloading',
'queued' => 'Queued',
'failed' => 'Failed',
_ => job.status,
};
return ListTile(
leading: Icon(_iconFor(job.status)),
title: Text(
job.seriesTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${job.chapterLabel} · $statusLabel',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (job.status == 'failed' && job.error != null) ...[
const SizedBox(height: 2),
Text(
job.error!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
],
),
isThreeLine: job.status == 'failed',
trailing: job.status == 'failed'
? IconButton(
tooltip: 'Retry',
icon: const Icon(Icons.refresh),
onPressed: job.chapterId == null
? null
: () => ref
.read(downloadQueueProvider)
.retryChapter(job.chapterId!),
)
: job.status == 'queued'
? IconButton(
tooltip: 'Cancel',
icon: const Icon(Icons.cancel_outlined),
onPressed: job.chapterId == null
? null
: () => ref
.read(downloadQueueProvider)
.cancelChapter(job.chapterId!),
)
: null,
onTap: job.titleId == null
? null
: () => context.push('/title/${job.titleId}'),
);
}
IconData _iconFor(String status) {
switch (status) {
case 'failed':
return Icons.error_outline;
case 'queued':
return Icons.hourglass_empty;
default:
return Icons.download_outlined;
}
}
}
class _DownloadedSeriesTile extends ConsumerWidget {
const _DownloadedSeriesTile({required this.group});
final DownloadedSeriesGroup group;
@override
Widget build(BuildContext context, WidgetRef ref) {
final sourceId = SourceId.tryParse(group.source);
return ExpansionTile(
leading: SizedBox(
width: 40,
height: 56,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: ComicCover(url: group.coverUrl, sourceId: sourceId),
),
),
title: Text(
group.seriesTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${group.chapters.length} chapter${group.chapters.length == 1 ? '' : 's'}',
),
children: [
for (final ch in group.chapters)
Dismissible(
key: ValueKey('dl-${ch.chapterId}'),
direction: DismissDirection.endToStart,
background: Container(
color: Theme.of(context).colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
confirmDismiss: (_) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete download?'),
content: Text(
'Remove “${ch.chapterLabel}” from Downloads and delete the CBZ file.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete'),
),
],
),
);
return ok == true;
},
onDismissed: (_) {
ref.read(downloadQueueProvider).deleteDownload(ch.chapterId);
},
child: ListTile(
contentPadding: const EdgeInsets.only(left: 72, right: 8),
title: Text(ch.chapterLabel),
subtitle: ch.downloadedAt == null
? null
: Text(_formatDate(ch.downloadedAt!)),
trailing: PopupMenuButton<String>(
onSelected: (v) {
if (v == 'delete') {
ref
.read(downloadQueueProvider)
.deleteDownload(ch.chapterId);
}
},
itemBuilder: (context) => const [
PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
onTap: () {
context.push('/read/${ch.titleId}/${ch.chapterId}');
},
),
),
],
);
}
String _formatDate(DateTime dt) {
final local = dt.toLocal();
final y = local.year.toString().padLeft(4, '0');
final m = local.month.toString().padLeft(2, '0');
final d = local.day.toString().padLeft(2, '0');
return 'Downloaded $y-$m-$d';
}
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart' hide Title;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/providers.dart';
import '../sources/source.dart';
import 'widgets/comic_cover.dart';
class HistoryPage extends ConsumerWidget {
const HistoryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final titlesAsync = ref.watch(historyTitlesProvider);
return Scaffold(
appBar: AppBar(title: const Text('History')),
body: titlesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (titles) {
if (titles.isEmpty) {
return const Center(
child: Text('Chapters you read will show up here.'),
);
}
return ListView.separated(
itemCount: titles.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, i) {
final t = titles[i];
final sourceId = SourceId.tryParse(t.source);
final label = sourceId?.label ?? t.source;
final when = t.lastReadAt;
final subtitle = when == null
? label
: '$label · ${_formatWhen(when)}';
return ListTile(
leading: SizedBox(
width: 48,
height: 64,
child: ComicCover(url: t.coverUrl, sourceId: sourceId),
),
title: Text(t.title),
subtitle: Text(subtitle),
onTap: () => context.push('/title/${t.id}'),
);
},
);
},
),
);
}
String _formatWhen(DateTime dt) {
final local = dt.toLocal();
final y = local.year.toString().padLeft(4, '0');
final m = local.month.toString().padLeft(2, '0');
final d = local.day.toString().padLeft(2, '0');
final hh = local.hour.toString().padLeft(2, '0');
final mm = local.minute.toString().padLeft(2, '0');
return '$y-$m-$d $hh:$mm';
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart' hide Title;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../providers/providers.dart';
import '../sources/source.dart';
import 'widgets/comic_cover.dart';
class LibraryPage extends ConsumerWidget {
const LibraryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final titlesAsync = ref.watch(libraryTitlesProvider);
return Scaffold(
appBar: AppBar(title: const Text('Library')),
body: titlesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (titles) {
if (titles.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Star a series on its title page to add it to your Library.',
textAlign: TextAlign.center,
),
),
);
}
return ListView.separated(
itemCount: titles.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, i) {
final t = titles[i];
final sourceId = SourceId.tryParse(t.source);
final label = sourceId?.label ?? t.source;
return ListTile(
leading: SizedBox(
width: 48,
height: 64,
child: ComicCover(url: t.coverUrl, sourceId: sourceId),
),
title: Text(t.title),
subtitle: Text(label),
trailing: const Icon(Icons.star, size: 18),
onTap: () => context.push('/title/${t.id}'),
);
},
);
},
),
);
}
}
File diff suppressed because it is too large Load Diff
+397
View File
@@ -0,0 +1,397 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/providers.dart';
import '../services/ntfy_settings.dart';
import '../services/reader_direction.dart';
import '../services/webdav_settings.dart';
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({super.key});
@override
ConsumerState<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends ConsumerState<SettingsPage> {
final _urlCtrl = TextEditingController();
final _userCtrl = TextEditingController();
final _passCtrl = TextEditingController();
final _pathCtrl = TextEditingController();
final _ntfyCompletedCtrl = TextEditingController();
final _ntfyFailedCtrl = TextEditingController();
final _ntfyDrainedCtrl = TextEditingController();
final _ntfyTokenCtrl = TextEditingController();
bool _enabled = false;
bool _ntfyEnabled = false;
NtfyAttachmentMode _ntfyAttachment = NtfyAttachmentMode.none;
bool _loading = true;
bool _busy = false;
String? _status;
String? _error;
DateTime? _lastSync;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_urlCtrl.dispose();
_userCtrl.dispose();
_passCtrl.dispose();
_pathCtrl.dispose();
_ntfyCompletedCtrl.dispose();
_ntfyFailedCtrl.dispose();
_ntfyDrainedCtrl.dispose();
_ntfyTokenCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
final s = await WebDavSettings.load();
final n = await NtfySettings.load();
if (!mounted) return;
setState(() {
_enabled = s.enabled;
_urlCtrl.text = s.url;
_userCtrl.text = s.username;
_passCtrl.text = s.password;
_pathCtrl.text = s.path;
_status = s.lastStatus;
_error = s.lastError;
_lastSync = s.lastSyncAt;
_ntfyEnabled = n.enabled;
_ntfyCompletedCtrl.text = n.completedUrl;
_ntfyFailedCtrl.text = n.failedUrl;
_ntfyDrainedCtrl.text = n.drainedUrl;
_ntfyTokenCtrl.text = n.authToken;
_ntfyAttachment = n.attachmentMode;
_loading = false;
});
}
Future<void> _save() async {
await WebDavSettings.save(
enabled: _enabled,
url: _urlCtrl.text,
username: _userCtrl.text,
password: _passCtrl.text,
path: _pathCtrl.text,
);
await NtfySettings.save(
enabled: _ntfyEnabled,
completedUrl: _ntfyCompletedCtrl.text,
failedUrl: _ntfyFailedCtrl.text,
drainedUrl: _ntfyDrainedCtrl.text,
attachmentMode: _ntfyAttachment,
authToken: _ntfyTokenCtrl.text,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings saved')),
);
}
Future<void> _test() async {
setState(() => _busy = true);
try {
await _save();
final msg = await ref.read(webDavSyncProvider).testConnection();
if (!mounted) return;
setState(() {
_status = msg;
_error = null;
});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
} catch (e) {
if (!mounted) return;
setState(() {
_status = 'Test failed';
_error = e.toString();
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Test failed: $e')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _syncNow() async {
setState(() => _busy = true);
try {
await _save();
await ref.read(webDavSyncProvider).sync();
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sync finished')),
);
} catch (e) {
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sync failed: $e')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _testNtfy(String kind, TextEditingController ctrl) async {
setState(() => _busy = true);
try {
await _save();
await ref.read(ntfyNotifierProvider).testTopic(ctrl.text, kind: kind);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Test notification sent')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('ntfy test failed: $e')),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Widget _ntfyUrlRow({
required String label,
required TextEditingController controller,
required String kind,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
hintText: 'https://ntfy.sh/mytopic',
),
keyboardType: TextInputType.url,
autocorrect: false,
enabled: _ntfyEnabled,
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: !_ntfyEnabled || _busy
? null
: () => _testNtfy(kind, controller),
child: const Text('Test'),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final direction = ref.watch(readingDirectionProvider);
if (_loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
actions: [
TextButton(
onPressed: _busy ? null : _save,
child: const Text('Save'),
),
],
),
body: SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Reader', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Fallback page direction when a title has no saved preference '
'(ComicLand). MangaRead / MangaTown default to Right → Left; '
'overrides are saved per title and sync via WebDAV.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
SegmentedButton<ReadingDirection>(
segments: const [
ButtonSegment(
value: ReadingDirection.ltr,
label: Text('Left → Right'),
icon: Icon(Icons.format_textdirection_l_to_r),
),
ButtonSegment(
value: ReadingDirection.rtl,
label: Text('Right → Left'),
icon: Icon(Icons.format_textdirection_r_to_l),
),
],
selected: {direction},
onSelectionChanged: (s) {
ref.read(readingDirectionProvider.notifier).setDirection(s.first);
},
),
const SizedBox(height: 24),
Text('WebDAV sync', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'Syncs reading progress, reader mode/direction, Library stars, '
'and History across devices.',
style: Theme.of(context).textTheme.bodySmall,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Enable sync'),
value: _enabled,
onChanged: (v) => setState(() => _enabled = v),
),
TextField(
controller: _urlCtrl,
decoration: const InputDecoration(
labelText: 'WebDAV URL',
hintText: 'https://dav.example.com/remote.php/dav/files/user',
),
keyboardType: TextInputType.url,
autocorrect: false,
),
TextField(
controller: _userCtrl,
decoration: const InputDecoration(labelText: 'Username'),
autocorrect: false,
),
TextField(
controller: _passCtrl,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
autocorrect: false,
),
TextField(
controller: _pathCtrl,
decoration: const InputDecoration(
labelText: 'Remote file path',
hintText: WebDavSettings.defaultPath,
),
autocorrect: false,
),
const SizedBox(height: 12),
if (_status != null)
Text('Status: $_status', style: Theme.of(context).textTheme.bodySmall),
if (_lastSync != null)
Text(
'Last sync: ${_lastSync!.toLocal()}',
style: Theme.of(context).textTheme.bodySmall,
),
if (_error != null)
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
Row(
children: [
FilledButton(
onPressed: _busy ? null : _test,
child: const Text('Test connection'),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: _busy || !_enabled ? null : _syncNow,
child: const Text('Sync now'),
),
],
),
const SizedBox(height: 32),
Text(
'Notifications (ntfy)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text(
'Send download events to ntfy topics. Leave a URL empty to disable that event.',
style: Theme.of(context).textTheme.bodySmall,
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Enable notifications'),
value: _ntfyEnabled,
onChanged: (v) => setState(() => _ntfyEnabled = v),
),
_ntfyUrlRow(
label: 'Chapter completed URL',
controller: _ntfyCompletedCtrl,
kind: 'completed',
),
_ntfyUrlRow(
label: 'Job failed URL',
controller: _ntfyFailedCtrl,
kind: 'failed',
),
_ntfyUrlRow(
label: 'Queue drained URL',
controller: _ntfyDrainedCtrl,
kind: 'drained',
),
const SizedBox(height: 8),
Text(
'Attachment on complete',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
DropdownMenu<NtfyAttachmentMode>(
initialSelection: _ntfyAttachment,
enabled: _ntfyEnabled,
width: double.infinity,
dropdownMenuEntries: [
for (final m in NtfyAttachmentMode.values)
DropdownMenuEntry(value: m, label: m.label),
],
onSelected: !_ntfyEnabled
? null
: (v) {
if (v != null) setState(() => _ntfyAttachment = v);
},
),
const SizedBox(height: 4),
Text(
'Cover uses the series cover URL. First page is resized to ≤720px / ≤250 KB. CBZ skips if over 15 MB.',
style: Theme.of(context).textTheme.bodySmall,
),
TextField(
controller: _ntfyTokenCtrl,
decoration: const InputDecoration(
labelText: 'Auth token (optional)',
hintText: 'Bearer token for private topics',
),
obscureText: true,
autocorrect: false,
enabled: _ntfyEnabled,
),
if (_busy) ...[
const SizedBox(height: 16),
const LinearProgressIndicator(),
],
const SizedBox(height: 32),
Text('About', style: Theme.of(context).textTheme.titleMedium),
const ListTile(
contentPadding: EdgeInsets.zero,
title: Text('Version'),
subtitle: Text('1.0'),
),
],
),
),
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class AppShell extends StatelessWidget {
const AppShell({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
void _onTap(int index) {
navigationShell.goBranch(
index,
initialLocation: index == navigationShell.currentIndex,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: _onTap,
destinations: const [
NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: 'Catalog',
),
NavigationDestination(
icon: Icon(Icons.collections_bookmark_outlined),
selectedIcon: Icon(Icons.collections_bookmark),
label: 'Library',
),
NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'History',
),
NavigationDestination(
icon: Icon(Icons.download_outlined),
selectedIcon: Icon(Icons.download),
label: 'Downloads',
),
],
),
);
}
}
+451
View File
@@ -0,0 +1,451 @@
import 'package:flutter/material.dart' hide Title;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../data/database.dart';
import '../providers/providers.dart';
import '../sources/source.dart';
import 'widgets/comic_cover.dart';
import 'widgets/source_logo.dart';
class TitlePage extends ConsumerStatefulWidget {
const TitlePage({super.key, required this.titleId});
final int titleId;
@override
ConsumerState<TitlePage> createState() => _TitlePageState();
}
class _TitlePageState extends ConsumerState<TitlePage> {
Title? _title;
List<Chapter> _chapters = const [];
final Set<int> _selected = {};
bool _newestFirst = true;
bool _loading = true;
bool _syncing = false;
String? _error;
@override
void initState() {
super.initState();
_reload();
}
List<Chapter> _sortedReadingOrder(List<Chapter> chapters) {
final copy = [...chapters];
copy.sort((a, b) {
final na = double.tryParse(a.chapterNumber ?? '') ?? 0;
final nb = double.tryParse(b.chapterNumber ?? '') ?? 0;
return na.compareTo(nb);
});
return copy;
}
Future<void> _reload({bool showLoading = true}) async {
if (showLoading) {
setState(() {
_loading = true;
_error = null;
});
}
final db = ref.read(databaseProvider);
final title = await db.getTitle(widget.titleId);
final chapters =
await db.listChapters(widget.titleId, newestFirst: _newestFirst);
chapters.sort((a, b) {
final na = double.tryParse(a.chapterNumber ?? '') ?? 0;
final nb = double.tryParse(b.chapterNumber ?? '') ?? 0;
return _newestFirst ? nb.compareTo(na) : na.compareTo(nb);
});
if (!mounted) return;
setState(() {
_title = title;
_chapters = chapters;
_loading = false;
});
}
Future<void> _sync() async {
final title = _title;
if (title == null) return;
setState(() => _syncing = true);
try {
final source = ref.read(sourceRegistryProvider).get(
SourceId.values.byName(title.source),
);
final series = await source.fetchSeries(title.url);
await ref.read(downloadQueueProvider).upsertSeries(series);
await _reload();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _syncing = false);
}
}
Future<void> _downloadSelected() async {
if (_selected.isEmpty) return;
await ref.read(downloadQueueProvider).enqueueChapters(_selected);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Queued ${_selected.length} chapter(s)')),
);
setState(() => _selected.clear());
await _reload();
}
Future<void> _downloadAll() async {
if (_chapters.isEmpty) return;
var unreadOnly = true;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setLocal) {
final ids = _chapters.where((c) {
if (c.status == 'downloaded') return false;
if (unreadOnly && c.completed) return false;
return true;
}).map((c) => c.id).toList();
return AlertDialog(
title: const Text('Download all'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ids.isEmpty
? 'No chapters match the current filter.'
: 'Queue ${ids.length} chapter(s) for download?',
),
const SizedBox(height: 12),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Download unread only'),
subtitle: const Text(
'Skip chapters already marked completed',
),
value: unreadOnly,
onChanged: (v) => setLocal(() => unreadOnly = v ?? true),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: ids.isEmpty
? null
: () => Navigator.pop(ctx, true),
child: const Text('Download'),
),
],
);
},
);
},
);
if (confirmed != true || !mounted) return;
final ids = _chapters.where((c) {
if (c.status == 'downloaded') return false;
if (unreadOnly && c.completed) return false;
return true;
}).map((c) => c.id);
await ref.read(downloadQueueProvider).enqueueChapters(ids);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Queued ${ids.length} chapter(s)')),
);
await _reload();
}
bool get _hasReadingHistory {
final title = _title;
if (title?.lastReadChapterId != null) return true;
return _chapters.any((c) => c.lastReadAt != null || c.lastPage > 0 || c.completed);
}
Chapter? get _continueChapter {
final title = _title;
if (title?.lastReadChapterId != null) {
final match =
_chapters.where((c) => c.id == title!.lastReadChapterId).firstOrNull;
if (match != null) return match;
}
Chapter? best;
for (final c in _chapters) {
if (c.lastReadAt == null && c.lastPage == 0 && !c.completed) continue;
if (best == null) {
best = c;
continue;
}
final a = c.lastReadAt;
final b = best.lastReadAt;
if (a != null && (b == null || a.isAfter(b))) best = c;
}
return best;
}
Chapter? get _firstChapter {
final ordered = _sortedReadingOrder(_chapters);
return ordered.isEmpty ? null : ordered.first;
}
Future<void> _toggleInLibrary() async {
final title = _title;
if (title == null) return;
final next = !title.inLibrary;
await ref.read(databaseProvider).setInLibrary(title.id, next);
ref.read(webDavSyncProvider).scheduleSync();
await _reload();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(next ? 'Added to Library' : 'Removed from Library'),
),
);
}
void _openChapter(Chapter chapter) {
context.push('/read/${widget.titleId}/${chapter.id}').then((_) async {
if (!mounted) return;
await _reload(showLoading: false);
});
}
void _onReadOrContinue() {
if (_hasReadingHistory) {
final ch = _continueChapter ?? _firstChapter;
if (ch != null) _openChapter(ch);
} else {
final ch = _firstChapter;
if (ch != null) _openChapter(ch);
}
}
Color? _statusColor(BuildContext context, Chapter ch) {
final scheme = Theme.of(context).colorScheme;
if (ch.completed) return scheme.primary;
if (ch.status == 'downloaded') return scheme.tertiary;
if (ch.status == 'downloading') return scheme.tertiary;
if (ch.status == 'failed') return scheme.error;
return null;
}
String _chapterSubtitle(Chapter ch) {
final parts = <String>[];
if (ch.completed) {
parts.add('Completed');
} else if (ch.pageCount > 0 && (ch.lastPage > 0 || ch.lastReadAt != null)) {
parts.add('Page ${ch.lastPage + 1}/${ch.pageCount}');
} else if (ch.lastReadAt != null || ch.lastPage > 0) {
parts.add('Reading');
}
if (ch.status == 'downloaded') {
parts.add('Downloaded');
} else if (ch.status == 'downloading') {
parts.add('Downloading');
} else if (ch.status == 'failed') {
parts.add('Failed');
}
return parts.isEmpty ? 'Unread' : parts.join(' · ');
}
@override
Widget build(BuildContext context) {
final title = _title;
final canRead = _chapters.isNotEmpty;
final continueLabel = _hasReadingHistory ? 'Continue' : 'Read first chapter';
final continueChapter =
_hasReadingHistory ? (_continueChapter ?? _firstChapter) : _firstChapter;
return Scaffold(
appBar: AppBar(
title: Text(title?.title ?? 'Title'),
actions: [
if (title != null)
IconButton(
tooltip: title.inLibrary ? 'Remove from Library' : 'Add to Library',
onPressed: _toggleInLibrary,
icon: Icon(title.inLibrary ? Icons.star : Icons.star_border),
),
IconButton(
tooltip: 'Download all',
onPressed: _chapters.isEmpty ? null : _downloadAll,
icon: const Icon(Icons.download_for_offline_outlined),
),
IconButton(
tooltip: _newestFirst ? 'Oldest first' : 'Newest first',
onPressed: () async {
setState(() => _newestFirst = !_newestFirst);
await _reload();
},
icon: Icon(_newestFirst ? Icons.arrow_downward : Icons.arrow_upward),
),
IconButton(
tooltip: 'Sync chapters',
onPressed: _syncing ? null : _sync,
icon: _syncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
),
],
),
floatingActionButton: _selected.isEmpty
? null
: FloatingActionButton.extended(
onPressed: _downloadSelected,
icon: const Icon(Icons.download),
label: Text('Download ${_selected.length}'),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: title == null
? const Center(child: Text('Title not found'))
: Column(
children: [
if (_error != null)
MaterialBanner(
content: Text(_error!),
actions: [
TextButton(
onPressed: () => setState(() => _error = null),
child: const Text('Dismiss'),
),
],
),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 96,
height: 128,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ComicCover(
url: title.coverUrl,
sourceId: SourceId.tryParse(title.source),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Row(
children: [
if (SourceId.tryParse(title.source)
case final sourceId?)
SourceLogo(
sourceId: sourceId,
size: 16,
borderRadius: 4,
),
if (SourceId.tryParse(title.source) != null)
const SizedBox(width: 6),
Expanded(
child: Text(
'${SourceId.tryParse(title.source)?.label ?? title.source} · ${_chapters.length} chapters',
style:
Theme.of(context).textTheme.bodySmall,
),
),
],
),
if (title.description != null) ...[
const SizedBox(height: 8),
Text(
title.description!,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
if (canRead) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _onReadOrContinue,
icon: Icon(
_hasReadingHistory
? Icons.play_arrow
: Icons.menu_book,
),
label: Text(
continueChapter == null
? continueLabel
: _hasReadingHistory
? 'Continue · ${continueChapter.label}'
: continueLabel,
),
),
],
],
),
),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
itemCount: _chapters.length,
itemBuilder: (context, i) {
final ch = _chapters[i];
final selected = _selected.contains(ch.id);
return ListTile(
leading: Checkbox(
value: selected,
onChanged: (v) {
setState(() {
if (v == true) {
_selected.add(ch.id);
} else {
_selected.remove(ch.id);
}
});
},
),
title: Text(ch.label),
subtitle: Text(_chapterSubtitle(ch)),
trailing: Icon(
ch.completed
? Icons.check_circle
: ch.status == 'downloaded'
? Icons.offline_pin
: Icons.chevron_right,
color: _statusColor(context, ch),
),
onTap: () => _openChapter(ch),
onLongPress: () {
setState(() {
if (selected) {
_selected.remove(ch.id);
} else {
_selected.add(ch.id);
}
});
},
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,178 @@
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/providers.dart';
import '../../sources/source.dart';
class ComicCover extends ConsumerStatefulWidget {
const ComicCover({
super.key,
required this.url,
this.sourceId,
this.width,
this.height,
this.fit = BoxFit.cover,
});
final String? url;
final SourceId? sourceId;
final double? width;
final double? height;
final BoxFit fit;
@override
ConsumerState<ComicCover> createState() => _ComicCoverState();
}
class _ComicCoverState extends ConsumerState<ComicCover> {
bool _revealed = false;
void _setRevealed(bool value) {
if (_revealed == value) return;
setState(() => _revealed = value);
}
@override
Widget build(BuildContext context) {
final peek = ref.watch(peekModeProvider);
final cache = ref.watch(imageCacheManagerProvider);
Map<String, String> headers = const {};
final sid = widget.sourceId;
if (sid != null) {
final registry = ref.watch(sourceRegistryProvider);
if (registry.isEnabled(sid)) {
headers = registry.get(sid).imageHeaders;
}
}
if (widget.url == null || widget.url!.isEmpty) {
return Container(
width: widget.width,
height: widget.height,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.menu_book_outlined),
);
}
final image = CachedNetworkImage(
imageUrl: widget.url!,
cacheManager: cache,
httpHeaders: headers,
width: widget.width,
height: widget.height,
fit: widget.fit,
placeholder: (context, url) => Container(
width: widget.width,
height: widget.height,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: widget.width,
height: widget.height,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: const Icon(Icons.broken_image_outlined),
),
);
if (!peek) return image;
if (_revealed) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressEnd: (_) => _setRevealed(false),
onLongPressCancel: () => _setRevealed(false),
child: image,
);
}
// Heavy blur + dark veil so covers are unreadable in public.
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (_) => _setRevealed(true),
child: SizedBox(
width: widget.width,
height: widget.height,
child: Stack(
fit: StackFit.expand,
children: [
ClipRect(
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: 36,
sigmaY: 36,
tileMode: TileMode.clamp,
),
child: image,
),
),
ColoredBox(
color: Colors.black.withValues(alpha: 0.45),
child: const Center(
child: Icon(
Icons.visibility_off,
color: Colors.white54,
size: 28,
),
),
),
],
),
),
);
}
}
class PeekModeToggleButton extends ConsumerWidget {
const PeekModeToggleButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final peek = ref.watch(peekModeProvider);
return IconButton(
tooltip: peek ? 'Peek mode on (covers hidden)' : 'Peek mode off',
onPressed: () => ref.read(peekModeProvider.notifier).toggle(),
icon: Icon(peek ? Icons.visibility_off : Icons.visibility),
);
}
}
class ComicGridTile extends StatelessWidget {
const ComicGridTile({
super.key,
required this.comic,
required this.onTap,
this.titleMaxLines = 2,
});
final CatalogComic comic;
final VoidCallback onTap;
final int titleMaxLines;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ComicCover(url: comic.coverUrl, sourceId: comic.source),
),
),
const SizedBox(height: 6),
Text(
comic.title,
maxLines: titleMaxLines,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
}
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import '../../sources/source.dart';
/// Bundled source favicon from [SourceId.logoAsset].
class SourceLogo extends StatelessWidget {
const SourceLogo({
super.key,
required this.sourceId,
this.size = 32,
this.borderRadius = 6,
});
final SourceId sourceId;
final double size;
final double borderRadius;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: Image.asset(
sourceId.logoAsset,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.public,
size: size * 0.75,
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
name: cxyz_android
description: On-device ComicLand manga/comic reader and downloader (cxyz Android).
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.1+1
environment:
sdk: ^3.13.1
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_riverpod: ^2.6.1
go_router: ^17.5.0
dio: ^5.11.0
path_provider: ^2.1.6
path: ^1.9.1
archive: ^4.2.0
cached_network_image: ^3.4.1
flutter_cache_manager: ^3.4.2
drift: ^2.34.3
sqlite3_flutter_libs: ^0.6.0+eol
shared_preferences: ^2.5.5
html: ^0.15.6
permission_handler: ^12.0.1
flutter_secure_storage: ^11.0.0
image: ^4.9.2
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
drift_dev: ^2.34.5
build_runner: ^2.16.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
assets:
- assets/sources/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Build cxyz Android APK and copy to a SOURCES-tagged filename.
#
# Usage:
# ./scripts/build_apk.sh
# ./scripts/build_apk.sh mangaread
# ./scripts/build_apk.sh all
# ./scripts/build_apk.sh comicland,mangaread,mangatown
# ./scripts/build_apk.sh mangaread --debug
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
SOURCES="${1:-comicland}"
MODE="release"
if [[ "${2:-}" == "--debug" ]] || [[ "${1:-}" == "--debug" ]]; then
MODE="debug"
if [[ "${1:-}" == "--debug" ]]; then
SOURCES="comicland"
fi
fi
# Normalize tag for filename: comicland,mangaread → comicland-mangaread
SOURCES_TAG="$(echo "$SOURCES" | tr '[:upper:]' '[:lower:]' | tr ',' '-' | tr -cd 'a-z0-9-')"
if [[ -z "$SOURCES_TAG" ]]; then
SOURCES_TAG="comicland"
SOURCES="comicland"
fi
REPO_ROOT="$(cd "$ROOT/.." && pwd)"
export PATH="${REPO_ROOT}/.tools/bin:${REPO_ROOT}/.tools/flutter-sdk/bin:${PATH:-}"
export CI=true BOT=true
echo "Building APK (SOURCES=${SOURCES}, mode=${MODE})…"
BUILD_ARGS=(build apk --dart-define="SOURCES=${SOURCES}")
if [[ "$MODE" == "debug" ]]; then
BUILD_ARGS+=(--debug)
fi
flutter "${BUILD_ARGS[@]}"
SRC_APK="build/app/outputs/flutter-apk/app-${MODE}.apk"
OUT_NAME="cxyz-${SOURCES_TAG}-${MODE}.apk"
OUT_APK="build/app/outputs/flutter-apk/${OUT_NAME}"
if [[ ! -f "$SRC_APK" ]]; then
echo "error: expected APK missing: $SRC_APK" >&2
exit 1
fi
cp -f "$SRC_APK" "$OUT_APK"
echo
echo "APK: ${ROOT}/${OUT_APK}"
echo " (also ${SRC_APK})"
@@ -0,0 +1,21 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/comicland/urls.dart';
void main() {
test('parseComicLandUrl extracts slug and chapter', () {
final parts = parseComicLandUrl(
'https://comicland.org/comic/the-delinquent-girl/chapter/12',
);
expect(parts.slug, 'the-delinquent-girl');
expect(parts.chapterIndex, '12');
});
test('transferLinkBasename pads chapter numbers', () {
expect(transferLinkBasename('12', 'Chapter 12', null), 'chapter 012.cbz');
expect(transferLinkBasename('1.5', null, null), 'chapter 001.5.cbz');
});
test('sanitizeComicDirName strips illegal chars', () {
expect(sanitizeComicDirName('A/B:C*'), 'ABC');
});
}
@@ -0,0 +1,27 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/mangaread/mangaread_source.dart';
import 'package:cxyz_android/sources/source.dart';
void main() {
test('latest pagination maps offset/limit to distinct site pages', () async {
final source = MangaReadSource();
final page1 = await source.loadCatalog(
CatalogMenuKind.latest,
offset: 0,
limit: 30,
);
final page2 = await source.loadCatalog(
CatalogMenuKind.latest,
offset: 30,
limit: 30,
);
expect(page1, isNotEmpty);
expect(page2, isNotEmpty);
expect(page1.first.slug, isNot(page2.first.slug));
final overlap = page1.map((c) => c.slug).toSet().intersection(
page2.map((c) => c.slug).toSet(),
);
expect(overlap, isEmpty);
}, timeout: const Timeout(Duration(seconds: 60)));
}
@@ -0,0 +1,33 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/mangatown/mangatown_source.dart';
import 'package:cxyz_android/sources/source.dart';
@Tags(['network'])
void main() {
test(
'latest pagination: offset 0 and 30 yield different first slugs',
() async {
final source = MangaTownSource();
final first = await source.loadCatalog(
CatalogMenuKind.latest,
offset: 0,
limit: 30,
);
final second = await source.loadCatalog(
CatalogMenuKind.latest,
offset: 30,
limit: 30,
);
expect(first, isNotEmpty);
expect(second, isNotEmpty);
expect(first.first.slug, isNot(second.first.slug));
final firstSlugs = first.map((c) => c.slug).toSet();
expect(
second.any((c) => firstSlugs.contains(c.slug)),
isFalse,
reason: 'page 2 should not overlap page 1',
);
},
timeout: const Timeout(Duration(seconds: 90)),
);
}
+114
View File
@@ -0,0 +1,114 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/mangatown/urls.dart';
import 'package:cxyz_android/sources/source.dart';
void main() {
group('mangatown catalog URLs', () {
test('latest page 1 and 2', () {
expect(catalogLatestUrl(), 'https://m.mangatown.com/latest');
expect(catalogLatestUrl(page: 2), 'https://m.mangatown.com/latest/2.html');
});
test('hot total and period queries with pagination', () {
expect(catalogHotUrl(), 'https://m.mangatown.com/hot');
expect(
catalogHotUrl(page: 2),
'https://m.mangatown.com/hot/2.html',
);
expect(
catalogHotUrl(period: MangaTownHotPeriod.today),
'https://m.mangatown.com/hot?tviews.za',
);
expect(
catalogHotUrl(period: MangaTownHotPeriod.week, page: 2),
'https://m.mangatown.com/hot/2.html?wviews.za',
);
expect(
catalogHotUrl(period: MangaTownHotPeriod.month, page: 2),
'https://m.mangatown.com/hot/2.html?mviews.za',
);
});
test('directory default and filters', () {
expect(
directoryUrl(const CatalogBrowseParams()),
'https://m.mangatown.com/directory/0-0-0-0-0-0',
);
expect(
directoryUrl(
const CatalogBrowseParams(
demographic: 'seinen',
genre: 'action',
status: 'ongoing',
initial: 'a',
),
page: 2,
),
'https://m.mangatown.com/directory/seinen-action-0-ongoing-a-0/2.html',
);
});
test('search URL', () {
expect(
catalogSearchUrl(query: 'naruto'),
'https://m.mangatown.com/search?name=naruto',
);
expect(
catalogSearchUrl(query: 'naruto', page: 2),
'https://m.mangatown.com/search/2.html?name=naruto',
);
});
});
group('mangatown series/chapter helpers', () {
test('mangaSlugFromUrl and listingUrlForSlug', () {
expect(
mangaSlugFromUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/'),
'ao_no_souyoku',
);
expect(
listingUrlForSlug('ao_no_souyoku'),
'https://m.mangatown.com/manga/ao_no_souyoku',
);
});
test('chapterBaseUrl and chapterPageUrl', () {
expect(
chapterBaseUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/3.html'),
'https://m.mangatown.com/manga/ao_no_souyoku/c005/',
);
expect(
chapterPageUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/', 1),
'https://m.mangatown.com/manga/ao_no_souyoku/c005/',
);
expect(
chapterPageUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/', 5),
'https://m.mangatown.com/manga/ao_no_souyoku/c005/5.html',
);
});
test('chapterKeyFromUrl and chapterNumberFromTitle', () {
expect(
chapterKeyFromUrl('https://m.mangatown.com/manga/ao_no_souyoku/c005/'),
'c005',
);
expect(chapterNumberFromTitle('C.5'), '5');
expect(chapterNumberFromTitle('C.031'), '031');
});
test('absoluteUrl upgrades protocol-relative', () {
expect(
absoluteUrl('//zjcdn.mangahere.org/a.jpg', 'https://m.mangatown.com/'),
'https://zjcdn.mangahere.org/a.jpg',
);
});
test('hotPeriodForMenu mapping', () {
expect(hotPeriodForMenu(CatalogMenuKind.popular), MangaTownHotPeriod.total);
expect(hotPeriodForMenu(CatalogMenuKind.hotToday), MangaTownHotPeriod.today);
expect(hotPeriodForMenu(CatalogMenuKind.hotWeek), MangaTownHotPeriod.week);
expect(hotPeriodForMenu(CatalogMenuKind.hotMonth), MangaTownHotPeriod.month);
expect(hotPeriodForMenu(CatalogMenuKind.latest), isNull);
});
});
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter_test/flutter_test.dart';
/// Mirrors DownloadQueue session drain gating used in `_pump`.
bool shouldNotifyQueueDrained({
required bool sessionHadWork,
required int queuedRemaining,
}) {
return sessionHadWork && queuedRemaining == 0;
}
void main() {
group('queue drained gating', () {
test('does not fire when no work was processed', () {
expect(
shouldNotifyQueueDrained(sessionHadWork: false, queuedRemaining: 0),
isFalse,
);
});
test('fires when work ran and queue is empty', () {
expect(
shouldNotifyQueueDrained(sessionHadWork: true, queuedRemaining: 0),
isTrue,
);
});
test('does not fire while items remain', () {
expect(
shouldNotifyQueueDrained(sessionHadWork: true, queuedRemaining: 2),
isFalse,
);
});
});
}
+115
View File
@@ -0,0 +1,115 @@
import 'dart:typed_data';
import 'package:cxyz_android/services/ntfy_notifier.dart';
import 'package:cxyz_android/services/ntfy_settings.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
void main() {
group('NtfyNotifier.formatBytes', () {
test('formats B KB MB', () {
expect(NtfyNotifier.formatBytes(500), '500 B');
expect(NtfyNotifier.formatBytes(1536), '1.5 KB');
expect(NtfyNotifier.formatBytes(5 * 1024 * 1024), '5.0 MB');
});
});
group('payload titles and body', () {
test('completed title is scannable', () {
expect(
NtfyNotifier.completedTitle('One Piece', 'Chapter 1100'),
'Downloaded · One Piece · Chapter 1100',
);
});
test('failed title includes series and chapter', () {
expect(
NtfyNotifier.failedTitle('One Piece', 'Chapter 1100'),
'Download failed · One Piece · Chapter 1100',
);
});
test('drained title includes counts', () {
expect(
NtfyNotifier.drainedTitle(ok: 5, failed: 1),
'Queue finished · 5 downloaded · 1 failed',
);
});
test('completed body includes sizes and savings', () {
final body = NtfyNotifier.completedBody(
imageCount: 36,
rawBytes: 10 * 1024 * 1024,
cbzBytes: 8 * 1024 * 1024,
);
expect(body, contains('Pages: 36'));
expect(body, contains('Images:'));
expect(body, contains('CBZ:'));
expect(body, contains('saved 20%'));
});
test('completed body appends note', () {
final body = NtfyNotifier.completedBody(
imageCount: 1,
rawBytes: 100,
cbzBytes: 90,
note: 'Attachment skipped (15 MB limit)',
);
expect(body, contains('Attachment skipped (15 MB limit)'));
});
});
group('NtfyAttachmentMode', () {
test('fromStorage defaults to none', () {
expect(NtfyAttachmentMode.fromStorage(null), NtfyAttachmentMode.none);
expect(NtfyAttachmentMode.fromStorage('nope'), NtfyAttachmentMode.none);
expect(
NtfyAttachmentMode.fromStorage('firstImage'),
NtfyAttachmentMode.firstImage,
);
});
});
group('preview resize', () {
test('resizePreviewIsolate shrinks large image under 250 KB', () {
final big = img.Image(width: 2000, height: 3000);
img.fill(big, color: img.ColorRgb8(40, 80, 120));
// Add noise so JPEG does not compress to near-zero.
for (var y = 0; y < big.height; y += 17) {
for (var x = 0; x < big.width; x += 13) {
big.setPixelRgb(x, y, (x * 3) % 255, (y * 5) % 255, 90);
}
}
final raw = Uint8List.fromList(img.encodeJpg(big, quality: 95));
expect(raw.length, greaterThan(250 * 1024));
final out = resizePreviewForTest(raw);
expect(out, isNotNull);
expect(out!.length, lessThanOrEqualTo(250 * 1024));
final decoded = img.decodeImage(out)!;
final longest =
decoded.width > decoded.height ? decoded.width : decoded.height;
expect(longest, lessThanOrEqualTo(720));
});
});
group('encodeNtfyHeader', () {
test('leaves plain ASCII unchanged', () {
expect(encodeNtfyHeader('Downloaded Chapter 1'), 'Downloaded Chapter 1');
});
test('RFC 2047-encodes middle-dot titles', () {
final encoded = encodeNtfyHeader('Downloaded · Series · Ch 1');
expect(encoded.startsWith('=?UTF-8?B?'), isTrue);
expect(encoded.endsWith('?='), isTrue);
expect(encoded.codeUnits.every((c) => c >= 0x20 && c <= 0x7e), isTrue);
});
test('RFC 2047-encodes multiline message', () {
final encoded = encodeNtfyHeader('Pages: 3\nImages: 1 MB\nCBZ: 0.9 MB');
expect(encoded.startsWith('=?UTF-8?B?'), isTrue);
expect(encoded.contains('\n'), isFalse);
});
});
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/services/reader_zoom.dart';
void main() {
test('clamp stays in range and snaps to step', () {
expect(ReaderZoom.clamp(0.1), ReaderZoom.min);
expect(ReaderZoom.clamp(3.0), ReaderZoom.max);
expect(ReaderZoom.clamp(0.66), closeTo(0.7, 0.001));
expect(ReaderZoom.clamp(1.0), 1.0);
});
test('percentLabel formats', () {
expect(ReaderZoom.percentLabel(0.7), '70%');
expect(ReaderZoom.percentLabel(1.0), '100%');
});
}
+166
View File
@@ -0,0 +1,166 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/sources/source.dart';
import 'package:cxyz_android/sources/source_flags.dart';
import 'package:cxyz_android/sources/comicland/comicland_source.dart';
import 'package:cxyz_android/sources/mangaread/mangaread_source.dart';
import 'package:cxyz_android/sources/mangaread/urls.dart';
import 'package:cxyz_android/sources/mangatown/mangatown_source.dart';
import 'package:cxyz_android/sources/registry.dart';
void main() {
group('parseEnabledSourceIds', () {
test('default comicland token', () {
expect(parseEnabledSourceIds('comicland'), {SourceId.comicland});
});
test('comma-separated both', () {
expect(
parseEnabledSourceIds('comicland,mangaread'),
{SourceId.comicland, SourceId.mangaread},
);
});
test('mangaread only', () {
expect(parseEnabledSourceIds('mangaread'), {SourceId.mangaread});
});
test('all enables every SourceId', () {
expect(parseEnabledSourceIds('all'), SourceId.values.toSet());
expect(parseEnabledSourceIds('ALL'), SourceId.values.toSet());
expect(parseEnabledSourceIds('all,mangaread'), SourceId.values.toSet());
});
test('mangatown token', () {
expect(parseEnabledSourceIds('mangatown'), {SourceId.mangatown});
});
test('ignores unknown and falls back when empty', () {
expect(parseEnabledSourceIds(''), {SourceId.comicland});
expect(parseEnabledSourceIds('nope,also-bad'), {SourceId.comicland});
});
test('trims and is case-insensitive', () {
expect(
parseEnabledSourceIds(' MangaRead , ComicLand '),
{SourceId.mangaread, SourceId.comicland},
);
});
});
group('SourceRegistry gating', () {
test('registers only enabled ids', () {
final reg = SourceRegistry(
enabled: {SourceId.mangaread},
mangaRead: MangaReadSource(),
);
expect(reg.all.map((s) => s.id), [SourceId.mangaread]);
expect(reg.hasMultiple, isFalse);
});
test('dual sources preserve declaration order', () {
final reg = SourceRegistry(
enabled: {SourceId.mangaread, SourceId.comicland},
comicLand: ComicLandSource(),
mangaRead: MangaReadSource(),
);
expect(reg.all.map((s) => s.id).toList(), [
SourceId.comicland,
SourceId.mangaread,
]);
expect(reg.hasMultiple, isTrue);
});
test('all three sources preserve declaration order', () {
final reg = SourceRegistry(
enabled: {
SourceId.mangatown,
SourceId.mangaread,
SourceId.comicland,
},
comicLand: ComicLandSource(),
mangaRead: MangaReadSource(),
mangaTown: MangaTownSource(),
);
expect(reg.all.map((s) => s.id).toList(), [
SourceId.comicland,
SourceId.mangaread,
SourceId.mangatown,
]);
});
});
group('catalogMenus capabilities', () {
test('ComicLand exposes newest, popular, official, uncensored, recommended, search', () {
final kinds =
ComicLandSource().catalogMenus.map((m) => m.kind).toList();
expect(kinds, [
CatalogMenuKind.latest,
CatalogMenuKind.popular,
CatalogMenuKind.official,
CatalogMenuKind.uncensored,
CatalogMenuKind.recommended,
CatalogMenuKind.search,
]);
expect(
ComicLandSource().catalogMenus.first.title,
'Newest',
);
});
test('MangaRead exposes latest and search only', () {
final kinds =
MangaReadSource().catalogMenus.map((m) => m.kind).toList();
expect(kinds, [
CatalogMenuKind.latest,
CatalogMenuKind.search,
]);
expect(kinds.contains(CatalogMenuKind.popular), isFalse);
});
test('MangaTown exposes latest, hot variants, directory, search', () {
final kinds =
MangaTownSource().catalogMenus.map((m) => m.kind).toList();
expect(kinds, [
CatalogMenuKind.latest,
CatalogMenuKind.popular,
CatalogMenuKind.hotToday,
CatalogMenuKind.hotWeek,
CatalogMenuKind.hotMonth,
CatalogMenuKind.directory,
CatalogMenuKind.search,
]);
});
});
group('mangaread urls', () {
test('mangaSlugFromUrl', () {
expect(
mangaSlugFromUrl(
'https://www.mangaread.org/manga/the-chaebeols-youngest-son/chapter-560/',
),
'the-chaebeols-youngest-son',
);
});
test('mangaBaseUrlFromListingUrl', () {
expect(
mangaBaseUrlFromListingUrl(
'https://www.mangaread.org/manga/foo-bar/chapter-1/',
),
'https://www.mangaread.org/manga/foo-bar/',
);
});
test('listingUrlForSlug', () {
expect(
listingUrlForSlug('foo-bar'),
'https://www.mangaread.org/manga/foo-bar/',
);
});
test('chapterNumberFromTitle', () {
expect(chapterNumberFromTitle('Chapter 560'), '560');
expect(chapterNumberFromTitle('Ch. 12.5'), '12.5');
});
});
}
@@ -0,0 +1,104 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/services/reader_direction.dart';
import 'package:cxyz_android/services/source_reader_defaults.dart';
void main() {
group('SourceReaderDefaults.modeFor', () {
test('comicland is webtoon', () {
expect(SourceReaderDefaults.modeFor('comicland'), ReaderMode.webtoon);
});
test('mangaread and mangatown are paged', () {
expect(SourceReaderDefaults.modeFor('mangaread'), ReaderMode.paged);
expect(SourceReaderDefaults.modeFor('mangatown'), ReaderMode.paged);
});
});
group('SourceReaderDefaults.directionFor', () {
test('mangaread and mangatown are rtl', () {
expect(
SourceReaderDefaults.directionFor('mangaread'),
ReadingDirection.rtl,
);
expect(
SourceReaderDefaults.directionFor('mangatown'),
ReadingDirection.rtl,
);
});
test('comicland has no built-in direction', () {
expect(SourceReaderDefaults.directionFor('comicland'), isNull);
});
});
group('SourceReaderDefaults.resolveMode', () {
test('stored override wins', () {
expect(
SourceReaderDefaults.resolveMode('mangatown', 'webtoon'),
ReaderMode.webtoon,
);
expect(
SourceReaderDefaults.resolveMode('comicland', 'paged'),
ReaderMode.paged,
);
});
test('empty / unknown falls back to source default', () {
expect(
SourceReaderDefaults.resolveMode('mangatown', ''),
ReaderMode.paged,
);
expect(
SourceReaderDefaults.resolveMode('mangatown', null),
ReaderMode.paged,
);
expect(
SourceReaderDefaults.resolveMode('comicland', ''),
ReaderMode.webtoon,
);
});
});
group('SourceReaderDefaults.resolveDirection', () {
test('stored override wins', () {
expect(
SourceReaderDefaults.resolveDirection(
'mangatown',
'ltr',
globalFallback: ReadingDirection.rtl,
),
ReadingDirection.ltr,
);
});
test('source default when unset', () {
expect(
SourceReaderDefaults.resolveDirection(
'mangaread',
null,
globalFallback: ReadingDirection.ltr,
),
ReadingDirection.rtl,
);
});
test('global fallback for comicland when unset', () {
expect(
SourceReaderDefaults.resolveDirection(
'comicland',
null,
globalFallback: ReadingDirection.rtl,
),
ReadingDirection.rtl,
);
expect(
SourceReaderDefaults.resolveDirection(
'comicland',
'',
globalFallback: ReadingDirection.ltr,
),
ReadingDirection.ltr,
);
});
});
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:cxyz_android/main.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
testWidgets('App builds', (tester) async {
await tester.pumpWidget(const ProviderScope(child: CxyzApp()));
// Catalog app bar title from ComicLand source.
expect(find.text('ComicLand'), findsOneWidget);
});
}