diff --git a/.gitignore b/.gitignore index afdb597..848b686 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,9 @@ # VS Code related .vscode/ -.history/ .ionide/ +.history/ +*.rdb # Flutter/Dart/Pub related **/doc/api/ @@ -30,7 +31,6 @@ .pub-cache/ .pub/ /build/ -bug/ # Web related lib/generated_plugin_registrant.dart @@ -41,13 +41,10 @@ app.*.symbols # Obfuscation related app.*.map.json -# Android related -key.properties -.project -.classpath -.settings/ -settings_aar.gradle -_*.png +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release -# Exceptions to above rules. -!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +### Project ### +_*.png diff --git a/.metadata b/.metadata index 83d975c..3c3e4b5 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: 6cb6eef615d757b18566709a1645140155c88f0e - channel: master + revision: 5464c5bac742001448fe4fc0597be939379f88ea + channel: stable project_type: app diff --git a/Makefile b/Makefile index bf621a4..4034391 100644 --- a/Makefile +++ b/Makefile @@ -2,3 +2,6 @@ build: flutter pub run build_runner build + +build_delete: + flutter pub run build_runner build --delete-conflicting-outputs diff --git a/README.md b/README.md index a2097f0..a2a5377 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # manhuagui_flutter -+ An unofficial application for manhuagui (https://www.manhuagui.com/) written in flutter. -+ Backend see [Aoi-hosizora/manhuagui-backend](https://github.com/Aoi-hosizora/manhuagui-backend) (private). ++ An unofficial application for manhuagui (https://www.manhuagui.com/), built in flutter. ++ You can visit [Aoi-hosizora/manhuagui-backend](https://github.com/Aoi-hosizora/manhuagui-backend) for backend part, currently this repository is private. ### Dependencies @@ -13,8 +13,7 @@ ### Screenshots -|![screenshot1](./assets/screenshot1.png)|![screenshot2](./assets/screenshot2.png)|![screenshot3](./assets/screenshot3.png)| -|---|---|---| -|![screenshot4](./assets/screenshot4.png)|![screenshot5](./assets/screenshot5.png)|![screenshot6](./assets/screenshot6.png)| -|---|---|---| -|![screenshot7](./assets/screenshot7.png)|![screenshot8](./assets/screenshot8.png)|![screenshot9](./assets/screenshot9.png)| +| ![screenshot1](./assets/screenshot1.png) | ![screenshot2](./assets/screenshot2.png) | ![screenshot3](./assets/screenshot3.png) | +|------------------------------------------|------------------------------------------|------------------------------------------| +| ![screenshot4](./assets/screenshot4.png) | ![screenshot5](./assets/screenshot5.png) | ![screenshot6](./assets/screenshot6.png) | +| ![screenshot7](./assets/screenshot7.png) | ![screenshot8](./assets/screenshot8.png) | ![screenshot9](./assets/screenshot9.png) | diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..a80cf62 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,11 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + avoid_print: false + sized_box_for_whitespace: false + prefer_single_quotes: true + prefer_const_constructors: false + prefer_const_literals_to_create_immutables: true + avoid_function_literals_in_foreach_calls: false + prefer_function_declarations_over_variables: false diff --git a/android/.gitignore b/android/.gitignore index 0a741cb..6f56801 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -9,3 +9,5 @@ GeneratedPluginRegistrant.java # Remember to never publicly share your keystore. # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle index 78dcc3a..5a1bbc9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -11,44 +11,37 @@ if (flutterRoot == null) { throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") } -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - apply plugin: 'com.android.application' apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" -def keystorePropertiesFile = rootProject.file("key.properties") def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file("key.properties") keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) android { - compileSdkVersion 29 + compileSdkVersion 33 // flutter.compileSdkVersion - sourceSets { - main.java.srcDirs += 'src/main/kotlin' + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' } - lintOptions { - disable 'InvalidPackage' + sourceSets { + main.java.srcDirs += 'src/main/kotlin' } defaultConfig { - applicationId "com.aoihosizora.manhuagui" - minSdkVersion 16 - targetSdkVersion 29 - // versionCode flutterVersionCode.toInteger() - // versionName flutterVersionName + applicationId 'com.aoihosizora.manhuagui' + versionCode 4 + versionName '1.2.0' - versionCode 3 - versionName "1.0.2" + minSdkVersion 19 // flutter.minSdkVersion + targetSdkVersion flutter.targetSdkVersion } signingConfigs { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a634996..275e191 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,24 +7,27 @@ + android:icon="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true"> + - - + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> @@ -33,7 +36,9 @@ - + diff --git a/android/app/src/main/kotlin/com/example/manhuagui_flutter/MainActivity.kt b/android/app/src/main/kotlin/com/example/manhuagui_flutter/MainActivity.kt index 024fb9c..b5adfc0 100644 --- a/android/app/src/main/kotlin/com/example/manhuagui_flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/manhuagui_flutter/MainActivity.kt @@ -1,6 +1,40 @@ package com.example.manhuagui_flutter +import android.content.Intent +import android.net.Uri import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result as MethodResult +import java.io.File -class MainActivity: FlutterActivity() { +class MainActivity: FlutterActivity(), MethodCallHandler { + companion object { + private const val CHANNEL = "com.aoihosizora.manhuagui" + private const val INSERT_MEDIA_METHOD = "insertMedia" + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodResult) { + when (call.method) { + INSERT_MEDIA_METHOD -> { + // https://github.com/CarnegieTechnologies/gallery_saver/blob/master/android/src/main/kotlin/carnegietechnologies/gallery_saver/FileUtils.kt + // https://github.com/hui-z/image_gallery_saver/blob/master/android/src/main/kotlin/com/example/imagegallerysaver/ImageGallerySaverPlugin.kt + // https://developer.android.com/training/camera/photobasics#TaskGallery + val filepath = call.argument("filepath").toString() ?: "" + val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) + val f = File(filepath) + intent.data = Uri.fromFile(f) + sendBroadcast(intent) + result.success(true) + } + else -> result.notImplemented() + } + } } diff --git a/android/app/src/main/res/drawable/flutter_icon.png b/android/app/src/main/res/drawable/flutter_icon.png new file mode 100644 index 0000000..c4cc798 Binary files /dev/null and b/android/app/src/main/res/drawable/flutter_icon.png differ diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index d74aa35..063deb3 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -14,5 +14,6 @@ This Theme is only used starting with V2 of Flutter's Android embedding. --> diff --git a/android/build.gradle b/android/build.gradle index 9eb07a3..4256f91 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.3.50' + ext.kotlin_version = '1.6.10' repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.0' + classpath 'com.android.tools.build:gradle:4.1.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -14,14 +14,15 @@ buildscript { allprojects { repositories { google() - jcenter() + mavenCentral() } } rootProject.buildDir = '../build' - subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { project.evaluationDependsOn(':app') } diff --git a/android/gradle.properties b/android/gradle.properties index a673820..94adc3a 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,3 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true android.enableJetifier=true -android.enableR8=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 296b146..bc6a58a 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 5a2f14f..44e62bc 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,15 +1,11 @@ include ':app' -def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() -def plugins = new Properties() -def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') -if (pluginsFile.exists()) { - pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } -} +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } -plugins.each { name, path -> - def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() - include ":$name" - project(":$name").projectDir = pluginDirectory -} +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/assets/actions.png b/assets/actions.png deleted file mode 100644 index 39a85a4..0000000 Binary files a/assets/actions.png and /dev/null differ diff --git a/ios/.gitignore b/ios/.gitignore deleted file mode 100644 index e96ef60..0000000 --- a/ios/.gitignore +++ /dev/null @@ -1,32 +0,0 @@ -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 6b4c0f7..0000000 --- a/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,26 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - MinimumOSVersion - 8.0 - - diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig deleted file mode 100644 index 592ceee..0000000 --- a/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig deleted file mode 100644 index 592ceee..0000000 --- a/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 2d93f8e..0000000 --- a/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,495 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 46; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 1020; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.manhuaguiFlutter; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.manhuaguiFlutter; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.manhuaguiFlutter; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a1..0000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c..0000000 --- a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index a28140c..0000000 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a1..0000000 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c..0000000 --- a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift deleted file mode 100644 index 70693e4..0000000 --- a/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import UIKit -import Flutter - -@UIApplicationMain -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fa..0000000 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada4..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 28c6bf0..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 2ccbfd9..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index f091b6b..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cde121..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index d0ef06e..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index dcdc230..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 2ccbfd9..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index c8f9ed8..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index a6d6b86..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index a6d6b86..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index 75b2d16..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index c4df70d..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 6a84f41..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index d0e1f58..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2..0000000 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725..0000000 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c..0000000 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c2851..0000000 --- a/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist deleted file mode 100644 index 3d9ca51..0000000 --- a/ios/Runner/Info.plist +++ /dev/null @@ -1,45 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - manhuagui_flutter - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - - diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a5..0000000 --- a/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/lib/assets/ic_launcher_h.png b/lib/assets/ic_launcher_h.png deleted file mode 100644 index 98fc59e..0000000 Binary files a/lib/assets/ic_launcher_h.png and /dev/null differ diff --git a/lib/assets/ic_launcher_xxhdpi.png b/lib/assets/ic_launcher_xxhdpi.png new file mode 100644 index 0000000..f97898a Binary files /dev/null and b/lib/assets/ic_launcher_xxhdpi.png differ diff --git a/lib/config.dart b/lib/config.dart index 50deddd..f11b392 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -1,28 +1,39 @@ -const DEBUG = true; +// ignore_for_file: constant_identifier_names + const APP_NAME = 'Manhuagui'; -const APP_VERSION = '1.0.2'; +const APP_VERSION = '1.2.0'; +const APP_LEGALESE = 'Copyright © 2020-2022 AoiHosizora'; const APP_DESCRIPTIONS = [ - '非官方的漫画柜 (manhuagui) 安卓客户端,使用 Flutter 开发。', - '作者: Github Aoi-hosizora ', - '', - '该客户端仅供学习研究使用,请勿用于商业用途。', - '本应用与内容提供方无任何联系,若有任何问题,请发邮件或 Issue 联系。', + '第三方漫画柜 ($WEB_HOMEPAGE_URL) 安卓客户端,使用 Flutter 开发。', + '作者:GitHub @Aoi-hosizora (青いほしぞら) ', + ' ', + '该客户端仅供学习使用,仅供非商业用途。', + ' ', + '本应用与漫画柜内容提供方无任何关系,若有问题,请发邮件或 Issue 联系。', ]; -const DB_NAME = 'db_manhuagui'; -const WEB_HOMEPAGE_URL = 'https://www.manhuagui.com/'; -const APP_HOMEPAGE_URL = 'https://github.com/Aoi-hosizora/manhuagui_flutter'; -const CONTACT = ''; -const FEEDBACK_URL = 'https://github.com/Aoi-hosizora/manhuagui_flutter/issues/new'; -const RELEASE_URL = 'https://github.com/Aoi-hosizora/manhuagui_flutter/releases'; +const ASSETS_PREFIX = 'lib/assets/'; +const DB_NAME = 'db_manhuagui'; +const DL_NTFC_ID = 'com.aoihosizora.manhuagui:download'; +const DL_NTFC_NAME = '漫画下载通知'; +const DL_NTFC_DESCRIPTION = '显示当前的漫画下载进度'; -const CONNECT_TIMEOUT = 10000; // 10s -const SEND_TIMEOUT = 5000; // 5s -const RECEIVE_TIMEOUT = 5000; // 5s - -// const BASE_API_URL = 'http://10.0.3.2:10018/v1/'; -const BASE_API_URL = 'http://api.manhuagui.aoihosizora.top/v1/'; -const REGISTER_URL = 'https://www.manhuagui.com/user/register'; +const DEBUG_ERROR = true; +const CONNECT_TIMEOUT = 5000; // 5.0s (local -> my server) +const SEND_TIMEOUT = 5000; // 5.0s (local -> my server) +const RECEIVE_TIMEOUT = 8000; // 8.0s (my server -> manhuagui server -> my server -> local) +const HEAD_TIMEOUT = 4000; // 4.0s (local -> manhuagui server -> local) +const DOWNLOAD_IMAGE_TIMEOUT = 15000; // 15.0s (local -> manhuagui server -> local) +const BASE_API_URL = 'https://api-manhuagui.aoihosizora.top/v1/'; const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36'; const REFERER = 'https://www.manhuagui.com/'; + +const WEB_HOMEPAGE_URL = 'https://www.manhuagui.com/'; +const USER_CENTER_URL = 'https://www.manhuagui.com/user/center/index'; +const MESSAGE_URL = 'https://www.manhuagui.com/user/message/system'; +const EDIT_PROFILE_URL = 'https://www.manhuagui.com/user/center/proinfo'; +const REGISTER_URL = 'https://www.manhuagui.com/user/register'; +const SOURCE_CODE_URL = 'https://github.com/Aoi-hosizora/manhuagui_flutter'; +const FEEDBACK_URL = 'https://github.com/Aoi-hosizora/manhuagui_flutter/issues/new'; +const RELEASE_URL = 'https://github.com/Aoi-hosizora/manhuagui_flutter/releases'; diff --git a/lib/main.dart b/lib/main.dart index dc1db9e..6dca29a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,29 +1,58 @@ -import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/material.dart'; import 'package:manhuagui_flutter/page/index.dart'; +import 'package:manhuagui_flutter/service/native/system_ui.dart'; void main() { - runApp(MyApp()); + runApp(const MyApp()); } class MyApp extends StatelessWidget { + const MyApp({Key? key}) : super(key: key); + @override Widget build(BuildContext context) { + setDefaultSystemUIOverlayStyle(); return MaterialApp( title: 'Manhuagui', theme: ThemeData( primarySwatch: Colors.deepOrange, - scaffoldBackgroundColor: Colors.grey[100], - ), + appBarTheme: AppBarTheme( + centerTitle: true, + toolbarHeight: 45, + ), + scaffoldBackgroundColor: Color.fromRGBO(245, 245, 245, 1.0), + splashFactory: CustomInkRipple.preferredSplashFactory, + pageTransitionsTheme: PageTransitionsTheme( + builders: const { + TargetPlatform.android: NoPopGestureCupertinoPageTransitionsBuilder(), + }, + ), + ).withPreferredButtonStyles(), debugShowCheckedModeBanner: false, - localizationsDelegates: [ + localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, ], - supportedLocales: [ + supportedLocales: const [ Locale('ja', 'JP'), + Locale('zh', 'CN'), ], home: IndexPage(), + builder: (context, child) => CustomPageRouteTheme( + data: CustomPageRouteThemeData( + transitionDuration: Duration(milliseconds: 400), + transitionsBuilder: NoPopGestureCupertinoPageTransitionsBuilder(), + ), + child: AppBarActionButtonTheme( + data: AppBarActionButtonThemeData( + splashRadius: 19, + ), + child: child!, + ), + ), ); } } diff --git a/lib/model/author.dart b/lib/model/author.dart index 47a07ee..de5fd02 100644 --- a/lib/model/author.dart +++ b/lib/model/author.dart @@ -4,58 +4,52 @@ part 'author.g.dart'; @JsonSerializable(fieldRename: FieldRename.snake) class Author { - int aid; - String name; - String alias; - String zone; - String cover; - String url; - int mangaCount; - int newestMangaId; - String newestMangaTitle; - String newestDate; - double averageScore; - String introduction; - - Author({this.aid, this.name, this.alias, this.zone, this.cover, this.url, this.mangaCount, this.newestMangaId, this.newestMangaTitle, this.newestDate, this.averageScore, this.introduction}); + final int aid; + final String name; + final String alias; + final String zone; + final String cover; + final String url; + final int mangaCount; + final int newestMangaId; + final String newestMangaTitle; + final String newestDate; + final double averageScore; + final String introduction; + + const Author({required this.aid, required this.name, required this.alias, required this.zone, required this.cover, required this.url, required this.mangaCount, required this.newestMangaId, required this.newestMangaTitle, required this.newestDate, required this.averageScore, required this.introduction}); factory Author.fromJson(Map json) => _$AuthorFromJson(json); Map toJson() => _$AuthorToJson(this); - - static const fields = ['aid', 'name', 'alias', 'zone', 'cover', 'url', 'manga_count', 'newest_manga_id', 'newest_manga_title', 'newest_date', 'average_score', 'introduction']; } @JsonSerializable(fieldRename: FieldRename.snake) class SmallAuthor { - int aid; - String name; - String zone; - String cover; - String url; - int mangaCount; - String newestDate; + final int aid; + final String name; + final String zone; + final String cover; + final String url; + final int mangaCount; + final String newestDate; - SmallAuthor({this.aid, this.name, this.zone, this.cover, this.url, this.mangaCount, this.newestDate}); + const SmallAuthor({required this.aid, required this.name, required this.zone, required this.cover, required this.url, required this.mangaCount, required this.newestDate}); factory SmallAuthor.fromJson(Map json) => _$SmallAuthorFromJson(json); Map toJson() => _$SmallAuthorToJson(this); - - static const fields = ['aid', 'name', 'zone', 'cover', 'url', 'manga_count', 'newest_date']; } @JsonSerializable(fieldRename: FieldRename.snake) class TinyAuthor { - int aid; - String name; - String url; + final int aid; + final String name; + final String url; - TinyAuthor({this.aid, this.name, this.url}); + const TinyAuthor({required this.aid, required this.name, required this.url}); factory TinyAuthor.fromJson(Map json) => _$TinyAuthorFromJson(json); Map toJson() => _$TinyAuthorToJson(this); - - static const fields = ['aid', 'name', 'url']; } diff --git a/lib/model/author.g.dart b/lib/model/author.g.dart index bcdde61..9887e89 100644 --- a/lib/model/author.g.dart +++ b/lib/model/author.g.dart @@ -6,22 +6,20 @@ part of 'author.dart'; // JsonSerializableGenerator // ************************************************************************** -Author _$AuthorFromJson(Map json) { - return Author( - aid: json['aid'] as int, - name: json['name'] as String, - alias: json['alias'] as String, - zone: json['zone'] as String, - cover: json['cover'] as String, - url: json['url'] as String, - mangaCount: json['manga_count'] as int, - newestMangaId: json['newest_manga_id'] as int, - newestMangaTitle: json['newest_manga_title'] as String, - newestDate: json['newest_date'] as String, - averageScore: (json['average_score'] as num)?.toDouble(), - introduction: json['introduction'] as String, - ); -} +Author _$AuthorFromJson(Map json) => Author( + aid: json['aid'] as int, + name: json['name'] as String, + alias: json['alias'] as String, + zone: json['zone'] as String, + cover: json['cover'] as String, + url: json['url'] as String, + mangaCount: json['manga_count'] as int, + newestMangaId: json['newest_manga_id'] as int, + newestMangaTitle: json['newest_manga_title'] as String, + newestDate: json['newest_date'] as String, + averageScore: (json['average_score'] as num).toDouble(), + introduction: json['introduction'] as String, + ); Map _$AuthorToJson(Author instance) => { 'aid': instance.aid, @@ -38,17 +36,15 @@ Map _$AuthorToJson(Author instance) => { 'introduction': instance.introduction, }; -SmallAuthor _$SmallAuthorFromJson(Map json) { - return SmallAuthor( - aid: json['aid'] as int, - name: json['name'] as String, - zone: json['zone'] as String, - cover: json['cover'] as String, - url: json['url'] as String, - mangaCount: json['manga_count'] as int, - newestDate: json['newest_date'] as String, - ); -} +SmallAuthor _$SmallAuthorFromJson(Map json) => SmallAuthor( + aid: json['aid'] as int, + name: json['name'] as String, + zone: json['zone'] as String, + cover: json['cover'] as String, + url: json['url'] as String, + mangaCount: json['manga_count'] as int, + newestDate: json['newest_date'] as String, + ); Map _$SmallAuthorToJson(SmallAuthor instance) => { @@ -61,13 +57,11 @@ Map _$SmallAuthorToJson(SmallAuthor instance) => 'newest_date': instance.newestDate, }; -TinyAuthor _$TinyAuthorFromJson(Map json) { - return TinyAuthor( - aid: json['aid'] as int, - name: json['name'] as String, - url: json['url'] as String, - ); -} +TinyAuthor _$TinyAuthorFromJson(Map json) => TinyAuthor( + aid: json['aid'] as int, + name: json['name'] as String, + url: json['url'] as String, + ); Map _$TinyAuthorToJson(TinyAuthor instance) => { diff --git a/lib/model/category.dart b/lib/model/category.dart index 7594333..04c9cbb 100644 --- a/lib/model/category.dart +++ b/lib/model/category.dart @@ -5,18 +5,16 @@ part 'category.g.dart'; @JsonSerializable(fieldRename: FieldRename.snake) class Category { - String name; - String title; - String url; + final String name; + final String title; + final String url; - Category({this.name, this.title, this.url}); + const Category({required this.name, required this.title, required this.url}); factory Category.fromJson(Map json) => _$CategoryFromJson(json); Map toJson() => _$CategoryToJson(this); - static const fields = ['name', 'title', 'url']; - TinyCategory toTiny() { return TinyCategory(name: name, title: title); } @@ -26,18 +24,18 @@ class TinyCategory { final String name; final String title; - const TinyCategory({this.name, this.title}); + const TinyCategory({required this.name, required this.title}); @override bool operator ==(Object other) { - return other is TinyCategory && other.name == this.name; + return other is TinyCategory && other.name == name; } @override - int get hashCode => hash2(this.name, this.title); + int get hashCode => hash2(name, title); bool isAll() { - return this.name == 'all'; + return name == 'all'; } } diff --git a/lib/model/category.g.dart b/lib/model/category.g.dart index d1e53ba..c3669da 100644 --- a/lib/model/category.g.dart +++ b/lib/model/category.g.dart @@ -6,13 +6,11 @@ part of 'category.dart'; // JsonSerializableGenerator // ************************************************************************** -Category _$CategoryFromJson(Map json) { - return Category( - name: json['name'] as String, - title: json['title'] as String, - url: json['url'] as String, - ); -} +Category _$CategoryFromJson(Map json) => Category( + name: json['name'] as String, + title: json['title'] as String, + url: json['url'] as String, + ); Map _$CategoryToJson(Category instance) => { 'name': instance.name, diff --git a/lib/model/chapter.dart b/lib/model/chapter.dart index aad5c6f..345cbff 100644 --- a/lib/model/chapter.dart +++ b/lib/model/chapter.dart @@ -1,56 +1,101 @@ +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:json_annotation/json_annotation.dart'; part 'chapter.g.dart'; @JsonSerializable(fieldRename: FieldRename.snake) class MangaChapter { - int cid; - String title; - int mid; - String mangaTitle; - String url; - List pages; - int pageCount; - int nextCid; - int prevCid; - - MangaChapter({this.cid, this.title, this.mid, this.mangaTitle, this.url, this.pages, this.pageCount, this.nextCid, this.prevCid}); + final int cid; + final String title; + final int mid; + final String mangaTitle; + final String url; + final List pages; + final int pageCount; + final int nextCid; + final int prevCid; + + const MangaChapter({required this.cid, required this.title, required this.mid, required this.mangaTitle, required this.url, required this.pages, required this.pageCount, required this.nextCid, required this.prevCid}); factory MangaChapter.fromJson(Map json) => _$MangaChapterFromJson(json); Map toJson() => _$MangaChapterToJson(this); - - static const fields = ['cid', 'title', 'mid', 'manga_title', 'url', 'pages', 'page_count', 'next_cid', 'prev_cid']; } @JsonSerializable(fieldRename: FieldRename.snake) class TinyMangaChapter { - int cid; - String title; - int mid; - String url; - int pageCount; - bool isNew; + final int cid; + final String title; + final int mid; + final String url; // useless + final int pageCount; + final bool isNew; - TinyMangaChapter({this.cid, this.title, this.mid, this.url, this.pageCount}); + const TinyMangaChapter({required this.cid, required this.title, required this.mid, required this.url, required this.pageCount, required this.isNew}); factory TinyMangaChapter.fromJson(Map json) => _$TinyMangaChapterFromJson(json); Map toJson() => _$TinyMangaChapterToJson(this); - - static const fields = ['cid', 'title', 'mid', 'url', 'page_count', 'is_new']; } @JsonSerializable(fieldRename: FieldRename.snake) class MangaChapterGroup { - String title; - List chapters; + final String title; + final List chapters; - MangaChapterGroup({this.title, this.chapters}); + const MangaChapterGroup({required this.title, required this.chapters}); factory MangaChapterGroup.fromJson(Map json) => _$MangaChapterGroupFromJson(json); Map toJson() => _$MangaChapterGroupToJson(this); +} - static const fields = ['title', 'chapters']; +extension MangaChapterGroupListExtension on List { + MangaChapterGroup? get regularGroup { + return where((g) => g.title == '单话').firstOrNull; + } + + List makeSureRegularGroupIsFirst() { + var rGroup = regularGroup; + if (rGroup == null) { + return this; + } + return [ + rGroup, + ...where((g) => g.title != '单话'), + ]; + } + + MangaChapterGroup? getFirstNotEmptyGroup() { + if (isEmpty) { + return null; + } + var group = regularGroup; + if (group == null || group.chapters.isNotEmpty) { + return group; + } + return where((g) => g.chapters.isNotEmpty).firstOrNull; + } + + TinyMangaChapter? findChapter(int cid) { + for (var group in this) { + for (var chapter in group.chapters) { + if (chapter.cid == cid) { + return chapter; + } + } + } + return null; + } + + Tuple2? findChapterAndGroupName(int cid) { + for (var group in this) { + for (var chapter in group.chapters) { + if (chapter.cid == cid) { + return Tuple2(chapter, group.title); + } + } + } + return null; + } } diff --git a/lib/model/chapter.g.dart b/lib/model/chapter.g.dart index f848262..3ad5ffa 100644 --- a/lib/model/chapter.g.dart +++ b/lib/model/chapter.g.dart @@ -6,19 +6,17 @@ part of 'chapter.dart'; // JsonSerializableGenerator // ************************************************************************** -MangaChapter _$MangaChapterFromJson(Map json) { - return MangaChapter( - cid: json['cid'] as int, - title: json['title'] as String, - mid: json['mid'] as int, - mangaTitle: json['manga_title'] as String, - url: json['url'] as String, - pages: (json['pages'] as List)?.map((e) => e as String)?.toList(), - pageCount: json['page_count'] as int, - nextCid: json['next_cid'] as int, - prevCid: json['prev_cid'] as int, - ); -} +MangaChapter _$MangaChapterFromJson(Map json) => MangaChapter( + cid: json['cid'] as int, + title: json['title'] as String, + mid: json['mid'] as int, + mangaTitle: json['manga_title'] as String, + url: json['url'] as String, + pages: (json['pages'] as List).map((e) => e as String).toList(), + pageCount: json['page_count'] as int, + nextCid: json['next_cid'] as int, + prevCid: json['prev_cid'] as int, + ); Map _$MangaChapterToJson(MangaChapter instance) => { @@ -33,15 +31,15 @@ Map _$MangaChapterToJson(MangaChapter instance) => 'prev_cid': instance.prevCid, }; -TinyMangaChapter _$TinyMangaChapterFromJson(Map json) { - return TinyMangaChapter( - cid: json['cid'] as int, - title: json['title'] as String, - mid: json['mid'] as int, - url: json['url'] as String, - pageCount: json['page_count'] as int, - )..isNew = json['is_new'] as bool; -} +TinyMangaChapter _$TinyMangaChapterFromJson(Map json) => + TinyMangaChapter( + cid: json['cid'] as int, + title: json['title'] as String, + mid: json['mid'] as int, + url: json['url'] as String, + pageCount: json['page_count'] as int, + isNew: json['is_new'] as bool, + ); Map _$TinyMangaChapterToJson(TinyMangaChapter instance) => { @@ -53,16 +51,13 @@ Map _$TinyMangaChapterToJson(TinyMangaChapter instance) => 'is_new': instance.isNew, }; -MangaChapterGroup _$MangaChapterGroupFromJson(Map json) { - return MangaChapterGroup( - title: json['title'] as String, - chapters: (json['chapters'] as List) - ?.map((e) => e == null - ? null - : TinyMangaChapter.fromJson(e as Map)) - ?.toList(), - ); -} +MangaChapterGroup _$MangaChapterGroupFromJson(Map json) => + MangaChapterGroup( + title: json['title'] as String, + chapters: (json['chapters'] as List) + .map((e) => TinyMangaChapter.fromJson(e as Map)) + .toList(), + ); Map _$MangaChapterGroupToJson(MangaChapterGroup instance) => { diff --git a/lib/model/comment.dart b/lib/model/comment.dart index 2b7aeb5..dbb9001 100644 --- a/lib/model/comment.dart +++ b/lib/model/comment.dart @@ -4,47 +4,43 @@ part 'comment.g.dart'; @JsonSerializable(fieldRename: FieldRename.snake) class Comment { - int cid; - int uid; - String username; - String avatar; - int gender; - String content; - int likeCount; - int replyCount; - String commentTime; - List replyTimeline; - - Comment({this.cid, this.uid, this.username, this.avatar, this.gender, this.content, this.likeCount, this.replyCount, this.commentTime, this.replyTimeline}); + final int cid; + final int uid; + final String username; + final String avatar; + final int gender; + final String content; + final int likeCount; + final int replyCount; + final String commentTime; + final List replyTimeline; + + Comment({required this.cid, required this.uid, required this.username, required this.avatar, required this.gender, required this.content, required this.likeCount, required this.replyCount, required this.commentTime, required this.replyTimeline}); factory Comment.fromJson(Map json) => _$CommentFromJson(json); Map toJson() => _$CommentToJson(this); - - static const fields = ['cid', 'uid', 'username', 'avatar', 'gender', 'content', 'like_count', 'reply_count', 'comment_time', 'reply_timeline']; - - RepliedComment toRepliedComment() { - return RepliedComment(cid: cid, uid: uid, username: username, avatar: avatar, gender: gender, content: content, likeCount: likeCount, replyCount: replyCount, commentTime: commentTime); - } } @JsonSerializable(fieldRename: FieldRename.snake) class RepliedComment { - int cid; - int uid; - String username; - String avatar; - int gender; - String content; - int likeCount; - int replyCount; - String commentTime; - - RepliedComment({this.cid, this.uid, this.username, this.avatar, this.gender, this.content, this.likeCount, this.replyCount, this.commentTime}); + final int cid; + final int uid; + final String username; + final String avatar; + final int gender; + final String content; + final int likeCount; + final int replyCount; + final String commentTime; + + const RepliedComment({required this.cid, required this.uid, required this.username, required this.avatar, required this.gender, required this.content, required this.likeCount, required this.replyCount, required this.commentTime}); factory RepliedComment.fromJson(Map json) => _$RepliedCommentFromJson(json); Map toJson() => _$RepliedCommentToJson(this); - static const fields = ['cid', 'uid', 'username', 'avatar', 'gender', 'content', 'like_count', 'reply_count', 'comment_time']; + Comment toComment() { + return Comment(cid: cid, uid: uid, username: username, avatar: avatar, gender: gender, content: content, likeCount: likeCount, replyCount: replyCount, commentTime: commentTime, replyTimeline: []); + } } diff --git a/lib/model/comment.g.dart b/lib/model/comment.g.dart index c873414..cd13478 100644 --- a/lib/model/comment.g.dart +++ b/lib/model/comment.g.dart @@ -6,24 +6,20 @@ part of 'comment.dart'; // JsonSerializableGenerator // ************************************************************************** -Comment _$CommentFromJson(Map json) { - return Comment( - cid: json['cid'] as int, - uid: json['uid'] as int, - username: json['username'] as String, - avatar: json['avatar'] as String, - gender: json['gender'] as int, - content: json['content'] as String, - likeCount: json['like_count'] as int, - replyCount: json['reply_count'] as int, - commentTime: json['comment_time'] as String, - replyTimeline: (json['reply_timeline'] as List) - ?.map((e) => e == null - ? null - : RepliedComment.fromJson(e as Map)) - ?.toList(), - ); -} +Comment _$CommentFromJson(Map json) => Comment( + cid: json['cid'] as int, + uid: json['uid'] as int, + username: json['username'] as String, + avatar: json['avatar'] as String, + gender: json['gender'] as int, + content: json['content'] as String, + likeCount: json['like_count'] as int, + replyCount: json['reply_count'] as int, + commentTime: json['comment_time'] as String, + replyTimeline: (json['reply_timeline'] as List) + .map((e) => RepliedComment.fromJson(e as Map)) + .toList(), + ); Map _$CommentToJson(Comment instance) => { 'cid': instance.cid, @@ -38,19 +34,18 @@ Map _$CommentToJson(Comment instance) => { 'reply_timeline': instance.replyTimeline, }; -RepliedComment _$RepliedCommentFromJson(Map json) { - return RepliedComment( - cid: json['cid'] as int, - uid: json['uid'] as int, - username: json['username'] as String, - avatar: json['avatar'] as String, - gender: json['gender'] as int, - content: json['content'] as String, - likeCount: json['like_count'] as int, - replyCount: json['reply_count'] as int, - commentTime: json['comment_time'] as String, - ); -} +RepliedComment _$RepliedCommentFromJson(Map json) => + RepliedComment( + cid: json['cid'] as int, + uid: json['uid'] as int, + username: json['username'] as String, + avatar: json['avatar'] as String, + gender: json['gender'] as int, + content: json['content'] as String, + likeCount: json['like_count'] as int, + replyCount: json['reply_count'] as int, + commentTime: json['comment_time'] as String, + ); Map _$RepliedCommentToJson(RepliedComment instance) => { diff --git a/lib/model/converter.dart b/lib/model/converter.dart deleted file mode 100644 index de161ce..0000000 --- a/lib/model/converter.dart +++ /dev/null @@ -1,184 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'package:manhuagui_flutter/model/author.dart'; -import 'package:manhuagui_flutter/model/category.dart'; -import 'package:manhuagui_flutter/model/chapter.dart'; -import 'package:manhuagui_flutter/model/comment.dart'; -import 'package:manhuagui_flutter/model/manga.dart'; -import 'package:manhuagui_flutter/model/result.dart'; -import 'package:manhuagui_flutter/model/user.dart'; - -bool _matchJson(List fields, Map json) { - for (var f in fields) { - if (!json.containsKey(f)) { - return false; - } - } - return true; -} - -bool _matchPageJson(Map json) { - if (!_matchJson(ResultPage.fields, json)) { - return false; - } - if (((json['data'] as List)?.length ?? 0) == 0) { - return ResultPage() is TPage; - } - return _jsonMapType(json['data'][0]) == TItem; -} - -Type _jsonMapType(Map json) { - if (_matchJson(Manga.fields, json)) { - return Manga; - } else if (_matchJson(MangaRank.fields, json)) { - return MangaRank; - } else if (_matchJson(SmallManga.fields, json)) { - return SmallManga; - } else if (_matchJson(TinyManga.fields, json)) { - return TinyManga; - } else if (_matchJson(TinyBlockManga.fields, json)) { - return TinyBlockManga; - } else if (_matchJson(ShelfManga.fields, json)) { - return ShelfManga; - } else if (_matchJson(MangaChapter.fields, json)) { - return MangaChapter; - } else if (_matchJson(TinyMangaChapter.fields, json)) { - return TinyMangaChapter; - } else if (_matchJson(Comment.fields, json)) { - return Comment; - } else if (_matchJson(RepliedComment.fields, json)) { - return RepliedComment; - } else if (_matchJson(Category.fields, json)) { - return Category; - } else if (_matchJson(Author.fields, json)) { - return Author; - } else if (_matchJson(SmallAuthor.fields, json)) { - return SmallAuthor; - } else if (_matchJson(TinyAuthor.fields, json)) { - return TinyAuthor; - } else if (_matchJson(MangaGroup.fields, json)) { - return MangaGroup; - } else if (_matchJson(MangaChapterGroup.fields, json)) { - return MangaChapterGroup; - } else if (_matchJson(MangaGroupList.fields, json)) { - return MangaGroupList; - } else if (_matchJson(HomepageMangaGroupList.fields, json)) { - return HomepageMangaGroupList; - } else if (_matchJson(Token.fields, json)) { - return Token; - } else if (_matchJson(User.fields, json)) { - return User; - } else if (_matchJson(ShelfStatus.fields, json)) { - return ShelfStatus; - } else if (_matchJson(LoginCheckResult.fields, json)) { - return LoginCheckResult; - } - return null; -} - -class GenericConverter implements JsonConverter { - const GenericConverter(); - - @override - T fromJson(Object json) { - if (json is Map) { - // Result - if (_matchJson(Manga.fields, json)) { - return Manga.fromJson(json) as T; // Manga - } else if (_matchJson(MangaRank.fields, json)) { - return MangaRank.fromJson(json) as T; // MangaRank - } else if (_matchJson(SmallManga.fields, json)) { - return SmallManga.fromJson(json) as T; // SmallManga - } else if (_matchJson(TinyManga.fields, json)) { - return TinyManga.fromJson(json) as T; // TinyManga - } else if (_matchJson(TinyBlockManga.fields, json)) { - return TinyBlockManga.fromJson(json) as T; // TinyBlockManga - } else if (_matchJson(ShelfManga.fields, json)) { - return ShelfManga.fromJson(json) as T; // ShelfManga - } else if (_matchJson(MangaChapter.fields, json)) { - return MangaChapter.fromJson(json) as T; // MangaChapter - } else if (_matchJson(TinyMangaChapter.fields, json)) { - return TinyMangaChapter.fromJson(json) as T; // TinyMangaChapter - } else if (_matchJson(Comment.fields, json)) { - return Comment.fromJson(json) as T; // Comment - } else if (_matchJson(RepliedComment.fields, json)) { - return RepliedComment.fromJson(json) as T; // RepliedComment - } else if (_matchJson(Category.fields, json)) { - return Category.fromJson(json) as T; // Category - } else if (_matchJson(Author.fields, json)) { - return Author.fromJson(json) as T; // Author - } else if (_matchJson(SmallAuthor.fields, json)) { - return SmallAuthor.fromJson(json) as T; // SmallAuthor - } else if (_matchJson(TinyAuthor.fields, json)) { - return TinyAuthor.fromJson(json) as T; // TinyAuthor - } else if (_matchJson(MangaGroup.fields, json)) { - return MangaGroup.fromJson(json) as T; // MangaGroup - } else if (_matchJson(MangaChapterGroup.fields, json)) { - return MangaChapterGroup.fromJson(json) as T; // MangaChapterGroup - } else if (_matchJson(MangaGroupList.fields, json)) { - return MangaGroupList.fromJson(json) as T; // MangaGroupList - } else if (_matchJson(HomepageMangaGroupList.fields, json)) { - return HomepageMangaGroupList.fromJson(json) as T; // HomepageMangaGroupList - } else if (_matchJson(Token.fields, json)) { - return Token.fromJson(json) as T; // Token - } else if (_matchJson(User.fields, json)) { - return User.fromJson(json) as T; // User - } else if (_matchJson(ShelfStatus.fields, json)) { - return ShelfStatus.fromJson(json) as T; // ShelfStatus - } else if (_matchJson(LoginCheckResult.fields, json)) { - return LoginCheckResult.fromJson(json) as T; // LoginCheckResult - } - // Result> - if (_matchPageJson(json)) { - return ResultPage.fromJson(json, Manga()) as T; // Manga - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, MangaRank()) as T; // MangaRank - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, SmallManga()) as T; // SmallManga - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, TinyManga()) as T; // TinyManga - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, TinyBlockManga()) as T; // TinyBlockManga - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, ShelfManga()) as T; // ShelfManga - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, MangaChapter()) as T; // MangaChapter - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, TinyMangaChapter()) as T; // TinyMangaChapter - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, Category()) as T; // Category - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, Comment()) as T; // Comment - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, RepliedComment()) as T; // RepliedComment - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, Author()) as T; // Author - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, SmallAuthor()) as T; // SmallAuthor - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, TinyAuthor()) as T; // TinyAuthor - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, MangaGroup()) as T; // MangaGroup - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, MangaChapterGroup()) as T; // MangaChapterGroup - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, MangaGroupList()) as T; // MangaGroupList - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, HomepageMangaGroupList()) as T; // HomepageMangaGroupList - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, Token()) as T; // Token - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, User()) as T; // User - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, ShelfStatus()) as T; // ShelfStatus - } else if (_matchPageJson(json)) { - return ResultPage.fromJson(json, LoginCheckResult()) as T; // LoginCheckResult - } - } - return json as T; - } - - @override - Object toJson(T object) { - return object; - } -} diff --git a/lib/model/entity.dart b/lib/model/entity.dart new file mode 100644 index 0000000..8a37904 --- /dev/null +++ b/lib/model/entity.dart @@ -0,0 +1,179 @@ +import 'package:manhuagui_flutter/model/chapter.dart'; + +class MangaHistory { + final int mangaId; + final String mangaTitle; + final String mangaCover; + final String mangaUrl; + final int chapterId; // 0 表示还没开始阅读(点进漫画页),非0 表示开始阅读(点进章节页) + final String chapterTitle; + final int chapterPage; + final DateTime lastTime; + + const MangaHistory({ + required this.mangaId, + required this.mangaTitle, + required this.mangaCover, + required this.mangaUrl, + required this.chapterId, + required this.chapterTitle, + required this.chapterPage, + required this.lastTime, + }); + + bool get read => chapterId != 0; + + MangaHistory copyWith({ + int? mangaId, + String? mangaTitle, + String? mangaCover, + String? mangaUrl, + int? chapterId, + String? chapterTitle, + int? chapterPage, + DateTime? lastTime, + }) { + return MangaHistory( + mangaId: mangaId ?? this.mangaId, + mangaTitle: mangaTitle ?? this.mangaTitle, + mangaCover: mangaCover ?? this.mangaCover, + mangaUrl: mangaUrl ?? this.mangaUrl, + chapterId: chapterId ?? this.chapterId, + chapterTitle: chapterTitle ?? this.chapterTitle, + chapterPage: chapterPage ?? this.chapterPage, + lastTime: lastTime ?? this.lastTime, + ); + } + + bool equals(MangaHistory o) { + return mangaId == o.mangaId && // + mangaTitle == o.mangaTitle && + mangaCover == o.mangaCover && + mangaUrl == o.mangaUrl && + chapterId == o.chapterId && + chapterTitle == o.chapterTitle && + chapterPage == o.chapterPage && + lastTime == o.lastTime; + } +} + +class DownloadedManga { + final int mangaId; + final String mangaTitle; + final String mangaCover; + final String mangaUrl; + final bool error; + final DateTime updatedAt; + final List downloadedChapters; + + const DownloadedManga({ + required this.mangaId, + required this.mangaTitle, + required this.mangaCover, + required this.mangaUrl, + required this.error, + required this.updatedAt, + required this.downloadedChapters, + }); + + List get totalChapterIds => // + downloadedChapters.map((el) => el.chapterId).toList(); + + List get triedChapterIds => // + downloadedChapters.where((el) => el.tried).map((el) => el.chapterId).toList(); + + List get successChapterIds => // + downloadedChapters.where((el) => el.succeeded).map((el) => el.chapterId).toList(); + + int get failedChapterCount => // + downloadedChapters.where((el) => !el.succeeded).length; + + int get totalPageCountInAll => // + downloadedChapters.map((el) => el.totalPageCount).reduce((val, el) => val + el); + + int get triedPageCountInAll => // + downloadedChapters.map((el) => el.triedPageCount).reduce((val, el) => val + el); + + int get successPageCountInAll => // + downloadedChapters.map((el) => el.successPageCount).reduce((val, el) => val + el); + + int get failedPageCountInAll => // + downloadedChapters.map((el) => el.totalPageCount - el.successPageCount).reduce((val, el) => val + el); + + DownloadedManga copyWith({ + int? mangaId, + String? mangaTitle, + String? mangaCover, + String? mangaUrl, + bool? error, + DateTime? updatedAt, + List? downloadedChapters, + }) { + return DownloadedManga( + mangaId: mangaId ?? this.mangaId, + mangaTitle: mangaTitle ?? this.mangaTitle, + mangaCover: mangaCover ?? this.mangaCover, + mangaUrl: mangaUrl ?? this.mangaUrl, + error: error ?? this.error, + updatedAt: updatedAt ?? this.updatedAt, + downloadedChapters: downloadedChapters ?? this.downloadedChapters, + ); + } +} + +class DownloadedChapter { + final int mangaId; + final int chapterId; + final String chapterTitle; + final String chapterGroup; + final int totalPageCount; + final int triedPageCount; + final int successPageCount; + + bool get tried => triedPageCount > 0; + + bool get succeeded => successPageCount == totalPageCount; + + bool get allTried => triedPageCount == totalPageCount; + + const DownloadedChapter({ + required this.mangaId, + required this.chapterId, + required this.chapterTitle, + required this.chapterGroup, + required this.totalPageCount, + required this.triedPageCount, + required this.successPageCount, + }); + + DownloadedChapter copyWith({ + int? mangaId, + int? chapterId, + String? chapterTitle, + String? chapterGroup, + int? totalPageCount, + int? triedPageCount, + int? successPageCount, + }) { + return DownloadedChapter( + mangaId: mangaId ?? this.mangaId, + chapterId: chapterId ?? this.chapterId, + chapterTitle: chapterTitle ?? this.chapterTitle, + chapterGroup: chapterGroup ?? this.chapterGroup, + totalPageCount: totalPageCount ?? this.totalPageCount, + triedPageCount: triedPageCount ?? this.triedPageCount, + successPageCount: successPageCount ?? this.successPageCount, + ); + } + + TinyMangaChapter toTiny() { + return TinyMangaChapter( + cid: chapterId, + title: chapterTitle, + mid: mangaId, + url: '', + pageCount: totalPageCount, + isNew: false, + ); + } +} diff --git a/lib/model/manga.dart b/lib/model/manga.dart index 0a29ed1..3718743 100644 --- a/lib/model/manga.dart +++ b/lib/model/manga.dart @@ -8,105 +8,109 @@ part 'manga.g.dart'; @JsonSerializable(fieldRename: FieldRename.snake) class Manga { - int mid; - String title; - String cover; - String url; - String publishYear; - String mangaZone; - List genres; - List authors; - String alias; - String aliasTitle; - bool finished; - String newestChapter; - String newestDate; - String briefIntroduction; - String introduction; - String mangaRank; - double averageScore; - int scoreCount; - List perScores; - bool banned; - bool copyright; - List chapterGroups; - - Manga({this.mid, this.title, this.cover, this.url, this.publishYear, this.mangaZone, this.genres, this.authors, this.alias, this.aliasTitle, this.finished, this.newestChapter, this.newestDate, this.briefIntroduction, this.introduction, this.mangaRank, this.averageScore, this.scoreCount, this.perScores, this.banned, this.copyright, this.chapterGroups}); + final int mid; + final String title; + final String cover; + final String url; + final String publishYear; + final String mangaZone; + final List genres; + final List authors; + final String alias; + final String aliasTitle; + final bool finished; + final String newestChapter; + final String newestDate; + final String briefIntroduction; + final String introduction; + final String mangaRank; + final double averageScore; + final int scoreCount; + final List perScores; + final bool banned; + final bool copyright; + final List chapterGroups; + + const Manga({required this.mid, required this.title, required this.cover, required this.url, required this.publishYear, required this.mangaZone, required this.genres, required this.authors, required this.alias, required this.aliasTitle, required this.finished, required this.newestChapter, required this.newestDate, required this.briefIntroduction, required this.introduction, required this.mangaRank, required this.averageScore, required this.scoreCount, required this.perScores, required this.banned, required this.copyright, required this.chapterGroups}); factory Manga.fromJson(Map json) => _$MangaFromJson(json); Map toJson() => _$MangaToJson(this); - - static const fields = ['mid', 'title', 'cover', 'url', 'publish_year', 'manga_zone', 'genres', 'authors', 'alias', 'alias_title', 'finished', 'newest_chapter', 'newest_date', 'brief_introduction', 'introduction', 'manga_rank', 'average_score', 'score_count', 'per_scores', 'banned', 'copyright', 'chapter_groups']; } @JsonSerializable(fieldRename: FieldRename.snake) class SmallManga { - int mid; - String title; - String cover; - String url; - String publishYear; - String mangaZone; - List genres; - List authors; - bool finished; - String newestChapter; - String newestDate; - String briefIntroduction; - - SmallManga({this.mid, this.title, this.cover, this.url, this.publishYear, this.mangaZone, this.genres, this.authors, this.finished, this.newestChapter, this.newestDate, this.briefIntroduction}); + final int mid; + final String title; + final String cover; + final String url; + final String publishYear; + final String mangaZone; + final List genres; + final List authors; + final bool finished; + final String newestChapter; + final String newestDate; + final String briefIntroduction; + + const SmallManga({required this.mid, required this.title, required this.cover, required this.url, required this.publishYear, required this.mangaZone, required this.genres, required this.authors, required this.finished, required this.newestChapter, required this.newestDate, required this.briefIntroduction}); factory SmallManga.fromJson(Map json) => _$SmallMangaFromJson(json); Map toJson() => _$SmallMangaToJson(this); - static const fields = ['mid', 'title', 'cover', 'url', 'publish_year', 'manga_zone', 'genres', 'authors', 'finished', 'newest_chapter', 'newest_date', 'brief_introduction']; - TinyManga toTiny() { - return TinyManga(mid: this.mid, title: this.title, cover: this.cover, url: this.url, finished: this.finished, newestChapter: this.newestChapter, newestDate: this.newestDate); + return TinyManga(mid: mid, title: title, cover: cover, url: url, finished: finished, newestChapter: newestChapter, newestDate: newestDate); } } @JsonSerializable(fieldRename: FieldRename.snake) class TinyManga { - int mid; - String title; - String cover; - String url; - bool finished; - String newestChapter; - String newestDate; + final int mid; + final String title; + final String cover; + final String url; + final bool finished; + final String newestChapter; + final String newestDate; - TinyManga({this.mid, this.title, this.cover, this.url, this.finished, this.newestChapter, this.newestDate}); + TinyManga({required this.mid, required this.title, required this.cover, required this.url, required this.finished, required this.newestChapter, required this.newestDate}); factory TinyManga.fromJson(Map json) => _$TinyMangaFromJson(json); Map toJson() => _$TinyMangaToJson(this); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class RandomMangaInfo { + final int mid; + final String url; - static const fields = ['mid', 'title', 'cover', 'url', 'finished', 'newest_chapter', 'newest_date']; + RandomMangaInfo({required this.mid, required this.url}); + + factory RandomMangaInfo.fromJson(Map json) => _$RandomMangaInfoFromJson(json); + + Map toJson() => _$RandomMangaInfoToJson(this); } @JsonSerializable(fieldRename: FieldRename.snake) class TinyBlockManga { - int mid; - String title; - String cover; - String url; - bool finished; - String newestChapter; + final int mid; + final String title; + final String cover; + final String url; + final bool finished; + final String newestChapter; - TinyBlockManga({this.mid, this.title, this.cover, this.url, this.finished, this.newestChapter}); + const TinyBlockManga({required this.mid, required this.title, required this.cover, required this.url, required this.finished, required this.newestChapter}); factory TinyBlockManga.fromJson(Map json) => _$TinyBlockMangaFromJson(json); Map toJson() => _$TinyBlockMangaToJson(this); - static const fields = ['mid', 'title', 'cover', 'url', 'finished', 'newest_chapter']; - @override bool operator ==(Object other) { - return other is TinyBlockManga && other.mid == this.mid; + return other is TinyBlockManga && other.mid == mid; } @override @@ -120,16 +124,14 @@ class MangaGroup { // 1. X (#12) // 2. 少女/爱情; 少年/热血; 竞技/体育; 武侠/格斗 (#10) // 3. 推理/恐怖/悬疑; 百合/后宫/治愈; 社会/历史/战争; 校园/励志/冒险 (#15) - String title; - List mangas; + final String title; + final List mangas; - MangaGroup({this.title, this.mangas}); + const MangaGroup({required this.title, required this.mangas}); factory MangaGroup.fromJson(Map json) => _$MangaGroupFromJson(json); Map toJson() => _$MangaGroupToJson(this); - - static const fields = ['title', 'mangas']; } /// [MangaGroupList.title] @@ -149,7 +151,6 @@ extension MangaGroupTitleExtension on MangaGroupType { case MangaGroupType.latest: return '最新上架'; } - return '?'; } } @@ -158,106 +159,88 @@ class MangaGroupList { // 1. 热门连载: top_group, groups (#4), other_groups (#4) // 2. 经典完结: top_group, groups (#4), other_groups (#4) // 3. 最新上架: top_group, groups (#4) - String title; - MangaGroup topGroup; - List groups; - List otherGroups; + final String title; + final MangaGroup topGroup; + final List groups; + final List otherGroups; - MangaGroupList({this.title, this.topGroup, this.groups, this.otherGroups}); + const MangaGroupList({required this.title, required this.topGroup, required this.groups, required this.otherGroups}); factory MangaGroupList.fromJson(Map json) => _$MangaGroupListFromJson(json); Map toJson() => _$MangaGroupListToJson(this); - - static const fields = ['title', 'top_group', 'groups', 'other_groups']; } @JsonSerializable(fieldRename: FieldRename.snake) class HomepageMangaGroupList { - MangaGroupList serial; // 热门连载 - MangaGroupList finish; // 经典完结 - MangaGroupList latest; // 最新上架 + final MangaGroupList serial; // 热门连载 + final MangaGroupList finish; // 经典完结 + final MangaGroupList latest; // 最新上架 - HomepageMangaGroupList({this.serial, this.finish, this.latest}); + const HomepageMangaGroupList({required this.serial, required this.finish, required this.latest}); factory HomepageMangaGroupList.fromJson(Map json) => _$HomepageMangaGroupListFromJson(json); Map toJson() => _$HomepageMangaGroupListToJson(this); - static const fields = ['serial', 'finish', 'latest']; + List get carouselMangas { + var p1 = serial.topGroup.mangas.sublist(0, 4); + var p2 = serial.groups.map((e) => e.mangas.first); + var p3 = serial.otherGroups.map((e) => e.mangas.first); + return [ + ...{...p1, ...p2, ...p3} + ]; + } } @JsonSerializable(fieldRename: FieldRename.snake) class MangaRank { - int mid; - String title; - String cover; - String url; - bool finished; - List authors; - String newestChapter; - String newestDate; - int order; - double score; - int trend; - - MangaRank({this.mid, this.title, this.cover, this.url, this.finished, this.authors, this.newestChapter, this.newestDate, this.order, this.score, this.trend}); + final int mid; + final String title; + final String cover; + final String url; + final bool finished; + final List authors; + final String newestChapter; + final String newestDate; + final int order; + final double score; + final int trend; + + const MangaRank({required this.mid, required this.title, required this.cover, required this.url, required this.finished, required this.authors, required this.newestChapter, required this.newestDate, required this.order, required this.score, required this.trend}); factory MangaRank.fromJson(Map json) => _$MangaRankFromJson(json); Map toJson() => _$MangaRankToJson(this); - - static const fields = ['mid', 'title', 'cover', 'url', 'finished', 'authors', 'newest_chapter', 'newest_date', 'order', 'score', 'trend']; } @JsonSerializable(fieldRename: FieldRename.snake) class ShelfManga { - int mid; - String title; - String cover; - String url; - String newestChapter; - String newestDuration; - String lastChapter; - String lastDuration; + final int mid; + final String title; + final String cover; + final String url; + final String newestChapter; + final String newestDuration; + final String lastChapter; + final String lastDuration; - ShelfManga({this.mid, this.title, this.cover, this.url, this.newestChapter, this.newestDuration, this.lastChapter, this.lastDuration}); + const ShelfManga({required this.mid, required this.title, required this.cover, required this.url, required this.newestChapter, required this.newestDuration, required this.lastChapter, required this.lastDuration}); factory ShelfManga.fromJson(Map json) => _$ShelfMangaFromJson(json); Map toJson() => _$ShelfMangaToJson(this); - - static const fields = ['mid', 'title', 'cover', 'url', 'newest_chapter', 'newest_duration', 'last_chapter', 'last_duration']; } @JsonSerializable(fieldRename: FieldRename.snake) class ShelfStatus { @JsonKey(name: 'in') - bool isIn; - int count; + final bool isIn; + final int count; - ShelfStatus({this.isIn, this.count}); + const ShelfStatus({required this.isIn, required this.count}); factory ShelfStatus.fromJson(Map json) => _$ShelfStatusFromJson(json); Map toJson() => _$ShelfStatusToJson(this); - - static const fields = ['in', 'count']; -} - -class MangaHistory { - int mangaId; - String mangaTitle; - String mangaCover; - String mangaUrl; - - int chapterId; // 0 表示还没开始阅读(点进漫画页),非0 表示开始阅读(点进章节页) - String chapterTitle; - int chapterPage; - - DateTime lastTime; - - bool get read => chapterId != 0; - - MangaHistory({this.mangaId, this.mangaTitle, this.mangaCover, this.mangaUrl, this.chapterId, this.chapterTitle, this.chapterPage, this.lastTime}); } diff --git a/lib/model/manga.g.dart b/lib/model/manga.g.dart index a711ec9..685e54e 100644 --- a/lib/model/manga.g.dart +++ b/lib/model/manga.g.dart @@ -6,42 +6,38 @@ part of 'manga.dart'; // JsonSerializableGenerator // ************************************************************************** -Manga _$MangaFromJson(Map json) { - return Manga( - mid: json['mid'] as int, - title: json['title'] as String, - cover: json['cover'] as String, - url: json['url'] as String, - publishYear: json['publish_year'] as String, - mangaZone: json['manga_zone'] as String, - genres: (json['genres'] as List) - ?.map((e) => - e == null ? null : Category.fromJson(e as Map)) - ?.toList(), - authors: (json['authors'] as List) - ?.map((e) => - e == null ? null : TinyAuthor.fromJson(e as Map)) - ?.toList(), - alias: json['alias'] as String, - aliasTitle: json['alias_title'] as String, - finished: json['finished'] as bool, - newestChapter: json['newest_chapter'] as String, - newestDate: json['newest_date'] as String, - briefIntroduction: json['brief_introduction'] as String, - introduction: json['introduction'] as String, - mangaRank: json['manga_rank'] as String, - averageScore: (json['average_score'] as num)?.toDouble(), - scoreCount: json['score_count'] as int, - perScores: (json['per_scores'] as List)?.map((e) => e as String)?.toList(), - banned: json['banned'] as bool, - copyright: json['copyright'] as bool, - chapterGroups: (json['chapter_groups'] as List) - ?.map((e) => e == null - ? null - : MangaChapterGroup.fromJson(e as Map)) - ?.toList(), - ); -} +Manga _$MangaFromJson(Map json) => Manga( + mid: json['mid'] as int, + title: json['title'] as String, + cover: json['cover'] as String, + url: json['url'] as String, + publishYear: json['publish_year'] as String, + mangaZone: json['manga_zone'] as String, + genres: (json['genres'] as List) + .map((e) => Category.fromJson(e as Map)) + .toList(), + authors: (json['authors'] as List) + .map((e) => TinyAuthor.fromJson(e as Map)) + .toList(), + alias: json['alias'] as String, + aliasTitle: json['alias_title'] as String, + finished: json['finished'] as bool, + newestChapter: json['newest_chapter'] as String, + newestDate: json['newest_date'] as String, + briefIntroduction: json['brief_introduction'] as String, + introduction: json['introduction'] as String, + mangaRank: json['manga_rank'] as String, + averageScore: (json['average_score'] as num).toDouble(), + scoreCount: json['score_count'] as int, + perScores: (json['per_scores'] as List) + .map((e) => e as String) + .toList(), + banned: json['banned'] as bool, + copyright: json['copyright'] as bool, + chapterGroups: (json['chapter_groups'] as List) + .map((e) => MangaChapterGroup.fromJson(e as Map)) + .toList(), + ); Map _$MangaToJson(Manga instance) => { 'mid': instance.mid, @@ -68,28 +64,24 @@ Map _$MangaToJson(Manga instance) => { 'chapter_groups': instance.chapterGroups, }; -SmallManga _$SmallMangaFromJson(Map json) { - return SmallManga( - mid: json['mid'] as int, - title: json['title'] as String, - cover: json['cover'] as String, - url: json['url'] as String, - publishYear: json['publish_year'] as String, - mangaZone: json['manga_zone'] as String, - genres: (json['genres'] as List) - ?.map((e) => - e == null ? null : Category.fromJson(e as Map)) - ?.toList(), - authors: (json['authors'] as List) - ?.map((e) => - e == null ? null : TinyAuthor.fromJson(e as Map)) - ?.toList(), - finished: json['finished'] as bool, - newestChapter: json['newest_chapter'] as String, - newestDate: json['newest_date'] as String, - briefIntroduction: json['brief_introduction'] as String, - ); -} +SmallManga _$SmallMangaFromJson(Map json) => SmallManga( + mid: json['mid'] as int, + title: json['title'] as String, + cover: json['cover'] as String, + url: json['url'] as String, + publishYear: json['publish_year'] as String, + mangaZone: json['manga_zone'] as String, + genres: (json['genres'] as List) + .map((e) => Category.fromJson(e as Map)) + .toList(), + authors: (json['authors'] as List) + .map((e) => TinyAuthor.fromJson(e as Map)) + .toList(), + finished: json['finished'] as bool, + newestChapter: json['newest_chapter'] as String, + newestDate: json['newest_date'] as String, + briefIntroduction: json['brief_introduction'] as String, + ); Map _$SmallMangaToJson(SmallManga instance) => { @@ -107,17 +99,15 @@ Map _$SmallMangaToJson(SmallManga instance) => 'brief_introduction': instance.briefIntroduction, }; -TinyManga _$TinyMangaFromJson(Map json) { - return TinyManga( - mid: json['mid'] as int, - title: json['title'] as String, - cover: json['cover'] as String, - url: json['url'] as String, - finished: json['finished'] as bool, - newestChapter: json['newest_chapter'] as String, - newestDate: json['newest_date'] as String, - ); -} +TinyManga _$TinyMangaFromJson(Map json) => TinyManga( + mid: json['mid'] as int, + title: json['title'] as String, + cover: json['cover'] as String, + url: json['url'] as String, + finished: json['finished'] as bool, + newestChapter: json['newest_chapter'] as String, + newestDate: json['newest_date'] as String, + ); Map _$TinyMangaToJson(TinyManga instance) => { 'mid': instance.mid, @@ -129,16 +119,27 @@ Map _$TinyMangaToJson(TinyManga instance) => { 'newest_date': instance.newestDate, }; -TinyBlockManga _$TinyBlockMangaFromJson(Map json) { - return TinyBlockManga( - mid: json['mid'] as int, - title: json['title'] as String, - cover: json['cover'] as String, - url: json['url'] as String, - finished: json['finished'] as bool, - newestChapter: json['newest_chapter'] as String, - ); -} +RandomMangaInfo _$RandomMangaInfoFromJson(Map json) => + RandomMangaInfo( + mid: json['mid'] as int, + url: json['url'] as String, + ); + +Map _$RandomMangaInfoToJson(RandomMangaInfo instance) => + { + 'mid': instance.mid, + 'url': instance.url, + }; + +TinyBlockManga _$TinyBlockMangaFromJson(Map json) => + TinyBlockManga( + mid: json['mid'] as int, + title: json['title'] as String, + cover: json['cover'] as String, + url: json['url'] as String, + finished: json['finished'] as bool, + newestChapter: json['newest_chapter'] as String, + ); Map _$TinyBlockMangaToJson(TinyBlockManga instance) => { @@ -150,16 +151,12 @@ Map _$TinyBlockMangaToJson(TinyBlockManga instance) => 'newest_chapter': instance.newestChapter, }; -MangaGroup _$MangaGroupFromJson(Map json) { - return MangaGroup( - title: json['title'] as String, - mangas: (json['mangas'] as List) - ?.map((e) => e == null - ? null - : TinyBlockManga.fromJson(e as Map)) - ?.toList(), - ); -} +MangaGroup _$MangaGroupFromJson(Map json) => MangaGroup( + title: json['title'] as String, + mangas: (json['mangas'] as List) + .map((e) => TinyBlockManga.fromJson(e as Map)) + .toList(), + ); Map _$MangaGroupToJson(MangaGroup instance) => { @@ -167,22 +164,17 @@ Map _$MangaGroupToJson(MangaGroup instance) => 'mangas': instance.mangas, }; -MangaGroupList _$MangaGroupListFromJson(Map json) { - return MangaGroupList( - title: json['title'] as String, - topGroup: json['top_group'] == null - ? null - : MangaGroup.fromJson(json['top_group'] as Map), - groups: (json['groups'] as List) - ?.map((e) => - e == null ? null : MangaGroup.fromJson(e as Map)) - ?.toList(), - otherGroups: (json['other_groups'] as List) - ?.map((e) => - e == null ? null : MangaGroup.fromJson(e as Map)) - ?.toList(), - ); -} +MangaGroupList _$MangaGroupListFromJson(Map json) => + MangaGroupList( + title: json['title'] as String, + topGroup: MangaGroup.fromJson(json['top_group'] as Map), + groups: (json['groups'] as List) + .map((e) => MangaGroup.fromJson(e as Map)) + .toList(), + otherGroups: (json['other_groups'] as List) + .map((e) => MangaGroup.fromJson(e as Map)) + .toList(), + ); Map _$MangaGroupListToJson(MangaGroupList instance) => { @@ -193,19 +185,12 @@ Map _$MangaGroupListToJson(MangaGroupList instance) => }; HomepageMangaGroupList _$HomepageMangaGroupListFromJson( - Map json) { - return HomepageMangaGroupList( - serial: json['serial'] == null - ? null - : MangaGroupList.fromJson(json['serial'] as Map), - finish: json['finish'] == null - ? null - : MangaGroupList.fromJson(json['finish'] as Map), - latest: json['latest'] == null - ? null - : MangaGroupList.fromJson(json['latest'] as Map), - ); -} + Map json) => + HomepageMangaGroupList( + serial: MangaGroupList.fromJson(json['serial'] as Map), + finish: MangaGroupList.fromJson(json['finish'] as Map), + latest: MangaGroupList.fromJson(json['latest'] as Map), + ); Map _$HomepageMangaGroupListToJson( HomepageMangaGroupList instance) => @@ -215,24 +200,21 @@ Map _$HomepageMangaGroupListToJson( 'latest': instance.latest, }; -MangaRank _$MangaRankFromJson(Map json) { - return MangaRank( - mid: json['mid'] as int, - title: json['title'] as String, - cover: json['cover'] as String, - url: json['url'] as String, - finished: json['finished'] as bool, - authors: (json['authors'] as List) - ?.map((e) => - e == null ? null : TinyAuthor.fromJson(e as Map)) - ?.toList(), - newestChapter: json['newest_chapter'] as String, - newestDate: json['newest_date'] as String, - order: json['order'] as int, - score: (json['score'] as num)?.toDouble(), - trend: json['trend'] as int, - ); -} +MangaRank _$MangaRankFromJson(Map json) => MangaRank( + mid: json['mid'] as int, + title: json['title'] as String, + cover: json['cover'] as String, + url: json['url'] as String, + finished: json['finished'] as bool, + authors: (json['authors'] as List) + .map((e) => TinyAuthor.fromJson(e as Map)) + .toList(), + newestChapter: json['newest_chapter'] as String, + newestDate: json['newest_date'] as String, + order: json['order'] as int, + score: (json['score'] as num).toDouble(), + trend: json['trend'] as int, + ); Map _$MangaRankToJson(MangaRank instance) => { 'mid': instance.mid, @@ -248,18 +230,16 @@ Map _$MangaRankToJson(MangaRank instance) => { 'trend': instance.trend, }; -ShelfManga _$ShelfMangaFromJson(Map json) { - return ShelfManga( - mid: json['mid'] as int, - title: json['title'] as String, - cover: json['cover'] as String, - url: json['url'] as String, - newestChapter: json['newest_chapter'] as String, - newestDuration: json['newest_duration'] as String, - lastChapter: json['last_chapter'] as String, - lastDuration: json['last_duration'] as String, - ); -} +ShelfManga _$ShelfMangaFromJson(Map json) => ShelfManga( + mid: json['mid'] as int, + title: json['title'] as String, + cover: json['cover'] as String, + url: json['url'] as String, + newestChapter: json['newest_chapter'] as String, + newestDuration: json['newest_duration'] as String, + lastChapter: json['last_chapter'] as String, + lastDuration: json['last_duration'] as String, + ); Map _$ShelfMangaToJson(ShelfManga instance) => { @@ -273,12 +253,10 @@ Map _$ShelfMangaToJson(ShelfManga instance) => 'last_duration': instance.lastDuration, }; -ShelfStatus _$ShelfStatusFromJson(Map json) { - return ShelfStatus( - isIn: json['in'] as bool, - count: json['count'] as int, - ); -} +ShelfStatus _$ShelfStatusFromJson(Map json) => ShelfStatus( + isIn: json['in'] as bool, + count: json['count'] as int, + ); Map _$ShelfStatusToJson(ShelfStatus instance) => { diff --git a/lib/model/result.dart b/lib/model/result.dart index 4198ae1..50dd3f4 100644 --- a/lib/model/result.dart +++ b/lib/model/result.dart @@ -1,35 +1,30 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:manhuagui_flutter/model/converter.dart'; part 'result.g.dart'; -@JsonSerializable(fieldRename: FieldRename.snake) +@JsonSerializable(fieldRename: FieldRename.snake, genericArgumentFactories: true) class Result { - int code; - String message; - @GenericConverter() - T data; + final int code; + final String message; + final T data; - Result({this.code, this.message, this.data}); + const Result({required this.code, required this.message, required this.data}); - factory Result.fromJson(Map json) => _$ResultFromJson(json); + factory Result.fromJson(Map json, T Function(Object? json) fromJsonT) => _$ResultFromJson(json, fromJsonT); - Map toJson() => _$ResultToJson(this); + Map toJson(Object? Function(T value) toJsonT) => _$ResultToJson(this, toJsonT); } -@JsonSerializable(fieldRename: FieldRename.snake) +@JsonSerializable(fieldRename: FieldRename.snake, genericArgumentFactories: true) class ResultPage { - int page; - int limit; - int total; - @GenericConverter() - List data; + final int page; + final int limit; + final int total; + final List data; - ResultPage({this.page, this.limit, this.total, this.data}); + const ResultPage({required this.page, required this.limit, required this.total, required this.data}); - factory ResultPage.fromJson(Map json, T t) => _$ResultPageFromJson(json); + factory ResultPage.fromJson(Map json, T Function(Object? json) fromJsonT) => _$ResultPageFromJson(json, fromJsonT); - Map toJson() => _$ResultPageToJson(this); - - static const fields = ['page', 'limit', 'total', 'data']; + Map toJson(Object? Function(T value) toJsonT) => _$ResultPageToJson(this, toJsonT); } diff --git a/lib/model/result.g.dart b/lib/model/result.g.dart index b8efdc9..f87468d 100644 --- a/lib/model/result.g.dart +++ b/lib/model/result.g.dart @@ -6,33 +6,44 @@ part of 'result.dart'; // JsonSerializableGenerator // ************************************************************************** -Result _$ResultFromJson(Map json) { - return Result( - code: json['code'] as int, - message: json['message'] as String, - data: GenericConverter().fromJson(json['data']), - ); -} +Result _$ResultFromJson( + Map json, + T Function(Object? json) fromJsonT, +) => + Result( + code: json['code'] as int, + message: json['message'] as String, + data: fromJsonT(json['data']), + ); -Map _$ResultToJson(Result instance) => { +Map _$ResultToJson( + Result instance, + Object? Function(T value) toJsonT, +) => + { 'code': instance.code, 'message': instance.message, - 'data': GenericConverter().toJson(instance.data), + 'data': toJsonT(instance.data), }; -ResultPage _$ResultPageFromJson(Map json) { - return ResultPage( - page: json['page'] as int, - limit: json['limit'] as int, - total: json['total'] as int, - data: (json['data'] as List)?.map(GenericConverter().fromJson)?.toList(), - ); -} +ResultPage _$ResultPageFromJson( + Map json, + T Function(Object? json) fromJsonT, +) => + ResultPage( + page: json['page'] as int, + limit: json['limit'] as int, + total: json['total'] as int, + data: (json['data'] as List).map(fromJsonT).toList(), + ); -Map _$ResultPageToJson(ResultPage instance) => +Map _$ResultPageToJson( + ResultPage instance, + Object? Function(T value) toJsonT, +) => { 'page': instance.page, 'limit': instance.limit, 'total': instance.total, - 'data': instance.data?.map(GenericConverter().toJson)?.toList(), + 'data': instance.data.map(toJsonT).toList(), }; diff --git a/lib/model/user.dart b/lib/model/user.dart index 014f6a1..a2a6e1c 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -4,47 +4,45 @@ part 'user.g.dart'; @JsonSerializable(fieldRename: FieldRename.snake) class Token { - String token; + final String token; - Token({this.token}); + const Token({required this.token}); factory Token.fromJson(Map json) => _$TokenFromJson(json); Map toJson() => _$TokenToJson(this); - - static const fields = ['token']; } @JsonSerializable(fieldRename: FieldRename.snake) class User { - String username; - String avatar; + final String username; + final String avatar; @JsonKey(name: 'class') - String className; - int score; - String loginIp; - String lastLoginIp; - String registerTime; - String lastLoginTime; - - User({this.username, this.avatar, this.className, this.score, this.loginIp, this.lastLoginIp, this.registerTime, this.lastLoginTime}); + final String className; + final int score; + final int accountPoint; + final int unreadMessageCount; + final String loginIp; + final String lastLoginIp; + final String registerTime; + final String lastLoginTime; + final int cumulativeDayCount; + final int totalCommentCount; + + const User({required this.username, required this.avatar, required this.className, required this.score, required this.accountPoint, required this.unreadMessageCount, required this.loginIp, required this.lastLoginIp, required this.registerTime, required this.lastLoginTime, required this.cumulativeDayCount, required this.totalCommentCount}); factory User.fromJson(Map json) => _$UserFromJson(json); Map toJson() => _$UserToJson(this); - - static const fields = ['username', 'avatar', 'class', 'score', 'login_ip', 'last_login_ip', 'register_time', 'last_login_time']; } @JsonSerializable(fieldRename: FieldRename.snake) class LoginCheckResult { - String username; + final String username; - LoginCheckResult({this.username}); + const LoginCheckResult({required this.username}); factory LoginCheckResult.fromJson(Map json) => _$LoginCheckResultFromJson(json); Map toJson() => _$LoginCheckResultToJson(this); - - static const fields = ['username']; } diff --git a/lib/model/user.g.dart b/lib/model/user.g.dart index 935287b..08f92b3 100644 --- a/lib/model/user.g.dart +++ b/lib/model/user.g.dart @@ -6,45 +6,48 @@ part of 'user.dart'; // JsonSerializableGenerator // ************************************************************************** -Token _$TokenFromJson(Map json) { - return Token( - token: json['token'] as String, - ); -} +Token _$TokenFromJson(Map json) => Token( + token: json['token'] as String, + ); Map _$TokenToJson(Token instance) => { 'token': instance.token, }; -User _$UserFromJson(Map json) { - return User( - username: json['username'] as String, - avatar: json['avatar'] as String, - className: json['class'] as String, - score: json['score'] as int, - loginIp: json['login_ip'] as String, - lastLoginIp: json['last_login_ip'] as String, - registerTime: json['register_time'] as String, - lastLoginTime: json['last_login_time'] as String, - ); -} +User _$UserFromJson(Map json) => User( + username: json['username'] as String, + avatar: json['avatar'] as String, + className: json['class'] as String, + score: json['score'] as int, + accountPoint: json['account_point'] as int, + unreadMessageCount: json['unread_message_count'] as int, + loginIp: json['login_ip'] as String, + lastLoginIp: json['last_login_ip'] as String, + registerTime: json['register_time'] as String, + lastLoginTime: json['last_login_time'] as String, + cumulativeDayCount: json['cumulative_day_count'] as int, + totalCommentCount: json['total_comment_count'] as int, + ); Map _$UserToJson(User instance) => { 'username': instance.username, 'avatar': instance.avatar, 'class': instance.className, 'score': instance.score, + 'account_point': instance.accountPoint, + 'unread_message_count': instance.unreadMessageCount, 'login_ip': instance.loginIp, 'last_login_ip': instance.lastLoginIp, 'register_time': instance.registerTime, 'last_login_time': instance.lastLoginTime, + 'cumulative_day_count': instance.cumulativeDayCount, + 'total_comment_count': instance.totalCommentCount, }; -LoginCheckResult _$LoginCheckResultFromJson(Map json) { - return LoginCheckResult( - username: json['username'] as String, - ); -} +LoginCheckResult _$LoginCheckResultFromJson(Map json) => + LoginCheckResult( + username: json['username'] as String, + ); Map _$LoginCheckResultToJson(LoginCheckResult instance) => { diff --git a/lib/page/author.dart b/lib/page/author.dart index b8c10ff..b2f137f 100644 --- a/lib/page/author.dart +++ b/lib/page/author.dart @@ -1,30 +1,29 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/list.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:manhuagui_flutter/model/manga.dart'; import 'package:manhuagui_flutter/model/order.dart'; +import 'package:manhuagui_flutter/page/image_viewer.dart'; +import 'package:manhuagui_flutter/page/view/full_ripple.dart'; +import 'package:manhuagui_flutter/page/view/list_hint.dart'; +import 'package:manhuagui_flutter/page/view/my_drawer.dart'; import 'package:manhuagui_flutter/page/view/network_image.dart'; import 'package:manhuagui_flutter/page/view/option_popup.dart'; import 'package:manhuagui_flutter/page/view/tiny_manga_line.dart'; -import 'package:manhuagui_flutter/service/natives/browser.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; +import 'package:manhuagui_flutter/service/native/browser.dart'; import 'package:manhuagui_flutter/model/author.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; -/// 漫画家 -/// Page for [Author]. +/// 漫画作者页,网络请求并展示 [Author] 信息 class AuthorPage extends StatefulWidget { const AuthorPage({ - Key key, - @required this.id, - @required this.name, - @required this.url, - }) : assert(id != null), - assert(name != null), - assert(url != null), - super(key: key); + Key? key, + required this.id, + required this.name, + required this.url, + }) : super(key: key); final int id; final String name; @@ -35,63 +34,59 @@ class AuthorPage extends StatefulWidget { } class _AuthorPageState extends State { + final _pdvKey = GlobalKey(); final _controller = ScrollController(); - final _udvController = UpdatableDataViewController(); final _fabController = AnimatedFabController(); - var _loading = true; - Author _data; - var _error = ''; - var _mangas = []; - var _total = 0; - var _order = MangaOrder.byPopular; - var _lastOrder = MangaOrder.byPopular; - var _disableOption = false; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadData()); + WidgetsBinding.instance?.addPostFrameCallback((_) => _loadData()); } @override void dispose() { _controller.dispose(); - _udvController.dispose(); _fabController.dispose(); super.dispose(); } - Future _loadData() { + var _loading = true; + Author? _data; + var _error = ''; + + Future _loadData() async { _loading = true; if (mounted) setState(() {}); - var dio = DioManager.instance.dio; - var client = RestClient(dio); - return client.getAuthor(aid: widget.id).then((r) async { - _error = ''; + final client = RestClient(DioManager.instance.dio); + try { + var result = await client.getAuthor(aid: widget.id); _data = null; + _error = ''; if (mounted) setState(() {}); await Future.delayed(Duration(milliseconds: 20)); - _data = r.data; - }).catchError((e) { + _data = result.data; + } catch (e, s) { _data = null; - _error = wrapError(e).text; - }).whenComplete(() { + _error = wrapError(e, s).text; + } finally { _loading = false; if (mounted) setState(() {}); - }); + } } - Future> _getMangas({int page}) async { - var dio = DioManager.instance.dio; - var client = RestClient(dio); - ErrorMessage err; - var result = await client.getAuthorMangas(aid: widget.id, page: page, order: _order).catchError((e) { - err = wrapError(e); + final _mangas = []; + var _total = 0; + var _currOrder = MangaOrder.byPopular; + late var _lastOrder = _currOrder; + var _getting = false; + + Future> _getMangas({required int page}) async { + final client = RestClient(DioManager.instance.dio); + var result = await client.getAuthorMangas(aid: widget.id, page: page, order: _currOrder).onError((e, s) { + return Future.error(wrapError(e, s).text); }); - if (err != null) { - return Future.error(err.text); - } _total = result.data.total; if (mounted) setState(() {}); return PagedList(list: result.data.data, next: result.data.page + 1); @@ -101,25 +96,27 @@ class _AuthorPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, - toolbarHeight: 45, title: Text(_data?.name ?? widget.name), + leading: AppBarActionButton.leading(context: context, allowDrawerButton: false), actions: [ - IconButton( + AppBarActionButton( icon: Icon(Icons.open_in_browser), - tooltip: '打开浏览器', + tooltip: '用浏览器打开', onPressed: () => launchInBrowser( context: context, - url: widget.url, + url: _data?.url ?? widget.url, ), ), ], ), + drawer: MyDrawer( + currentDrawerSelection: DrawerSelection.none, + ), body: PlaceholderText.from( isLoading: _loading, errorText: _error, isEmpty: _data == null, - setting: PlaceholderSetting().toChinese(), + setting: PlaceholderSetting().copyWithChinese(), onRefresh: () => _loadData(), childBuilder: (c) => NestedScrollView( headerSliverBuilder: (c, o) => [ @@ -129,81 +126,97 @@ class _AuthorPageState extends State { SliverToBoxAdapter( child: Container( width: MediaQuery.of(context).size.width, - height: 150, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - stops: [0, 0.5, 1], + stops: const [0, 0.5, 1], colors: [ - Colors.blue[100], - Colors.orange[100], - Colors.purple[100], + Colors.blue[100]!, + Colors.orange[100]!, + Colors.purple[100]!, ], ), ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ // **************************************************************** - // 封面 + // 头像 // **************************************************************** Container( padding: EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: NetworkImageView( - url: _data.cover, - height: 130, - width: 100, - fit: BoxFit.cover, + child: FullRippleWidget( + child: NetworkImageView( + url: _data!.cover, + height: 130, + width: 100, + ), + onTap: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => ImageViewerPage( + url: _data!.cover, + title: '作者头像', + ), + ), + ), ), ), // **************************************************************** // 信息 // **************************************************************** Container( - width: MediaQuery.of(context).size.width - 14 * 3 - 100, // | ▢ ▢ | - height: 150, - margin: EdgeInsets.only(top: 14, bottom: 14, right: 14), - child: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - IconText( - icon: Icon(Icons.person, size: 20, color: Colors.orange), - text: Text('别名 ${_data.alias}'), - space: 8, - ), - IconText( - icon: Icon(Icons.place, size: 20, color: Colors.orange), - text: Text(_data.zone), - space: 8, - ), - IconText( - icon: Icon(Icons.trending_up, size: 20, color: Colors.orange), - text: Text('平均评分 ${_data.averageScore}'), - space: 8, - ), - IconText( - icon: Icon(Icons.edit, size: 20, color: Colors.orange), - text: Text('共收录 ${_data.mangaCount} 部漫画'), - space: 8, - ), - IconText( - icon: Icon(Icons.fiber_new_outlined, size: 20, color: Colors.orange), - text: Text( - '最新收录 ${_data.newestMangaTitle}', + width: MediaQuery.of(context).size.width - 14 * 3 - 100, // | ▢ ▢▢ | + padding: EdgeInsets.only(top: 10, bottom: 10, right: 0), + alignment: Alignment.centerLeft, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + IconText( + icon: Icon(Icons.person, size: 20, color: Colors.orange), + text: Flexible( + child: Text( + '别名 ${_data!.alias}', maxLines: 1, overflow: TextOverflow.ellipsis, ), - space: 8, ), - IconText( - icon: Icon(Icons.access_time, size: 20, color: Colors.orange), - text: Text('更新于 ${_data.newestDate}'), - space: 8, + space: 8, + ), + IconText( + icon: Icon(Icons.place, size: 20, color: Colors.orange), + text: Text(_data!.zone), + space: 8, + ), + IconText( + icon: Icon(Icons.trending_up, size: 20, color: Colors.orange), + text: Text('平均评分 ${_data!.averageScore}'), + space: 8, + ), + IconText( + icon: Icon(Icons.edit, size: 20, color: Colors.orange), + text: Text('共收录 ${_data!.mangaCount} 部漫画'), + space: 8, + ), + IconText( + icon: Icon(Icons.fiber_new_outlined, size: 20, color: Colors.orange), + text: Flexible( + child: Text( + '最新收录 ${_data!.newestMangaTitle}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - ], - ), + space: 8, + ), + IconText( + icon: Icon(Icons.access_time, size: 20, color: Colors.orange), + text: Text('更新于 ${_data!.newestDate}'), + space: 8, + ), + ], ), ), ], @@ -218,8 +231,8 @@ class _AuthorPageState extends State { padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), color: Colors.white, child: Text( - _data.introduction.trim().isEmpty ? '暂无介绍' : _data.introduction.trim(), - style: Theme.of(context).textTheme.subtitle1, + _data!.introduction.trim().isEmpty ? '暂无介绍' : _data!.introduction.trim(), + style: Theme.of(context).textTheme.bodyText2, ), ), ), @@ -231,50 +244,25 @@ class _AuthorPageState extends State { sliver: SliverPersistentHeader( pinned: true, floating: true, - delegate: SliverAppBarSizedDelegate( - minHeight: 26.0 + 5 * 2 + 1, - maxHeight: 26.0 + 5 * 2 + 1, - child: Container( - color: Colors.white, - child: Column( - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - height: 26, - padding: EdgeInsets.only(left: 5), - child: Center( - child: Text('全部漫画 (共 $_total 部)'), - ), - ), - // **************************************************************** - // 漫画排序 - // **************************************************************** - if (_total > 0) - OptionPopupView( - title: _order.toTitle(), - top: 4, - value: _order, - items: [MangaOrder.byPopular, MangaOrder.byNew, MangaOrder.byUpdate], - onSelect: (o) { - if (_order != o) { - _lastOrder = _order; - _order = o; - if (mounted) setState(() {}); - _udvController.refresh(); - } - }, - optionBuilder: (c, v) => v.toTitle(), - enable: !_disableOption, - ), - ], - ), - ), - Divider(height: 1, thickness: 1), - ], + delegate: SliverHeaderDelegate( + child: PreferredSize( + preferredSize: Size.fromHeight(26.0 + 5 * 2 + 1), // height: 26, padding: vertical_5, extra: divider_1 + child: ListHintView.textWidget( + leftText: '全部漫画 (共 $_total 部)', + rightWidget: OptionPopupView( + items: const [MangaOrder.byPopular, MangaOrder.byNew, MangaOrder.byUpdate], + value: _currOrder, + titleBuilder: (c, v) => v.toTitle(), + enable: !_getting, + onSelect: (o) { + if (_currOrder != o) { + _lastOrder = _currOrder; + _currOrder = o; + if (mounted) setState(() {}); + _pdvKey.currentState?.refresh(); + } + }, + ), ), ), ), @@ -287,9 +275,9 @@ class _AuthorPageState extends State { // **************************************************************** body: Builder( builder: (c) => PaginationSliverListView( + key: _pdvKey, data: _mangas, getData: ({indicator}) => _getMangas(page: indicator), - controller: _udvController, scrollController: PrimaryScrollController.of(c), paginationSetting: PaginationSetting( initialIndicator: 1, @@ -297,30 +285,30 @@ class _AuthorPageState extends State { ), setting: UpdatableDataViewSetting( padding: EdgeInsets.zero, - placeholderSetting: PlaceholderSetting().toChinese(), + interactiveScrollbar: true, + scrollbarCrossAxisMargin: 2, + placeholderSetting: PlaceholderSetting().copyWithChinese(), + onPlaceholderStateChanged: (_, __) => _fabController.hide(), refreshFirst: true, - clearWhenError: false, clearWhenRefresh: false, + clearWhenError: false, updateOnlyIfNotEmpty: false, - onStateChanged: (_, __) => _fabController.hide(), - onStartLoading: () => mountedSetState(() => _disableOption = true), - onStopLoading: () => mountedSetState(() => _disableOption = false), - onAppend: (l) { - if (l.length > 0) { - Fluttertoast.showToast(msg: '新添了 ${l.length} 部漫画'); - } - _lastOrder = _order; - if (mounted) setState(() {}); + onStartGettingData: () => mountedSetState(() => _getting = true), + onStopGettingData: () => mountedSetState(() => _getting = false), + onAppend: (_, l) { + _lastOrder = _currOrder; }, onError: (e) { - Fluttertoast.showToast(msg: e.toString()); - _order = _lastOrder; + if (_mangas.isNotEmpty) { + Fluttertoast.showToast(msg: e.toString()); + } + _currOrder = _lastOrder; if (mounted) setState(() {}); }, ), useOverlapInjector: true, - separator: Divider(height: 1), - itemBuilder: (c, item) => TinyMangaLineView(manga: item.toTiny()), + separator: Divider(height: 0, thickness: 1), + itemBuilder: (c, _, item) => TinyMangaLineView(manga: item.toTiny()), ), ), ), @@ -331,7 +319,7 @@ class _AuthorPageState extends State { condition: ScrollAnimatedCondition.direction, fab: FloatingActionButton( child: Icon(Icons.vertical_align_top), - heroTag: 'AuthorPage', + heroTag: null, onPressed: () => _controller.scrollToTop(), ), ), diff --git a/lib/page/chapter.dart b/lib/page/chapter.dart deleted file mode 100644 index 09c9947..0000000 --- a/lib/page/chapter.dart +++ /dev/null @@ -1,801 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/image.dart'; -import 'package:flutter_ahlib/util.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:manhuagui_flutter/config.dart'; -import 'package:manhuagui_flutter/model/chapter.dart'; -import 'package:manhuagui_flutter/model/manga.dart'; -import 'package:manhuagui_flutter/page/view/gallery_page_view.dart'; -import 'package:manhuagui_flutter/page/view/image_load_view.dart'; -import 'package:manhuagui_flutter/service/database/history.dart'; -import 'package:manhuagui_flutter/service/natives/browser.dart'; -import 'package:manhuagui_flutter/service/prefs/chapter.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; -import 'package:manhuagui_flutter/service/state/auth.dart'; -import 'package:photo_view/photo_view.dart'; - -/// 章节 -/// Page for [TinyMangaChapter]. -class ChapterPage extends StatefulWidget { - const ChapterPage({ - Key key, - this.action, - @required this.mid, - @required this.cid, - @required this.mangaTitle, - @required this.mangaCover, - @required this.mangaUrl, - this.initialPage = 1, - this.showAppBar = false, - }) : assert(mid != null), - assert(cid != null), - assert(mangaTitle != null), - assert(mangaCover != null), - assert(mangaUrl != null), - assert(initialPage != null), - assert(showAppBar != null), - super(key: key); - - final ActionController action; - final int mid; - final int cid; - final String mangaTitle; - final String mangaCover; - final String mangaUrl; - final int initialPage; - final bool showAppBar; - - @override - _ChapterPageState createState() => _ChapterPageState(); -} - -final _kSlideWidthRatio = 0.2; // 点击跳转页面的区域比例 -final _kChapterSwipeWidth = 75; // 滑动跳转章节的比例 -final _kViewportFraction = 1.08; // 页面间隔 - -class _ChapterPageState extends State with AutomaticKeepAliveClientMixin { - PageController _controller; - var _loading = true; - MangaChapter _data; - var _error = ''; - var _currentPage = 1; - var _progressValue = 1; - - Timer _timer; - var _currentTime = '00:00'; - var _fileProvider = () async => null; - var _imageProviders = Function()>[]; - - var _showRegion = false; // 显示区域提示 - var _showAppBar = false; // 显示工具栏 - var _setting = ChapterPageSetting.defaultSetting(); - var _pointerDownXPosition = 0.0; // 按住的横坐标 - var _swipeOffsetX = 0.0; // 滑动的水平偏移量 - var _swipeFirstOver = false; // 是否划出第一页 - var _swipeLastOver = false; // 是否划出最后一页 - - @override - void initState() { - super.initState(); - _showAppBar = widget.showAppBar; - _setting.existed().then((ok) async { - if (!ok) { - _setting = ChapterPageSetting.defaultSetting(); - await _setting.save(); - } else { - await _setting.load(); - } - WidgetsBinding.instance.addPostFrameCallback((_) => _loadData()); - }); - var now = DateTime.now(); - _currentTime = '${now.hour}:${now.minute.toString().padLeft(2, '0')}'; - } - - @override - void dispose() { - _timer?.cancel(); - _controller?.dispose(); - if (_data != null) { - addHistory( - username: AuthState.instance.username, - history: MangaHistory( - mangaId: widget.mid, - mangaTitle: widget.mangaTitle ?? '?', - mangaCover: widget.mangaCover ?? '?', - mangaUrl: widget.mangaUrl ?? '', - chapterId: widget.cid, - chapterTitle: _data.title, - chapterPage: _currentPage, - ), - ).then((_) { - widget.action?.invoke('history'); - widget.action?.invoke('history_toc'); - }).catchError((_) {}); - } - super.dispose(); - } - - Future _loadData() { - _loading = true; - if (mounted) setState(() {}); - - var dio = DioManager.instance.dio; - var client = RestClient(dio); - if (AuthState.instance.logined) { - client.recordManga(token: AuthState.instance.token, mid: widget.mid, cid: widget.cid).catchError((_) {}); - } - - return client.getMangaChapter(mid: widget.mid, cid: widget.cid).then((r) async { - _error = ''; - _data = null; - if (mounted) setState(() {}); - await Future.delayed(Duration(milliseconds: 500)); - _data = r.data; - _imageProviders = [for (var url in _data.pages) () async => url]; - - // !!! - var initialPage = widget.initialPage <= 0 ? _data.pageCount : widget.initialPage; // 指定初始页 - _controller = PageController( - initialPage: initialPage - 1, - viewportFraction: _setting.enablePageSpace ? _kViewportFraction : 1, - ); - _currentPage = initialPage; - _progressValue = initialPage; - - addHistory( - username: AuthState.instance.username, - history: MangaHistory( - mangaId: widget.mid, - mangaTitle: widget.mangaTitle ?? '?', - mangaCover: widget.mangaCover ?? '?', - mangaUrl: widget.mangaUrl ?? '', - chapterId: _data.cid, - chapterTitle: _data.title, - chapterPage: _currentPage, - ), - ).then((_) { - widget.action?.invoke('history'); - widget.action?.invoke('history_toc'); - }).catchError((_) {}); - - if (mounted && (_timer == null || !_timer.isActive)) { - _timer = Timer.periodic(Duration(seconds: 1), (t) { - if (t.isActive) { - var now = DateTime.now(); - _currentTime = '${now.hour}:${now.minute.toString().padLeft(2, '0')}'; - if (mounted) setState(() {}); - } - }); - } - }).catchError((e) { - _data = null; - _error = wrapError(e).text; - }).whenComplete(() { - _loading = false; - if (mounted) setState(() {}); - }); - } - - void _onPointerDown(Offset pos) { - _pointerDownXPosition = pos?.dx ?? 0; - } - - void _onPointerUp(Offset pos) { - var width = MediaQuery.of(context).size.width; - if (pos != null) { - var x = pos.dx; - if (x == _pointerDownXPosition && x < width * _kSlideWidthRatio) { - _gotoPage(!_setting.reverseScroll ? _currentPage - 1 : _currentPage + 1); // 上一页 / 下一页(反) - } else if (x == _pointerDownXPosition && x > width * (1 - _kSlideWidthRatio)) { - _gotoPage(!_setting.reverseScroll ? _currentPage + 1 : _currentPage - 1); // 下一页 / 上一页(反) - } else { - _showAppBar = !_showAppBar; - if (mounted) setState(() {}); - } - } - } - - void _onPageChanged(int page) { - _currentPage = page + 1; - _progressValue = page + 1; - if (mounted) setState(() {}); - } - - void _onSliderChanged(double p) { - _progressValue = p.toInt(); - _gotoPage(_progressValue); - if (mounted) setState(() {}); - } - - /// goto page - void _gotoPage(int page) { - if (page <= 0) { - if (_setting.useClickForChapter) { - _gotoChapter(last: true); - } - } else if (page > _data.pages.length) { - if (_setting.useClickForChapter) { - _gotoChapter(last: false); - } - } else { - _controller.animateToPage( - page - 1, - duration: Duration(milliseconds: 1), - curve: Curves.ease, - ); - } - } - - /// goto chapter - void _gotoChapter({bool last, bool isAppBar = false}) { - if ((last && _data.prevCid == 0) || (!last && _data.nextCid == 0)) { - showDialog( - context: context, - builder: (c) => AlertDialog( - title: Text(last ? '上一章节' : '下一章节'), - content: Text(last ? '没有上一章节了。' : '没有下一章节了。'), - actions: [ - FlatButton( - child: Text('确定'), - onPressed: () => Navigator.of(c).pop(), - ), - ], - ), - ); - return; - } - var _go = () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => ChapterPage( - action: widget.action, - mid: widget.mid, - cid: last ? _data.prevCid : _data.nextCid, - mangaTitle: widget.mangaTitle, - mangaCover: widget.mangaCover, - mangaUrl: widget.mangaUrl, - showAppBar: _showAppBar, - initialPage: (!last || isAppBar) ? 1 : -1, // 下一章节 || 工具栏点击的上一章节 => 第一页,否则 => 最后一页 - ), - ), - ); - }; - if (_setting.needCheckForChapter) { - showDialog( - context: context, - builder: (c) => AlertDialog( - title: Text(last ? '上一章节' : '下一章节'), - content: Text(last ? '即将跳转至上一章节?' : '即将跳转至下一章节?'), - actions: [ - FlatButton( - child: Text('取消'), - onPressed: () => Navigator.of(c).pop(), - ), - FlatButton( - child: Text('跳转'), - onPressed: () { - Navigator.of(c).pop(); - _go(); - }, - ), - ], - ), - ); - } else { - _go(); - } - } - - void _onSettingPressed() { - _showAppBar = false; - if (mounted) setState(() {}); - - Widget _buildCombo({String title, double width = 120, T value, List values, Widget Function(T) builder, void Function(T) onChanged}) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(title), - Container( - height: 38, - width: width, - child: DropdownButton( - value: value, - items: values.map((s) => DropdownMenuItem(child: builder(s), value: s)).toList(), - underline: Container(color: Colors.white), - isExpanded: true, - onChanged: onChanged, - ), - ), - ], - ); - } - - Widget _buildSlider({String title, bool value, void Function(bool) onChanged}) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(title), - Container( - height: 38, - child: Switch( - value: value, - onChanged: onChanged, - ), - ), - ], - ); - } - - showDialog( - context: context, - builder: (c) => AlertDialog( - title: Text('设置'), - content: StatefulBuilder( - builder: (_, _setState) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _buildCombo( - title: '阅读方向', - value: _setting.reverseScroll, - values: [false, true], - builder: (s) => Text( - s == false ? '从左往右' : '从右往左', - style: Theme.of(context).textTheme.bodyText1, - ), - onChanged: (s) async { - _setting.reverseScroll = s; - await _setting.save(); - _setState(() {}); - if (mounted) setState(() {}); - }, - ), - _buildSlider( - title: '显示页码', - value: _setting.showPageHint, - onChanged: (b) async { - _setting.showPageHint = b; - await _setting.save(); - _setState(() {}); - if (mounted) setState(() {}); - }, - ), - _buildSlider( - title: '滑动跳转至章节', - value: _setting.useSwipeForChapter, - onChanged: (b) async { - _setting.useSwipeForChapter = b; - await _setting.save(); - _setState(() {}); - if (mounted) setState(() {}); - }, - ), - _buildSlider( - title: '点击跳转至章节', - value: _setting.useClickForChapter, - onChanged: (b) async { - _setting.useClickForChapter = b; - await _setting.save(); - _setState(() {}); - if (mounted) setState(() {}); - }, - ), - _buildSlider( - title: '跳转章节时弹出提示', - value: _setting.needCheckForChapter, - onChanged: (b) async { - _setting.needCheckForChapter = b; - await _setting.save(); - _setState(() {}); - if (mounted) setState(() {}); - }, - ), - _buildSlider( - title: '显示页面间隔', - value: _setting.enablePageSpace, - onChanged: (b) async { - _setting.enablePageSpace = b; - await _setting.save(); - _setState(() {}); - _controller = PageController( - initialPage: _controller.initialPage, - viewportFraction: b ? _kViewportFraction : 1, - ); - if (mounted) setState(() {}); - }, - ), - _buildCombo( - title: '预加载页数', - width: 80, - value: _setting.preloadCount.clamp(0, 5), - values: [0, 1, 2, 3, 4, 5], - builder: (s) => Text( - '$s页', - style: Theme.of(context).textTheme.bodyText1, - ), - onChanged: (c) async { - _setting.preloadCount = c.clamp(0, 5); - await _setting.save(); - _setState(() {}); - if (mounted) setState(() {}); - }, - ), - ], - ), - ), - actions: [ - FlatButton( - child: Text('操作'), - onPressed: () { - Navigator.of(c).pop(); - _showRegion = true; - _showAppBar = false; - if (mounted) setState(() {}); - }, - ), - FlatButton( - child: Text('返回'), - onPressed: () => Navigator.of(c).pop(), - ), - ], - ), - ); - } - - bool _onScrollNotification(Notification n) { - if (!_setting.useSwipeForChapter) { - return true; // 不开启滑动跳转章节 - } - - if (n is ScrollUpdateNotification) { - var dx = n.dragDetails?.delta?.dx; - if (dx == null) { - _swipeOffsetX = 0; - if (_swipeFirstOver) { - _gotoChapter(last: !_setting.reverseScroll); // 上一章 / 下一章(反) - } else if (_swipeLastOver) { - _gotoChapter(last: _setting.reverseScroll); // 下一章 / 上一章(反) - } - } else { - _swipeOffsetX += dx; - } - - var willSwipeFirst = ((!_setting.reverseScroll && _currentPage == 1) || (_setting.reverseScroll && _currentPage == _data.pageCount)); // 第一页 / 最后一页(反) - var willSwipeLast = ((!_setting.reverseScroll && _currentPage == _data.pageCount) || (_setting.reverseScroll && _currentPage == 1)); // 最后一页 / 第一页(反) - var nowSwipeFirstOver = _swipeOffsetX >= _kChapterSwipeWidth; // 当前划出第一页 - var nowSwipeLastOver = _swipeOffsetX <= -_kChapterSwipeWidth; // 当前划出最后一页 - if (willSwipeFirst && _swipeOffsetX >= 0 && _swipeOffsetX < _kChapterSwipeWidth * 2) { - if (!_swipeFirstOver && nowSwipeFirstOver) { - _swipeFirstOver = true; - if (mounted) setState(() {}); - } else if (_swipeFirstOver && !nowSwipeFirstOver) { - _swipeFirstOver = false; - if (mounted) setState(() {}); - } - } else if (willSwipeLast && _swipeOffsetX <= 0 && _swipeOffsetX > -_kChapterSwipeWidth * 2) { - if (!_swipeLastOver && nowSwipeLastOver) { - _swipeLastOver = true; - if (mounted) setState(() {}); - } else if (_swipeLastOver && !nowSwipeLastOver) { - _swipeLastOver = false; - if (mounted) setState(() {}); - } - } - } - - return true; - } - - @override - bool get wantKeepAlive => true; - - @override - Widget build(BuildContext context) { - super.build(context); - - var width = MediaQuery.of(context).size.width; - var height = MediaQuery.of(context).size.height - MediaQuery.of(context).padding.top - (_showAppBar ? 45 : 0); - return SafeArea( - top: !_showAppBar, - child: Scaffold( - appBar: _loading || _data == null || !_showAppBar - ? null - : AppBar( - centerTitle: false, - toolbarHeight: 45, - title: Text(_data.title), - actions: [ - IconButton( - icon: Icon(Icons.settings), - tooltip: '设置', - onPressed: _onSettingPressed, - ), - IconButton( - icon: Transform.rotate( - angle: pi, - child: Icon(Icons.arrow_right_alt), - ), - tooltip: !_setting.reverseScroll ? '上一章节' : '下一章节', // 上一章节 / 下一章节(反) - onPressed: () => _gotoChapter(last: !_setting.reverseScroll, isAppBar: true), - ), - IconButton( - icon: Icon(Icons.arrow_right_alt), - tooltip: !_setting.reverseScroll ? '下一章节' : '上一章节', // 下一章节 / 上一章节(反) - onPressed: () => _gotoChapter(last: _setting.reverseScroll, isAppBar: true), - ), - IconButton( - icon: Icon(Icons.open_in_browser), - tooltip: '用浏览器打开', - onPressed: () => launchInBrowser(context: context, url: _data.url), - ), - ], - ), - body: Container( - color: Colors.black, - child: PlaceholderText( - onRefresh: () => _loadData(), - state: _loading - ? PlaceholderState.loading - : _data == null - ? PlaceholderState.error - : PlaceholderState.normal, - setting: PlaceholderSetting( - iconColor: Colors.grey, - showLoadingText: false, - textStyle: TextStyle( - fontSize: Theme.of(context).textTheme.headline6.fontSize, - color: Colors.grey, - ), - buttonTextStyle: TextStyle(color: Colors.grey), - buttonBorderSide: BorderSide(color: Colors.grey), - ).toChinese(), - errorText: _error, - childBuilder: (c) => Stack( - children: [ - // **************************************************************** - // 漫画显示 - // **************************************************************** - Positioned.fill( - child: NotificationListener( - onNotification: _onScrollNotification, - child: GalleryPageView( - scrollPhysics: BouncingScrollPhysics(), - reverse: _setting.reverseScroll, - backgroundDecoration: BoxDecoration(color: Colors.black), - pageController: _controller, - onPageChanged: _onPageChanged, - itemCount: _data.pages.length, - preloadPagesCount: _setting.preloadCount, - // 2 - loadingBuilder: (c, ImageChunkEvent e) => Listener( - onPointerUp: (e) => _onPointerUp(e.position), - onPointerDown: (e) => _onPointerDown(e.position), - child: ImageLoadingView( - title: _currentPage.toString(), - event: e, - height: height, - width: width, - ), - ), - loadFailedChild: Listener( - onPointerUp: (e) => _onPointerUp(e.position), - onPointerDown: (e) => _onPointerDown(e.position), - child: ImageLoadFailedView( - title: _currentPage.toString(), - height: height, - width: width, - ), - ), - // **************************************************************** - // 漫画显示选项 - // **************************************************************** - builder: (c, idx) => GalleryPageViewPageOptions( - initialScale: PhotoViewComputedScale.contained, - minScale: PhotoViewComputedScale.contained / 2, - maxScale: PhotoViewComputedScale.covered * 2, - filterQuality: FilterQuality.high, - onTapDown: (c, d, v) => _onPointerDown(d.globalPosition), - onTapUp: (c, d, v) => _onPointerUp(d.globalPosition), - imageProvider: FileOrNetworkImageProvider( - url: _imageProviders[idx], - file: _fileProvider, - headers: { - 'User-Agent': USER_AGENT, - 'Referer': REFERER, - }, - ), - ), - ), - ), - ), - // **************************************************************** - // 左边导航: 上一章节 / 下一章节(反) - // **************************************************************** - if (_setting.useSwipeForChapter) - AnimatedPositioned( - left: _swipeFirstOver ? 0 : -30, - duration: Duration(milliseconds: 200), - child: AnimatedOpacity( - opacity: _swipeFirstOver ? 1.0 : 0.0, - duration: Duration(milliseconds: 200), - child: Container( - padding: EdgeInsets.symmetric(horizontal: 2), - width: 34, - height: height, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Transform.rotate( - angle: pi, - child: Icon(Icons.arrow_right_alt, size: 24, color: Colors.white), - ), - Text( - !_setting.reverseScroll ? '前\n往\n上\n一\n章\n节' : '前\n往\n下\n一\n章\n节', - style: TextStyle( - color: Colors.white, - fontSize: Theme.of(context).textTheme.headline6.fontSize, - ), - ), - ], - ), - ), - ), - ), - // **************************************************************** - // 右边导航: 下一章节 / 上一章节(反) - // **************************************************************** - if (_setting.useSwipeForChapter) - AnimatedPositioned( - right: _swipeLastOver ? 0 : -30, - duration: Duration(milliseconds: 200), - child: AnimatedOpacity( - opacity: _swipeLastOver ? 1.0 : 0.0, - duration: Duration(milliseconds: 200), - child: Container( - padding: EdgeInsets.symmetric(horizontal: 2), - width: 34, - height: height, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.arrow_right_alt, size: 24, color: Colors.white), - Text( - !_setting.reverseScroll ? '前\n往\n下\n一\n章\n节' : '前\n往\n上\n一\n章\n节', - style: TextStyle( - color: Colors.white, - fontSize: Theme.of(context).textTheme.headline6.fontSize, - ), - ), - ], - ), - ), - ), - ), - // **************************************************************** - // 右下角的提示文字 - // **************************************************************** - if (_setting.showPageHint && !_showAppBar && _data != null) - Positioned( - bottom: 0, - right: 0, - child: Container( - color: Colors.black.withOpacity(0.7), - padding: EdgeInsets.only(left: 8, right: 8, top: 1.5, bottom: 1.5), - child: Text( - '${_data.title} $_currentPage/${_data.pageCount}页 $_currentTime', - style: TextStyle(color: Colors.white), - ), - ), - ), - // **************************************************************** - // 最下面的滚动条 - // **************************************************************** - if (_showAppBar && _data != null) - Positioned( - bottom: 0, - child: Container( - color: Colors.black.withOpacity(0.75), - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 2), - width: MediaQuery.of(context).size.width, - child: Row( - children: [ - Expanded( - child: Directionality( - textDirection: !_setting.reverseScroll ? TextDirection.ltr : TextDirection.rtl, - child: Slider( - value: _progressValue.toDouble(), - min: 1, - max: _data.pageCount.toDouble(), - onChanged: (p) { - _progressValue = p.toInt(); - if (mounted) setState(() {}); - }, - onChangeEnd: _onSliderChanged, - ), - ), - ), - Padding( - padding: EdgeInsets.only(left: 4, right: 18), - child: Text( - '$_progressValue/${_data.pageCount}页', - style: TextStyle(color: Colors.white), - ), - ), - ], - ), - ), - ), - // **************************************************************** - // 帮助区域显示 - // **************************************************************** - if (_showRegion) - Positioned.fill( - child: GestureDetector( - onTap: () { - _showRegion = false; - if (mounted) setState(() {}); - }, - child: Row( - children: [ - Container( - height: height, - width: width * _kSlideWidthRatio, - color: Colors.yellow[800].withAlpha(200), - child: Center( - child: Text( - !_setting.reverseScroll ? '上\n一\n页' : '下\n一\n页', // 上一页 / 下一页(反) - style: TextStyle( - color: Colors.white, - fontSize: Theme.of(context).textTheme.headline6.fontSize, - ), - ), - ), - ), - Container( - height: height, - width: width * (1 - 2 * _kSlideWidthRatio), - color: Colors.blue[300].withAlpha(200), - child: Center( - child: Text( - '菜单', - style: TextStyle( - color: Colors.white, - fontSize: Theme.of(context).textTheme.headline6.fontSize, - ), - ), - ), - ), - Container( - height: height, - width: width * _kSlideWidthRatio, - color: Colors.red[200].withAlpha(200), - child: Center( - child: Text( - !_setting.reverseScroll ? '下\n一\n页' : '上\n一\n页', // 下一页 / 上一页(反) - style: TextStyle( - color: Colors.white, - fontSize: Theme.of(context).textTheme.headline6.fontSize, - ), - ), - ), - ), - ], - ), - ), - ), - // **************************************************************** - // ================================================================ - // **************************************************************** - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/page/comment.dart b/lib/page/comment.dart index 6c0097e..76ee07c 100644 --- a/lib/page/comment.dart +++ b/lib/page/comment.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/model/comment.dart'; -import 'package:manhuagui_flutter/page/view/network_image.dart'; -import 'package:manhuagui_flutter/service/natives/clipboard.dart'; +import 'package:manhuagui_flutter/page/view/comment_line.dart'; -/// 评论详情页 +/// 漫画评论详情页,展示所给 [Comment] 信息 class CommentPage extends StatefulWidget { const CommentPage({ - Key key, - @required this.comment, - }) : assert(comment != null), - super(key: key); + Key? key, + required this.comment, + }) : super(key: key); final Comment comment; @@ -18,193 +17,61 @@ class CommentPage extends StatefulWidget { } class _CommentPageState extends State { - Widget _buildLine({@required RepliedComment comment, @required int idx}) { - assert(comment != null); - assert(idx != null); - return Stack( - children: [ - Container( - color: Colors.white, - padding: EdgeInsets.only(top: 15, bottom: 15, left: 15, right: 15), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipOval( - child: NetworkImageView( - url: comment.avatar, - height: 40, - width: 40, - fit: BoxFit.cover, - ), - ), - SizedBox(width: 15), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // **************************************************************** - // 第一行 - // **************************************************************** - Container( - width: MediaQuery.of(context).size.width - 3 * 15 - 40, // | ▢▢ ▢▢▢▢▢ | - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // **************************************************************** - // 用户名 性别 - // **************************************************************** - Expanded( - child: Row( - children: [ - Flexible( - child: Text( - comment.username == '-' ? '匿名用户' : comment.username, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.subtitle1, - ), - ), - SizedBox(width: 8), - Container( - decoration: BoxDecoration( - color: comment.gender == 1 ? Colors.blue[300] : Colors.red[400], - borderRadius: BorderRadius.all(Radius.circular(3)), - ), - height: 18, - width: 18, - child: Center( - child: Text( - widget.comment.gender == 1 ? '♂' : '♀', - style: TextStyle(fontSize: 14, color: Colors.white), - ), - ), - ), - ], - ), - ), - // **************************************************************** - // 楼层 - // **************************************************************** - Container( - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.all(Radius.circular(3)), - ), - height: 18, - width: 26, - child: Center( - child: Text( - '#$idx', - style: TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - ), - ), - ], - ), - ), - SizedBox(height: 15), - // **************************************************************** - // 评论内容 - // **************************************************************** - Container( - width: MediaQuery.of(context).size.width - 3 * 15 - 40, - child: Text( - comment.content, - style: Theme.of(context).textTheme.subtitle1, - ), - ), - SizedBox(height: 15), - // **************************************************************** - // 评论数据 - // **************************************************************** - Container( - width: MediaQuery.of(context).size.width - 3 * 15 - 40, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - comment.commentTime, - style: TextStyle(color: Colors.grey), - ), - Row( - children: [ - Icon( - Icons.thumb_up, - color: Colors.grey[400], - size: 16, - ), - SizedBox(width: 4), - Text(comment.likeCount.toString()), - SizedBox(width: 10), - Icon( - Icons.chat_bubble, - color: Colors.grey[400], - size: 16, - ), - SizedBox(width: 4), - Text(comment.replyCount.toString()), - ], - ), - ], - ), - ), - ], - ), - ], - ), - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => copyText(comment.content), - ), - ), - ), - ], - ); - } + final _controller = ScrollController(); + final _fabController = AnimatedFabController(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, - toolbarHeight: 45, title: Text('评论详情'), + leading: AppBarActionButton.leading(context: context), ), - body: Container( - width: MediaQuery.of(context).size.width, + body: ScrollbarWithMore( + controller: _controller, + interactive: true, + crossAxisMargin: 2, child: ListView( + controller: _controller, + padding: EdgeInsets.zero, + physics: AlwaysScrollableScrollPhysics(), children: [ - _buildLine( - comment: widget.comment.toRepliedComment(), - idx: widget.comment.replyTimeline.length + 1, + CommentLineView( + comment: widget.comment, + style: CommentLineViewStyle.large, ), - Container(height: 12), - if (widget.comment.replyTimeline.length > 0) + if (widget.comment.replyTimeline.isNotEmpty) ...[ + Container(height: 12), for (var i = 0; i < widget.comment.replyTimeline.length - 1; i++) ...[ - _buildLine( - comment: widget.comment.replyTimeline[i], - idx: i + 1, + CommentLineView( + comment: widget.comment.replyTimeline[i].toComment(), + index: i + 1, + style: CommentLineViewStyle.large, ), Container( color: Colors.white, - padding: EdgeInsets.only(left: 2.0 * 15 + 40), - width: MediaQuery.of(context).size.width - 3 * 15 - 40, - child: Divider(height: 1, thickness: 1), - ), + child: Divider(height: 0, thickness: 1, indent: 40 + 2.0 * 15), + ) ], - if (widget.comment.replyTimeline.length > 0) - _buildLine( - comment: widget.comment.replyTimeline.last, - idx: widget.comment.replyTimeline.length, + CommentLineView( + comment: widget.comment.replyTimeline.last.toComment(), + index: widget.comment.replyTimeline.length, + style: CommentLineViewStyle.large, ), + ], ], ), ), + floatingActionButton: ScrollAnimatedFab( + controller: _fabController, + scrollController: _controller, + condition: ScrollAnimatedCondition.direction, + fab: FloatingActionButton( + child: Icon(Icons.vertical_align_top), + heroTag: null, + onPressed: () => _controller.scrollToTop(), + ), + ), ); } } diff --git a/lib/page/comments.dart b/lib/page/comments.dart new file mode 100644 index 0000000..2f0453b --- /dev/null +++ b/lib/page/comments.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:manhuagui_flutter/model/comment.dart'; +import 'package:manhuagui_flutter/page/view/comment_line.dart'; +import 'package:manhuagui_flutter/page/view/list_hint.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; + +/// 漫画评论列表页,网络请求并展示 [Comment] 列表信息 +class CommentsPage extends StatefulWidget { + const CommentsPage({ + Key? key, + required this.mangaId, + required this.mangaTitle, + }) : super(key: key); + + final int mangaId; + final String mangaTitle; + + @override + _CommentsPageState createState() => _CommentsPageState(); +} + +class _CommentsPageState extends State { + final _controller = ScrollController(); + final _fabController = AnimatedFabController(); + + @override + void dispose() { + _controller.dispose(); + _fabController.dispose(); + super.dispose(); + } + + final _data = []; + var _total = 0; + + Future> _getData({required int page}) async { + final client = RestClient(DioManager.instance.dio); + var result = await client.getMangaComments(mid: widget.mangaId, page: page).onError((e, s) { + return Future.error(wrapError(e, s).text); + }); + _total = result.data.total; + if (mounted) setState(() {}); + return PagedList(list: result.data.data, next: result.data.page + 1); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('漫画评论'), + leading: AppBarActionButton.leading(context: context), + ), + body: PaginationListView( + data: _data, + getData: ({indicator}) => _getData(page: indicator), + scrollController: _controller, + paginationSetting: PaginationSetting( + initialIndicator: 1, + nothingIndicator: 0, + ), + setting: UpdatableDataViewSetting( + padding: EdgeInsets.symmetric(vertical: 0), + interactiveScrollbar: true, + scrollbarCrossAxisMargin: 2, + placeholderSetting: PlaceholderSetting().copyWithChinese(), + onPlaceholderStateChanged: (_, __) => _fabController.hide(), + refreshFirst: true, + clearWhenRefresh: false, + clearWhenError: false, + updateOnlyIfNotEmpty: false, + onError: (e) { + if (_data.isNotEmpty) { + Fluttertoast.showToast(msg: e.toString()); + } + }, + ), + separator: Container( + color: Colors.white, + child: Divider(height: 0, thickness: 1, indent: 2.0 * 12 + 32), + ), + itemBuilder: (c, _, item) => CommentLineView( + comment: item, + style: CommentLineViewStyle.normal, + ), + extra: UpdatableDataViewExtraWidgets( + innerTopWidgets: [ + ListHintView.textText( + leftText: '《${widget.mangaTitle}》', + rightText: '共 $_total 条评论', + ), + ], + ), + ), + floatingActionButton: ScrollAnimatedFab( + controller: _fabController, + scrollController: _controller, + condition: ScrollAnimatedCondition.direction, + fab: FloatingActionButton( + child: Icon(Icons.vertical_align_top), + heroTag: null, + onPressed: () => _controller.scrollToTop(), + ), + ), + ); + } +} diff --git a/lib/page/download.dart b/lib/page/download.dart new file mode 100644 index 0000000..17e8d00 --- /dev/null +++ b/lib/page/download.dart @@ -0,0 +1,354 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/page/download_toc.dart'; +import 'package:manhuagui_flutter/page/page/dl_setting.dart'; +import 'package:manhuagui_flutter/page/view/download_manga_line.dart'; +import 'package:manhuagui_flutter/page/view/my_drawer.dart'; +import 'package:manhuagui_flutter/service/db/download.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; +import 'package:manhuagui_flutter/service/prefs/dl_setting.dart'; +import 'package:manhuagui_flutter/service/storage/download_image.dart'; +import 'package:manhuagui_flutter/service/storage/download_manga_task.dart'; +import 'package:manhuagui_flutter/service/storage/queue_manager.dart'; + +/// 下载列表页,查询数据库并展示 [DownloadedManga] 列表信息,以及展示 [DownloadMangaProgressChangedEvent] 进度信息 +class DownloadPage extends StatefulWidget { + const DownloadPage({ + Key? key, + }) : super(key: key); + + @override + State createState() => _DownloadPageState(); +} + +class _DownloadPageState extends State { + final _controller = ScrollController(); + final _fabController = AnimatedFabController(); + final _cancelHandlers = []; + + @override + void initState() { + super.initState(); + + // progress related + _cancelHandlers.add(EventBusManager.instance.listen((event) async { + if (event.finished) { + _tasks.removeWhere((key, _) => key == event.mangaId); + if (mounted) setState(() {}); + } else { + var task = QueueManager.instance.getDownloadMangaQueueTask(event.mangaId); + if (task != null) { + _tasks[event.mangaId] = task; + if (mounted) setState(() {}); + if (task.progress.stage == DownloadMangaProgressStage.waiting || task.progress.stage == DownloadMangaProgressStage.gotChapter) { + getDownloadedMangaBytes(mangaId: event.mangaId).then((b) { + _bytes[event.mangaId] = b; // 只有在最开始等待、以及每次获得新章节时才遍历统计文件大小 + if (mounted) setState(() {}); + }); + } + } + } + })); + + // entity related + _cancelHandlers.add(EventBusManager.instance.listen((event) async { + var newEntity = await DownloadDao.getManga(mid: event.mangaId); + if (newEntity != null) { + _data.removeWhere((el) => el.mangaId == event.mangaId); + _data.insert(0, newEntity); + _data.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + if (mounted) setState(() {}); + getDownloadedMangaBytes(mangaId: event.mangaId).then((b) { + _bytes[event.mangaId] = b; // 在每次数据库发生变化时都遍历统计文件大小 + if (mounted) setState(() {}); + }); + } + })); + } + + @override + void dispose() { + _cancelHandlers.forEach((c) => c.call()); + _controller.dispose(); + _fabController.dispose(); + super.dispose(); + } + + final _data = []; + var _total = 0; + final _tasks = {}; + final _bytes = {}; + + Future> _getData() async { + var data = await DownloadDao.getMangas() ?? []; + _total = await DownloadDao.getMangaCount() ?? 0; + _tasks.clear(); + for (var t in QueueManager.instance.getDownloadMangaQueueTasks()) { + _tasks[t.mangaId] = t; + } + if (mounted) setState(() {}); + for (var entity in data) { + getDownloadedMangaBytes(mangaId: entity.mangaId).then((b) { + _bytes[entity.mangaId] = b; + if (mounted) setState(() {}); + }); + } + return data; + } + + Future _pauseOrContinue({required DownloadedManga entity, required DownloadMangaQueueTask? task, bool addTask = true}) async { + if (task != null && !task.canceled && !task.succeeded) { + // 暂停 => 取消任务 + task.cancel(); + return null; + } + + // 继续 => 快速构造下载任务,同步更新数据库,并根据 addTask 参数按要求入队 + var setting = await DlSettingPrefs.getSetting(); + DownloadMangaQueueTask? newTask = await quickBuildDownloadMangaQueueTask( + mangaId: entity.mangaId, + mangaTitle: entity.mangaTitle, + mangaCover: entity.mangaCover, + mangaUrl: entity.mangaUrl, + chapterIds: entity.downloadedChapters.map((el) => el.chapterId).toList(), + parallel: setting.downloadPagesTogether, + invertOrder: setting.invertDownloadOrder, + addToTask: false, + throughChapterList: entity.downloadedChapters, + ); + if (addTask && newTask != null) { + QueueManager.instance.addTask(newTask); + } + return newTask; + } + + Future _deleteManga(DownloadedManga entity) async { + var setting = await DlSettingPrefs.getSetting(); + var alsoDeleteFile = setting.defaultToDeleteFiles; + await showDialog( + context: context, + builder: (c) => StatefulBuilder( + builder: (c, _setState) => AlertDialog( + title: Text('漫画删除确认'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('是否删除漫画《${entity.mangaTitle}》?'), + SizedBox(height: 5), + CheckboxListTile( + title: Text('同时删除已下载的文件'), + value: alsoDeleteFile, + onChanged: (v) { + alsoDeleteFile = v ?? false; + _setState(() {}); + }, + dense: false, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + ], + ), + actions: [ + TextButton( + child: Text('删除'), + onPressed: () async { + Navigator.of(c).pop(); + _data.remove(entity); + _total--; + await DownloadDao.deleteManga(mid: entity.mangaId); + await DownloadDao.deleteAllChapters(mid: entity.mangaId); + if (mounted) setState(() {}); + + var setting = await DlSettingPrefs.getSetting(); + setting = setting.copyWith(defaultToDeleteFiles: alsoDeleteFile); + await DlSettingPrefs.setSetting(setting); + if (alsoDeleteFile) { + await deleteDownloadedManga(mangaId: entity.mangaId); + } + }, + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), + ), + ); + } + + Completer? _allStartCompleter; + + Future _allStartOrPause({required bool allStart}) async { + if (allStart) { + // => 全部开始 + + // 1. 先判断当前是否在"全部开始" + if (_allStartCompleter != null) { + return; + } + _allStartCompleter = Completer(); + + // 2. 逐漫画继续下载,异步,并等待所有"下载准备"结束 + var entities = await DownloadDao.getMangas() ?? _data; + var tasks = QueueManager.instance.getDownloadMangaQueueTasks(); + var prepares = >[]; + for (var entity in entities) { + var task = tasks.where((el) => el.mangaId == entity.mangaId).firstOrNull; + if (task == null || !task.canceled) { + prepares.add(_pauseOrContinue(entity: entity, task: null, addTask: false)); + } + } + var newTasks = await Future.wait(prepares); + + // 3. 按照下载时间逆序,并将新的下载任务入队 + var newTaskMap = {}; + for (var task in newTasks) { + if (task != null) { + newTaskMap[task.mangaId] = task; + } + } + for (var entity in entities) { + var newTask = newTaskMap[entity.mangaId]; + if (newTask != null) { + QueueManager.instance.addTask(newTask); + } + } + + // 4. 记录"全部开始"已完成 + _allStartCompleter?.complete(); + _allStartCompleter = null; + } else { + // => 全部暂停 + + // 1. 先取消目前所有的任务 + for (var t in QueueManager.instance.getDownloadMangaQueueTasks()) { + if (!t.canceled) { + t.cancel(); + } + } + + // 2. 等待"全部开始"结束 + await _allStartCompleter?.future; + + // 3. 再取消"全部开始"结束后所有的任务 + for (var t in QueueManager.instance.getDownloadMangaQueueTasks()) { + if (!t.canceled) { + t.cancel(); + } + } + } + } + + Future _onSettingPressed() async { + var setting = await DlSettingPrefs.getSetting(); + await showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('下载设置'), + content: DlSettingSubPage( + setting: setting, + onSettingChanged: (s) => setting = s, + ), + actions: [ + TextButton( + child: Text('确定'), + onPressed: () async { + Navigator.of(c).pop(); + await DlSettingPrefs.setSetting(setting); + for (var t in QueueManager.instance.getDownloadMangaQueueTasks()) { + t.changeParallel(setting.downloadPagesTogether); + } + }, + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('下载列表 (共 $_total 部)'), + leading: AppBarActionButton.leading(context: context, allowDrawerButton: false), + actions: [ + AppBarActionButton( + icon: Icon(Icons.play_arrow), + tooltip: '全部开始', + onPressed: () => _allStartOrPause(allStart: true), + ), + AppBarActionButton( + icon: Icon(Icons.pause), + tooltip: '全部暂停', + onPressed: () => _allStartOrPause(allStart: false), + ), + AppBarActionButton( + icon: Icon(Icons.settings), + tooltip: '下载设置', + onPressed: () => _onSettingPressed(), + ), + ], + ), + drawer: MyDrawer( + currentDrawerSelection: DrawerSelection.download, + ), + body: RefreshableListView( + data: _data, + getData: () => _getData(), + scrollController: _controller, + setting: UpdatableDataViewSetting( + padding: EdgeInsets.symmetric(vertical: 0), + interactiveScrollbar: true, + scrollbarCrossAxisMargin: 2, + placeholderSetting: PlaceholderSetting().copyWithChinese(), + onPlaceholderStateChanged: (_, __) => _fabController.hide(), + refreshFirst: true, + clearWhenRefresh: false, + clearWhenError: false, + ), + separator: Divider(height: 0, thickness: 1), + itemBuilder: (c, _, entity) { + DownloadMangaQueueTask? task = _tasks[entity.mangaId]; + return DownloadMangaLineView( + mangaEntity: entity, + downloadTask: task, + downloadedBytes: _bytes[entity.mangaId] ?? 0, + onActionPressed: () => _pauseOrContinue(entity: entity, task: task), + onLinePressed: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => DownloadTocPage( + mangaId: entity.mangaId, + ), + settings: DownloadTocPage.buildRouteSetting( + mangaId: entity.mangaId, + ), + ), + ), + onLineLongPressed: () => _deleteManga(entity), + ); + }, + ), + floatingActionButton: ScrollAnimatedFab( + controller: _fabController, + scrollController: _controller, + condition: ScrollAnimatedCondition.direction, + fab: FloatingActionButton( + child: Icon(Icons.vertical_align_top), + heroTag: null, + onPressed: () => _controller.scrollToTop(), + ), + ), + ); + } +} diff --git a/lib/page/download_select.dart b/lib/page/download_select.dart new file mode 100644 index 0000000..f7e3b00 --- /dev/null +++ b/lib/page/download_select.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/model/chapter.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/page/download_toc.dart'; +import 'package:manhuagui_flutter/page/page/dl_setting.dart'; +import 'package:manhuagui_flutter/page/view/manga_toc.dart'; +import 'package:manhuagui_flutter/page/view/warning_text.dart'; +import 'package:manhuagui_flutter/service/db/download.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; +import 'package:manhuagui_flutter/service/prefs/dl_setting.dart'; +import 'package:manhuagui_flutter/service/storage/download_manga_task.dart'; + +/// 选择下载章节页,展示所给 [MangaChapterGroup] 列表信息,并提供章节选择功能 +class DownloadSelectPage extends StatefulWidget { + const DownloadSelectPage({ + Key? key, + required this.mangaId, + required this.mangaTitle, + required this.mangaCover, + required this.mangaUrl, + required this.groups, + }) : super(key: key); + + final int mangaId; + final String mangaTitle; + final String mangaCover; + final String mangaUrl; + final List groups; + + @override + State createState() => _DownloadSelectPageState(); +} + +class _DownloadSelectPageState extends State { + final _controller = ScrollController(); + var _loading = true; // fake loading flag + VoidCallback? _cancelHandler; + + var _setting = DlSetting.defaultSetting(); + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance?.addPostFrameCallback((_) async { + _getDownloadedChapters(); // get in async + Future.delayed(Duration(milliseconds: 300), () { + _loading = false; + if (mounted) setState(() {}); + }); + _setting = await DlSettingPrefs.getSetting(); + if (mounted) setState(() {}); + }); + + _cancelHandler = EventBusManager.instance.listen((event) async { + if (event.mangaId == widget.mangaId) { + await _getDownloadedChapters(); + } + }); + } + + @override + void dispose() { + _cancelHandler?.call(); + _controller.dispose(); + super.dispose(); + } + + final _selected = []; + final _downloadedChapters = []; + + Future _getDownloadedChapters() async { + var entity = await DownloadDao.getManga(mid: widget.mangaId); + _downloadedChapters.clear(); + _downloadedChapters.addAll(entity?.downloadedChapters ?? []); + if (mounted) setState(() {}); + } + + Future _downloadManga() async { + // 1. 获取需要下载的章节 + if (_selected.isEmpty) { + showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('下载'), + content: Text('请选择需要下载的章节。'), + actions: [ + TextButton( + child: Text('确定'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), + ); + return; + } + var chapterIds = []; + for (var cid in _selected) { + var oldChapter = _downloadedChapters.where((el) => el.chapterId == cid).firstOrNull; + if (oldChapter != null && oldChapter.succeeded) { + continue; // 过滤掉已下载成功的章节 + } + chapterIds.add(cid); + } + if (chapterIds.isEmpty) { + showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('下载'), + content: Text('所选章节均已下载完毕。'), + actions: [ + TextButton( + child: Text('确定'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), + ); + return; + } + + // 2. 显示下载确认 + var ok = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('下载确认'), + content: Text('确定下载所选的 ${chapterIds.length} 个章节吗?'), + actions: [ + TextButton( + child: Text('下载'), + onPressed: () => Navigator.of(c).pop(true), + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(c).pop(false), + ), + ], + ), + ); + if (ok != true) { + return; + } + + // 3. 快速构造下载任务,同步更新数据库,并入队异步等待执行 + await quickBuildDownloadMangaQueueTask( + mangaId: widget.mangaId, + mangaTitle: widget.mangaTitle, + mangaCover: widget.mangaCover, + mangaUrl: widget.mangaUrl, + chapterIds: chapterIds.toList(), + parallel: _setting.downloadPagesTogether, + invertOrder: _setting.invertDownloadOrder, + addToTask: true, + throughGroupList: widget.groups, + throughChapterList: null, + ); + + // 4. 更新界面,并显示提示 + await _getDownloadedChapters(); + _selected.clear(); + if (mounted) setState(() {}); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('已添加 ${chapterIds.length} 个章节至漫画下载任务'), + action: SnackBarAction( + label: '查看', + onPressed: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => DownloadTocPage( + mangaId: widget.mangaId, + gotoDownloading: true, + ), + settings: DownloadTocPage.buildRouteSetting( + mangaId: widget.mangaId, + ), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('下载 ${widget.mangaTitle}'), + leading: AppBarActionButton.leading(context: context), + actions: [ + AppBarActionButton( + icon: Icon(Icons.download), + tooltip: '下载', + onPressed: () => _downloadManga(), + ), + AppBarActionButton( + icon: Icon(Icons.select_all), + tooltip: '全选', + onPressed: () { + var allChapterIds = widget.groups.expand((group) => group.chapters.map((chapter) => chapter.cid)).toList(); + if (_selected.length == allChapterIds.length) { + _selected.clear(); + } else { + _selected.clear(); + _selected.addAll(allChapterIds); + } + if (mounted) setState(() {}); + }, + ), + ], + ), + body: PlaceholderText( + state: _loading ? PlaceholderState.loading : PlaceholderState.normal, + setting: PlaceholderSetting().copyWithChinese(), + childBuilder: (c) => Container( + color: Colors.white, + child: ScrollbarWithMore( + controller: _controller, + interactive: true, + crossAxisMargin: 2, + child: ListView( + controller: _controller, + padding: EdgeInsets.zero, + physics: AlwaysScrollableScrollPhysics(), + children: [ + WarningTextView( + text: '由于本应用为漫画柜第三方客户端,所以请不要连续下载过多章节,避免因短时间内访问频繁而当前IP被封禁。', + isWarning: true, + ), + MangaTocView( + groups: widget.groups, + full: true, + highlightColor: Theme.of(context).primaryColor.withOpacity(0.5), + highlightedChapters: _selected, + customBadgeBuilder: (cid) => DownloadBadge.fromEntity( + entity: _downloadedChapters.where((el) => el.chapterId == cid).firstOrNull, + ), + onChapterPressed: (cid) { + if (!_selected.contains(cid)) { + _selected.add(cid); + } else { + _selected.remove(cid); + } + if (mounted) setState(() {}); + }, + ), + ], + ), + ), + ), + ), + floatingActionButton: _loading + ? null + : ScrollAnimatedFab( + scrollController: _controller, + condition: ScrollAnimatedCondition.direction, + fab: FloatingActionButton( + child: Icon(Icons.vertical_align_top), + heroTag: null, + onPressed: () => _controller.scrollToTop(), + ), + ), + ); + } +} diff --git a/lib/page/download_toc.dart b/lib/page/download_toc.dart new file mode 100644 index 0000000..77abac4 --- /dev/null +++ b/lib/page/download_toc.dart @@ -0,0 +1,467 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:manhuagui_flutter/model/chapter.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/page/download.dart'; +import 'package:manhuagui_flutter/page/manga.dart'; +import 'package:manhuagui_flutter/page/manga_viewer.dart'; +import 'package:manhuagui_flutter/page/page/dl_finished.dart'; +import 'package:manhuagui_flutter/page/page/dl_unfinished.dart'; +import 'package:manhuagui_flutter/page/view/action_row.dart'; +import 'package:manhuagui_flutter/page/view/download_manga_line.dart'; +import 'package:manhuagui_flutter/page/view/my_drawer.dart'; +import 'package:manhuagui_flutter/service/db/download.dart'; +import 'package:manhuagui_flutter/service/db/history.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; +import 'package:manhuagui_flutter/service/evb/auth_manager.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; +import 'package:manhuagui_flutter/service/prefs/dl_setting.dart'; +import 'package:manhuagui_flutter/service/storage/download_image.dart'; +import 'package:manhuagui_flutter/service/storage/download_manga_task.dart'; +import 'package:manhuagui_flutter/service/storage/queue_manager.dart'; + +/// 章节下载管理页,查询数据库并展示 [DownloadedManga] 信息,以及展示 [DownloadMangaProgressChangedEvent] 进度信息 +class DownloadTocPage extends StatefulWidget { + const DownloadTocPage({ + Key? key, + required this.mangaId, + this.gotoDownloading = false, + }) : super(key: key); + + final int mangaId; + final bool gotoDownloading; + + @override + State createState() => _DownloadTocPageState(); + + static RouteSettings buildRouteSetting({required int mangaId}) { + return RouteSettings( + name: '/DownloadTocPage', + arguments: {'mangaId': mangaId}, + ); + } + + static bool isCurrentRoute(BuildContext context, int mangaId) { + var setting = RouteSettings(); + Navigator.popUntil(context, (route) { + setting = route.settings; + return true; + }); + + if (setting.name != '/DownloadTocPage' || setting.arguments is! Map) { + return false; + } + return (setting.arguments! as Map)['mangaId'] == mangaId; + } +} + +class _DownloadTocPageState extends State with SingleTickerProviderStateMixin { + final _refreshIndicatorKey = GlobalKey(); + late final _tabController = TabController(length: 2, vsync: this); + final _scrollController = ScrollController(); + final _cancelHandlers = []; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance?.addPostFrameCallback((_) => _refreshIndicatorKey.currentState?.show()); + WidgetsBinding.instance?.addPostFrameCallback((_) async { + if (widget.gotoDownloading) { + _tabController.animateTo(1); + } + }); + + // progress related + _cancelHandlers.add(EventBusManager.instance.listen((event) async { + if (event.mangaId != widget.mangaId) { + return; + } + if (event.finished) { + _task = null; + if (mounted) setState(() {}); + } else { + _task = QueueManager.instance.getDownloadMangaQueueTask(event.mangaId); + if (mounted) setState(() {}); + if (_task != null && (_task!.progress.stage == DownloadMangaProgressStage.waiting || _task!.progress.stage == DownloadMangaProgressStage.gotChapter)) { + getDownloadedMangaBytes(mangaId: event.mangaId).then((b) { + _byte = b; // 只有在最开始等待、以及每次获得新章节时才遍历统计文件大小 + if (mounted) setState(() {}); + }); + } + } + })); + + // entity related + _cancelHandlers.add(EventBusManager.instance.listen((event) async { + if (event.mangaId != widget.mangaId) { + return; + } + var newEntity = await DownloadDao.getManga(mid: event.mangaId); + if (newEntity != null) { + _entity = newEntity; + if (mounted) setState(() {}); + getDownloadedMangaBytes(mangaId: event.mangaId).then((b) { + _byte = b; // 在每次数据库发生变化时都遍历统计文件大小 + if (mounted) setState(() {}); + }); + } + })); + + // history related + _cancelHandlers.add(EventBusManager.instance.listen((_) async { + try { + _history = await HistoryDao.getHistory(username: AuthManager.instance.username, mid: widget.mangaId); + if (mounted) setState(() {}); + } catch (_) {} + })); + } + + @override + void dispose() { + _cancelHandlers.forEach((c) => c.call()); + _tabController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + var _loading = true; + DownloadedManga? _entity; + DownloadMangaQueueTask? _task; + var _byte = 0; + var _invertOrder = true; + MangaHistory? _history; + var _error = ''; + + Future _loadData() async { + _loading = true; + _entity = null; + _task = null; + _history = null; + if (mounted) setState(() {}); + + // 异步请求章节目录 + _getChapterGroupsAsync(forceRefresh: true); + + // 获取漫画下载记录,并更新下载任务等数据 + var data = await DownloadDao.getManga(mid: widget.mangaId); + if (data != null) { + _error = ''; + if (mounted) setState(() {}); + await Future.delayed(Duration(milliseconds: 20)); + _entity = data; + _task = QueueManager.instance.getDownloadMangaQueueTask(widget.mangaId); + getDownloadedMangaBytes(mangaId: widget.mangaId).then((b) { + _byte = b; + if (mounted) setState(() {}); + }); + _history = await HistoryDao.getHistory(username: AuthManager.instance.username, mid: widget.mangaId); + } else { + _error = '无法获取漫画下载记录'; + } + _loading = false; + if (mounted) setState(() {}); + } + + List? _chapterGroups; + + Future _getChapterGroupsAsync({bool forceRefresh = false}) async { + if (_chapterGroups != null && !forceRefresh) { + return; + } + + final client = RestClient(DioManager.instance.dio); + try { + var result = await client.getManga(mid: widget.mangaId); + _chapterGroups = result.data.chapterGroups; + } catch (e, s) { + // ignored + print('===> exception when _getChapterGroupsAsync:\n${wrapError(e, s).text}'); + } + } + + Future _startOrPause({required bool start}) async { + if (!start) { + // 暂停 => 获取最新的任务,并取消 + _task = QueueManager.instance.getDownloadMangaQueueTask(widget.mangaId); + _task?.cancel(); + return; + } + + // 开始 => 快速构造下载任务,同步更新数据库,并入队异步等待执行 + var setting = await DlSettingPrefs.getSetting(); + await quickBuildDownloadMangaQueueTask( + mangaId: _entity!.mangaId, + mangaTitle: _entity!.mangaTitle, + mangaCover: _entity!.mangaCover, + mangaUrl: _entity!.mangaUrl, + chapterIds: _entity!.downloadedChapters.map((el) => el.chapterId).toList(), + parallel: setting.downloadPagesTogether, + invertOrder: setting.invertDownloadOrder, + addToTask: true, + throughChapterList: _entity!.downloadedChapters, + ); + } + + void _readChapter(int chapterId) { + // TODO 离线阅读功能,跳过请求章节信息 + _getChapterGroupsAsync(); // 异步请求章节目录,尽量避免 MangaViewer 做多次请求 + Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaViewerPage( + mangaId: widget.mangaId, + mangaTitle: _entity!.mangaTitle, + mangaCover: _entity!.mangaCover, + mangaUrl: _entity!.mangaUrl, + chapterGroups: _chapterGroups /* nullable */, + chapterId: chapterId, + initialPage: _history?.chapterId == chapterId + ? _history?.chapterPage ?? 1 // have read + : 1, // have not read + ), + ), + ); + } + + Future _deleteChapter(DownloadedChapter entity) async { + _task = QueueManager.instance.getDownloadMangaQueueTask(widget.mangaId); + if (_task != null) { + Fluttertoast.showToast(msg: '当前仅支持在漫画暂停下载时删除章节'); + return; + } + + var setting = await DlSettingPrefs.getSetting(); + var alsoDeleteFile = setting.defaultToDeleteFiles; + await showDialog( + context: context, + builder: (c) => StatefulBuilder( + builder: (c, _setState) => AlertDialog( + title: Text('章节删除确认'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('是否删除漫画章节《${_entity!.mangaTitle}》${entity.chapterTitle}?'), + SizedBox(height: 5), + CheckboxListTile( + title: Text('同时删除已下载的文件'), + value: alsoDeleteFile, + onChanged: (v) { + alsoDeleteFile = v ?? false; + _setState(() {}); + }, + dense: false, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + ], + ), + actions: [ + TextButton( + child: Text('删除'), + onPressed: () async { + Navigator.of(c).pop(); + _entity!.downloadedChapters.remove(entity); + await DownloadDao.deleteChapter(mid: entity.mangaId, cid: entity.chapterId); + if (mounted) setState(() {}); + + var setting = await DlSettingPrefs.getSetting(); + setting = setting.copyWith(defaultToDeleteFiles: alsoDeleteFile); + await DlSettingPrefs.setSetting(setting); + if (alsoDeleteFile) { + await deleteDownloadedChapter(mangaId: entity.mangaId, chapterId: entity.chapterId); + getDownloadedMangaBytes(mangaId: entity.mangaId).then((b) { + _byte = b; + if (mounted) setState(() {}); + }); + } + }, + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('章节下载管理'), + leading: AppBarActionButton.leading(context: context, allowDrawerButton: false), + actions: [ + AppBarActionButton( + icon: Icon(Icons.list), + tooltip: '查看下载列表', + onPressed: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => DownloadPage(), + ), + ), + ), + ], + ), + drawer: MyDrawer( + currentDrawerSelection: DrawerSelection.none, + ), + body: RefreshIndicator( + key: _refreshIndicatorKey, + notificationPredicate: (n) => n.depth <= 2, + onRefresh: _loadData, + child: PlaceholderText.from( + isLoading: _loading, + errorText: _error, + isEmpty: _entity == null, + setting: PlaceholderSetting().copyWithChinese(), + onRefresh: () => _loadData(), + childBuilder: (c) => ExtendedNestedScrollView( + controller: _scrollController, + headerSliverBuilder: (context, _) => [ + SliverToBoxAdapter( + child: Column( + children: [ + // **************************************************************** + // 漫画下载信息头部 + // **************************************************************** + Container( + color: Colors.white, + child: LargeDownloadMangaLineView( + mangaEntity: _entity!, + downloadTask: _task, + downloadedBytes: _byte, + ), + ), + Container(height: 12), + // **************************************************************** + // 四个按钮 + // **************************************************************** + Container( + color: Colors.white, + child: ActionRowView.four( + action1: ActionItem.simple( + '查看漫画', + Icons.description, + () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaPage( + id: widget.mangaId, + title: _entity!.mangaTitle, + url: _entity!.mangaUrl, + ), + ), + ), + ), + action2: ActionItem.simple( + _invertOrder ? '倒序显示' : '正序显示', + _invertOrder ? Icons.arrow_downward : Icons.arrow_upward, + () => mountedSetState(() => _invertOrder = !_invertOrder), + ), + action3: ActionItem.simple( + '开始下载', + Icons.play_arrow, + () => _startOrPause(start: true), + ), + action4: ActionItem.simple( + '暂停下载', + Icons.pause, + () => _startOrPause(start: false), + ), + ), + ), + Container(height: 12), + ], + ), + ), + SliverOverlapAbsorber( + handle: ExtendedNestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: SliverPersistentHeader( + pinned: true, + floating: true, + delegate: SliverHeaderDelegate( + child: PreferredSize( + preferredSize: Size.fromHeight(36.0), + child: Material( + color: Colors.white, + elevation: 2, + child: Center( + child: TabBar( + controller: _tabController, + labelColor: Theme.of(context).primaryColor, + unselectedLabelColor: Colors.grey[600], + indicatorColor: Theme.of(context).primaryColor, + isScrollable: true, + indicatorSize: TabBarIndicatorSize.label, + tabs: const [ + SizedBox(height: 36.0, child: Center(child: Text('已完成'))), + SizedBox(height: 36.0, child: Center(child: Text('未完成'))), + ], + ), + ), + ), + ), + ), + ), + ), + ], + innerControllerCount: _tabController.length, + activeControllerIndex: _tabController.index, + bodyBuilder: (c, controllers) => TabBarView( + controller: _tabController, + children: [ + // **************************************************************** + // 已下载的章节 + // **************************************************************** + DlFinishedSubPage( + innerController: controllers[0], + outerController: _scrollController, + injectorHandler: ExtendedNestedScrollView.sliverOverlapAbsorberHandleFor(c), + mangaEntity: _entity!, + invertOrder: _invertOrder, + history: _history, + toReadChapter: _readChapter, + toDeleteChapter: (cid) async { + var chapterEntity = _entity!.downloadedChapters.where((el) => el.chapterId == cid).firstOrNull; + if (chapterEntity != null) { + await _deleteChapter(chapterEntity); + } + }, + ), + // **************************************************************** + // 未完成下载(正在下载/下载失败)的章节 + // **************************************************************** + DlUnfinishedSubPage( + innerController: controllers[1], + outerController: _scrollController, + injectorHandler: ExtendedNestedScrollView.sliverOverlapAbsorberHandleFor(c), + mangaEntity: _entity!, + downloadTask: _task, + invertOrder: _invertOrder, + toControlChapter: (cid) { + Fluttertoast.showToast(msg: '目前暂不支持单独下载或暂停某一章节'); // TODO 单个漫画下载特定章节/按照特定顺序下载 + }, + toReadChapter: _readChapter, + toDeleteChapter: (cid) async { + var chapterEntity = _entity!.downloadedChapters.where((el) => el.chapterId == cid).firstOrNull; + if (chapterEntity != null) { + await _deleteChapter(chapterEntity); + } + }, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/page/genre.dart b/lib/page/genre.dart index 92511dd..2bdd805 100644 --- a/lib/page/genre.dart +++ b/lib/page/genre.dart @@ -1,15 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/model/category.dart'; import 'package:manhuagui_flutter/page/page/genre.dart'; -/// 类别 -/// Page for [TinyCategory]. +/// 漫画类别页,同 [GenreSubPage] class GenrePage extends StatefulWidget { const GenrePage({ - Key key, - @required this.genre, - }) : assert(genre != null), - super(key: key); + Key? key, + required this.genre, + }) : super(key: key); final TinyCategory genre; @@ -22,9 +21,8 @@ class _GenrePageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, - toolbarHeight: 45, - title: Text('漫画分类'), + title: Text('漫画类别'), + leading: AppBarActionButton.leading(context: context), ), body: GenreSubPage( defaultGenre: widget.genre, diff --git a/lib/page/image_viewer.dart b/lib/page/image_viewer.dart new file mode 100644 index 0000000..c736de1 --- /dev/null +++ b/lib/page/image_viewer.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:manhuagui_flutter/config.dart'; +import 'package:manhuagui_flutter/page/view/image_load.dart'; +import 'package:manhuagui_flutter/service/native/system_ui.dart'; +import 'package:manhuagui_flutter/service/storage/download_image.dart'; +import 'package:photo_view/photo_view.dart'; + +class ImageViewerPage extends StatefulWidget { + const ImageViewerPage({ + Key? key, + required this.url, + required this.title, + }) : super(key: key); + + final String url; + final String title; + + @override + State createState() => _ImageViewerPageState(); +} + +class _ImageViewerPageState extends State { + final _cache = DefaultCacheManager(); + final _notifier = ValueNotifier(''); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance?.addPostFrameCallback((_) { + setSystemUIOverlayStyle( + navigationBarIconBrightness: Brightness.light, + navigationBarColor: Colors.black, + navigationBarDividerColor: Colors.black, + ); + }); + } + + String get url { + var url = widget.url; + if (url.startsWith('//')) { + url = 'https:$url'; + } + return url; + } + + void _reload() { + _notifier.value = DateTime.now().microsecondsSinceEpoch.toString(); + } + + Future _download(String url) async { + var f = await downloadImageToGallery(url); + if (f != null) { + Fluttertoast.showToast(msg: '图片已保存至 ${f.path}'); + } else { + Fluttertoast.showToast(msg: '无法保存图片'); + } + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + setDefaultSystemUIOverlayStyle(); + return true; + }, + child: Scaffold( + appBar: AppBar( + title: Text(widget.title), + leading: AppBarActionButton.leading(context: context), + actions: [ + AppBarActionButton( + icon: Icon(Icons.refresh), + tooltip: '重新加载', + onPressed: () => _reload(), + ), + AppBarActionButton( + icon: Icon(Icons.file_download), + tooltip: '下載图片', + onPressed: () => _download(url), + ), + ], + ), + body: Container( + decoration: BoxDecoration( + color: Colors.black, + border: Border.all(color: Colors.black), + ), + constraints: BoxConstraints.expand( + height: MediaQuery.of(context).size.height, + ), + child: ValueListenableBuilder( + valueListenable: _notifier, + builder: (_, v, __) => PhotoView( + key: ValueKey(v), + imageProvider: LocalOrCachedNetworkImageProvider.fromNetwork( + key: ValueKey(v), + url: url, + cacheManager: _cache, + headers: { + 'User-Agent': USER_AGENT, + 'Referer': REFERER, + }, + ), + initialScale: PhotoViewComputedScale.contained / 2, + minScale: PhotoViewComputedScale.contained / 2, + maxScale: PhotoViewComputedScale.covered * 2, + filterQuality: FilterQuality.high, + loadingBuilder: (_, ev) => ImageLoadingView( + title: '', + event: ev, + ), + errorBuilder: (_, err, __) => ImageLoadFailedView( + title: '', + error: err, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/page/index.dart b/lib/page/index.dart index 1637206..8b084c9 100644 --- a/lib/page/index.dart +++ b/lib/page/index.dart @@ -1,60 +1,67 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:manhuagui_flutter/page/page/category.dart'; import 'package:manhuagui_flutter/page/page/home.dart'; import 'package:manhuagui_flutter/page/page/mine.dart'; import 'package:manhuagui_flutter/page/page/subscribe.dart'; -import 'package:manhuagui_flutter/service/auth/auth.dart'; +import 'package:manhuagui_flutter/page/view/my_drawer.dart'; +import 'package:manhuagui_flutter/service/evb/auth_manager.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; +import 'package:manhuagui_flutter/service/native/notification.dart'; import 'package:permission_handler/permission_handler.dart'; /// 主页 class IndexPage extends StatefulWidget { - const IndexPage({Key key}) : super(key: key); + const IndexPage({Key? key}) : super(key: key); @override _IndexPageState createState() => _IndexPageState(); } -class _IndexPageState extends State { - PageController _controller; +class _IndexPageState extends State with SingleTickerProviderStateMixin { + late final _controller = TabController(length: 4, vsync: this); var _selectedIndex = 0; - DateTime _lastBackPressedTime; - var _tabs = >[ - Tuple2('首页', Icons.home), - Tuple2('分类', Icons.category), - Tuple2('订阅', Icons.notifications), - Tuple2('我的', Icons.person), + late final _actions = List.generate(4, (_) => ActionController()); + late final _tabs = [ + Tuple3('首页', Icons.home, HomeSubPage(action: _actions[0])), + Tuple3('分类', Icons.category, CategorySubPage(action: _actions[1])), + Tuple3('订阅', Icons.notifications, SubscribeSubPage(action: _actions[2])), + Tuple3('我的', Icons.person, MineSubPage(action: _actions[3])), ]; - var _actions = []; - var _pages = []; + final _cancelHandlers = []; @override void initState() { super.initState(); - _controller = PageController(); - _actions = List.generate(_tabs.length, (_) => ActionController()); - _pages = [ - HomeSubPage(action: _actions[0]), - CategorySubPage(action: _actions[1]), - SubscribeSubPage(action: _actions[2]), - MineSubPage(action: _actions[3]), - ]; - - WidgetsBinding.instance.addPostFrameCallback((_) => checkAuth()); - _actions[0].addAction('to_shelf', () { - _controller.animateToPage(2, duration: kTabScrollDuration, curve: Curves.easeOutQuad); - _actions[2].invoke('to_shelf'); + WidgetsBinding.instance?.addPostFrameCallback((_) async { + var ok = await _checkPermission(); + if (!ok) { + Fluttertoast.showToast(msg: '权限授予失败,Manhuagui 即将退出'); + SystemNavigator.pop(); + } }); - _actions[0].addAction('to_genre', () { - _controller.animateToPage(1, duration: kTabScrollDuration, curve: Curves.easeOutQuad); - _actions[1].invoke('to_genre'); + WidgetsBinding.instance?.addPostFrameCallback((_) async { + var r = await AuthManager.instance.check(); + if (!r.logined && r.error != null) { + Fluttertoast.showToast(msg: '无法检查登录状态:${r.error!.text}'); + } }); + WidgetsBinding.instance?.addPostFrameCallback((_) async { + NotificationManager.instance.registerContext(context); + }); + _cancelHandlers.add(EventBusManager.instance.listen((ev) => _jumpToPageByEvent(2, ev))); + _cancelHandlers.add(EventBusManager.instance.listen((ev) => _jumpToPageByEvent(2, ev))); + _cancelHandlers.add(EventBusManager.instance.listen((ev) => _jumpToPageByEvent(1, ev))); + _cancelHandlers.add(EventBusManager.instance.listen((ev) => _jumpToPageByEvent(0, ev))); + _cancelHandlers.add(EventBusManager.instance.listen((ev) => _jumpToPageByEvent(0, ev))); } @override void dispose() { + _cancelHandlers.forEach((h) => h.call()); _controller.dispose(); _actions.forEach((a) => a.dispose()); super.dispose(); @@ -68,57 +75,77 @@ class _IndexPageState extends State { return true; } - Future _onWillPop() async { + Future _jumpToPageByEvent(int index, T event) async { + _controller.animateTo(index); + if (_selectedIndex != index) { + // need to wait for animating, and then re-fire event (only fire twice in total) + await Future.delayed(_controller.animationDuration); + EventBusManager.instance.fire(event); + } + _selectedIndex = index; + if (mounted) setState(() {}); + } + + DateTime? _lastBackPressedTime; + + Future _onWillPop() { DateTime now = DateTime.now(); - if (_lastBackPressedTime == null || now.difference(_lastBackPressedTime) > Duration(seconds: 2)) { + if (_lastBackPressedTime == null || now.difference(_lastBackPressedTime!) > Duration(seconds: 2)) { _lastBackPressedTime = now; - Fluttertoast.showToast(msg: '再按一次退出'); - return false; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('再按一次退出'), + duration: Duration(seconds: 2), + action: SnackBarAction( + label: '退出', + onPressed: () => SystemNavigator.pop(), + ), + ), + ); + return Future.value(false); } - return true; + return Future.value(true); } @override Widget build(BuildContext context) { - _checkPermission().then((ok) { - if (!ok) { - Fluttertoast.showToast(msg: '权限授予失败,退出应用'); - SystemNavigator.pop(); - } - }); - return WillPopScope( onWillPop: _onWillPop, child: Scaffold( - body: PageView.builder( + drawer: MyDrawer( + currentDrawerSelection: DrawerSelection.home, + ), + body: TabBarView( physics: NeverScrollableScrollPhysics(), controller: _controller, - onPageChanged: (index) { - _selectedIndex = index; - if (mounted) setState(() {}); - }, - itemCount: _tabs.length, - itemBuilder: (_, idx) => _pages[idx], + children: _tabs.map((t) => t.item3).toList(), ), - bottomNavigationBar: BottomNavigationBar( - type: BottomNavigationBarType.fixed, - currentIndex: _selectedIndex, - items: _tabs - .map( - (t) => BottomNavigationBarItem( - label: t.item1, - icon: Icon(t.item2), - ), - ) - .toList(), - onTap: (index) async { - if (_selectedIndex == index) { - _actions[_selectedIndex].invoke(''); - } else { - _controller.animateToPage(index, duration: kTabScrollDuration, curve: Curves.easeOutQuad); - if (mounted) setState(() {}); - } - }, + bottomNavigationBar: Theme( + data: Theme.of(context).copyWith( + highlightColor: null, + splashColor: Colors.transparent, + ), + child: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + currentIndex: _selectedIndex, + items: _tabs + .map( + (t) => BottomNavigationBarItem( + label: t.item1, + icon: Icon(t.item2), + ), + ) + .toList(), + onTap: (index) async { + if (_selectedIndex == index) { + _actions[_selectedIndex].invoke(); + } else { + _controller.animateTo(index); + _selectedIndex = index; + if (mounted) setState(() {}); + } + }, + ), ), ), ); diff --git a/lib/page/login.dart b/lib/page/login.dart index 3d3c07d..d2694c0 100644 --- a/lib/page/login.dart +++ b/lib/page/login.dart @@ -1,17 +1,18 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:manhuagui_flutter/config.dart'; -import 'package:manhuagui_flutter/service/natives/browser.dart'; +import 'package:manhuagui_flutter/service/evb/auth_manager.dart'; +import 'package:manhuagui_flutter/service/native/browser.dart'; import 'package:manhuagui_flutter/service/prefs/auth.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; -import 'package:manhuagui_flutter/service/state/auth.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; -/// 登录 +/// 登录页 class LoginPage extends StatefulWidget { - const LoginPage({Key key}) : super(key: key); + const LoginPage({Key? key}) : super(key: key); @override _LoginPageState createState() => _LoginPageState(); @@ -24,6 +25,7 @@ class _LoginPageState extends State { final _suggestionController = SuggestionsBoxController(); var _passwordVisible = false; var _logining = false; + var _rememberUsername = true; var _rememberPassword = false; var _usernamePasswordPairs = >[]; @@ -31,11 +33,11 @@ class _LoginPageState extends State { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) async { - var remTuple = await getRememberOptions(); + WidgetsBinding.instance?.addPostFrameCallback((_) async { + var remTuple = await AuthPrefs.getRememberOption(); _rememberUsername = remTuple.item1; _rememberPassword = remTuple.item2; - _usernamePasswordPairs = await getUsernamePasswordPairs(); + _usernamePasswordPairs = await AuthPrefs.getUsernamePasswordPairs(); if (_usernamePasswordPairs.isNotEmpty) { var currentUser = _usernamePasswordPairs.first; _usernameController.text = currentUser.item1; @@ -52,8 +54,8 @@ class _LoginPageState extends State { super.dispose(); } - void _login() async { - if (!_formKey.currentState.validate()) { + Future _login() async { + if (_formKey.currentState?.validate() != true) { return; } @@ -62,38 +64,33 @@ class _LoginPageState extends State { var username = _usernameController.text.trim(); var password = _passwordController.text.trim(); - var dio = DioManager.instance.dio; - var client = RestClient(dio); - ErrorMessage err; - var result = await client.login(username: username, password: password).catchError((e) { - err = wrapError(e); - }).whenComplete(() { + final client = RestClient(DioManager.instance.dio); + String token; + try { + var result = await client.login(username: username, password: password); + token = result.data.token; + } catch (e, s) { + Fluttertoast.showToast(msg: wrapError(e, s).text); + return; + } finally { _logining = false; if (mounted) setState(() {}); - }); - if (err != null) { - Fluttertoast.showToast(msg: err.text); - return; } // state - var token = result.data.token; Fluttertoast.showToast(msg: '$username 登录成功'); - AuthState.instance.token = token; - AuthState.instance.username = username; - AuthState.instance.notifyAll(); + AuthManager.instance.record(username: username, token: token); + AuthManager.instance.notify(logined: true); // prefs - setToken(token); - await setRememberOptions(_rememberUsername, _rememberPassword); + await AuthPrefs.setToken(token); + await AuthPrefs.setRememberOption(_rememberUsername, _rememberPassword); if (!_rememberUsername) { - await removeUsernamePasswordPair(username); + await AuthPrefs.removeUsernamePasswordPair(username); + } else if (_rememberPassword) { + await AuthPrefs.addUsernamePasswordPair(username, password); } else { - if (_rememberPassword) { - await addUsernamePasswordPair(username, password); - } else { - await addUsernamePasswordPair(username, ''); - } + await AuthPrefs.addUsernamePasswordPair(username, ''); } // pop @@ -105,12 +102,30 @@ class _LoginPageState extends State { return Scaffold( appBar: AppBar( title: Text('账号登录'), - centerTitle: true, - toolbarHeight: 48, + leading: AppBarActionButton.leading(context: context), actions: [ - IconButton( + AppBarActionButton( icon: Text('注册'), - onPressed: () => launchInBrowser(context: context, url: REGISTER_URL), + onPressed: () => showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('用户注册'), + content: Text('是否跳转到 Manhuagui 官网来注册?'), + actions: [ + TextButton( + child: Text('跳转'), + onPressed: () => launchInBrowser( + context: context, + url: REGISTER_URL, + ), + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), + ), ), ], ), @@ -123,29 +138,6 @@ class _LoginPageState extends State { Padding( padding: EdgeInsets.only(left: 15, right: 15, top: 10), child: TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - autofocus: true, - controller: _usernameController, - enabled: !_logining, - decoration: InputDecoration( - contentPadding: EdgeInsets.symmetric(vertical: 5), - labelText: '用户名', - hintText: '请输入用户名', - icon: Icon(Icons.person), - ), - ), - validator: (value) => value.trim().isEmpty ? '用户名不能为空' : null, - hideOnLoading: true, - hideOnEmpty: true, - hideOnError: true, - hideSuggestionsOnKeyboardHide: true, - suggestionsBoxVerticalOffset: 5, - suggestionsBoxDecoration: SuggestionsBoxDecoration( - offsetX: 40, - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width - 70, - ), - ), suggestionsBoxController: _suggestionController, suggestionsCallback: (pattern) => _usernamePasswordPairs.map((e) => e.item1).where((t) => t.contains(pattern)), onSuggestionSelected: (_) {}, @@ -171,16 +163,16 @@ class _LoginPageState extends State { title: Text('删除登录记录'), content: Text('确定要删除 $username 吗?'), actions: [ - FlatButton( + TextButton( child: Text('删除'), onPressed: () async { Navigator.of(c).pop(); _usernamePasswordPairs.removeWhere((t) => t.item1 == username); - await removeUsernamePasswordPair(username); + await AuthPrefs.removeUsernamePasswordPair(username); if (mounted) setState(() {}); }, ), - FlatButton( + TextButton( child: Text('取消'), onPressed: () => Navigator.of(c).pop(), ), @@ -188,6 +180,29 @@ class _LoginPageState extends State { ), ), ), + validator: (value) => value?.trim().isNotEmpty != true ? '用户名不能为空' : null, + textFieldConfiguration: TextFieldConfiguration( + controller: _usernameController, + autofocus: true, + enabled: !_logining, + decoration: InputDecoration( + contentPadding: EdgeInsets.symmetric(vertical: 5), + labelText: '用户名', + hintText: '请输入用户名', + icon: Icon(Icons.person), + ), + ), + hideOnLoading: true, + hideOnEmpty: true, + hideOnError: true, + hideSuggestionsOnKeyboardHide: true, + suggestionsBoxVerticalOffset: 5, + suggestionsBoxDecoration: SuggestionsBoxDecoration( + offsetX: 40, + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width - 70, + ), + ), ), ), Padding( @@ -212,7 +227,7 @@ class _LoginPageState extends State { }, ), ), - validator: (value) => value.trim().isEmpty ? '密码不能为空' : null, + validator: (value) => value?.trim().isNotEmpty != true ? '密码不能为空' : null, ), ), Padding( @@ -224,7 +239,7 @@ class _LoginPageState extends State { children: [ Checkbox( value: _rememberUsername, - onChanged: _logining ? null : (b) => mountedSetState(() => _rememberUsername = b), + onChanged: _logining ? null : (b) => mountedSetState(() => _rememberUsername = b ?? true), ), Text( '记住账号', @@ -243,7 +258,7 @@ class _LoginPageState extends State { children: [ Checkbox( value: _rememberUsername && _rememberPassword, - onChanged: (_logining ?? !_rememberUsername) ? null : (b) => mountedSetState(() => _rememberPassword = b), + onChanged: (_logining) ? null : (b) => mountedSetState(() => _rememberPassword = b ?? false), ), Text( '记住密码', @@ -254,7 +269,7 @@ class _LoginPageState extends State { ), ), Padding( - padding: EdgeInsets.only(top: 2), + padding: EdgeInsets.only(top: 12), child: !_logining ? SizedBox( height: 42, diff --git a/lib/page/manga.dart b/lib/page/manga.dart index 2bee156..5df327a 100644 --- a/lib/page/manga.dart +++ b/lib/page/manga.dart @@ -1,37 +1,44 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:flutter_rating_bar/flutter_rating_bar.dart'; import 'package:fluttertoast/fluttertoast.dart'; +import 'package:manhuagui_flutter/model/chapter.dart'; import 'package:manhuagui_flutter/model/comment.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; import 'package:manhuagui_flutter/model/manga.dart'; -import 'package:manhuagui_flutter/model/result.dart'; import 'package:manhuagui_flutter/page/author.dart'; -import 'package:manhuagui_flutter/page/chapter.dart'; +import 'package:manhuagui_flutter/page/download_select.dart'; import 'package:manhuagui_flutter/page/genre.dart'; -import 'package:manhuagui_flutter/page/manga_comment.dart'; +import 'package:manhuagui_flutter/page/comments.dart'; +import 'package:manhuagui_flutter/page/image_viewer.dart'; import 'package:manhuagui_flutter/page/manga_detail.dart'; -import 'package:manhuagui_flutter/page/view/chapter_group.dart'; +import 'package:manhuagui_flutter/page/manga_toc.dart'; +import 'package:manhuagui_flutter/page/manga_viewer.dart'; +import 'package:manhuagui_flutter/page/view/action_row.dart'; +import 'package:manhuagui_flutter/page/view/full_ripple.dart'; +import 'package:manhuagui_flutter/page/view/manga_toc.dart'; import 'package:manhuagui_flutter/page/view/comment_line.dart'; +import 'package:manhuagui_flutter/page/view/my_drawer.dart'; import 'package:manhuagui_flutter/page/view/network_image.dart'; -import 'package:manhuagui_flutter/service/database/history.dart'; -import 'package:manhuagui_flutter/service/natives/browser.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; -import 'package:manhuagui_flutter/service/state/auth.dart'; +import 'package:manhuagui_flutter/service/db/download.dart'; +import 'package:manhuagui_flutter/service/db/history.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; +import 'package:manhuagui_flutter/service/evb/auth_manager.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; +import 'package:manhuagui_flutter/service/native/browser.dart'; +import 'package:manhuagui_flutter/service/native/share.dart'; -/// 漫画页 -/// Page for [Manga]. +/// 漫画页,网络请求并展示 [Manga] 和 [Comment] 信息 class MangaPage extends StatefulWidget { const MangaPage({ - Key key, - @required this.id, - @required this.title, - @required this.url, - }) : assert(id != null), - assert(title != null), - assert(url != null), - super(key: key); + Key? key, + required this.id, + required this.title, + required this.url, + }) : super(key: key); final int id; final String title; @@ -42,184 +49,268 @@ class MangaPage extends StatefulWidget { } class _MangaPageState extends State { - final _indicatorKey = GlobalKey(); + final _refreshIndicatorKey = GlobalKey(); final _controller = ScrollController(); final _fabController = AnimatedFabController(); - final _action = ActionController(); - var _loading = true; - Manga _data; - var _error = ''; - var _subscribing = false; - bool _subscribed; - MangaHistory _history; - var _showBriefIntroduction = true; - var _commentLoading = true; - var _commentError = ''; - var _comments = []; - int _commentTotal; + final _cancelHandlers = []; + AuthData? _oldAuthData; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _indicatorKey?.currentState?.show()); - _action.addAction('history', () async { - _history = await getHistory(username: AuthState.instance.username, mid: widget.id).catchError((_) {}); - if (mounted) setState(() {}); + WidgetsBinding.instance?.addPostFrameCallback((_) => _refreshIndicatorKey.currentState?.show()); + WidgetsBinding.instance?.addPostFrameCallback((_) async { + _cancelHandlers.add(AuthManager.instance.listen(() => _oldAuthData, (_) async { + _oldAuthData = AuthManager.instance.authData; + _history = null; + _refreshIndicatorKey.currentState?.show(); + })); + await AuthManager.instance.check(); }); + _cancelHandlers.add(EventBusManager.instance.listen((_) => _loadHistory())); + _cancelHandlers.add(EventBusManager.instance.listen((_) => _loadDownload())); + _cancelHandlers.add(EventBusManager.instance.listen((e) { + if (e.mangaId == widget.id) { + _subscribed = e.subscribe; + if (mounted) setState(() {}); + } + })); } @override void dispose() { + _cancelHandlers.forEach((c) => c.call()); _controller.dispose(); _fabController.dispose(); - _action.dispose(); super.dispose(); } + var _loading = true; + Manga? _data; + var _error = ''; + MangaHistory? _history; + DownloadedManga? _downloadEntity; + + int? _subscribeCount; + var _subscribing = false; + var _subscribed = false; + var _showBriefIntroduction = true; + Future _loadData() async { _loading = true; - _commentLoading = true; _data = null; - _comments = []; if (mounted) setState(() {}); - var dio = DioManager.instance.dio; - var client = RestClient(dio); - if (AuthState.instance.logined) { - client.checkShelfMangas(token: AuthState.instance.token, mid: widget.id).then((r) { - _subscribed = r.data.isIn; - if (mounted) setState(() {}); - }).catchError((_) {}); + final client = RestClient(DioManager.instance.dio); + + // 1. 异步加载漫画评论首页 + _getComments(); + + // 2. 异步获取漫画订阅信息 + if (AuthManager.instance.logined) { + Future.microtask(() async { + try { + var r = await client.checkShelfManga(token: AuthManager.instance.token, mid: widget.id); + _subscribed = r.data.isIn; + _subscribeCount = r.data.count; + if (mounted) setState(() {}); + } catch (e, s) { + if (_error.isEmpty) { + Fluttertoast.showToast(msg: wrapError(e, s).text); + } + } + }); } - client.getMangaComments(mid: widget.id, page: 1).then((r) async { - _commentError = ''; - _comments = r.data.data; - _commentTotal = r.data.total; - }).catchError((e) { - _commentTotal = 0; - _comments.clear(); - _commentError = wrapError(e).text; - }).whenComplete(() { - _commentLoading = false; - if (mounted) setState(() {}); - }); + // 3. 异步获取下载信息 + _loadDownload(); - return client.getManga(mid: widget.id).then((r) async { - _error = ''; + try { + // 4. 获取漫画信息 + var result = await client.getManga(mid: widget.id); _data = null; + _error = ''; if (mounted) setState(() {}); await Future.delayed(Duration(milliseconds: 20)); - _data = r.data; + _data = result.data; - // <<< - _history = await getHistory(username: AuthState.instance.username, mid: widget.id).catchError((_) {}); // 可能已经开始阅读,也可能还没访问 - if (mounted) setState(() {}); - if (_history?.read != true) { - addHistory( - username: AuthState.instance.username, - history: MangaHistory( - mangaId: _data.mid, - mangaTitle: _data.title ?? '?', - mangaCover: _data.cover ?? '?', - mangaUrl: _data.url ?? '', - chapterId: 0, - // 还没开始阅读 + // 5. 更新漫画阅读历史 + await _loadHistory(); + var newHistory = _history?.copyWith( + mangaId: _data!.mid, + mangaTitle: _data!.title, + mangaCover: _data!.cover, + mangaUrl: _data!.url, + lastTime: _history?.read == true ? _history!.lastTime : DateTime.now(), // 只有未阅读过才修改时间 + ) ?? + MangaHistory( + mangaId: _data!.mid, + mangaTitle: _data!.title, + mangaCover: _data!.cover, + mangaUrl: _data!.url, + chapterId: 0 /* 未开始阅读 */, chapterTitle: '', - chapterPage: 0, - ), - ).catchError((_) {}); - } else { - updateHistory( - username: AuthState.instance.username, - history: MangaHistory( - mangaId: _data.mid, - mangaTitle: _data.title ?? '?', - mangaCover: _data.cover ?? '?', - mangaUrl: _data.url ?? '', - ), - ); + chapterPage: 1, + lastTime: DateTime.now(), + ); + if (_history == null || !newHistory.equals(_history!)) { + _history = newHistory; + await HistoryDao.addOrUpdateHistory(username: AuthManager.instance.username, history: _history!); + EventBusManager.instance.fire(HistoryUpdatedEvent()); } - }).catchError((e) { + } catch (e, s) { _data = null; - _error = wrapError(e).text; - }).whenComplete(() { + _error = wrapError(e, s).text; + } finally { _loading = false; if (mounted) setState(() {}); - }); + } + } + + Future _loadHistory() async { + _history = await HistoryDao.getHistory(username: AuthManager.instance.username, mid: widget.id); + if (mounted) setState(() {}); + } + + Future _loadDownload() async { + _downloadEntity = await DownloadDao.getManga(mid: widget.id); + if (mounted) setState(() {}); + } + + var _commentLoading = true; + final _comments = []; + var _commentError = ''; + var _commentTotal = 0; + + Future _getComments() async { + _commentLoading = true; + _comments.clear(); + _commentTotal = 0; + if (mounted) setState(() {}); + + final client = RestClient(DioManager.instance.dio); + try { + var result = await client.getMangaComments(mid: widget.id, page: 1); + _comments.addAll(result.data.data); + _commentError = ''; + _commentTotal = result.data.total; + } catch (e, s) { + _comments.clear(); + _commentError = wrapError(e, s).text; + } finally { + _commentLoading = false; + if (mounted) setState(() {}); + } } - void _subscribe() { - if (!AuthState.instance.logined) { + Future _subscribe() async { + if (!AuthManager.instance.logined) { Fluttertoast.showToast(msg: '用户未登录'); return; } - var dio = DioManager.instance.dio; - var client = RestClient(dio); + final client = RestClient(DioManager.instance.dio); var toSubscribe = _subscribed != true; // 去订阅 - - Future result; - if (toSubscribe) { - result = client.addToShelf(token: AuthState.instance.token, mid: widget.id); - } else { - result = client.removeFromShelf(token: AuthState.instance.token, mid: widget.id); + if (!toSubscribe) { + var ok = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('取消订阅确认'), + content: Text('是否取消订阅《${_data!.title}》?'), + actions: [ + TextButton( + child: Text('确定'), + onPressed: () => Navigator.of(context).pop(true), + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(context).pop(false), + ), + ], + ), + ); + if (ok != true) { + return; + } } _subscribing = true; if (mounted) setState(() {}); - result.then((r) { + + try { + await (toSubscribe ? client.addToShelf : client.removeFromShelf)(token: AuthManager.instance.token, mid: _data!.mid); _subscribed = toSubscribe; - Fluttertoast.showToast(msg: toSubscribe ? '订阅成功' : '取消订阅成功'); - if (mounted) setState(() {}); - }).catchError((e) { - var err = wrapError(e).text; - Fluttertoast.showToast(msg: toSubscribe ? '订阅失败,$err' : '取消订阅失败,$err'); - }).whenComplete(() { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(toSubscribe ? '订阅漫画成功' : '取消订阅漫画成功'), + ), + ); + EventBusManager.instance.fire(SubscribeUpdatedEvent(mangaId: _data!.mid, subscribe: _subscribed)); + } catch (e, s) { + var err = wrapError(e, s).text; + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(toSubscribe ? '订阅漫画失败,$err' : '取消订阅漫画失败,$err'), + ), + ); + } finally { _subscribing = false; if (mounted) setState(() {}); - }); + } } - void _read() async { + void _read({required int? chapterId}) async { + if (chapterId != null) { + // 选择章节阅读 + Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaViewerPage( + mangaId: _data!.mid, + mangaTitle: _data!.title, + mangaCover: _data!.cover, + mangaUrl: _data!.url, + chapterGroups: _data!.chapterGroups, + chapterId: chapterId, + initialPage: _history?.chapterId == chapterId + ? _history?.chapterPage ?? 1 // have read + : 1, // have not read + ), + ), + ); + return; + } + + // 开始阅读 / 继续阅读 int cid; int page; if (_history?.read != true) { - // 开始阅读 - if (_data.chapterGroups.length == 0) { + // 未访问 or 未开始阅读 => 开始阅读 + var group = _data!.chapterGroups.getFirstNotEmptyGroup(); // 首要选【单话】分组,否则选首个拥有非空章节的分组 + if (group == null) { Fluttertoast.showToast(msg: '该漫画还没有章节,无法开始阅读'); return; } - var sGroup = _data.chapterGroups.first; - var specificGroups = _data.chapterGroups.where((g) => g.title == '单话'); - if (specificGroups.length != 0) { - sGroup = specificGroups.first; - } - if (sGroup.chapters.length == 0) { - var specificGroups = _data.chapterGroups.where((g) => g.chapters.length != 0); - if (specificGroups.length == 0) { - Fluttertoast.showToast(msg: '该漫画还没有章节,无法开始阅读'); - return; - } - sGroup = specificGroups.first; - } - cid = sGroup.chapters.last.cid; + cid = group.chapters.last.cid; page = 1; } else { // 继续阅读 - cid = _history.chapterId; - page = _history.chapterPage; + cid = _history!.chapterId; + page = _history!.chapterPage; } Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => ChapterPage( - action: _action, - mid: _data.mid, - mangaTitle: _data.title, - mangaCover: _data.cover, - mangaUrl: _data.url, - cid: cid, + CustomPageRoute( + context: context, + builder: (c) => MangaViewerPage( + mangaId: _data!.mid, + mangaTitle: _data!.title, + mangaCover: _data!.cover, + mangaUrl: _data!.url, + chapterGroups: _data!.chapterGroups, + chapterId: cid, initialPage: page, ), ), @@ -230,13 +321,12 @@ class _MangaPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, - toolbarHeight: 45, title: Text(_data?.title ?? widget.title), + leading: AppBarActionButton.leading(context: context, allowDrawerButton: false), actions: [ - IconButton( + AppBarActionButton( icon: Icon(Icons.open_in_browser), - tooltip: '打开浏览器', + tooltip: '用浏览器打开', onPressed: () => launchInBrowser( context: context, url: _data?.url ?? widget.url, @@ -244,18 +334,25 @@ class _MangaPageState extends State { ), ], ), + drawer: MyDrawer( + currentDrawerSelection: DrawerSelection.none, + ), body: RefreshIndicator( - key: _indicatorKey, + key: _refreshIndicatorKey, onRefresh: _loadData, child: PlaceholderText.from( isLoading: _loading, errorText: _error, isEmpty: _data == null, - setting: PlaceholderSetting().toChinese(), + setting: PlaceholderSetting().copyWithChinese(), onRefresh: () => _loadData(), - childBuilder: (c) => Scrollbar( + childBuilder: (c) => ScrollbarWithMore( + controller: _controller, + interactive: true, + crossAxisMargin: 2, child: ListView( controller: _controller, + padding: EdgeInsets.zero, physics: AlwaysScrollableScrollPhysics(), children: [ // **************************************************************** @@ -263,145 +360,137 @@ class _MangaPageState extends State { // **************************************************************** Container( width: MediaQuery.of(context).size.width, - height: 180, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - stops: [0, 0.5, 1], + stops: const [0, 0.5, 1], colors: [ - Colors.blue[100], - Colors.orange[100], - Colors.purple[100], + Colors.blue[100]!, + Colors.orange[100]!, + Colors.purple[100]!, ], ), ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ // **************************************************************** // 封面 // **************************************************************** Container( padding: EdgeInsets.symmetric(horizontal: 14, vertical: 10), - child: NetworkImageView( - url: _data.cover, - height: 160, - width: 120, - fit: BoxFit.cover, + child: FullRippleWidget( + child: NetworkImageView( + url: _data!.cover, + height: 160, + width: 120, + ), + onTap: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => ImageViewerPage( + url: _data!.cover, + title: '漫画封面', + ), + ), + ), ), ), // **************************************************************** // 信息 // **************************************************************** Container( - width: MediaQuery.of(context).size.width - 14 * 3 - 120, // | ▢ ▢ | - height: 180, - padding: EdgeInsets.only(top: 14, bottom: 14, right: 14), + width: MediaQuery.of(context).size.width - 14 * 3 - 120, // | ▢ ▢▢ | + padding: EdgeInsets.only(top: 10, bottom: 10, right: 0), + alignment: Alignment.centerLeft, child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ IconText( - icon: Icon(Icons.date_range, size: 20, color: Colors.orange), - text: Text('${_data.publishYear} ${_data.mangaZone}'), - space: 8, - ), - IconText( - icon: Icon(Icons.bookmark, size: 20, color: Colors.orange), - text: TextGroup( - textScaleFactor: MediaQuery.of(context).textScaleFactor, + icon: Icon(Icons.person, size: 20, color: Colors.orange), + text: TextGroup.normal( texts: [ - for (var i = 0; i < _data.genres.length; i++) ...[ - LinkGroupText( - text: _data.genres[i].title, + PlainTextItem(text: '作者:'), + for (var i = 0; i < _data!.authors.length; i++) ...[ + LinkTextItem( + text: _data!.authors[i].name, pressedColor: Theme.of(context).primaryColor, showUnderline: true, onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => GenrePage( - genre: _data.genres[i].toTiny(), + CustomPageRoute( + context: context, + builder: (c) => AuthorPage( + id: _data!.authors[i].aid, + name: _data!.authors[i].name, + url: _data!.authors[i].url, ), ), ), ), - if (i != _data.genres.length - 1) NormalGroupText(text: ' / '), + if (i != _data!.authors.length - 1) PlainTextItem(text: ' / '), ], ], ), space: 8, + iconPadding: EdgeInsets.symmetric(vertical: 2.8), ), IconText( - icon: Icon(Icons.person, size: 20, color: Colors.orange), - text: TextGroup( - textScaleFactor: MediaQuery.of(context).textScaleFactor, + icon: Icon(Icons.bookmark, size: 20, color: Colors.orange), + text: TextGroup.normal( texts: [ - for (var i = 0; i < _data.authors.length; i++) ...[ - LinkGroupText( - text: _data.authors[i].name, + PlainTextItem(text: '类别:'), + for (var i = 0; i < _data!.genres.length; i++) ...[ + LinkTextItem( + text: _data!.genres[i].title, pressedColor: Theme.of(context).primaryColor, showUnderline: true, onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => AuthorPage( - id: _data.authors[i].aid, - name: _data.authors[i].name, - url: _data.authors[i].url, + CustomPageRoute( + context: context, + builder: (c) => GenrePage( + genre: _data!.genres[i].toTiny(), ), ), ), ), - if (i != _data.authors.length - 1) NormalGroupText(text: ' / '), + if (i != _data!.genres.length - 1) PlainTextItem(text: ' / '), ], ], ), space: 8, + iconPadding: EdgeInsets.symmetric(vertical: 2.8), + ), + IconText( + icon: Icon(Icons.date_range, size: 20, color: Colors.orange), + text: Text('发布于 ${_data!.publishYear} / ${_data!.mangaZone.replaceAll('漫画', '')}'), + space: 8, + iconPadding: EdgeInsets.symmetric(vertical: 2.8), ), IconText( icon: Icon(Icons.trending_up, size: 20, color: Colors.orange), - text: Text('排名 ${_data.mangaRank}'), + text: Text('订阅 ${_subscribeCount ?? '?'} / 排名 ${_data!.mangaRank}'), space: 8, + iconPadding: EdgeInsets.symmetric(vertical: 2.8), ), IconText( icon: Icon(Icons.subject, size: 20, color: Colors.orange), - text: Text((_data.finished ? '共 ' : '更新至 ') + _data.newestChapter), + text: Flexible( + child: Text( + '最新章节:${_data!.newestChapter}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), space: 8, + iconPadding: EdgeInsets.symmetric(vertical: 2.8), ), IconText( icon: Icon(Icons.access_time, size: 20, color: Colors.orange), - text: Text(_data.newestDate + (_data.finished ? ' 已完结' : ' 连载中')), + text: Text(_data!.newestDate + (_data!.finished ? ' 已完结' : ' 连载中')), space: 8, - ), - // SizedBox(height: 4), - Spacer(), - // **************************************************************** - // 两个按钮 - // **************************************************************** - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: 28, - width: 75, - child: OutlineButton( - padding: EdgeInsets.all(2), - child: Text( - _subscribed == true ? '取消订阅' : '订阅漫画', - style: TextStyle(color: _subscribing ? Colors.grey : Theme.of(context).textTheme.button.color), - ), - onPressed: _subscribing == true ? null : () => _subscribe(), - ), - ), - SizedBox(width: 14), - Container( - height: 28, - width: 75, - child: OutlineButton( - padding: EdgeInsets.all(2), - child: Text(_history?.read == true ? '继续阅读' : '开始阅读'), - onPressed: () => _read(), - ), - ), - ], + iconPadding: EdgeInsets.symmetric(vertical: 2.8), ), ], ), @@ -410,6 +499,67 @@ class _MangaPageState extends State { ), ), // **************************************************************** + // 五个按钮 + // **************************************************************** + Container( + color: Colors.white, + child: ActionRowView.five( + action1: ActionItem( + text: _subscribed == true ? '取消订阅' : '订阅漫画', + icon: _subscribed == true ? Icons.star : Icons.star_border, + action: _subscribing ? null : () => _subscribe(), + enable: !_subscribing, + ), + action2: ActionItem( + text: '下载漫画', + icon: Icons.download, + action: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => DownloadSelectPage( + mangaId: _data!.mid, + mangaTitle: _data!.title, + mangaCover: _data!.cover, + mangaUrl: _data!.url, + groups: _data!.chapterGroups, + ), + ), + ), + ), + action3: ActionItem( + text: _history?.read == true ? '继续阅读' : '开始阅读', + icon: Icons.import_contacts, + action: () => _read(chapterId: null), + longPress: () { + if (_history == null || !_history!.read) { + Fluttertoast.showToast(msg: '未开始阅读该漫画'); + } else if (_history != null) { + Fluttertoast.showToast(msg: '上次阅读到 ${_history!.chapterTitle} 第${_history!.chapterPage}页'); + } + }, + ), + action4: ActionItem( + text: '漫画详情', + icon: Icons.subject, + action: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaDetailPage(data: _data!), + ), + ), + ), + action5: ActionItem( + text: '分享漫画', + icon: Icons.share, + action: () => shareText( + title: '漫画柜分享', + text: '【${_data!.title}】${_data!.url}', + ), + ), + ), + ), + Container(height: 12), + // **************************************************************** // 介绍 // **************************************************************** Container( @@ -417,32 +567,30 @@ class _MangaPageState extends State { child: Material( color: Colors.transparent, child: InkWell( - onTap: () => mountedSetState(() => _showBriefIntroduction = !_showBriefIntroduction), + onTap: () { + _showBriefIntroduction = !_showBriefIntroduction; + if (mounted) setState(() {}); + }, child: Container( padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: RichText( - textScaleFactor: MediaQuery.of(context).textScaleFactor, - text: TextSpan( - text: '', - style: TextStyle(color: Colors.black), - children: [ - if (_showBriefIntroduction) ...[ - TextSpan(text: _data.briefIntroduction), - TextSpan( - text: ' 展开详情', - style: TextStyle(color: Theme.of(context).primaryColor), - ), - ], - if (!_showBriefIntroduction) ...[ - TextSpan(text: _data.introduction), - TextSpan( - text: ' 收起介绍', - style: TextStyle(color: Theme.of(context).primaryColor), - ), - ], - TextSpan(text: ' '), + child: TextGroup.normal( + style: TextStyle(color: Colors.black), + texts: [ + if (_showBriefIntroduction) ...[ + PlainTextItem(text: _data!.briefIntroduction), + PlainTextItem( + text: ' 展开详情', + style: TextStyle(color: Theme.of(context).primaryColor), + ), ], - ), + if (!_showBriefIntroduction) ...[ + PlainTextItem(text: _data!.introduction), + PlainTextItem( + text: ' 收起介绍', + style: TextStyle(color: Theme.of(context).primaryColor), + ), + ], + ], ), ), ), @@ -451,56 +599,110 @@ class _MangaPageState extends State { Container( padding: EdgeInsets.symmetric(horizontal: 12), color: Colors.white, - child: Divider(height: 1, thickness: 1), + child: Divider(height: 0, thickness: 1), ), // **************************************************************** - // 排名 + // 排名评价 // **************************************************************** - Container( + Material( color: Colors.white, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => MangaDetailPage(data: _data), - ), + child: InkWell( + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + children: [ + RatingBar.builder( + itemCount: 5, + itemBuilder: (c, i) => Icon(Icons.star, color: Colors.amber), + initialRating: _data!.averageScore / 2.0, + minRating: 0, + itemSize: 32, + itemPadding: EdgeInsets.symmetric(horizontal: 4), + direction: Axis.horizontal, + allowHalfRating: true, + ignoreGestures: true, + onRatingUpdate: (_) {}, + ), + SizedBox(height: 4), + Text('平均分数: ${_data!.averageScore} / 10.0,共 ${_data!.scoreCount} 人评分'), + ], ), - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Stack( + ), + onTap: () => showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('评分投票'), + content: Column( + mainAxisSize: MainAxisSize.min, children: [ - Align( - alignment: Alignment.center, - child: Column( - children: [ - RatingBar.builder( - direction: Axis.horizontal, - allowHalfRating: true, - itemCount: 5, - itemPadding: EdgeInsets.symmetric(horizontal: 4), - itemBuilder: (c, i) => Icon(Icons.star, color: Colors.amber), - initialRating: _data.averageScore / 2.0, - minRating: 0, - itemSize: 32, - ignoreGestures: true, - onRatingUpdate: (_) {}, - ), - SizedBox(height: 4), - Text('平均分数: ${_data.averageScore} / 10.0,共 ${_data.scoreCount} 人评价'), - ], - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + RatingBar.builder( + itemCount: 5, + itemBuilder: (c, i) => Icon(Icons.star, color: Colors.amber), + initialRating: _data!.averageScore / 2.0, + itemSize: 32, + itemPadding: EdgeInsets.symmetric(horizontal: 2), + allowHalfRating: true, + ignoreGestures: true, + onRatingUpdate: (_) {}, + ), + SizedBox(width: 10), + Text( + _data!.averageScore.toString(), + style: Theme.of(context).textTheme.bodyText1?.copyWith( + fontSize: 28, + color: Colors.orangeAccent, + ), + ), + ], ), - Positioned( - bottom: 0, - right: 0, + SizedBox(height: 2), + Align( + alignment: Alignment.centerRight, child: Text( - '查看详情', - style: TextStyle(color: Theme.of(context).primaryColor), + '共 ${_data!.scoreCount} 人评分', + style: Theme.of(context).textTheme.bodyText2, ), ), + Divider(height: 16, thickness: 1), + for (var i = 4; i >= 0; i--) + Padding( + padding: EdgeInsets.only(bottom: i == 0 ? 0 : 5), + child: Row( + children: [ + RatingBar.builder( + itemCount: 5, + itemBuilder: (c, i) => Icon(Icons.star, color: Colors.amber), + initialRating: (i + 1).toDouble(), + itemSize: 16, + allowHalfRating: false, + ignoreGestures: true, + onRatingUpdate: (_) {}, + ), + Container( + width: 250 * (double.tryParse(_data!.perScores[i + 1].replaceAll('%', '')) ?? 0) / 100, + height: 16, + color: Colors.amber, + margin: EdgeInsets.only(left: 4, right: 6), + ), + Text( + _data!.perScores[i + 1], + style: Theme.of(context).textTheme.bodyText2, + ), + ], + ), + ), ], ), + actions: [ + TextButton( + child: Text('确定'), + onPressed: () => Navigator.of(c).pop(), + ), + ], ), ), ), @@ -511,98 +713,115 @@ class _MangaPageState extends State { // **************************************************************** Container( color: Colors.white, - child: ChapterGroupView( - action: _action, - groups: _data.chapterGroups, - complete: false, - highlightChapter: _history?.chapterId ?? 0, - mangaId: _data.mid, - mangaTitle: _data.title, - mangaCover: _data.cover, - mangaUrl: _data.url, + child: MangaTocView( + groups: _data!.chapterGroups, + full: false, + gridPadding: EdgeInsets.symmetric(horizontal: 12), + highlightedChapters: [_history?.chapterId ?? 0], + customBadgeBuilder: (cid) => DownloadBadge.fromEntity( + entity: _downloadEntity?.downloadedChapters.where((el) => el.chapterId == cid).firstOrNull, + ), + onChapterPressed: (cid) => _read(chapterId: cid), + onMoreChaptersPressed: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaTocPage( + mangaId: _data!.mid, + mangaTitle: _data!.title, + groups: _data!.chapterGroups, + onChapterPressed: (cid) => _read(chapterId: cid), + ), + ), + ), ), ), Container(height: 12), // **************************************************************** // 评论 // **************************************************************** - Container( - color: Colors.white, - padding: EdgeInsets.symmetric(vertical: 10), - child: PlaceholderText( - state: _commentLoading - ? PlaceholderState.loading - : _commentError?.isNotEmpty == true - ? PlaceholderState.error - : _comments.isEmpty - ? PlaceholderState.nothing - : PlaceholderState.normal, - errorText: _commentError, - setting: PlaceholderSetting( - loadingText: '评论加载中...', - nothingText: '暂无评论', - ), - childBuilder: (_) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - color: Colors.white, - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 7), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '评论区', - style: Theme.of(context).textTheme.subtitle1, - ), - Text( - '共 ${_commentTotal == null ? '?' : _commentTotal} 条', - style: Theme.of(context).textTheme.subtitle1, - ), - ], - ), + PlaceholderText( + state: _commentLoading + ? PlaceholderState.loading + : _commentError.isNotEmpty + ? PlaceholderState.error + : _comments.isEmpty + ? PlaceholderState.nothing + : PlaceholderState.normal, + errorText: _commentError.isEmpty ? '' : '加载漫画评论失败\n$_commentError', + setting: PlaceholderSetting().copyWithChinese( + loadingText: '评论加载中...', + nothingText: '暂无评论', + ), + onRefresh: () => _getComments(), + childBuilder: (_) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + color: Colors.white, + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 7), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '评论区', + style: Theme.of(context).textTheme.subtitle1, + ), + Text( + '共 $_commentTotal 条', + style: Theme.of(context).textTheme.subtitle1, + ), + ], ), - Container( - padding: EdgeInsets.symmetric(horizontal: 12), - color: Colors.white, - child: Divider(height: 1, thickness: 1), + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 12), + color: Colors.white, + child: Divider(height: 0, thickness: 1), + ), + for (var comment in _comments.sublist(0, _comments.length - 1)) ...[ + CommentLineView( + comment: comment, + style: CommentLineViewStyle.normal, ), - for (var comment in _comments.sublist(0, _comments.length - 1)) ...[ - CommentLineView(comment: comment), - Container( - margin: EdgeInsets.only(left: 2.0 * 12 + 32), - width: MediaQuery.of(context).size.width - 3 * 12 - 32, - child: Divider(height: 1, thickness: 1), - ), - ], - CommentLineView(comment: _comments.last), Container( - padding: EdgeInsets.symmetric(horizontal: 12), color: Colors.white, - child: Divider(height: 1, thickness: 1), + child: Divider(height: 0, thickness: 1, indent: 2.0 * 12 + 32), ), - Material( - color: Colors.transparent, - child: InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => MangaCommentPage(mid: widget.id), + ], + CommentLineView( + comment: _comments.last, + style: CommentLineViewStyle.normal, + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 12), + color: Colors.white, + child: Divider(height: 0, thickness: 1), + ), + Material( + color: Colors.white, + child: InkWell( + onTap: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => CommentsPage( + mangaId: _data!.mid, + mangaTitle: _data!.title, ), ), - child: Container( - width: MediaQuery.of(context).size.width, - height: 42, - child: Center( - child: Text( - '查看更多评论...', - style: Theme.of(context).textTheme.subtitle1, - ), + ), + child: Container( + width: MediaQuery.of(context).size.width, + height: 42, + child: Center( + child: Text( + '查看更多评论...', + style: Theme.of(context).textTheme.subtitle1, ), ), ), - ) - ], - ), + ), + ) + ], ), ), ], @@ -616,7 +835,7 @@ class _MangaPageState extends State { condition: ScrollAnimatedCondition.direction, fab: FloatingActionButton( child: Icon(Icons.vertical_align_top), - heroTag: 'MangaPage', + heroTag: null, onPressed: () => _controller.scrollToTop(), ), ), diff --git a/lib/page/manga_comment.dart b/lib/page/manga_comment.dart deleted file mode 100644 index 3f2fbf1..0000000 --- a/lib/page/manga_comment.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/list.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:manhuagui_flutter/model/comment.dart'; -import 'package:manhuagui_flutter/page/view/comment_line.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; - -class MangaCommentPage extends StatefulWidget { - const MangaCommentPage({ - Key key, - @required this.mid, - }) : assert(mid != null), - super(key: key); - - final int mid; - - @override - _MangaCommentPageState createState() => _MangaCommentPageState(); -} - -class _MangaCommentPageState extends State { - final _controller = ScrollController(); - final _udvController = UpdatableDataViewController(); - final _fabController = AnimatedFabController(); - var _data = []; - int _total; - - @override - void dispose() { - _controller.dispose(); - _udvController.dispose(); - _fabController.dispose(); - super.dispose(); - } - - Future> _getData({int page}) async { - var dio = DioManager.instance.dio; - var client = RestClient(dio); - ErrorMessage err; - var result = await client.getMangaComments(mid: widget.mid, page: page).catchError((e) { - err = wrapError(e); - }); - if (err != null) { - return Future.error(err.text); - } - _total = result.data.total; - if (mounted) setState(() {}); - return PagedList(list: result.data.data, next: result.data.page + 1); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - centerTitle: true, - toolbarHeight: 45, - title: Text('漫画评论${_total == null ? '' : ' (共 $_total 条)'}'), - ), - body: PaginationListView( - data: _data, - getData: ({indicator}) => _getData(page: indicator), - scrollController: _controller, - controller: _udvController, - paginationSetting: PaginationSetting( - initialIndicator: 1, - nothingIndicator: 0, - ), - setting: UpdatableDataViewSetting( - padding: EdgeInsets.zero, - placeholderSetting: PlaceholderSetting().toChinese(), - refreshFirst: true, - clearWhenError: false, - clearWhenRefresh: false, - updateOnlyIfNotEmpty: false, - onStateChanged: (_, __) => _fabController.hide(), - onAppend: (l) { - if (l.length > 0) { - Fluttertoast.showToast(msg: '新添了 ${l.length} 条评论'); - } - }, - onError: (e) => Fluttertoast.showToast(msg: e.toString()), - ), - separator: Container( - margin: EdgeInsets.only(left: 2.0 * 12 + 32), - width: MediaQuery.of(context).size.width - 3 * 12 - 32, - child: Divider(height: 1, thickness: 1), - ), - itemBuilder: (c, item) => CommentLineView(comment: item), - ), - floatingActionButton: ScrollAnimatedFab( - controller: _fabController, - scrollController: _controller, - condition: ScrollAnimatedCondition.direction, - fab: FloatingActionButton( - child: Icon(Icons.vertical_align_top), - heroTag: 'MangaCommentPage', - onPressed: () => _controller.scrollToTop(), - ), - ), - ); - } -} diff --git a/lib/page/manga_detail.dart b/lib/page/manga_detail.dart index 8be2bdb..f00747c 100644 --- a/lib/page/manga_detail.dart +++ b/lib/page/manga_detail.dart @@ -1,14 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/model/manga.dart'; -import 'package:manhuagui_flutter/service/natives/clipboard.dart'; +import 'package:manhuagui_flutter/service/native/clipboard.dart'; +/// 漫画详情页,展示所给 [Manga] 信息 class MangaDetailPage extends StatefulWidget { const MangaDetailPage({ - Key key, - @required this.data, - }) : assert(data != null), - super(key: key); + Key? key, + required this.data, + }) : super(key: key); final Manga data; @@ -17,97 +17,125 @@ class MangaDetailPage extends StatefulWidget { } class _MangaDetailPageState extends State { - var _details = >[]; + final _controller = ScrollController(); + late final _details = [ + Tuple2('mid', widget.data.mid.toString()), + Tuple2('标题', widget.data.title), + Tuple2('标题别名', widget.data.aliasTitle.trim().isNotEmpty ? widget.data.aliasTitle.trim() : '暂无'), + Tuple2('别名', widget.data.alias.trim().isNotEmpty ? widget.data.alias.trim() : '暂无'), + Tuple2('封面链接', widget.data.cover), + Tuple2('网页链接', widget.data.url), + Tuple2('状态', widget.data.finished ? '已完结' : '连载中'), + Tuple2('出版年份', widget.data.publishYear), + Tuple2('漫画地区', widget.data.mangaZone), + Tuple2('漫画类别', widget.data.genres.map((g) => g.title).join(', ')), + Tuple2('漫画作者', widget.data.authors.map((a) => a.name).join(', ')), + Tuple2('最新章节', widget.data.newestChapter), + Tuple2('更新时间', widget.data.newestDate), + Tuple2('总章节数', widget.data.chapterGroups.expand((g) => g.chapters).length.toString()), + Tuple2('章节分组数', widget.data.chapterGroups.length.toString()), + for (var group in widget.data.chapterGroups) Tuple2('《${group.title}》章节数', group.chapters.length.toString()), + Tuple2('包含色情暴力', widget.data.banned ? '是' : '否'), + Tuple2('拥有版权', widget.data.copyright ? '是' : '否'), + Tuple2('漫画排名', widget.data.mangaRank), + Tuple2('平均得分', widget.data.averageScore.toStringAsFixed(1)), + Tuple2('评分人数', widget.data.scoreCount.toString()), + for (var num in [1, 2, 3, 4, 5]) Tuple2('评 $num 星比例', widget.data.perScores[num]), + Tuple2('简要介绍', widget.data.briefIntroduction), + Tuple2('详细介绍', widget.data.introduction), + ]; + late final _helper = TableCellHelper(_details.length, 2); @override - void initState() { - super.initState(); - _details = [ - Tuple2('mid', widget.data.mid.toString()), - Tuple2('标题', widget.data.title), - Tuple2('标题别名', widget.data.aliasTitle ?? '暂无'), - Tuple2('别名', widget.data.alias), - Tuple2('封面链接', widget.data.cover), - Tuple2('网页链接', widget.data.url), - Tuple2('状态', widget.data.finished ? '已完结' : '连载中'), - Tuple2('出版年份', widget.data.publishYear), - Tuple2('漫画地区', widget.data.mangaZone), - Tuple2('漫画类别', widget.data.genres.map((g) => g.title).join(', ')), - Tuple2('漫画作者', widget.data.authors.map((a) => a.name).join(', ')), - Tuple2('最新章节', widget.data.newestChapter), - Tuple2('更新时间', widget.data.newestDate), - Tuple2('总章节数', widget.data.chapterGroups.expand((g) => g.chapters).length.toString()), - Tuple2('章节分组数', widget.data.chapterGroups.length.toString()), - for (var group in widget.data.chapterGroups) Tuple2('《${group.title}》章节数', group.chapters.length.toString()), - Tuple2('包含色情暴力', widget.data.banned ? '是' : '否'), - Tuple2('拥有版权', widget.data.copyright ? '是' : '否'), - Tuple2('漫画排名', widget.data.mangaRank), - Tuple2('平均得分', widget.data.averageScore.toStringAsFixed(1)), - Tuple2('评分人数', widget.data.scoreCount.toString()), - for (var num in [1, 2, 3, 4, 5]) Tuple2('评 $num 星比例', widget.data.perScores[num]), - Tuple2('简要介绍', widget.data.briefIntroduction), - Tuple2('详细介绍', widget.data.introduction), - ]; + void dispose() { + _controller.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { + var tableWidth = MediaQuery.of(context).size.width - MediaQuery.of(context).padding.horizontal - 40; + return Scaffold( appBar: AppBar( - centerTitle: true, - toolbarHeight: 45, title: Text('漫画详情'), + leading: AppBarActionButton.leading(context: context), ), - body: Scrollbar( - child: ListView( - padding: EdgeInsets.symmetric(horizontal: 20, vertical: 8), - children: [ - Table( - columnWidths: { + body: ScrollbarWithMore( + controller: _controller, + interactive: true, + crossAxisMargin: 2, + child: SingleChildScrollView( + controller: _controller, + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 15), + physics: AlwaysScrollableScrollPhysics(), + child: StatefulWidgetWithCallback( + postFrameCallbackForBuild: _helper.hasSearched() + ? null + : (_) { + if (_helper.searchForHighestCells()) { + if (mounted) setState(() {}); + } + }, + child: Table( + columnWidths: const { 0: FractionColumnWidth(0.3), }, border: TableBorder( - horizontalInside: BorderSide( - width: 1, - color: Colors.grey, - style: BorderStyle.solid, - ), + horizontalInside: BorderSide(width: 1, color: Colors.grey), ), children: [ TableRow( - children: [ + children: const [ Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Text('键', style: TextStyle(color: Colors.grey)), ), Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6), child: Text('值', style: TextStyle(color: Colors.grey)), ), ], ), - for (var data in _details) + for (var i = 0; i < _details.length; i++) TableRow( children: [ - TableRowInkWell( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 5), - child: Text('${data.item1} '), + TableCell( + key: _helper.getCellKey(i, 0), + verticalAlignment: _helper.determineCellAlignment(i, 0, TableCellVerticalAlignment.top), + child: TableWholeRowInkWell.preferred( + child: Text('${_details[i].item1} '), + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6), + onTap: () => copyText(_details[i].item2), + tableWidth: tableWidth, + accumulativeWidthRatio: 0, ), - onTap: () => copyText(data.item2), ), - TableRowInkWell( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 5), - child: Text('${data.item2} '), + TableCell( + key: _helper.getCellKey(i, 1), + verticalAlignment: _helper.determineCellAlignment(i, 1, TableCellVerticalAlignment.top), + child: TableWholeRowInkWell.preferred( + child: Text('${_details[i].item2} '), + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6), + onTap: () => copyText(_details[i].item2), + tableWidth: tableWidth, + accumulativeWidthRatio: 0.3, ), - onTap: () => copyText(data.item2), ), ], ), ], - ) - ], + ), + ), + ), + ), + floatingActionButton: ScrollAnimatedFab( + scrollController: _controller, + condition: ScrollAnimatedCondition.direction, + fab: FloatingActionButton( + child: Icon(Icons.vertical_align_top), + heroTag: null, + onPressed: () => _controller.scrollToTop(), ), ), ); diff --git a/lib/page/manga_group.dart b/lib/page/manga_group.dart index cda4d2c..81d651b 100644 --- a/lib/page/manga_group.dart +++ b/lib/page/manga_group.dart @@ -1,19 +1,15 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/model/manga.dart'; -import 'package:manhuagui_flutter/page/view/manga_column.dart'; +import 'package:manhuagui_flutter/page/view/manga_group.dart'; -/// 漫画分组 -/// Page for [MangaGroup]. +/// 漫画分组页,展示所给 [MangaGroup] 信息 class MangaGroupPage extends StatefulWidget { const MangaGroupPage({ - Key key, - @required this.group, - @required this.type, - }) : assert(group != null), - assert(type != null), - super(key: key); + Key? key, + required this.group, + required this.type, + }) : super(key: key); final MangaGroup group; final MangaGroupType type; @@ -35,18 +31,21 @@ class _MangaGroupPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, - toolbarHeight: 45, - title: Text('漫画分组详细'), + title: Text('漫画分组'), + leading: AppBarActionButton.leading(context: context), ), - body: Padding( - padding: EdgeInsets.only(bottom: 4, top: 2), - child: MangaColumnView( - group: widget.group, - type: widget.type, + body: ScrollbarWithMore( + controller: _controller, + interactive: true, + crossAxisMargin: 2, + child: SingleChildScrollView( controller: _controller, - complete: true, - showTopMargin: false, + child: MangaGroupView( + group: widget.group, + type: widget.type, + controller: _controller, + style: MangaGroupViewStyle.normalFull, + ), ), ), floatingActionButton: ScrollAnimatedFab( @@ -54,7 +53,7 @@ class _MangaGroupPageState extends State { condition: ScrollAnimatedCondition.direction, fab: FloatingActionButton( child: Icon(Icons.vertical_align_top), - heroTag: 'MangaGroupPage', + heroTag: null, onPressed: () => _controller.scrollToTop(), ), ), diff --git a/lib/page/manga_random.dart b/lib/page/manga_random.dart new file mode 100644 index 0000000..0ff7eb5 --- /dev/null +++ b/lib/page/manga_random.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/page/manga.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; + +/// 随机漫画页,网络请求并展示 [RandomMangaInfo] 并跳转至 [MangaPage] +class MangaRandomPage extends StatefulWidget { + const MangaRandomPage({Key? key}) : super(key: key); + + @override + State createState() => _MangaRandomPageState(); +} + +class _MangaRandomPageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance?.addPostFrameCallback((_) => _loadData()); + } + + Future _loadData() async { + final client = RestClient(DioManager.instance.dio); + try { + var random = await client.getRandomManga(); + var mid = random.data.mid; + var url = random.data.url; + Navigator.of(context).pop(); + Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaPage( + id: mid, + title: '漫画 mid: $mid', + url: url, + ), + ), + ); + } catch (e, s) { + var we = wrapError(e, s); + showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('随机漫画'), + content: Text('无法获取随机漫画:${we.text}。'), + actions: [ + TextButton( + child: Text('确定'), + onPressed: () { + Navigator.of(c).pop(); // 本对话框 + Navigator.of(context).pop(); // 本页 + }, + ), + ], + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('随机漫画'), + leading: AppBarActionButton.leading(context: context), + ), + body: Center( + child: SizedBox( + height: 50, + width: 50, + child: CircularProgressIndicator(), + ), + ), + ); + } +} diff --git a/lib/page/manga_toc.dart b/lib/page/manga_toc.dart index 980e241..4aee18a 100644 --- a/lib/page/manga_toc.dart +++ b/lib/page/manga_toc.dart @@ -1,100 +1,121 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/model/chapter.dart'; -import 'package:manhuagui_flutter/model/manga.dart'; -import 'package:manhuagui_flutter/page/view/chapter_group.dart'; -import 'package:manhuagui_flutter/service/database/history.dart'; -import 'package:manhuagui_flutter/service/natives/browser.dart'; -import 'package:manhuagui_flutter/service/state/auth.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/page/view/manga_toc.dart'; +import 'package:manhuagui_flutter/service/db/download.dart'; +import 'package:manhuagui_flutter/service/db/history.dart'; +import 'package:manhuagui_flutter/service/evb/auth_manager.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; -/// 漫画章节目录 -/// Page for [MangaChapterGroup]. +/// 漫画章节目录页,展示所给 [MangaChapterGroup] 信息 class MangaTocPage extends StatefulWidget { const MangaTocPage({ - Key key, - this.action, - @required this.mid, - @required this.title, - @required this.cover, - @required this.url, - @required this.groups, - this.highlightChapter, - }) : assert(mid != null), - assert(title != null), - assert(cover != null), - assert(url != null), - assert(groups != null), - super(key: key); + Key? key, + required this.mangaId, + required this.mangaTitle, + required this.groups, + required this.onChapterPressed, + }) : super(key: key); - final ActionController action; - final int mid; - final String title; - final String cover; - final String url; + final int mangaId; + final String mangaTitle; final List groups; - final int highlightChapter; + final void Function(int cid) onChapterPressed; @override _MangaTocPageState createState() => _MangaTocPageState(); } class _MangaTocPageState extends State { - MangaHistory _history; + final _controller = ScrollController(); + var _loading = true; // fake loading flag + final _cancelHandlers = []; @override void initState() { super.initState(); - getHistory(username: AuthState.instance.username, mid: widget.mid).then((r) => _history = r).catchError((_) {}); - - widget?.action?.addAction('history_toc', () async { - _history = await getHistory(username: AuthState.instance.username, mid: widget.mid).catchError((_) {}); - if (mounted) setState(() {}); + WidgetsBinding.instance?.addPostFrameCallback((_) { + Future.delayed(Duration(milliseconds: 300), () { + _loading = false; + if (mounted) setState(() {}); + }); }); + WidgetsBinding.instance?.addPostFrameCallback((_) { + _loadHistory(); + _loadDownload(); + }); + _cancelHandlers.add(EventBusManager.instance.listen((_) => _loadHistory())); + _cancelHandlers.add(EventBusManager.instance.listen((_) => _loadDownload())); } @override void dispose() { - widget?.action?.removeAction('history_toc'); + _cancelHandlers.forEach((c) => c.call()); + _controller.dispose(); super.dispose(); } + MangaHistory? _history; + DownloadedManga? _downloadEntity; + + Future _loadHistory() async { + try { + _history = await HistoryDao.getHistory(username: AuthManager.instance.username, mid: widget.mangaId); + if (mounted) setState(() {}); + } catch (_) {} + } + + Future _loadDownload() async { + try { + _downloadEntity = await DownloadDao.getManga(mid: widget.mangaId); + if (mounted) setState(() {}); + } catch (_) {} + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, - toolbarHeight: 45, - title: Text(widget.title), - actions: [ - IconButton( - icon: Icon(Icons.open_in_browser), - tooltip: '打开浏览器', - onPressed: () => launchInBrowser( - context: context, - url: widget.url, - ), - ), - ], + title: Text(widget.mangaTitle), + leading: AppBarActionButton.leading(context: context), ), - body: Container( - color: Colors.white, - child: Scrollbar( - child: ListView( - children: [ - ChapterGroupView( - action: widget.action, + body: PlaceholderText( + state: _loading ? PlaceholderState.loading : PlaceholderState.normal, + setting: PlaceholderSetting().copyWithChinese(), + childBuilder: (c) => Container( + color: Colors.white, + child: ScrollbarWithMore( + controller: _controller, + interactive: true, + crossAxisMargin: 2, + child: SingleChildScrollView( + controller: _controller, + child: MangaTocView( groups: widget.groups, - complete: true, - highlightChapter: _history?.chapterId ?? widget.highlightChapter, - mangaId: widget.mid, - mangaTitle: widget.title, - mangaCover: widget.cover, - mangaUrl: widget.url, + full: true, + highlightedChapters: [_history?.chapterId ?? 0], + customBadgeBuilder: (cid) => DownloadBadge.fromEntity( + entity: _downloadEntity?.downloadedChapters.where((el) => el.chapterId == cid).firstOrNull, + ), + onChapterPressed: widget.onChapterPressed, ), - ], + ), ), ), ), + floatingActionButton: _loading + ? null + : ScrollAnimatedFab( + scrollController: _controller, + condition: ScrollAnimatedCondition.direction, + fab: FloatingActionButton( + child: Icon(Icons.vertical_align_top), + heroTag: null, + onPressed: () => _controller.scrollToTop(), + ), + ), ); } } diff --git a/lib/page/manga_viewer.dart b/lib/page/manga_viewer.dart new file mode 100644 index 0000000..f95817e --- /dev/null +++ b/lib/page/manga_viewer.dart @@ -0,0 +1,995 @@ +import 'dart:async' show Timer; +import 'dart:io' show File, Platform; +import 'dart:math' as math; + +import 'package:battery_info/battery_info_plugin.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:manhuagui_flutter/model/chapter.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/page/comments.dart'; +import 'package:manhuagui_flutter/page/download_select.dart'; +import 'package:manhuagui_flutter/page/download_toc.dart'; +import 'package:manhuagui_flutter/page/page/view_extra.dart'; +import 'package:manhuagui_flutter/page/page/view_setting.dart'; +import 'package:manhuagui_flutter/page/page/view_toc.dart'; +import 'package:manhuagui_flutter/page/view/action_row.dart'; +import 'package:manhuagui_flutter/page/view/manga_gallery.dart'; +import 'package:manhuagui_flutter/service/db/download.dart'; +import 'package:manhuagui_flutter/service/db/history.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; +import 'package:manhuagui_flutter/service/evb/auth_manager.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; +import 'package:manhuagui_flutter/service/native/share.dart'; +import 'package:manhuagui_flutter/service/native/system_ui.dart'; +import 'package:manhuagui_flutter/service/storage/download_image.dart'; +import 'package:manhuagui_flutter/service/prefs/view_setting.dart'; +import 'package:wakelock/wakelock.dart'; + +/// 漫画章节阅读页 +class MangaViewerPage extends StatefulWidget { + const MangaViewerPage({ + Key? key, + required this.mangaId, + required this.chapterId, + required this.mangaTitle, + required this.mangaCover, + required this.mangaUrl, + required this.chapterGroups, + this.initialPage = 1, // starts from 1 + }) : super(key: key); + + final int mangaId; + final int chapterId; + final String mangaTitle; + final String mangaCover; + final String mangaUrl; + final List? chapterGroups; + final int initialPage; + + @override + _MangaViewerPageState createState() => _MangaViewerPageState(); +} + +const _kSlideWidthRatio = 0.2; // 点击跳转页面的区域比例 +const _kSlideHeightRatio = 0.2; // 点击跳转页面的区域比例 +const _kViewportFraction = 1.08; // 页面间隔 +const _kViewportPageSpace = 25.0; // 页面间隔 +const _kAnimationDuration = Duration(milliseconds: 150); // 动画时长 +const _kOverlayAnimationDuration = Duration(milliseconds: 100); // SystemUI 动画时长 + +class _MangaViewerPageState extends State with AutomaticKeepAliveClientMixin { + final _mangaGalleryViewKey = GlobalKey(); + final _cancelHandlers = []; + + var _setting = ViewSetting.defaultSetting(); + Timer? _timer; + var _currentTime = '00:00'; + var _networkInfo = 'WIFI'; + var _batteryInfo = '0%'; + + @override + void initState() { + super.initState(); + + // data related + WidgetsBinding.instance?.addPostFrameCallback((_) => _loadData()); + _cancelHandlers.add(EventBusManager.instance.listen((e) { + if (e.mangaId == widget.mangaId) { + _subscribed = e.subscribe; + if (mounted) setState(() {}); + } + })); + _cancelHandlers.add(EventBusManager.instance.listen((_) => _loadDownload())); + + // setting and screen related + WidgetsBinding.instance?.addPostFrameCallback((_) async { + // initialize in async manner + _ScreenHelper.initialize( + context: context, + setState: () => mountedSetState(() {}), + ); + + // setting + _setting = await ViewSettingPrefs.getSetting(); + if (mounted) setState(() {}); + + // apply settings + await _ScreenHelper.toggleWakelock(enable: _setting.keepScreenOn); + await _ScreenHelper.setSystemUIWhenEnter(fullscreen: _setting.fullscreen); + }); + + // timer related + WidgetsBinding.instance?.addPostFrameCallback((_) { + Future getInfo() async { + var now = DateTime.now(); + _currentTime = '${now.hour}:${now.minute.toString().padLeft(2, '0')}'; + var conn = await Connectivity().checkConnectivity(); + _networkInfo = conn == ConnectivityResult.wifi ? 'WIFI' : (conn == ConnectivityResult.mobile ? '移动网络' : '无网络'); + var battery = await BatteryInfoPlugin().androidBatteryInfo; + _batteryInfo = '电源${(battery?.batteryLevel ?? 0).clamp(0, 100)}%'; + if (mounted) setState(() {}); + } + + getInfo(); + var now = DateTime.now(); + var nextMinute = DateTime(now.year, now.month, now.day, now.hour, now.minute + 1); + if (mounted && (_timer == null || !_timer!.isActive)) { + Timer(nextMinute.difference(now), () { + _timer = Timer.periodic(const Duration(minutes: 1), (_) async { + if (_timer != null && _timer!.isActive) { + await getInfo(); + } + }); + }); + } + }); + } + + @override + void dispose() { + _cancelHandlers.forEach((c) => c.call()); + _timer?.cancel(); + super.dispose(); + } + + var _loading = true; + MangaChapter? _data; + DownloadedManga? _downloadEntity; + List? _chapterGroups; + int? _initialPage; + List>? _urlFutures; + List>? _fileFutures; + var _error = ''; + + var _subscribing = false; + var _subscribed = false; + + Future _loadData() async { + _loading = true; + if (mounted) setState(() {}); + + final client = RestClient(DioManager.instance.dio); + + if (AuthManager.instance.logined) { + // 1. 异步更新章节阅读记录 + Future.microtask(() async { + try { + await client.recordManga(token: AuthManager.instance.token, mid: widget.mangaId, cid: widget.chapterId); + } catch (_) {} + }); + + // 2. 异步获取漫画订阅信息 + Future.microtask(() async { + try { + var r = await client.checkShelfManga(token: AuthManager.instance.token, mid: widget.mangaId); + _subscribed = r.data.isIn; + if (mounted) setState(() {}); + } catch (e, s) { + if (_error.isEmpty) { + Fluttertoast.showToast(msg: wrapError(e, s).text); + } + } + }); + } + + // 3. 异步获取下载信息 + _loadDownload(); + + try { + // 4. 异步请求章节目录 + Future groupsFuture; + if (widget.chapterGroups != null) { + _chapterGroups = widget.chapterGroups!; + groupsFuture = Future.value(null); + } else { + groupsFuture = Future.microtask(() async { + var result = await client.getManga(mid: widget.mangaId); + _chapterGroups = result.data.chapterGroups; + }); + } + + // 5. 获取章节数据 + var result = await client.getMangaChapter(mid: widget.mangaId, cid: widget.chapterId); + _data = null; + _error = ''; + if (mounted) setState(() {}); + await Future.delayed(Duration(milliseconds: 20)); + _data = result.data; + await groupsFuture; // 等待成功获取章节目录 + + // 6. 指定起始页 + _initialPage = widget.initialPage.clamp(1, _data!.pageCount); + _currentPage = _initialPage!; + _progressValue = _initialPage!; + + // 7. 提前保存 future 列表 + _urlFutures = _data!.pages.map((el) => Future.value(el)).toList(); + _fileFutures = [ + for (int idx = 0; idx < _data!.pageCount; idx++) + Future.microtask(() async { + var filepath = await getDownloadedChapterPageFilePath( + mangaId: widget.mangaId, + chapterId: _data!.cid, + pageIndex: idx, + url: _data!.pages[idx], + ); + var f = File(filepath); + if (!(await f.exists())) { + return null; + } + return f; + }), + ]; + + // 8. 异步更新浏览历史 + _updateHistory(); + } catch (e, s) { + _data = null; + _error = wrapError(e, s).text; + } finally { + _loading = false; + if (mounted) setState(() {}); + } + } + + Future _updateHistory() async { + if (_data != null) { + await HistoryDao.addOrUpdateHistory( + username: AuthManager.instance.username, + history: MangaHistory( + mangaId: widget.mangaId, + mangaTitle: widget.mangaTitle, + mangaCover: widget.mangaCover, + mangaUrl: widget.mangaUrl, + chapterId: _data!.cid, + chapterTitle: _data!.title, + chapterPage: _currentPage, + lastTime: DateTime.now(), + ), + ); + EventBusManager.instance.fire(HistoryUpdatedEvent()); + } + } + + Future _loadDownload() async { + _downloadEntity = await DownloadDao.getManga(mid: widget.mangaId); + if (mounted) setState(() {}); + } + + var _currentPage = 1; // image page only, starts from 1 + var _progressValue = 1; // image page only, starts from 1 + var _inExtraPage = true; + + void _onPageChanged(int imageIndex, bool inFirstExtraPage, bool inLastExtraPage) { + _currentPage = imageIndex; + _progressValue = imageIndex; + var inExtraPage = inFirstExtraPage || inLastExtraPage; + if (inExtraPage != _inExtraPage) { + _ScreenHelper.toggleAppBarVisibility(show: false, fullscreen: _setting.fullscreen); + _inExtraPage = inExtraPage; + } + if (mounted) setState(() {}); + } + + void _onSliderChanged(double p) { + _progressValue = p.toInt(); + _mangaGalleryViewKey.currentState?.jumpToImage(_progressValue); + if (mounted) setState(() {}); + } + + void _gotoChapter({required bool gotoPrevious}) { + if ((gotoPrevious && _data!.prevCid == 0) || (!gotoPrevious && _data!.nextCid == 0)) { + showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text(gotoPrevious ? '上一章节' : '下一章节'), + content: Text(gotoPrevious ? '没有上一章节了。' : '没有下一章节了。'), + actions: [ + TextButton( + child: Text('确定'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), + ); + return; + } + + Navigator.of(context).pop(); // pop this page, should not use maybePop + Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaViewerPage( + mangaId: widget.mangaId, + mangaTitle: widget.mangaTitle, + mangaCover: widget.mangaCover, + mangaUrl: widget.mangaUrl, + chapterGroups: _chapterGroups, + chapterId: gotoPrevious ? _data!.prevCid : _data!.nextCid, + initialPage: 1, + ), + ), + ); + } + + var _showHelpRegion = false; // 显示区域提示 + + Future _onSettingPressed() async { + _setting = await ViewSettingPrefs.getSetting(); + var setting = _setting.copyWith(); + return showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('阅读设置'), + content: ViewSettingSubPage( + setting: setting, + onSettingChanged: (s) => setting = s, + ), + actionsAlignment: MainAxisAlignment.spaceBetween, + actions: [ + TextButton( + child: Text('操作'), + onPressed: () { + Navigator.of(c).pop(); + _showHelpRegion = true; + if (mounted) setState(() {}); + _ScreenHelper.toggleAppBarVisibility(show: false, fullscreen: _setting.fullscreen); + }, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + child: Text('确定'), + onPressed: () async { + Navigator.of(c).pop(); + _setting = setting; + if (mounted) setState(() {}); + await ViewSettingPrefs.setSetting(_setting); + + // apply settings + await _ScreenHelper.toggleWakelock(enable: _setting.keepScreenOn); + await _ScreenHelper.setSystemUIWhenSettingChanged(fullscreen: _setting.fullscreen); + }, + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), + ], + ), + ); + } + + Future _download(int imageIndex, String url) async { + var f = await downloadImageToGallery(url); + if (f != null) { + Fluttertoast.showToast(msg: '第$imageIndex页已保存至 ${f.path}'); + } else { + Fluttertoast.showToast(msg: '无法保存第$imageIndex页'); + } + } + + Future _subscribe() async { + if (!AuthManager.instance.logined) { + Fluttertoast.showToast(msg: '用户未登录'); + return; + } + + final client = RestClient(DioManager.instance.dio); + var toSubscribe = _subscribed != true; // 去订阅 + if (!toSubscribe) { + var ok = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('取消订阅确认'), + content: Text('是否取消订阅《${_data!.mangaTitle}》?'), + actions: [ + TextButton( + child: Text('确定'), + onPressed: () => Navigator.of(context).pop(true), + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(context).pop(false), + ), + ], + ), + ); + if (ok != true) { + return; + } + } + + _subscribing = true; + if (mounted) setState(() {}); + + try { + await (toSubscribe ? client.addToShelf : client.removeFromShelf)(token: AuthManager.instance.token, mid: widget.mangaId); + _subscribed = toSubscribe; + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(toSubscribe ? '订阅漫画成功' : '取消订漫画阅成功'), + ), + ); + EventBusManager.instance.fire(SubscribeUpdatedEvent(mangaId: widget.mangaId, subscribe: _subscribed)); + } catch (e, s) { + var err = wrapError(e, s).text; + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(toSubscribe ? '订阅漫画失败,$err' : '取消订阅漫画失败,$err'), + ), + ); + } finally { + _subscribing = false; + if (mounted) setState(() {}); + } + } + + Future _downloadManga() async { + await _ScreenHelper.restoreSystemUI(); + await Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => DownloadSelectPage( + mangaId: widget.mangaId, + mangaTitle: widget.mangaTitle, + mangaCover: widget.mangaCover, + mangaUrl: widget.mangaUrl, + groups: _chapterGroups!, + ), + ), + ); + await _ScreenHelper.setSystemUIWhenEnter(fullscreen: _setting.fullscreen); + } + + Future _showDownloadedManga() async { + await _ScreenHelper.restoreSystemUI(); + await Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => DownloadTocPage( + mangaId: widget.mangaId, + ), + settings: DownloadTocPage.buildRouteSetting( + mangaId: widget.mangaId, + ), + ), + ); + await _ScreenHelper.setSystemUIWhenEnter(fullscreen: _setting.fullscreen); + } + + Future _showToc() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (c) => MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: Container( + height: MediaQuery.of(context).size.height - MediaQuery.of(context).padding.vertical - Theme.of(context).appBarTheme.toolbarHeight!, + margin: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), + child: ViewTocSubPage( + mangaId: widget.mangaId, + mangaTitle: widget.mangaTitle, + groups: _chapterGroups!, + highlightedChapter: _data!.cid, + downloadedChapters: _downloadEntity?.downloadedChapters ?? [], + onChapterPressed: (cid) { + if (cid == _data!.cid) { + Fluttertoast.showToast(msg: '当前正在阅读 ${_data!.title}'); + } else { + Navigator.of(c).pop(); // bottom sheet + Navigator.of(context).pop(); // this page, should not use maybePop + Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaViewerPage( + mangaId: _data!.mid, + mangaTitle: _data!.mangaTitle, + mangaCover: widget.mangaCover, + mangaUrl: widget.mangaUrl, + chapterGroups: _chapterGroups, + chapterId: cid, + initialPage: 1, // always turn to the first page + ), + ), + ); + } + }, + ), + ), + ), + ); + await Future.delayed(Duration(milliseconds: 200 + 10)); // bottomSheetExitDuration + await _ScreenHelper.setSystemUIWhenEnter(fullscreen: _setting.fullscreen); + } + + Future _showComments() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (c) => MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: Container( + height: MediaQuery.of(context).size.height - MediaQuery.of(context).padding.vertical - Theme.of(context).appBarTheme.toolbarHeight!, + margin: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), + child: CommentsPage( + mangaId: _data!.mid, + mangaTitle: _data!.mangaTitle, + ), + ), + ), + ); + await Future.delayed(Duration(milliseconds: 200 + 10)); // bottomSheetExitDuration + await _ScreenHelper.setSystemUIWhenEnter(fullscreen: _setting.fullscreen); + } + + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return WillPopScope( + onWillPop: () async { + // 全部都异步执行 + _updateHistory(); + _ScreenHelper.restoreWakelock(); + _ScreenHelper.restoreSystemUI(); + _ScreenHelper.restoreAppBarVisibility(); + return true; + }, + child: SafeArea( + top: _ScreenHelper.safeAreaTop, + bottom: false, + child: Scaffold( + backgroundColor: Colors.black, + extendBodyBehindAppBar: true, + appBar: PreferredSize( + preferredSize: Size.fromHeight(Theme.of(context).appBarTheme.toolbarHeight!), + child: AnimatedSwitcher( + duration: _kAnimationDuration, + child: !(!_loading && _data != null && _ScreenHelper.showAppBar) + ? SizedBox(height: 0) + : AppBar( + backgroundColor: Colors.black.withOpacity(0.65), + elevation: 0, + title: Text( + _data!.title, + style: Theme.of(context).textTheme.subtitle1?.copyWith(color: Colors.white), + ), + leading: AppBarActionButton( + icon: Icon(Icons.arrow_back), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + highlightColor: Colors.transparent, + onPressed: () => Navigator.of(context).maybePop(), + ), + actions: [ + if (_downloadEntity?.downloadedChapters.any((el) => el.chapterId == widget.chapterId) == true) + AppBarActionButton( + icon: Icon(Icons.download_done), + tooltip: '下载情况', + highlightColor: Colors.transparent, + onPressed: () { + var chapter = _downloadEntity?.downloadedChapters.where((el) => el.chapterId == widget.chapterId).firstOrNull; + if (chapter == null) { + return; + } + showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('下载情况'), + content: Text( + !chapter.tried + ? '该章节正在等待下载。' + : chapter.succeeded + ? '该章节已下载完成。' + : '该章节部分页已下载完成。', + ), + actions: [ + TextButton( + child: Text('查看'), + onPressed: () { + Navigator.of(context).pop(); + _showDownloadedManga(); + }, + ), + TextButton( + child: Text('确定'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), + ); + }, + ) + ], + ), + ), + ), + body: PlaceholderText( + onRefresh: () => _loadData(), + state: _loading + ? PlaceholderState.loading + : _data == null + ? PlaceholderState.error + : PlaceholderState.normal, + errorText: _error, + setting: PlaceholderSetting( + iconColor: Colors.grey[400]!, + showLoadingText: false, + textStyle: Theme.of(context).textTheme.headline6!.copyWith(color: Colors.grey[400]!), + buttonTextStyle: TextStyle(color: Colors.grey[400]!), + buttonStyle: ButtonStyle( + side: MaterialStateProperty.all(BorderSide(color: Colors.grey[400]!)), + ), + ).copyWithChinese(), + childBuilder: (c) => Stack( + children: [ + // **************************************************************** + // 漫画显示 + // **************************************************************** + Positioned.fill( + child: MangaGalleryView( + key: _mangaGalleryViewKey, + imageCount: _data!.pages.length, + imageUrls: _data!.pages, + imageUrlFutures: _urlFutures!, + imageFileFutures: _fileFutures!, + preloadPagesCount: _setting.preloadCount, + verticalScroll: _setting.viewDirection == ViewDirection.topToBottom, + horizontalReverseScroll: _setting.viewDirection == ViewDirection.rightToLeft, + horizontalViewportFraction: _setting.enablePageSpace ? _kViewportFraction : 1, + verticalViewportPageSpace: _setting.enablePageSpace ? _kViewportPageSpace : 0, + slideWidthRatio: _kSlideWidthRatio, + slideHeightRatio: _kSlideHeightRatio, + initialImageIndex: _initialPage ?? 1, + onPageChanged: _onPageChanged, + onSaveImage: (imageIndex) => _download(imageIndex, _data!.pages[imageIndex - 1]), + onShareImage: (imageIndex) => shareText( + title: '漫画柜分享', + text: '【${_data!.mangaTitle} ${_data!.title}】第$imageIndex页 ${_data!.pages[imageIndex - 1]}', + ), + onCenterAreaTapped: () { + _ScreenHelper.toggleAppBarVisibility(show: !_ScreenHelper.showAppBar, fullscreen: _setting.fullscreen); + if (mounted) setState(() {}); + }, + firstPageBuilder: (c) => ViewExtraSubPage( + isHeader: true, + reverseScroll: _setting.viewDirection == ViewDirection.rightToLeft, + chapter: _data!, + mangaCover: widget.mangaCover, + chapterTitleGetter: (cid) => _chapterGroups?.findChapter(cid)?.title, + subscribing: _subscribing, + subscribed: _subscribed, + toJumpToImage: (idx, anim) => _mangaGalleryViewKey.currentState?.jumpToImage(idx, animated: anim), + toGotoChapter: (prev) => _gotoChapter(gotoPrevious: prev), + toSubscribe: _subscribe, + toDownload: _downloadManga, + toShowToc: _showToc, + toShowComments: _showComments, + toPop: () => Navigator.of(context).maybePop(), + ), + lastPageBuilder: (c) => ViewExtraSubPage( + isHeader: false, + reverseScroll: _setting.viewDirection == ViewDirection.rightToLeft, + chapter: _data!, + mangaCover: widget.mangaCover, + chapterTitleGetter: (cid) => _chapterGroups?.findChapter(cid)?.title, + subscribing: _subscribing, + subscribed: _subscribed, + toJumpToImage: (idx, anim) => _mangaGalleryViewKey.currentState?.jumpToImage(idx, animated: anim), + toGotoChapter: (prev) => _gotoChapter(gotoPrevious: prev), + toSubscribe: _subscribe, + toDownload: _downloadManga, + toShowToc: _showToc, + toShowComments: _showComments, + toPop: () => Navigator.of(context).maybePop(), + ), + ), + ), + // **************************************************************** + // 右下角的提示文字 + // **************************************************************** + Positioned( + bottom: 0, + right: 0, + child: AnimatedSwitcher( + duration: _kAnimationDuration, + child: !(_data != null && !_ScreenHelper.showAppBar && !_inExtraPage && _setting.showPageHint) + ? SizedBox(height: 0) + : Container( + color: Colors.black.withOpacity(0.65), + padding: EdgeInsets.only(left: 8, right: 8, top: 1.5, bottom: 1.5), + child: Text( + [ + _data!.title, + '$_currentPage/${_data!.pageCount}页', + if (_setting.showNetwork) _networkInfo, + if (_setting.showBattery) _batteryInfo, + if (_setting.showClock) _currentTime, + ].join(' '), + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + // **************************************************************** + // 最下面的滚动条和按钮 + // **************************************************************** + Positioned( + left: 0, + right: 0, + bottom: 0, + child: AnimatedSwitcher( + duration: _kAnimationDuration, + child: !(_data != null && _ScreenHelper.showAppBar) + ? SizedBox(height: 0) + : Container( + color: Colors.black.withOpacity(0.75), + padding: EdgeInsets.only(left: 12, right: 12, top: 0, bottom: _ScreenHelper.bottomPanelDistance + 6), + width: MediaQuery.of(context).size.width, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Directionality( + textDirection: _setting.viewDirection == ViewDirection.rightToLeft ? TextDirection.rtl : TextDirection.ltr, + child: SliderTheme( + data: Theme.of(context).sliderTheme.copyWith( + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 10.0), + overlayShape: RoundSliderOverlayShape(overlayRadius: 20.0), + ), + child: Slider( + value: _progressValue.toDouble(), + min: 1, + max: _data!.pageCount.toDouble(), + onChanged: (p) { + _progressValue = p.toInt(); + if (mounted) setState(() {}); + }, + onChangeEnd: _onSliderChanged, + ), + ), + ), + ), + Padding( + padding: EdgeInsets.only(left: 4, right: 18), + child: Text( + '$_progressValue/${_data!.pageCount}页', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ActionRowView.four( + compact: true, + shrink: false, + textColor: Colors.white, + iconColor: Colors.white, + action1: ActionItem( + text: _setting.viewDirection != ViewDirection.rightToLeft ? '上一章节' : '下一章节', + icon: Icons.arrow_right_alt, + rotateAngle: math.pi, + action: () => _gotoChapter(gotoPrevious: _setting.viewDirection != ViewDirection.rightToLeft), + ), + action2: ActionItem( + text: _setting.viewDirection != ViewDirection.rightToLeft ? '下一章节' : '上一章节', + icon: Icons.arrow_right_alt, + action: () => _gotoChapter(gotoPrevious: _setting.viewDirection != ViewDirection.rightToLeft), + ), + action3: ActionItem( + text: '阅读设置', + icon: Icons.settings, + action: () => _onSettingPressed(), + ), + action4: ActionItem( + text: '漫画目录', + icon: Icons.menu, + action: () => _showToc(), + ), + ), + ], + ), + ), + ), + ), + // **************************************************************** + // 帮助区域显示 + // **************************************************************** + if (_showHelpRegion) + Positioned.fill( + child: GestureDetector( + onTap: () { + _showHelpRegion = false; + if (mounted) setState(() {}); + }, + child: DefaultTextStyle( + style: Theme.of(context).textTheme.headline6!.copyWith(color: Colors.white), + child: _setting.viewDirection != ViewDirection.topToBottom + ? Row( + children: [ + Container( + width: MediaQuery.of(context).size.width * _kSlideWidthRatio, + color: Colors.orange[300]!.withOpacity(0.75), + alignment: Alignment.center, + child: Text(_setting.viewDirection == ViewDirection.leftToRight ? '上\n一\n页' : '下\n一\n页'), + ), + Container( + width: MediaQuery.of(context).size.width * (1 - 2 * _kSlideWidthRatio), + color: Colors.blue[300]!.withOpacity(0.75), + alignment: Alignment.center, + child: Text('菜单'), + ), + Container( + width: MediaQuery.of(context).size.width * _kSlideWidthRatio, + color: Colors.pink[300]!.withOpacity(0.75), + alignment: Alignment.center, + child: Text(_setting.viewDirection == ViewDirection.leftToRight ? '下\n一\n页' : '上\n一\n页'), + ), + ], + ) + : Column( + children: [ + Container( + height: (MediaQuery.of(context).size.height - MediaQuery.of(context).padding.vertical) * _kSlideHeightRatio, + color: Colors.orange[300]!.withOpacity(0.75), + alignment: Alignment.center, + child: Text('上一页'), + ), + Container( + height: (MediaQuery.of(context).size.height - MediaQuery.of(context).padding.vertical) * (1 - 2 * _kSlideHeightRatio), + color: Colors.blue[300]!.withOpacity(0.75), + alignment: Alignment.center, + child: Text('菜单'), + ), + Container( + height: (MediaQuery.of(context).size.height - MediaQuery.of(context).padding.vertical) * _kSlideHeightRatio, + color: Colors.pink[300]!.withOpacity(0.75), + alignment: Alignment.center, + child: Text('下一页'), + ), + ], + ), + ), + ), + ), + // **************************************************************** + // Stack children 结束 + // **************************************************************** + ], + ), + ), + ), + ), + ); + } +} + +class _ScreenHelper { + static late BuildContext _context; + static void Function() _setState = () {}; + + static void initialize({required BuildContext context, required void Function() setState}) { + _context = context; + _setState = setState; + } + + static bool? __lowerThanAndroidQ; + + static Future _lowerThanAndroidQ() async { + __lowerThanAndroidQ ??= Platform.isAndroid && (await DeviceInfoPlugin().androidInfo).version.sdkInt! < 29; // SDK 29 => Android 10 + return __lowerThanAndroidQ!; + } + + static Future toggleWakelock({required bool enable}) { + return Wakelock.toggle(enable: enable); + } + + static Future restoreWakelock() { + return Wakelock.toggle(enable: false); + } + + static bool _showAppBar = false; // default to hide + + static bool get showAppBar => _showAppBar; + + static Future toggleAppBarVisibility({required bool show, required bool fullscreen}) async { + if (_showAppBar == true && show == false) { + _showAppBar = false; + _setState(); + if (fullscreen) { + await Future.delayed(_kAnimationDuration + Duration(milliseconds: 50)); + await _ScreenHelper.setSystemUIWhenAppbarChanged(fullscreen: fullscreen, isAppbarShown: false); + } + } else if (_showAppBar == false && show == true) { + if (fullscreen) { + await _ScreenHelper.setSystemUIWhenAppbarChanged(fullscreen: fullscreen, isAppbarShown: true); + await Future.delayed(_kOverlayAnimationDuration + Duration(milliseconds: 50)); + } + _showAppBar = true; + _setState(); + } + await WidgetsBinding.instance?.endOfFrame; + } + + static Future restoreAppBarVisibility() async { + _showAppBar = false; + _safeAreaTop = true; + _bottomPanelDistance = 0; + // no setState + } + + static bool _safeAreaTop = true; // defaults to non-fullscreen + + static bool get safeAreaTop => _safeAreaTop; + + static double _bottomPanelDistance = 0; // defaults to non-fullscreen + + static double get bottomPanelDistance => _bottomPanelDistance; + + static Future setSystemUIWhenEnter({required bool fullscreen}) async { + var color = !fullscreen || await _lowerThanAndroidQ() ? Colors.black : Colors.transparent; + setSystemUIOverlayStyle( + navigationBarIconBrightness: Brightness.light, + navigationBarColor: color, + navigationBarDividerColor: color, + ); + await setSystemUIWhenAppbarChanged(fullscreen: fullscreen, isAppbarShown: _showAppBar); + } + + static Future setSystemUIWhenSettingChanged({required bool fullscreen}) async { + await setSystemUIWhenEnter(fullscreen: fullscreen); + } + + static Future setSystemUIWhenAppbarChanged({required bool fullscreen, required bool isAppbarShown}) async { + // https://hiyoko-programming.com/953/ + if (!fullscreen) { + // 不全屏 => 全部显示,不透明 (manual) + await setManualSystemUIMode(SystemUiOverlay.values); + _safeAreaTop = true; + _bottomPanelDistance = 0; + } else if (!isAppbarShown) { + // 全屏,且不显示 AppBar => 全部隐藏 (manual) + await setManualSystemUIMode([]); + _safeAreaTop = false; + _bottomPanelDistance = 0; + } else { + // 全屏,且显示 AppBar => 全部显示,尽量透明 (edgeToEdge / manual) + if (!(await _lowerThanAndroidQ())) { + await setEdgeToEdgeSystemUIMode(); + _safeAreaTop = false; + await Future.delayed(_kOverlayAnimationDuration + Duration(milliseconds: 50)); + _bottomPanelDistance = MediaQuery.of(_context).padding.bottom; + } else { + await setManualSystemUIMode(SystemUiOverlay.values); + _safeAreaTop = false; + _bottomPanelDistance = 0; + } + } + _setState(); + await WidgetsBinding.instance?.endOfFrame; + } + + static Future restoreSystemUI() async { + setDefaultSystemUIOverlayStyle(); + await setManualSystemUIMode(SystemUiOverlay.values); + } +} diff --git a/lib/page/page/author.dart b/lib/page/page/author.dart index 670af41..b8b7fbe 100644 --- a/lib/page/page/author.dart +++ b/lib/page/page/author.dart @@ -1,102 +1,99 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/list.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:manhuagui_flutter/model/author.dart'; import 'package:manhuagui_flutter/model/category.dart'; import 'package:manhuagui_flutter/model/order.dart'; +import 'package:manhuagui_flutter/page/view/list_hint.dart'; import 'package:manhuagui_flutter/page/view/option_popup.dart'; import 'package:manhuagui_flutter/page/view/small_author_line.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; -/// 分类漫画家 +/// 分类-漫画作者 class AuthorSubPage extends StatefulWidget { const AuthorSubPage({ - Key key, + Key? key, this.action, }) : super(key: key); - final ActionController action; + final ActionController? action; @override _AuthorSubPageState createState() => _AuthorSubPageState(); } class _AuthorSubPageState extends State with AutomaticKeepAliveClientMixin { + final _pdvKey = GlobalKey(); final _controller = ScrollController(); - final _udvController = UpdatableDataViewController(); final _fabController = AnimatedFabController(); - var _genreLoading = true; - var _genres = []; - var _genreError = ''; - var _data = []; - int _total; - var _order = AuthorOrder.byPopular; - var _lastOrder = AuthorOrder.byPopular; - var _selectedGenre = allGenres[0]; - var _selectedAge = allAges[0]; - var _selectedZone = allZones[0]; - var _lastGenre = allGenres[0]; - var _lastAge = allAges[0]; - var _lastZone = allZones[0]; - var _disableOption = false; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadGenres()); - widget.action?.addAction('', () => _controller.scrollToTop()); + widget.action?.addAction(() => _controller.scrollToTop()); + WidgetsBinding.instance?.addPostFrameCallback((_) => _loadGenres()); } @override void dispose() { - widget.action?.removeAction(''); + widget.action?.removeAction(); _controller.dispose(); - _udvController.dispose(); _fabController.dispose(); super.dispose(); } - Future _loadGenres() { + var _genreLoading = true; + final _genres = []; + var _genreError = ''; + + Future _loadGenres() async { _genreLoading = true; if (mounted) setState(() {}); - var dio = DioManager.instance.dio; - var client = RestClient(dio); - return client.getGenres().then((r) async { - _genreError = ''; + final client = RestClient(DioManager.instance.dio); + try { + var result = await client.getGenres(); _genres.clear(); + _genreError = ''; if (mounted) setState(() {}); await Future.delayed(Duration(milliseconds: 20)); - _genres = r.data.data; - }).catchError((e) { + _genres.add(allGenres[0]); + _genres.addAll(result.data.data.map((c) => c.toTiny())); + } catch (e, s) { _genres.clear(); - _genreError = wrapError(e).text; - }).whenComplete(() { + _genreError = wrapError(e, s).text; + } finally { _genreLoading = false; if (mounted) setState(() {}); - }); + } } - Future> _getData({int page}) async { - var dio = DioManager.instance.dio; - var client = RestClient(dio); - ErrorMessage err; + final _data = []; + var _total = 0; + var _currOrder = AuthorOrder.byPopular; + var _lastOrder = AuthorOrder.byPopular; + var _currGenre = allGenres[0]; + var _lastGenre = allGenres[0]; + var _currAge = allAges[0]; + var _lastAge = allAges[0]; + var _currZone = allZones[0]; + var _lastZone = allZones[0]; + var _getting = false; + + Future> _getData({required int page}) async { + final client = RestClient(DioManager.instance.dio); var f = client.getAllAuthors( - genre: _selectedGenre.name, - zone: _selectedZone.name, - age: _selectedAge.name, + genre: _currGenre.name, + zone: _currZone.name, + age: _currAge.name, page: page, - order: _order, + order: _currOrder, ); - var result = await f.catchError((e) { - err = wrapError(e); + var result = await f.onError((e, s) { + return Future.error(wrapError(e, s).text); }); - if (err != null) { - return Future.error(err.text); - } _total = result.data.total; if (mounted) setState(() {}); @@ -110,157 +107,120 @@ class _AuthorSubPageState extends State with AutomaticKeepAliveCl Widget build(BuildContext context) { super.build(context); return Scaffold( - // **************************************************************** - // 加载 Genre - // **************************************************************** body: PlaceholderText.from( isLoading: _genreLoading, errorText: _genreError, - isEmpty: _genres?.isNotEmpty != true, - setting: PlaceholderSetting().toChinese(), + isEmpty: _genres.isEmpty, + setting: PlaceholderSetting().copyWithChinese(), onRefresh: () => _loadGenres(), childBuilder: (c) => PaginationListView( + key: _pdvKey, data: _data, getData: ({indicator}) => _getData(page: indicator), scrollController: _controller, - controller: _udvController, paginationSetting: PaginationSetting( initialIndicator: 1, nothingIndicator: 0, ), setting: UpdatableDataViewSetting( - padding: EdgeInsets.zero, - placeholderSetting: PlaceholderSetting().toChinese(), + padding: EdgeInsets.symmetric(vertical: 0), + interactiveScrollbar: true, + scrollbarCrossAxisMargin: 2, + placeholderSetting: PlaceholderSetting().copyWithChinese(), + onPlaceholderStateChanged: (_, __) => _fabController.hide(), refreshFirst: true, - clearWhenError: false, clearWhenRefresh: false, + clearWhenError: false, updateOnlyIfNotEmpty: false, - onStateChanged: (_, __) => _fabController.hide(), - onStartLoading: () => mountedSetState(() => _disableOption = true), - onStopLoading: () => mountedSetState(() => _disableOption = false), - onAppend: (l) { - if (l.length > 0) { - Fluttertoast.showToast(msg: '新添了 ${l.length} 位漫画家'); - } - _lastOrder = _order; - _lastGenre = _selectedGenre; - _lastAge = _selectedAge; - _lastZone = _selectedZone; - if (mounted) setState(() {}); + onStartGettingData: () => mountedSetState(() => _getting = true), + onStopGettingData: () => mountedSetState(() => _getting = false), + onAppend: (_, l) { + _lastOrder = _currOrder; + _lastGenre = _currGenre; + _lastAge = _currAge; + _lastZone = _currZone; }, onError: (e) { - Fluttertoast.showToast(msg: e.toString()); - _order = _lastOrder; - _selectedGenre = _lastGenre; - _selectedAge = _lastAge; - _selectedZone = _lastZone; + if (_data.isNotEmpty) { + Fluttertoast.showToast(msg: e.toString()); + } + _currOrder = _lastOrder; + _currGenre = _lastGenre; + _currAge = _lastAge; + _currZone = _lastZone; if (mounted) setState(() {}); }, ), - separator: Divider(height: 1), - itemBuilder: (c, item) => SmallAuthorLineView(author: item), + separator: Divider(height: 0, thickness: 1), + itemBuilder: (c, _, item) => SmallAuthorLineView(author: item), extra: UpdatableDataViewExtraWidgets( - outerTopWidget: Container( - color: Colors.white, - padding: EdgeInsets.symmetric(vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - // **************************************************************** - // 检索条件 - // **************************************************************** + outerTopWidgets: [ + ListHintView.widgets( + widgets: [ OptionPopupView( - title: _selectedGenre.isAll() ? '剧情' : _selectedGenre.title, - top: 4, - doHighlight: true, - value: _selectedGenre, - items: _genres.map((g) => g.toTiny()).toList()..insert(0, allGenres[0]), + items: _genres, + value: _currGenre, + titleBuilder: (c, v) => v.isAll() ? '剧情' : v.title, + enable: !_getting, onSelect: (g) { - if (_selectedGenre != g) { - _lastGenre = _selectedGenre; - _selectedGenre = g; + if (_currGenre != g) { + _lastGenre = _currGenre; + _currGenre = g; if (mounted) setState(() {}); - _udvController.refresh(); + _pdvKey.currentState?.refresh(); } }, - optionBuilder: (c, v) => v.title, - enable: !_disableOption, ), OptionPopupView( - title: _selectedAge.isAll() ? '受众' : _selectedAge.title, - top: 4, - doHighlight: true, - value: _selectedAge, items: allAges, + value: _currAge, + titleBuilder: (c, v) => v.isAll() ? '受众' : v.title, + enable: !_getting, onSelect: (a) { - if (_selectedAge != a) { - _lastAge = _selectedAge; - _selectedAge = a; + if (_currAge != a) { + _lastAge = _currAge; + _currAge = a; if (mounted) setState(() {}); - _udvController.refresh(); + _pdvKey.currentState?.refresh(); } }, - optionBuilder: (c, v) => v.title, - enable: !_disableOption, ), OptionPopupView( - title: _selectedZone.isAll() ? '地区' : _selectedZone.title, - top: 4, - doHighlight: true, - value: _selectedZone, items: allZones, + value: _currZone, + titleBuilder: (c, v) => v.isAll() ? '地区' : v.title, + enable: !_getting, onSelect: (z) { - if (_selectedZone != z) { - _lastZone = _selectedZone; - _selectedZone = z; + if (_currZone != z) { + _lastZone = _currZone; + _currZone = z; if (mounted) setState(() {}); - _udvController.refresh(); + _pdvKey.currentState?.refresh(); } }, - optionBuilder: (c, v) => v.title, - enable: !_disableOption, ), ], ), - ), - outerTopDivider: Divider(height: 1, thickness: 1), - innerTopWidget: Container( - color: Colors.white, - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - height: 26, - padding: EdgeInsets.only(left: 5), - child: Center( - child: Text('搜索结果 (共 ${_total == null ? '?' : _total.toString()} 位)'), - ), - ), - // **************************************************************** - // 检索排序 - // **************************************************************** - OptionPopupView( - title: _order.toTitle(), - top: 4, - doHighlight: true, - value: _order, - items: [AuthorOrder.byPopular, AuthorOrder.byComic, AuthorOrder.byUpdate], - onSelect: (o) { - if (_order != o) { - _lastOrder = _order; - _order = o; - if (mounted) setState(() {}); - _udvController.refresh(); - } - }, - optionBuilder: (c, v) => v.toTitle(), - enable: !_disableOption, - ), - ], + ], + innerTopWidgets: [ + ListHintView.textWidget( + leftText: '搜索结果 (共 $_total 位)', + rightWidget: OptionPopupView( + items: const [AuthorOrder.byPopular, AuthorOrder.byComic, AuthorOrder.byUpdate], + value: _currOrder, + titleBuilder: (c, v) => v.toTitle(), + enable: !_getting, + onSelect: (o) { + if (_currOrder != o) { + _lastOrder = _currOrder; + _currOrder = o; + if (mounted) setState(() {}); + _pdvKey.currentState?.refresh(); + } + }, + ), ), - ), - innerTopDivider: Divider(height: 1, thickness: 1), + ], ), ), ), @@ -270,7 +230,7 @@ class _AuthorSubPageState extends State with AutomaticKeepAliveCl condition: ScrollAnimatedCondition.direction, fab: FloatingActionButton( child: Icon(Icons.vertical_align_top), - heroTag: 'AuthorSubPage', + heroTag: null, onPressed: () => _controller.scrollToTop(), ), ), diff --git a/lib/page/page/category.dart b/lib/page/page/category.dart index 53fb89f..6f5226f 100644 --- a/lib/page/page/category.dart +++ b/lib/page/page/category.dart @@ -1,50 +1,47 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/page/page/author.dart'; import 'package:manhuagui_flutter/page/page/genre.dart'; import 'package:manhuagui_flutter/page/search.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; /// 分类 class CategorySubPage extends StatefulWidget { const CategorySubPage({ - Key key, + Key? key, this.action, }) : super(key: key); - final ActionController action; + final ActionController? action; @override _CategorySubPageState createState() => _CategorySubPageState(); } class _CategorySubPageState extends State with SingleTickerProviderStateMixin { - TabController _controller; + late final _controller = TabController(length: _tabs.length, vsync: this); var _selectedIndex = 0; - var _tabs = ['类别', '漫画家']; - var _actions = []; - var _pages = []; + late final _actions = List.generate(2, (_) => ActionController()); + late final _tabs = [ + Tuple2('类别', GenreSubPage(action: _actions[0])), + Tuple2('漫画作者', AuthorSubPage(action: _actions[1])), + ]; + VoidCallback? _cancelHandler; @override void initState() { super.initState(); - _controller = TabController( - length: _tabs.length, - vsync: this, - ); - _actions = List.generate(_tabs.length, (_) => ActionController()); - _pages = [ - GenreSubPage(action: _actions[0]), - AuthorSubPage(action: _actions[1]), - ]; - - widget.action?.addAction('', () => _actions[_controller.index].invoke('')); - widget.action?.addAction('to_genre', () => _controller.animateTo(0)); + widget.action?.addAction(() => _actions[_controller.index].invoke()); + _cancelHandler = EventBusManager.instance.listen((_) { + _controller.animateTo(0); + }); } @override void dispose() { - widget.action?.removeAction(''); - widget.action?.removeAction('to_genre'); + _cancelHandler?.call(); + widget.action?.removeAction(); _controller.dispose(); _actions.forEach((a) => a.dispose()); super.dispose(); @@ -54,35 +51,40 @@ class _CategorySubPageState extends State with SingleTickerProv Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, - toolbarHeight: 45, title: TabBar( controller: _controller, isScrollable: true, indicatorSize: TabBarIndicatorSize.label, - labelStyle: Theme.of(context).primaryTextTheme.subtitle1, tabs: _tabs .map( (t) => Padding( - padding: EdgeInsets.symmetric(vertical: 6), - child: Text(t), + padding: EdgeInsets.symmetric(vertical: 5), + child: Text( + t.item1, + style: Theme.of(context).textTheme.subtitle1?.copyWith( + color: Colors.white, + fontSize: 16, + ), + ), ), ) .toList(), onTap: (idx) { if (idx == _selectedIndex) { - _actions[idx].invoke(''); + _actions[idx].invoke(); } else { _selectedIndex = idx; } }, ), + leading: AppBarActionButton.leading(context: context, allowDrawerButton: true), actions: [ - IconButton( + AppBarActionButton( icon: Icon(Icons.search), tooltip: '搜索', onPressed: () => Navigator.of(context).push( - MaterialPageRoute( + CustomPageRoute( + context: context, builder: (c) => SearchPage(), ), ), @@ -91,7 +93,7 @@ class _CategorySubPageState extends State with SingleTickerProv ), body: TabBarView( controller: _controller, - children: _pages, + children: _tabs.map((t) => t.item2).toList(), ), ); } diff --git a/lib/page/page/dl_finished.dart b/lib/page/page/dl_finished.dart new file mode 100644 index 0000000..855ace8 --- /dev/null +++ b/lib/page/page/dl_finished.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/page/view/manga_simple_toc.dart'; +import 'package:manhuagui_flutter/page/view/manga_toc.dart'; + +/// 章节下载管理页-已完成 +class DlFinishedSubPage extends StatefulWidget { + const DlFinishedSubPage({ + Key? key, + required this.innerController, + required this.outerController, + required this.injectorHandler, + required this.mangaEntity, + required this.invertOrder, + required this.history, + required this.toReadChapter, + required this.toDeleteChapter, + }) : super(key: key); + + final ScrollController innerController; + final ScrollController outerController; + final SliverOverlapAbsorberHandle injectorHandler; + final DownloadedManga mangaEntity; + final bool invertOrder; + final MangaHistory? history; + final void Function(int cid) toReadChapter; + final void Function(int cid) toDeleteChapter; + + @override + State createState() => _DlFinishedSubPageState(); +} + +class _DlFinishedSubPageState extends State with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + var succeededChapters = widget.mangaEntity.downloadedChapters // + .where((el) => el.succeeded) + .map((el) => Tuple2(el.chapterGroup, el.toTiny())) + .toList(); + + return Scaffold( + body: ScrollbarWithMore( + controller: widget.innerController, + interactive: true, + crossAxisMargin: 2, + child: CustomScrollView( + controller: widget.innerController, + physics: AlwaysScrollableScrollPhysics(), + slivers: [ + SliverOverlapInjector( + handle: widget.injectorHandler, + ), + SliverToBoxAdapter( + child: Container( + color: Colors.white, + child: MangaSimpleTocView( + chapters: succeededChapters, + invertOrder: widget.invertOrder, + showNewBadge: false, + highlightedChapters: [widget.history?.chapterId ?? 0], + customBadgeBuilder: (cid) => DownloadBadge.fromEntity( + entity: widget.mangaEntity.downloadedChapters.where((el) => el.chapterId == cid).firstOrNull, + ), + onChapterPressed: widget.toReadChapter, + onChapterLongPressed: widget.toDeleteChapter, + ), + ), + ), + ], + ), + ), + floatingActionButton: ScrollAnimatedFab( + scrollController: widget.innerController, + condition: ScrollAnimatedCondition.direction, + fab: FloatingActionButton( + child: Icon(Icons.vertical_align_top), + heroTag: null, + onPressed: () => widget.outerController.scrollToTop(), + ), + ), + ); + } +} diff --git a/lib/page/page/dl_setting.dart b/lib/page/page/dl_setting.dart new file mode 100644 index 0000000..33dd5de --- /dev/null +++ b/lib/page/page/dl_setting.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; + +/// 下载列表页-下载设置 + +class DlSetting { + DlSetting({ + required this.defaultToDeleteFiles, + required this.downloadPagesTogether, + required this.invertDownloadOrder, + }); + + final bool defaultToDeleteFiles; // 默认删除已下载的文件 + final int downloadPagesTogether; // 同时下载的页面数量 + final bool invertDownloadOrder; // 漫画章节下载顺序 + + DlSetting.defaultSetting() + : this( + defaultToDeleteFiles: false, + downloadPagesTogether: 4, + invertDownloadOrder: false, + ); + + DlSetting copyWith({ + bool? defaultToDeleteFiles, + int? downloadPagesTogether, + bool? invertDownloadOrder, + }) { + return DlSetting( + defaultToDeleteFiles: defaultToDeleteFiles ?? this.defaultToDeleteFiles, + downloadPagesTogether: downloadPagesTogether ?? this.downloadPagesTogether, + invertDownloadOrder: invertDownloadOrder ?? this.invertDownloadOrder, + ); + } +} + +class DlSettingSubPage extends StatefulWidget { + const DlSettingSubPage({ + Key? key, + required this.setting, + required this.onSettingChanged, + }) : super(key: key); + + final DlSetting setting; + final void Function(DlSetting) onSettingChanged; + + @override + State createState() => _DlSettingSubPageState(); +} + +class _DlSettingSubPageState extends State { + late var _defaultToDeleteFiles = widget.setting.defaultToDeleteFiles; + late var _downloadPagesTogether = widget.setting.downloadPagesTogether; + late var _invertDownloadOrder = widget.setting.invertDownloadOrder; + + DlSetting get _newestSetting => DlSetting( + defaultToDeleteFiles: _defaultToDeleteFiles, + downloadPagesTogether: _downloadPagesTogether, + invertDownloadOrder: _invertDownloadOrder, + ); + + Widget _buildComboBox({ + required String title, + double width = 120, + required T value, + required List values, + required Widget Function(T) builder, + required void Function(T) onChanged, + }) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyText1, + ), + SizedBox( + height: 38, + width: width, + child: DropdownButton( + value: value, + items: values.map((s) => DropdownMenuItem(child: builder(s), value: s)).toList(), + underline: Container(color: Colors.white), + isExpanded: true, + onChanged: (v) { + if (v != null) { + onChanged.call(v); + } + }, + ), + ), + ], + ); + } + + Widget _buildSwitcher({ + required String title, + required bool value, + required void Function(bool) onChanged, + bool enable = true, + }) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyText1, + ), + SizedBox( + height: 38, + child: Switch( + value: value, + onChanged: enable ? onChanged : null, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildSwitcher( + title: '默认删除已下载的文件      ', + value: _defaultToDeleteFiles, + onChanged: (b) { + _defaultToDeleteFiles = b; + widget.onSettingChanged.call(_newestSetting); + if (mounted) setState(() {}); + }, + ), + _buildComboBox( + title: '同时下载的页面数量', + width: 75, + value: _downloadPagesTogether.clamp(1, 8), + values: List.generate(8, (i) => i + 1), + builder: (s) => Text( + '$s页', + style: Theme.of(context).textTheme.bodyText2, + ), + onChanged: (c) { + _downloadPagesTogether = c.clamp(1, 8); + widget.onSettingChanged.call(_newestSetting); + if (mounted) setState(() {}); + }, + ), + _buildComboBox( + title: '漫画章节下载顺序', + value: _invertDownloadOrder, + values: [false, true], + builder: (s) => Text( + !s ? '正序 (旧到新)' : '逆序 (新到旧)', + style: Theme.of(context).textTheme.bodyText2, + ), + onChanged: (c) { + _invertDownloadOrder = c; + widget.onSettingChanged.call(_newestSetting); + if (mounted) setState(() {}); + }, + ), + ], + ); + } +} diff --git a/lib/page/page/dl_unfinished.dart b/lib/page/page/dl_unfinished.dart new file mode 100644 index 0000000..6e47969 --- /dev/null +++ b/lib/page/page/dl_unfinished.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/page/view/download_chapter_line.dart'; +import 'package:manhuagui_flutter/service/storage/download_manga_task.dart'; + +/// 章节下载管理页-未完成 +class DlUnfinishedSubPage extends StatefulWidget { + const DlUnfinishedSubPage({ + Key? key, + required this.innerController, + required this.outerController, + required this.injectorHandler, + required this.mangaEntity, + required this.downloadTask, + required this.invertOrder, + required this.toControlChapter, + required this.toReadChapter, + required this.toDeleteChapter, + }) : super(key: key); + + final ScrollController innerController; + final ScrollController outerController; + final SliverOverlapAbsorberHandle injectorHandler; + final DownloadedManga mangaEntity; + final DownloadMangaQueueTask? downloadTask; + final bool invertOrder; + final void Function(int cid) toControlChapter; + final void Function(int cid) toReadChapter; + final void Function(int cid) toDeleteChapter; + + @override + State createState() => _DlUnfinishedSubPageState(); +} + +class _DlUnfinishedSubPageState extends State with AutomaticKeepAliveClientMixin { + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + var unfinishedChapters = widget.mangaEntity.downloadedChapters // + .where((el) => !el.succeeded) + .toList(); + if (!widget.invertOrder) { + unfinishedChapters.sort((i, j) => i.chapterId.compareTo(j.chapterId)); + } else { + unfinishedChapters.sort((i, j) => j.chapterId.compareTo(i.chapterId)); + } + + return Scaffold( + body: ScrollbarWithMore( + controller: widget.innerController, + interactive: true, + crossAxisMargin: 2, + child: CustomScrollView( + controller: widget.innerController, + physics: AlwaysScrollableScrollPhysics(), + slivers: [ + SliverOverlapInjector( + handle: widget.injectorHandler, + ), + if (unfinishedChapters.isEmpty) + SliverToBoxAdapter( + child: Container( + color: Colors.white, + padding: EdgeInsets.symmetric(vertical: 15, horizontal: 15), + child: Center( + child: Text( + '暂无章节', + style: Theme.of(context).textTheme.subtitle1, + ), + ), + ), + ), + if (unfinishedChapters.isNotEmpty) + SliverList( + delegate: SliverChildListDelegate( + [ + for (var chapter in unfinishedChapters) + Material( + color: Colors.white, + child: DownloadChapterLineView( + chapterEntity: chapter, + downloadTask: widget.downloadTask, + onPressedWhenEnabled: () => widget.toControlChapter.call(chapter.chapterId), + onPressedWhenDisabled: () => widget.toReadChapter.call(chapter.chapterId), + onLongPressed: () => widget.toDeleteChapter.call(chapter.chapterId), + ), + ) + ].separate( + Container( + color: Colors.white, + child: Divider(height: 0, thickness: 1), + ), + ), + ), + ), + ], + ), + ), + floatingActionButton: ScrollAnimatedFab( + scrollController: widget.innerController, + condition: ScrollAnimatedCondition.direction, + fab: FloatingActionButton( + child: Icon(Icons.vertical_align_top), + heroTag: null, + onPressed: () => widget.outerController.scrollToTop(), + ), + ), + ); + } +} diff --git a/lib/page/page/genre.dart b/lib/page/page/genre.dart index 13a7401..f0e54cf 100644 --- a/lib/page/page/genre.dart +++ b/lib/page/page/genre.dart @@ -1,112 +1,104 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/list.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:manhuagui_flutter/model/category.dart'; import 'package:manhuagui_flutter/model/order.dart'; import 'package:manhuagui_flutter/model/manga.dart'; +import 'package:manhuagui_flutter/page/view/list_hint.dart'; import 'package:manhuagui_flutter/page/view/option_popup.dart'; import 'package:manhuagui_flutter/page/view/tiny_manga_line.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; -/// 分类类别 +/// 分类-类别 class GenreSubPage extends StatefulWidget { const GenreSubPage({ - Key key, + Key? key, this.defaultGenre, this.action, }) : super(key: key); - final TinyCategory defaultGenre; - final ActionController action; + final TinyCategory? defaultGenre; + final ActionController? action; @override _GenreSubPageState createState() => _GenreSubPageState(); } class _GenreSubPageState extends State with AutomaticKeepAliveClientMixin { + final _pdvKey = GlobalKey(); final _controller = ScrollController(); - final _udvController = UpdatableDataViewController(); final _fabController = AnimatedFabController(); - var _genreLoading = true; - var _genres = []; - var _genreError = ''; - var _data = []; - int _total; - var _order = MangaOrder.byPopular; - var _lastOrder = MangaOrder.byPopular; - var _selectedGenre = allGenres[0]; - var _selectedAge = allAges[0]; - var _selectedZone = allZones[0]; - var _selectedStatus = allStatuses[0]; - var _lastGenre = allGenres[0]; - var _lastAge = allAges[0]; - var _lastZone = allZones[0]; - var _lastStatus = allStatuses[0]; - var _disableOption = false; @override void initState() { - if (widget.defaultGenre != null) { - _selectedGenre = widget.defaultGenre; - _lastGenre = widget.defaultGenre; - } super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) => _loadGenres()); - widget.action?.addAction('', () => _controller.scrollToTop()); + widget.action?.addAction(() => _controller.scrollToTop()); + WidgetsBinding.instance?.addPostFrameCallback((_) => _loadGenres()); } @override void dispose() { - widget.action?.removeAction(''); + widget.action?.removeAction(); _controller.dispose(); - _udvController.dispose(); _fabController.dispose(); super.dispose(); } - Future _loadGenres() { + var _genreLoading = true; + final _genres = []; + var _genreError = ''; + + Future _loadGenres() async { _genreLoading = true; if (mounted) setState(() {}); - var dio = DioManager.instance.dio; - var client = RestClient(dio); - return client.getGenres().then((r) async { - _genreError = ''; + final client = RestClient(DioManager.instance.dio); + try { + var result = await client.getGenres(); _genres.clear(); + _genreError = ''; if (mounted) setState(() {}); await Future.delayed(Duration(milliseconds: 20)); - _genres = r.data.data; - }).catchError((e) { + _genres.add(allGenres[0]); + _genres.addAll(result.data.data.map((c) => c.toTiny())); + } catch (e, s) { _genres.clear(); - _genreError = wrapError(e).text; - }).whenComplete(() { + _genreError = wrapError(e, s).text; + } finally { _genreLoading = false; if (mounted) setState(() {}); - }); + } } - Future> _getData({int page}) async { - var dio = DioManager.instance.dio; - var client = RestClient(dio); - ErrorMessage err; + final _data = []; + var _total = 0; + var _currOrder = MangaOrder.byPopular; + var _lastOrder = MangaOrder.byPopular; + late var _currGenre = widget.defaultGenre ?? allGenres[0]; + late var _lastGenre = widget.defaultGenre ?? allGenres[0]; + var _currAge = allAges[0]; + var _lastAge = allAges[0]; + var _currZone = allZones[0]; + var _lastZone = allZones[0]; + var _currStatus = allStatuses[0]; + var _lastStatus = allStatuses[0]; + var _getting = false; + + Future> _getData({required int page}) async { + final client = RestClient(DioManager.instance.dio); var f = client.getGenreMangas( - genre: _selectedGenre.name, - zone: _selectedZone.name, - age: _selectedAge.name, - status: _selectedStatus.name, + genre: _currGenre.name, + zone: _currZone.name, + age: _currAge.name, + status: _currStatus.name, page: page, - order: _order, + order: _currOrder, ); - var result = await f.catchError((e) { - err = wrapError(e); + var result = await f.onError((e, s) { + return Future.error(wrapError(e, s).text); }); - if (err != null) { - return Future.error(err.text); - } _total = result.data.total; if (mounted) setState(() {}); @@ -120,176 +112,136 @@ class _GenreSubPageState extends State with AutomaticKeepAliveClie Widget build(BuildContext context) { super.build(context); return Scaffold( - // **************************************************************** - // 加载 Genre - // **************************************************************** body: PlaceholderText.from( isLoading: _genreLoading, errorText: _genreError, - isEmpty: _genres?.isNotEmpty != true, - setting: PlaceholderSetting().toChinese(), + isEmpty: _genres.isEmpty, + setting: PlaceholderSetting().copyWithChinese(), onRefresh: () => _loadGenres(), childBuilder: (c) => PaginationListView( + key: _pdvKey, data: _data, getData: ({indicator}) => _getData(page: indicator), - controller: _udvController, scrollController: _controller, paginationSetting: PaginationSetting( initialIndicator: 1, nothingIndicator: 0, ), setting: UpdatableDataViewSetting( - padding: EdgeInsets.zero, - placeholderSetting: PlaceholderSetting().toChinese(), + padding: EdgeInsets.symmetric(vertical: 0), + interactiveScrollbar: true, + scrollbarCrossAxisMargin: 2, + placeholderSetting: PlaceholderSetting().copyWithChinese(), + onPlaceholderStateChanged: (_, __) => _fabController.hide(), refreshFirst: true, - clearWhenError: false, clearWhenRefresh: false, + clearWhenError: false, updateOnlyIfNotEmpty: false, - onStartLoading: () => mountedSetState(() => _disableOption = true), - onStopLoading: () => mountedSetState(() => _disableOption = false), - onStateChanged: (_, __) => _fabController.hide(), - onAppend: (l) { - if (l.length > 0) { - Fluttertoast.showToast(msg: '新添了 ${l.length} 部漫画'); - } - _lastOrder = _order; - _lastGenre = _selectedGenre; - _lastAge = _selectedAge; - _lastZone = _selectedZone; - _lastStatus = _selectedStatus; - if (mounted) setState(() {}); + onStartGettingData: () => mountedSetState(() => _getting = true), + onStopGettingData: () => mountedSetState(() => _getting = false), + onAppend: (_, l) { + _lastOrder = _currOrder; + _lastGenre = _currGenre; + _lastAge = _currAge; + _lastZone = _currZone; + _lastStatus = _currStatus; }, onError: (e) { - Fluttertoast.showToast(msg: e.toString()); - _order = _lastOrder; - _selectedGenre = _lastGenre; - _selectedAge = _lastAge; - _selectedZone = _lastZone; - _selectedStatus = _lastStatus; + if (_data.isNotEmpty) { + Fluttertoast.showToast(msg: e.toString()); + } + _currOrder = _lastOrder; + _currGenre = _lastGenre; + _currAge = _lastAge; + _currZone = _lastZone; + _currStatus = _lastStatus; if (mounted) setState(() {}); }, ), - separator: Divider(height: 1), - itemBuilder: (c, item) => TinyMangaLineView(manga: item), + separator: Divider(height: 0, thickness: 1), + itemBuilder: (c, _, item) => TinyMangaLineView(manga: item), extra: UpdatableDataViewExtraWidgets( - outerTopWidget: Container( - color: Colors.white, - padding: EdgeInsets.symmetric(vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - // **************************************************************** - // 检索条件 - // **************************************************************** + outerTopWidgets: [ + ListHintView.widgets( + widgets: [ OptionPopupView( - title: _selectedGenre.isAll() ? '剧情' : _selectedGenre.title, - top: 4, - doHighlight: true, - value: _selectedGenre, - items: _genres.map((g) => g.toTiny()).toList()..insert(0, allGenres[0]), + items: _genres, + value: _currGenre, + titleBuilder: (c, v) => v.isAll() ? '剧情' : v.title, + enable: !_getting, onSelect: (g) { - if (_selectedGenre != g) { - _lastGenre = _selectedGenre; - _selectedGenre = g; + if (_currGenre != g) { + _lastGenre = _currGenre; + _currGenre = g; if (mounted) setState(() {}); - _udvController.refresh(); + _pdvKey.currentState?.refresh(); } }, - optionBuilder: (c, v) => v.title, - enable: !_disableOption, ), OptionPopupView( - title: _selectedAge.isAll() ? '受众' : _selectedAge.title, - top: 4, - doHighlight: true, - value: _selectedAge, items: allAges, + value: _currAge, + titleBuilder: (c, v) => v.isAll() ? '受众' : v.title, + enable: !_getting, onSelect: (a) { - if (_selectedAge != a) { - _lastAge = _selectedAge; - _selectedAge = a; + if (_currAge != a) { + _lastAge = _currAge; + _currAge = a; if (mounted) setState(() {}); - _udvController.refresh(); + _pdvKey.currentState?.refresh(); } }, - optionBuilder: (c, v) => v.title, - enable: !_disableOption, ), OptionPopupView( - title: _selectedZone.isAll() ? '地区' : _selectedZone.title, - top: 4, - doHighlight: true, - value: _selectedZone, items: allZones, + value: _currZone, + titleBuilder: (c, v) => v.isAll() ? '地区' : v.title, + enable: !_getting, onSelect: (z) { - if (_selectedZone != z) { - _lastZone = _selectedZone; - _selectedZone = z; + if (_currZone != z) { + _lastZone = _currZone; + _currZone = z; if (mounted) setState(() {}); - _udvController.refresh(); + _pdvKey.currentState?.refresh(); } }, - optionBuilder: (c, v) => v.title, - enable: !_disableOption, ), OptionPopupView( - title: _selectedStatus.isAll() ? '进度' : _selectedStatus.title, - top: 4, - doHighlight: true, - value: _selectedStatus, items: allStatuses, + value: _currStatus, + titleBuilder: (c, v) => v.isAll() ? '进度' : v.title, + enable: !_getting, onSelect: (s) { - if (_selectedStatus != s) { - _lastStatus = _selectedStatus; - _selectedStatus = s; + if (_currStatus != s) { + _lastStatus = _currStatus; + _currStatus = s; if (mounted) setState(() {}); - _udvController.refresh(); + _pdvKey.currentState?.refresh(); } }, - optionBuilder: (c, v) => v.title, - enable: !_disableOption, ), ], ), - ), - outerTopDivider: Divider(height: 1, thickness: 1), - innerTopWidget: Container( - color: Colors.white, - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - height: 26, - padding: EdgeInsets.only(left: 5), - child: Center( - child: Text('搜索结果 (共 ${_total == null ? '?' : _total.toString()} 部)'), - ), - ), - // **************************************************************** - // 检索排序 - // **************************************************************** - OptionPopupView( - title: _order.toTitle(), - top: 4, - doHighlight: true, - value: _order, - items: [MangaOrder.byPopular, MangaOrder.byNew, MangaOrder.byUpdate], - onSelect: (o) { - if (_order != o) { - _lastOrder = _order; - _order = o; - if (mounted) setState(() {}); - _udvController.refresh(); - } - }, - optionBuilder: (c, v) => v.toTitle(), - enable: !_disableOption, - ), - ], + ], + innerTopWidgets: [ + ListHintView.textWidget( + leftText: '搜索结果 (共 $_total 部)', + rightWidget: OptionPopupView( + items: const [MangaOrder.byPopular, MangaOrder.byNew, MangaOrder.byUpdate], + value: _currOrder, + titleBuilder: (c, v) => v.toTitle(), + enable: !_getting, + onSelect: (o) { + if (_currOrder != o) { + _lastOrder = _currOrder; + _currOrder = o; + if (mounted) setState(() {}); + _pdvKey.currentState?.refresh(); + } + }, + ), ), - ), - innerTopDivider: Divider(height: 1, thickness: 1), + ], ), ), ), @@ -299,7 +251,7 @@ class _GenreSubPageState extends State with AutomaticKeepAliveClie condition: ScrollAnimatedCondition.direction, fab: FloatingActionButton( child: Icon(Icons.vertical_align_top), - heroTag: 'GenreSubPage', + heroTag: null, onPressed: () => _controller.scrollToTop(), ), ), diff --git a/lib/page/page/history.dart b/lib/page/page/history.dart index fa3ef61..6b2d7cf 100644 --- a/lib/page/page/history.dart +++ b/lib/page/page/history.dart @@ -1,74 +1,96 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/list.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:manhuagui_flutter/model/manga.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/page/view/list_hint.dart'; import 'package:manhuagui_flutter/page/view/manga_history_line.dart'; -import 'package:manhuagui_flutter/service/database/history.dart'; -import 'package:manhuagui_flutter/service/state/auth.dart'; +import 'package:manhuagui_flutter/service/db/history.dart'; +import 'package:manhuagui_flutter/service/evb/auth_manager.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; -/// 订阅浏览历史 +/// 订阅-浏览历史 class HistorySubPage extends StatefulWidget { const HistorySubPage({ - Key key, + Key? key, this.action, }) : super(key: key); - final ActionController action; + final ActionController? action; @override _HistorySubPageState createState() => _HistorySubPageState(); } -class _HistorySubPageState extends State with AutomaticKeepAliveClientMixin, NotifyReceiverMixin { +class _HistorySubPageState extends State with AutomaticKeepAliveClientMixin { + final _pdvKey = GlobalKey(); final _controller = ScrollController(); - final _udvController = UpdatableDataViewController(); final _fabController = AnimatedFabController(); - var _data = []; - int _total; - var _removed = 0; - - @override - String get receiverKey => "HistorySubPage"; + final _cancelHandlers = []; + var _historyUpdated = false; + AuthData? _oldAuthData; @override void initState() { super.initState(); - AuthState.instance.registerDefault(this, () => _udvController.refresh()); - widget.action?.addAction('', () => _controller.scrollToTop()); + widget.action?.addAction(() => _controller.scrollToTop()); + WidgetsBinding.instance?.addPostFrameCallback((_) async { + _cancelHandlers.add(AuthManager.instance.listen(() => _oldAuthData, (_) { + _oldAuthData = AuthManager.instance.authData; + _pdvKey.currentState?.refresh(); + })); + await AuthManager.instance.check(); + }); + _cancelHandlers.add(EventBusManager.instance.listen((_) { + _historyUpdated = true; + if (mounted) setState(() {}); + })); } @override void dispose() { - AuthState.instance.unregisterDefault(this); - widget.action?.removeAction(''); + widget.action?.removeAction(); + _cancelHandlers.forEach((c) => c.call()); _controller.dispose(); - _udvController.dispose(); _fabController.dispose(); super.dispose(); } - void _delete({@required MangaHistory history}) { - assert(history != null); + final _data = []; + var _total = 0; + var _removed = 0; + + Future> _getData({required int page}) async { + if (page == 1) { + // refresh + _removed = 0; + _historyUpdated = false; + } + var username = AuthManager.instance.username; // maybe empty, which represents local history + var data = await HistoryDao.getHistories(username: username, page: page, offset: _removed) ?? []; + _total = await HistoryDao.getHistoryCount(username: username) ?? 0; + if (mounted) setState(() {}); + return PagedList(list: data, next: page + 1); + } + + void _delete({required MangaHistory history}) { showDialog( context: context, builder: (c) => AlertDialog( - title: Text('删除历史记录'), - content: Text('是否删除 ${history.mangaTitle}?'), + title: Text('历史记录刪除确认'), + content: Text('是否删除阅读历史《${history.mangaTitle}》?'), actions: [ - FlatButton( + TextButton( child: Text('删除'), onPressed: () async { _data.remove(history); _removed++; _total--; - await deleteHistory(username: AuthState.instance.username, mid: history.mangaId); + await HistoryDao.deleteHistory(username: AuthManager.instance.username, mid: history.mangaId); if (mounted) setState(() {}); Navigator.of(c).pop(); }, ), - FlatButton( + TextButton( child: Text('取消'), onPressed: () => Navigator.of(c).pop(), ), @@ -77,16 +99,6 @@ class _HistorySubPageState extends State with AutomaticKeepAlive ); } - Future> _getData({int page}) async { - if (page == 1) { - _removed = 0; // refresh - } - var data = await getHistories(username: AuthState.instance.username, page: page, offset: _removed); - _total = await getHistoryCount(username: AuthState.instance.username); - if (mounted) setState(() {}); - return PagedList(list: data, next: page + 1); - } - @override bool get wantKeepAlive => true; @@ -95,55 +107,65 @@ class _HistorySubPageState extends State with AutomaticKeepAlive super.build(context); return Scaffold( body: PaginationListView( + key: _pdvKey, data: _data, getData: ({indicator}) => _getData(page: indicator), - controller: _udvController, scrollController: _controller, paginationSetting: PaginationSetting( initialIndicator: 1, nothingIndicator: 0, ), setting: UpdatableDataViewSetting( - padding: EdgeInsets.zero, - placeholderSetting: PlaceholderSetting().toChinese(), + padding: EdgeInsets.symmetric(vertical: 0), + interactiveScrollbar: true, + scrollbarCrossAxisMargin: 2, + placeholderSetting: PlaceholderSetting().copyWithChinese(), + onPlaceholderStateChanged: (_, __) => _fabController.hide(), refreshFirst: true, - clearWhenError: false, clearWhenRefresh: false, + clearWhenError: false, updateOnlyIfNotEmpty: false, - onStateChanged: (_, __) => _fabController.hide(), - onAppend: (l) {}, - onError: (e) => Fluttertoast.showToast(msg: e.toString()), ), - separator: Divider(height: 1), - itemBuilder: (c, item) => MangaHistoryLineView( + separator: Divider(height: 0, thickness: 1), + itemBuilder: (c, _, item) => MangaHistoryLineView( history: item, onLongPressed: () => _delete(history: item), ), extra: UpdatableDataViewExtraWidgets( - innerTopWidget: Container( - color: Colors.white, - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - height: 26, - padding: EdgeInsets.only(left: 5), - child: Center( - child: Text(AuthState.instance.logined ? '${AuthState.instance.username} 的浏览历史' : '本地的浏览历史'), + innerTopWidgets: [ + ListHintView.textWidget( + leftText: (AuthManager.instance.logined ? '${AuthManager.instance.username} 的浏览历史' : '本地浏览历史') + (_historyUpdated ? ' (有更新)' : ''), + rightWidget: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('共 $_total 部'), + SizedBox(width: 5), + Material( + color: Colors.transparent, + child: InkResponse( + child: Padding( + padding: EdgeInsets.all(3), + child: Icon(Icons.help_outline, size: 20), + ), + onTap: () => showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('浏览历史'), + content: Text('注意:由于漫画柜官方并未提供漫画浏览历史记录的功能,所以本应用的浏览历史仅被记录在移动端本地,且不同账号的浏览历史互不影响。'), + actions: [ + TextButton( + child: Text('确定'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), + ), + ), ), - ), - Container( - height: 26, - padding: EdgeInsets.only(right: 5), - child: Center( - child: Text('共 ${_total == null ? '?' : _total.toString()} 部'), - ), - ), - ], + ], + ), ), - ), - innerTopDivider: Divider(height: 1, thickness: 1), + ], ), ), floatingActionButton: ScrollAnimatedFab( @@ -152,7 +174,7 @@ class _HistorySubPageState extends State with AutomaticKeepAlive condition: ScrollAnimatedCondition.direction, fab: FloatingActionButton( child: Icon(Icons.vertical_align_top), - heroTag: 'HistorySubPage', + heroTag: null, onPressed: () => _controller.scrollToTop(), ), ), diff --git a/lib/page/page/home.dart b/lib/page/page/home.dart index cf71676..340a83c 100644 --- a/lib/page/page/home.dart +++ b/lib/page/page/home.dart @@ -1,58 +1,54 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/util.dart'; -import 'package:manhuagui_flutter/config.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/page/page/overall.dart'; import 'package:manhuagui_flutter/page/page/ranking.dart'; import 'package:manhuagui_flutter/page/page/recent.dart'; import 'package:manhuagui_flutter/page/page/recommend.dart'; import 'package:manhuagui_flutter/page/search.dart'; -import 'package:manhuagui_flutter/service/natives/browser.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; /// 首页 class HomeSubPage extends StatefulWidget { const HomeSubPage({ - Key key, + Key? key, this.action, }) : super(key: key); - final ActionController action; + final ActionController? action; @override _HomeSubPageState createState() => _HomeSubPageState(); } class _HomeSubPageState extends State with SingleTickerProviderStateMixin { - TabController _controller; + late final _controller = TabController(length: _tabs.length, vsync: this); var _selectedIndex = 0; - var _tabs = ['推荐', '更新', '全部', '排行']; - var _actions = []; - var _pages = []; + late final _actions = List.generate(4, (_) => ActionController()); + late final _tabs = [ + Tuple2('推荐', RecommendSubPage(action: _actions[0])), + Tuple2('更新', RecentSubPage(action: _actions[1])), + Tuple2('全部', OverallSubPage(action: _actions[2])), + Tuple2('排行', RankingSubPage(action: _actions[3])), + ]; + final _cancelHandlers = []; @override void initState() { super.initState(); - _controller = TabController( - length: _tabs.length, - vsync: this, - ); - _actions = List.generate(_tabs.length, (_) => ActionController()); - _pages = [ - RecommendSubPage(action: _actions[0]), - RecentSubPage(action: _actions[1]), - OverallSubPage(action: _actions[2]), - RankingSubPage(action: _actions[3]), - ]; - - widget.action?.addAction('', () => _actions[_controller.index].invoke('')); - _actions[0].addAction('to_shelf', () => widget.action.invoke('to_shelf')); - _actions[0].addAction('to_update', () => _controller.animateTo(1)); - _actions[0].addAction('to_ranking', () => _controller.animateTo(3)); - _actions[0].addAction('to_genre', () => widget.action.invoke('to_genre')); + widget.action?.addAction(() => _actions[_controller.index].invoke()); + _cancelHandlers.add(EventBusManager.instance.listen((_) { + _controller.animateTo(1); + })); + _cancelHandlers.add(EventBusManager.instance.listen((_) { + _controller.animateTo(3); + })); } @override void dispose() { - widget.action?.removeAction(''); + widget.action?.removeAction(); + _cancelHandlers.forEach((h) => h.call()); _controller.dispose(); _actions.forEach((a) => a.dispose()); super.dispose(); @@ -62,44 +58,40 @@ class _HomeSubPageState extends State with SingleTickerProviderStat Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, - toolbarHeight: 45, title: TabBar( controller: _controller, isScrollable: true, indicatorSize: TabBarIndicatorSize.label, - labelStyle: Theme.of(context).primaryTextTheme.subtitle1, tabs: _tabs .map( (t) => Padding( - padding: EdgeInsets.symmetric(vertical: 6), - child: Text(t), + padding: EdgeInsets.symmetric(vertical: 5), + child: Text( + t.item1, + style: Theme.of(context).textTheme.subtitle1?.copyWith( + color: Colors.white, + fontSize: 16, + ), + ), ), ) .toList(), onTap: (idx) { if (idx == _selectedIndex) { - _actions[idx].invoke(''); + _actions[idx].invoke(); } else { _selectedIndex = idx; } }, ), + leading: AppBarActionButton.leading(context: context, allowDrawerButton: true), actions: [ - IconButton( - icon: Icon(Icons.open_in_browser), - tooltip: '浏览器打开', - onPressed: () => launchInBrowser( - context: context, - url: WEB_HOMEPAGE_URL, - ), - ), - IconButton( + AppBarActionButton( icon: Icon(Icons.search), tooltip: '搜索', onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - // builder: (c) => SearchPage(), + CustomPageRoute( + context: context, builder: (c) => SearchPage(), ), ), @@ -108,7 +100,7 @@ class _HomeSubPageState extends State with SingleTickerProviderStat ), body: TabBarView( controller: _controller, - children: _pages, + children: _tabs.map((t) => t.item2).toList(), ), ); } diff --git a/lib/page/page/mine.dart b/lib/page/page/mine.dart index c2394c5..ba85912 100644 --- a/lib/page/page/mine.dart +++ b/lib/page/page/mine.dart @@ -1,104 +1,127 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/util.dart'; -import 'package:flutter_ahlib/widget.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:manhuagui_flutter/config.dart'; import 'package:manhuagui_flutter/model/user.dart'; +import 'package:manhuagui_flutter/page/download.dart'; +import 'package:manhuagui_flutter/page/image_viewer.dart'; +import 'package:manhuagui_flutter/page/login.dart'; import 'package:manhuagui_flutter/page/setting.dart'; +import 'package:manhuagui_flutter/page/view/action_row.dart'; +import 'package:manhuagui_flutter/page/view/full_ripple.dart'; import 'package:manhuagui_flutter/page/view/login_first.dart'; import 'package:manhuagui_flutter/page/view/network_image.dart'; +import 'package:manhuagui_flutter/service/evb/auth_manager.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; +import 'package:manhuagui_flutter/service/native/browser.dart'; import 'package:manhuagui_flutter/service/prefs/auth.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; -import 'package:manhuagui_flutter/service/state/auth.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; /// 我的 class MineSubPage extends StatefulWidget { const MineSubPage({ - Key key, + Key? key, this.action, }) : super(key: key); - final ActionController action; + final ActionController? action; @override _MineSubPageState createState() => _MineSubPageState(); } -class _MineSubPageState extends State with AutomaticKeepAliveClientMixin, NotifyReceiverMixin { - bool _loading = false; - User _data; - String _error; +class _MineSubPageState extends State with AutomaticKeepAliveClientMixin { + final _refreshIndicatorKey = GlobalKey(); + VoidCallback? _cancelHandler; + AuthData? _oldAuthData; - @override - String get receiverKey => 'MineSubPage'; + var _loginChecking = true; + var _loginCheckError = ''; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (AuthState.instance.logined) { + WidgetsBinding.instance?.addPostFrameCallback((_) async { + _cancelHandler = AuthManager.instance.listen(() => _oldAuthData, (ev) { + _oldAuthData = AuthManager.instance.authData; + _loginChecking = false; + _loginCheckError = ev.error?.text ?? ''; if (mounted) setState(() {}); - _loadUser(); - } - }); - AuthState.instance.registerDefault(this, () { - if (mounted) setState(() {}); - if (AuthState.instance.logined) { - _loadUser(); - } + if (AuthManager.instance.logined) { + WidgetsBinding.instance?.addPostFrameCallback((_) => _refreshIndicatorKey.currentState?.show()); + } + }); + _loginChecking = true; + await AuthManager.instance.check(); }); - widget.action?.addAction('', () {}); } @override void dispose() { - AuthState.instance.unregisterDefault(this); - widget.action?.removeAction(''); + _cancelHandler?.call(); super.dispose(); } - Future _loadUser() { + bool _loading = false; + User? _data; + var _error = ''; + + Future _loadUser() async { _loading = true; if (mounted) setState(() {}); - var dio = DioManager.instance.dio; - var client = RestClient(dio); - return client.getUserInfo(token: AuthState.instance.token).then((r) async { - _error = ''; + final client = RestClient(DioManager.instance.dio); + try { + var result = await client.getUserInfo(token: AuthManager.instance.token); _data = null; + _error = ''; if (mounted) setState(() {}); await Future.delayed(Duration(milliseconds: 20)); - _data = r.data; - }).catchError((e) { + _data = result.data; + } catch (e, s) { _data = null; - var we = wrapError(e); + var we = wrapError(e, s); _error = we.text; - if (we.httpCode == 401) { - _logout(); + if (we.response?.statusCode == 401) { + Fluttertoast.showToast(msg: '登录失效,请重新登录'); + Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => LoginPage(), + ), + ); } - }).whenComplete(() { + } finally { _loading = false; if (mounted) setState(() {}); - }); + } } - void _logout() { - showDialog( + Future _logout({bool sure = false}) async { + if (sure) { + await AuthPrefs.setToken(''); + AuthManager.instance.record(username: '', token: ''); + AuthManager.instance.notify(logined: false); + return; + } + + return showDialog( context: context, builder: (c) => AlertDialog( title: Text('退出登录'), content: Text('确定要退出登录吗?'), actions: [ - FlatButton( + TextButton( child: Text('确定'), onPressed: () async { Navigator.of(c).pop(); - await removeToken(); - AuthState.instance.token = null; - AuthState.instance.username = null; - AuthState.instance.notifyAll(); + await _logout(sure: true); }, ), - FlatButton( + TextButton( child: Text('取消'), onPressed: () => Navigator.of(c).pop(), ), @@ -107,185 +130,212 @@ class _MineSubPageState extends State with AutomaticKeepAliveClient ); } + Widget _buildActionLine({required IconData icon, required String text, required void Function() action}) { + return Material( + color: Colors.white, + child: InkWell( + child: Padding( + padding: EdgeInsets.only(left: 15, right: 8, top: 8, bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconText( + icon: Icon(icon, color: Colors.black54), + text: Text(text, style: Theme.of(context).textTheme.subtitle1), + space: 15, + ), + Icon(Icons.chevron_right, color: Colors.black54), + ], + ), + ), + onTap: action, + ), + ); + } + + Widget _buildDivider({double thickness = 0.8, double indent = 10}) { + return Divider(height: 0, thickness: thickness, indent: indent, endIndent: indent); + } + + Widget _buildInfoLines({required String title, required List lines}) { + return Container( + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 15, right: 15, top: 8), + child: Text( + title, + style: Theme.of(context).textTheme.subtitle1, + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: _buildDivider(thickness: 1, indent: 0), + ), + for (var line in lines) + Padding( + padding: EdgeInsets.only(left: 15, right: 15, bottom: 8), + child: Text( + line, + style: Theme.of(context).textTheme.subtitle1, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + @override bool get wantKeepAlive => true; @override Widget build(BuildContext context) { super.build(context); - if (!AuthState.instance.logined) { + var appBar = AppBar( + automaticallyImplyLeading: false, + actions: [ + AppBarActionButton( + icon: Icon(Icons.settings, color: Colors.black54), + tooltip: '应用设置', + onPressed: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => SettingPage(), + ), + ), + ), + ], + foregroundColor: Colors.transparent, + backgroundColor: Colors.transparent, + elevation: 0, + ); + + if (_loginChecking || _loginCheckError.isNotEmpty || !AuthManager.instance.logined) { _data = null; - return LoginFirstView(); + _error = ''; + return Scaffold( + appBar: appBar, + extendBodyBehindAppBar: true, + body: LoginFirstView( + checking: _loginChecking, + error: _loginCheckError, + onErrorRetry: () async { + _loginChecking = true; + _loginCheckError = ''; + if (mounted) setState(() {}); + await AuthManager.instance.check(); + }, + ), + ); } - return RefreshIndicator( - onRefresh: _loadUser, - child: PlaceholderText.from( - isLoading: _loading, - errorText: _error, - isEmpty: _data == null, - setting: PlaceholderSetting().toChinese(), - onRefresh: () => _loadUser(), - childBuilder: (c) => ListView( - padding: EdgeInsets.zero, - physics: AlwaysScrollableScrollPhysics(), - children: [ - Stack( - children: [ - Container( - height: 200, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - stops: [0, 0.5, 1], - colors: [ - Colors.blue[100], - Colors.orange[100], - Colors.purple[100], - ], - ), + return Scaffold( + appBar: appBar, + extendBodyBehindAppBar: true, + body: RefreshIndicator( + key: _refreshIndicatorKey, + onRefresh: _loadUser, + child: PlaceholderText.from( + isLoading: _loading, + errorText: _error, + isEmpty: _data == null, + setting: PlaceholderSetting().copyWithChinese(), + onRefresh: () => _loadUser(), + childBuilder: (c) => ListView( + padding: EdgeInsets.zero, + physics: AlwaysScrollableScrollPhysics(), + children: [ + Container( + height: MediaQuery.of(context).padding.top + 180, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: const [0, 0.5, 1], + colors: [ + Colors.blue[100]!, + Colors.orange[100]!, + Colors.purple[100]!, + ], ), - child: Padding( - padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - NetworkImageView( - url: _data.avatar, + ), + child: Padding( + padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FullRippleWidget( + child: NetworkImageView( + url: _data!.avatar, height: 75, width: 75, - fit: BoxFit.cover, ), - Padding( - padding: EdgeInsets.only(top: 8, left: 15, right: 15), - child: Text( - _data.username, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.subtitle1, + onTap: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => ImageViewerPage( + url: _data!.avatar, + title: '我的头像', + ), ), ), - ], - ), - ), - ), - ), - Positioned( - top: MediaQuery.of(context).padding.top - 10, - right: 2.0 - 10, - child: ClipOval( - child: Material( - color: Colors.transparent, - child: IconButton( - icon: Icon(Icons.settings, color: Colors.black54), - tooltip: '设置', - padding: EdgeInsets.all(25), - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => SettingPage(), + ), + Padding( + padding: EdgeInsets.only(top: 8, left: 15, right: 15), + child: Text( + _data!.username, + style: Theme.of(context).textTheme.headline6?.copyWith(fontWeight: FontWeight.normal), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), - ), + ], ), ), ), - ], - ), - Container( - color: Colors.white, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 15, vertical: 6), - child: Text( - '个人信息', - style: Theme.of(context).textTheme.subtitle1, - ), - ), - Padding( - padding: EdgeInsets.only(left: 10, right: 10, bottom: 4), - child: Divider(height: 1, thickness: 1), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 15, vertical: 2), - child: Text( - '您的会员等级:${_data.className}', - style: Theme.of(context).textTheme.subtitle1, - ), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 15, vertical: 2), - child: Text( - '个人成长值:${_data.score} 点', - style: Theme.of(context).textTheme.subtitle1, - ), - ), - SizedBox(height: 6), - ], ), - ), - SizedBox(height: 12), - Container( - color: Colors.white, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 15, vertical: 6), - child: Text( - '登录统计', - style: Theme.of(context).textTheme.subtitle1, - ), - ), - Padding( - padding: EdgeInsets.only(left: 10, right: 10, bottom: 4), - child: Divider(height: 1, thickness: 1), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 15, vertical: 2), - child: Text( - '本次登录IP:${_data.loginIp}', - style: Theme.of(context).textTheme.subtitle1, - ), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 15, vertical: 2), - child: Text( - '上次登录IP:${_data.lastLoginIp}', - style: Theme.of(context).textTheme.subtitle1, - ), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 15, vertical: 2), - child: Text( - '注册时间:${_data.registerTime}', - style: Theme.of(context).textTheme.subtitle1, - ), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: 15, vertical: 2), - child: Text( - '上次登录时间:${_data.lastLoginTime}', - style: Theme.of(context).textTheme.subtitle1, - ), - ), - SizedBox(height: 6), + Container( + color: Colors.white, + child: ActionRowView.four( + action1: ActionItem.simple('用户中心', Icons.account_circle, () => launchInBrowser(context: context, url: USER_CENTER_URL)), + action2: ActionItem.simple('站内信息', Icons.message, () => launchInBrowser(context: context, url: MESSAGE_URL)), + action3: ActionItem.simple('修改资料', Icons.edit, () => launchInBrowser(context: context, url: EDIT_PROFILE_URL)), + action4: ActionItem.simple('退出登录', Icons.logout, () => _logout(sure: false)), + ), + ), + SizedBox(height: 12), + _buildActionLine(text: '我的书架', icon: Icons.star_outlined, action: () => EventBusManager.instance.fire(ToShelfRequestedEvent())), + _buildDivider(), + _buildActionLine(text: '浏览历史', icon: Icons.history, action: () => EventBusManager.instance.fire(ToHistoryRequestedEvent())), + _buildDivider(), + _buildActionLine(text: '下载列表', icon: Icons.download, action: () => Navigator.of(context).push(CustomPageRoute.simple(context, (c) => DownloadPage()))), + SizedBox(height: 12), + _buildInfoLines( + title: '个人信息', + lines: [ + '您的会员等级:${_data!.className}', + '个人成长值 / 账户积分:${_data!.score} 点', + '累计发送 ${_data!.totalCommentCount} 条评论,当前 ${_data!.unreadMessageCount} 条消息未读', + '注册时间:${_data!.registerTime}', ], ), - ), - Align( - child: Container( - padding: EdgeInsets.only(top: 10), - child: OutlineButton( - child: Text('退出登录'), - onPressed: _logout, - ), + SizedBox(height: 12), + _buildInfoLines( + title: '登录统计', + lines: [ + '本次登录IP:${_data!.loginIp}', + '上次登录IP:${_data!.lastLoginIp}', + '上次登录时间:${_data!.lastLoginTime}', + '累计登录天数:${_data!.cumulativeDayCount} 天', + ], ), - ), - ], + ], + ), ), ), ); diff --git a/lib/page/page/overall.dart b/lib/page/page/overall.dart index a8d1f4b..8a13dd0 100644 --- a/lib/page/page/overall.dart +++ b/lib/page/page/overall.dart @@ -1,63 +1,58 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/list.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:manhuagui_flutter/model/order.dart'; import 'package:manhuagui_flutter/model/manga.dart'; +import 'package:manhuagui_flutter/page/view/list_hint.dart'; import 'package:manhuagui_flutter/page/view/option_popup.dart'; import 'package:manhuagui_flutter/page/view/tiny_manga_line.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; -/// 首页全部 +/// 首页-全部 class OverallSubPage extends StatefulWidget { const OverallSubPage({ - Key key, + Key? key, this.action, }) : super(key: key); - final ActionController action; + final ActionController? action; @override _OverallSubPageState createState() => _OverallSubPageState(); } class _OverallSubPageState extends State with AutomaticKeepAliveClientMixin { + final _pdvKey = GlobalKey(); final _controller = ScrollController(); - final _udvController = UpdatableDataViewController(); final _fabController = AnimatedFabController(); - var _data = []; - int _total; - var _order = MangaOrder.byNew; - var _lastOrder = MangaOrder.byNew; - var _disableOption = false; @override void initState() { super.initState(); - widget.action?.addAction('', () => _controller.scrollToTop()); + widget.action?.addAction(() => _controller.scrollToTop()); } @override void dispose() { - widget.action?.removeAction(''); + widget.action?.removeAction(); _controller.dispose(); - _udvController.dispose(); _fabController.dispose(); super.dispose(); } - Future> _getData({int page}) async { - var dio = DioManager.instance.dio; - var client = RestClient(dio); - ErrorMessage err; - var result = await client.getAllMangas(page: page, order: _order).catchError((e) { - err = wrapError(e); + final _data = []; + var _total = 0; + var _currOrder = MangaOrder.byNew; + var _lastOrder = MangaOrder.byNew; + var _getting = false; + + Future> _getData({required int page}) async { + final client = RestClient(DioManager.instance.dio); + var result = await client.getAllMangas(page: page, order: _currOrder).onError((e, s) { + return Future.error(wrapError(e, s).text); }); - if (err != null) { - return Future.error(err.text); - } _total = result.data.total; if (mounted) setState(() {}); return PagedList(list: result.data.data, next: result.data.page + 1); @@ -71,73 +66,59 @@ class _OverallSubPageState extends State with AutomaticKeepAlive super.build(context); return Scaffold( body: PaginationListView( + key: _pdvKey, data: _data, getData: ({indicator}) => _getData(page: indicator), - controller: _udvController, scrollController: _controller, paginationSetting: PaginationSetting( initialIndicator: 1, nothingIndicator: 0, ), setting: UpdatableDataViewSetting( - padding: EdgeInsets.zero, - placeholderSetting: PlaceholderSetting().toChinese(), + padding: EdgeInsets.symmetric(vertical: 0), + interactiveScrollbar: true, + scrollbarCrossAxisMargin: 2, + placeholderSetting: PlaceholderSetting().copyWithChinese(), + onPlaceholderStateChanged: (_, __) => _fabController.hide(), refreshFirst: true, - clearWhenError: false, clearWhenRefresh: false, + clearWhenError: false, updateOnlyIfNotEmpty: false, - onStateChanged: (_, __) => _fabController.hide(), - onStartLoading: () => mountedSetState(() => _disableOption = true), - onStopLoading: () => mountedSetState(() => _disableOption = false), - onAppend: (l) { - if (l.length > 0) { - Fluttertoast.showToast(msg: '新添了 ${l.length} 部漫画'); - } - _lastOrder = _order; - if (mounted) setState(() {}); + onStartGettingData: () => mountedSetState(() => _getting = true), + onStopGettingData: () => mountedSetState(() => _getting = false), + onAppend: (_, l) { + _lastOrder = _currOrder; }, onError: (e) { - Fluttertoast.showToast(msg: e.toString()); - _order = _lastOrder; + if (_data.isNotEmpty) { + Fluttertoast.showToast(msg: e.toString()); + } + _currOrder = _lastOrder; if (mounted) setState(() {}); }, ), - separator: Divider(height: 1), - itemBuilder: (c, item) => TinyMangaLineView(manga: item), + separator: Divider(height: 0, thickness: 1), + itemBuilder: (c, _, item) => TinyMangaLineView(manga: item), extra: UpdatableDataViewExtraWidgets( - innerTopWidget: Container( - color: Colors.white, - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - height: 26, - padding: EdgeInsets.only(left: 5), - child: Center( - child: Text('全部漫画 (共 ${_total == null ? '?' : _total.toString()} 部)'), - ), - ), - OptionPopupView( - title: _order.toTitle(), - top: 4, - value: _order, - items: [MangaOrder.byPopular, MangaOrder.byNew, MangaOrder.byUpdate], - onSelect: (o) { - if (_order != o) { - _lastOrder = _order; - _order = o; - if (mounted) setState(() {}); - _udvController.refresh(); - } - }, - optionBuilder: (c, v) => v.toTitle(), - enable: !_disableOption, - ), - ], + innerTopWidgets: [ + ListHintView.textWidget( + leftText: '全部漫画 (共 $_total 部)', + rightWidget: OptionPopupView( + items: const [MangaOrder.byPopular, MangaOrder.byNew, MangaOrder.byUpdate], + value: _currOrder, + titleBuilder: (c, v) => v.toTitle(), + enable: !_getting, + onSelect: (o) { + if (_currOrder != o) { + _lastOrder = _currOrder; + _currOrder = o; + if (mounted) setState(() {}); + _pdvKey.currentState?.refresh(); + } + }, + ), ), - ), - innerTopDivider: Divider(height: 1, thickness: 1), + ], ), ), floatingActionButton: ScrollAnimatedFab( @@ -146,7 +127,7 @@ class _OverallSubPageState extends State with AutomaticKeepAlive condition: ScrollAnimatedCondition.direction, fab: FloatingActionButton( child: Icon(Icons.vertical_align_top), - heroTag: 'OverallSubPage', + heroTag: null, onPressed: () => _controller.scrollToTop(), ), ), diff --git a/lib/page/page/ranking.dart b/lib/page/page/ranking.dart index 6999296..099e22b 100644 --- a/lib/page/page/ranking.dart +++ b/lib/page/page/ranking.dart @@ -1,96 +1,93 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/list.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:manhuagui_flutter/model/category.dart'; import 'package:manhuagui_flutter/model/manga.dart'; -import 'package:manhuagui_flutter/page/view/manga_rank.dart'; +import 'package:manhuagui_flutter/page/view/list_hint.dart'; +import 'package:manhuagui_flutter/page/view/manga_rank_line.dart'; import 'package:manhuagui_flutter/page/view/option_popup.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; -/// 首页排行 +/// 首页-排行 class RankingSubPage extends StatefulWidget { const RankingSubPage({ - Key key, + Key? key, this.action, }) : super(key: key); - final ActionController action; + final ActionController? action; @override _RankingSubPageState createState() => _RankingSubPageState(); } class _RankingSubPageState extends State with AutomaticKeepAliveClientMixin { + final _rdvKey = GlobalKey(); final _controller = ScrollController(); - final _udvController = UpdatableDataViewController(); final _fabController = AnimatedFabController(); - var _genreLoading = true; - var _genres = []; - var _genreError = ''; - var _data = []; - var _duration = allRankDurations[0]; - var _lastDuration = allRankDurations[0]; - var _selectedType = allRankTypes[0]; - var _lastType = allRankTypes[0]; - var _disableOption = false; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadGenres()); - widget.action?.addAction('', () => _controller.scrollToTop()); + widget.action?.addAction(() => _controller.scrollToTop()); + WidgetsBinding.instance?.addPostFrameCallback((_) => _loadGenres()); } @override void dispose() { - widget.action?.removeAction(''); + widget.action?.removeAction(); _controller.dispose(); - _udvController.dispose(); _fabController.dispose(); super.dispose(); } - Future _loadGenres() { + var _genreLoading = true; + final _genres = []; + var _genreError = ''; + + Future _loadGenres() async { _genreLoading = true; if (mounted) setState(() {}); - var dio = DioManager.instance.dio; - var client = RestClient(dio); - return client.getGenres().then((r) async { - _genreError = ''; + final client = RestClient(DioManager.instance.dio); + try { + var result = await client.getGenres(); _genres.clear(); + _genreError = ''; if (mounted) setState(() {}); await Future.delayed(Duration(milliseconds: 20)); - _genres = r.data.data; - }).catchError((e) { + _genres.addAll(allRankTypes); + _genres.addAll(result.data.data.map((c) => c.toTiny())); + } catch (e, s) { _genres.clear(); - _genreError = wrapError(e).text; - }).whenComplete(() { + _genreError = wrapError(e, s).text; + } finally { _genreLoading = false; if (mounted) setState(() {}); - }); + } } + final _data = []; + var _currType = allRankTypes[0]; + var _lastType = allRankTypes[0]; + var _currDuration = allRankDurations[0]; + var _lastDuration = allRankDurations[0]; + var _getting = false; + Future> _getData() async { - var dio = DioManager.instance.dio; - var client = RestClient(dio); - ErrorMessage err; - var f = _duration.name == 'day' + final client = RestClient(DioManager.instance.dio); + var f = _currDuration.name == 'day' ? client.getDayRanking - : _duration.name == 'week' + : _currDuration.name == 'week' ? client.getWeekRanking - : _duration.name == 'month' + : _currDuration.name == 'month' ? client.getMonthRanking : client.getTotalRanking; - var result = await f(type: _selectedType.name).catchError((e) { - err = wrapError(e); + var result = await f(type: _currType.name).onError((e, s) { + return Future.error(wrapError(e, s).text); }); - if (err != null) { - return Future.error(err.text); - } return result.data.data; } @@ -101,91 +98,80 @@ class _RankingSubPageState extends State with AutomaticKeepAlive Widget build(BuildContext context) { super.build(context); return Scaffold( - // **************************************************************** - // 加载 Genre - // **************************************************************** body: PlaceholderText.from( isLoading: _genreLoading, errorText: _genreError, - isEmpty: _genres?.isNotEmpty != true, - setting: PlaceholderSetting().toChinese(), + isEmpty: _genres.isEmpty, + setting: PlaceholderSetting().copyWithChinese(), onRefresh: () => _loadGenres(), + onChanged: (_, __) => _fabController.hide(), childBuilder: (c) => RefreshableListView( + key: _rdvKey, data: _data, getData: () => _getData(), - controller: _udvController, scrollController: _controller, setting: UpdatableDataViewSetting( - padding: EdgeInsets.zero, - placeholderSetting: PlaceholderSetting().toChinese(), + padding: EdgeInsets.symmetric(vertical: 0), + interactiveScrollbar: true, + scrollbarCrossAxisMargin: 2, + placeholderSetting: PlaceholderSetting().copyWithChinese(), + onPlaceholderStateChanged: (_, __) => _fabController.hide(), refreshFirst: true, - clearWhenError: false, clearWhenRefresh: false, - onStateChanged: (_, __) => _fabController.hide(), - onStartLoading: () => mountedSetState(() => _disableOption = true), - onStopLoading: () => mountedSetState(() => _disableOption = false), - onAppend: (l) { - if (l.length > 0) { - Fluttertoast.showToast(msg: '新添了 ${l.length} 部漫画'); - } - _lastDuration = _duration; - _lastType = _selectedType; - if (mounted) setState(() {}); + clearWhenError: false, + onStartGettingData: () => mountedSetState(() => _getting = true), + onStopGettingData: () => mountedSetState(() => _getting = false), + onAppend: (_, l) { + _lastDuration = _currDuration; + _lastType = _currType; }, onError: (e) { - Fluttertoast.showToast(msg: e.toString()); - _duration = _lastDuration; - _selectedType = _lastType; + if (_data.isNotEmpty) { + Fluttertoast.showToast(msg: e.toString()); + } + _currDuration = _lastDuration; + _currType = _lastType; if (mounted) setState(() {}); }, ), - separator: Divider(height: 1), - itemBuilder: (c, item) => MangaRankView(manga: item), + separator: Divider(height: 0, thickness: 1), + itemBuilder: (c, _, item) => MangaRankLineView(manga: item), extra: UpdatableDataViewExtraWidgets( - outerTopWidget: Container( - color: Colors.white, - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + outerTopWidgets: [ + ListHintView.widgets( + widgets: [ OptionPopupView( - title: _selectedType.isAll() ? '分类' : _selectedType.title, - top: 4, - doHighlight: true, - value: _selectedType, - items: _genres.map((g) => g.toTiny()).toList()..insertAll(0, allRankTypes), + items: _genres, + value: _currType, + titleBuilder: (c, v) => v.isAll() ? '分类' : v.title, + enable: !_getting, onSelect: (t) { - if (_selectedType != t) { - _lastType = _selectedType; - _selectedType = t; + if (_currType != t) { + _lastType = _currType; + _currType = t; if (mounted) setState(() {}); - _udvController.refresh(); + _rdvKey.currentState?.refresh(); } }, - optionBuilder: (c, v) => v.title, - enable: !_disableOption, ), + Expanded(child: SizedBox(width: 0)), OptionPopupView( - title: _duration.title, - top: 4, - doHighlight: true, - value: _duration, items: allRankDurations, + value: _currDuration, + titleBuilder: (c, v) => v.title, + enable: !_getting, onSelect: (d) { - if (_duration != d) { - _lastDuration = _duration; - _duration = d; + if (_currDuration != d) { + _lastDuration = _currDuration; + _currDuration = d; if (mounted) setState(() {}); - _udvController.refresh(); + _rdvKey.currentState?.refresh(); } }, - optionBuilder: (c, v) => v.title, - enable: !_disableOption, ), ], ), - ), - outerTopDivider: Divider(height: 1, thickness: 1), + ], ), ), ), @@ -195,7 +181,7 @@ class _RankingSubPageState extends State with AutomaticKeepAlive condition: ScrollAnimatedCondition.direction, fab: FloatingActionButton( child: Icon(Icons.vertical_align_top), - heroTag: 'RankingSubPage', + heroTag: null, onPressed: () => _controller.scrollToTop(), ), ), diff --git a/lib/page/page/recent.dart b/lib/page/page/recent.dart index 75fc8e4..a0c8bf4 100644 --- a/lib/page/page/recent.dart +++ b/lib/page/page/recent.dart @@ -1,21 +1,21 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/list.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:manhuagui_flutter/model/manga.dart'; +import 'package:manhuagui_flutter/page/view/list_hint.dart'; import 'package:manhuagui_flutter/page/view/tiny_manga_line.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; -/// 首页更新 +/// 首页-更新 class RecentSubPage extends StatefulWidget { const RecentSubPage({ - Key key, + Key? key, this.action, }) : super(key: key); - final ActionController action; + final ActionController? action; @override _RecentSubPageState createState() => _RecentSubPageState(); @@ -24,32 +24,31 @@ class RecentSubPage extends StatefulWidget { class _RecentSubPageState extends State with AutomaticKeepAliveClientMixin { final _controller = ScrollController(); final _fabController = AnimatedFabController(); - var _data = []; @override void initState() { super.initState(); - widget.action?.addAction('', () => _controller.scrollToTop()); + widget.action?.addAction(() => _controller.scrollToTop()); } @override void dispose() { - widget.action?.removeAction(''); + widget.action?.removeAction(); _controller.dispose(); _fabController.dispose(); super.dispose(); } - Future> _getData({int page}) async { - var dio = DioManager.instance.dio; - var client = RestClient(dio); - ErrorMessage err; - var result = await client.getRecentUpdatedMangas(page: page).catchError((e) { - err = wrapError(e); + final _data = []; + var _total = 0; + + Future> _getData({required int page}) async { + final client = RestClient(DioManager.instance.dio); + var result = await client.getRecentUpdatedMangas(page: page).onError((e, s) { + return Future.error(wrapError(e, s).text); }); - if (err != null) { - return Future.error(err.text); - } + _total = result.data.total; + if (mounted) setState(() {}); return PagedList(list: result.data.data, next: result.data.page + 1); } @@ -69,22 +68,31 @@ class _RecentSubPageState extends State with AutomaticKeepAliveCl nothingIndicator: 0, ), setting: UpdatableDataViewSetting( - padding: EdgeInsets.zero, - placeholderSetting: PlaceholderSetting().toChinese(), + padding: EdgeInsets.symmetric(vertical: 0), + interactiveScrollbar: true, + scrollbarCrossAxisMargin: 2, + placeholderSetting: PlaceholderSetting().copyWithChinese(), + onPlaceholderStateChanged: (_, __) => _fabController.hide(), refreshFirst: true, clearWhenRefresh: false, clearWhenError: false, updateOnlyIfNotEmpty: false, - onStateChanged: (_, __) => _fabController.hide(), - onAppend: (l) { - if (l.length > 0) { - Fluttertoast.showToast(msg: '新添了 ${l.length} 部漫画'); + onError: (e) { + if (_data.isNotEmpty) { + Fluttertoast.showToast(msg: e.toString()); } }, - onError: (e) => Fluttertoast.showToast(msg: e.toString()), ), - separator: Divider(height: 1), - itemBuilder: (c, item) => TinyMangaLineView(manga: item), + separator: Divider(height: 0, thickness: 1), + itemBuilder: (c, _, item) => TinyMangaLineView(manga: item), + extra: UpdatableDataViewExtraWidgets( + innerTopWidgets: [ + ListHintView.textText( + leftText: '30天内更新的漫画', + rightText: '共 $_total 部', + ), + ], + ), ), floatingActionButton: ScrollAnimatedFab( controller: _fabController, @@ -92,7 +100,7 @@ class _RecentSubPageState extends State with AutomaticKeepAliveCl condition: ScrollAnimatedCondition.direction, fab: FloatingActionButton( child: Icon(Icons.vertical_align_top), - heroTag: 'RecentSubPage', + heroTag: null, onPressed: () => _controller.scrollToTop(), ), ), diff --git a/lib/page/page/recommend.dart b/lib/page/page/recommend.dart index b0484cf..597640a 100644 --- a/lib/page/page/recommend.dart +++ b/lib/page/page/recommend.dart @@ -1,91 +1,85 @@ import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_ahlib/list.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/config.dart'; import 'package:manhuagui_flutter/model/manga.dart'; +import 'package:manhuagui_flutter/page/download.dart'; +import 'package:manhuagui_flutter/page/manga_random.dart'; +import 'package:manhuagui_flutter/page/view/action_row.dart'; import 'package:manhuagui_flutter/page/view/manga_carousel.dart'; -import 'package:manhuagui_flutter/page/view/manga_column.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; +import 'package:manhuagui_flutter/page/view/manga_group.dart'; +import 'package:manhuagui_flutter/page/view/warning_text.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; +import 'package:manhuagui_flutter/service/native/browser.dart'; -/// 首页推荐 -/// Page for [HomepageMangaGroupList] / [MangaGroupList]. +/// 首页-推荐 class RecommendSubPage extends StatefulWidget { const RecommendSubPage({ - Key key, + Key? key, this.action, }) : super(key: key); - final ActionController action; + final ActionController? action; @override _RecommendSubPageState createState() => _RecommendSubPageState(); } class _RecommendSubPageState extends State with AutomaticKeepAliveClientMixin { + final _refreshIndicatorKey = GlobalKey(); final _controller = ScrollController(); final _fabController = AnimatedFabController(); - final _indicatorKey = GlobalKey(); - var _carouselPages = []; - var _loading = true; - HomepageMangaGroupList _data; - var _error = ''; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _indicatorKey?.currentState?.show()); - widget.action?.addAction('', () => _controller.scrollToTop()); + widget.action?.addAction(() => _controller.scrollToTop()); + WidgetsBinding.instance?.addPostFrameCallback((_) => _refreshIndicatorKey.currentState?.show()); } @override void dispose() { - widget.action?.removeAction(''); + widget.action?.removeAction(); _controller.dispose(); _fabController.dispose(); super.dispose(); } - Future _loadData() { + var _loading = true; + HomepageMangaGroupList? _data; + var _error = ''; + + Future _loadData() async { _loading = true; if (mounted) setState(() {}); - var dio = DioManager.instance.dio; - var client = RestClient(dio); - return client.getHomepageMangas().then((r) async { - _error = ''; + final client = RestClient(DioManager.instance.dio); + try { + var r = await client.getHomepageMangas(); _data = null; + _error = ''; if (mounted) setState(() {}); await Future.delayed(Duration(milliseconds: 20)); _data = r.data; - var p1 = _data.serial.topGroup.mangas.sublist(0, 4); - var p2 = _data.serial.groups.map((e) => e.mangas.first); - var p3 = _data.serial.otherGroups.map((e) => e.mangas.first); - _carouselPages = [ - ...{...p1, ...p2, ...p3} - ]; - }).catchError((e) { + } catch (e, s) { _data = null; - _error = wrapError(e).text; - }).whenComplete(() { + _error = wrapError(e, s).text; + } finally { _loading = false; if (mounted) setState(() {}); - }); + } } - Widget _buildAction(String text, IconData icon, void Function() action) { - return InkWell( - onTap: () => action(), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), - child: IconText( - alignment: IconTextAlignment.t2b, - space: 8, - icon: Icon(icon, color: Colors.black54), - text: Text(text), - ), - ), + Widget _buildGroup(MangaGroup group, MangaGroupType type, MangaGroupViewStyle style) { + return MangaGroupView( + group: group, + type: type, + style: style, + margin: EdgeInsets.only(top: 12), + padding: EdgeInsets.only(bottom: 6), ); } @@ -97,49 +91,64 @@ class _RecommendSubPageState extends State with AutomaticKeepA super.build(context); return Scaffold( body: RefreshIndicator( - key: _indicatorKey, + key: _refreshIndicatorKey, onRefresh: () => _loadData(), child: PlaceholderText.from( isLoading: _loading, errorText: _error, isEmpty: _data == null, - setting: PlaceholderSetting().toChinese(), + setting: PlaceholderSetting().copyWithChinese(), onRefresh: () => _loadData(), onChanged: (_, __) => _fabController.hide(), - childBuilder: (c) => Scrollbar( + childBuilder: (c) => ScrollbarWithMore( + controller: _controller, + interactive: true, + crossAxisMargin: 2, child: ListView( controller: _controller, + padding: EdgeInsets.zero, + physics: AlwaysScrollableScrollPhysics(), children: [ - if (_carouselPages.length != 0) MangaCarouselView(mangas: _carouselPages), + MangaCarouselView( + mangas: _data!.carouselMangas, + height: 220, + imageWidth: 165, + ), SizedBox(height: 12), Container( color: Colors.white, - child: Material( - color: Colors.transparent, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 35, vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildAction('我的书架', Icons.favorite, () => widget.action.invoke('to_shelf')), - _buildAction('最近更新', Icons.cached, () => widget.action.invoke('to_update')), - _buildAction('漫画排行', Icons.trending_up, () => widget.action.invoke('to_ranking')), - _buildAction('漫画分类', Icons.category, () => widget.action.invoke('to_genre')), - ], + child: Column( + children: [ + ActionRowView.four( + action1: ActionItem.simple('我的书架', Icons.star_outlined, () => EventBusManager.instance.fire(ToShelfRequestedEvent())), + action2: ActionItem.simple('浏览历史', Icons.history, () => EventBusManager.instance.fire(ToHistoryRequestedEvent())), + action3: ActionItem.simple('下载列表', Icons.download, () => Navigator.of(context).push(CustomPageRoute.simple(context, (c) => DownloadPage()))), + action4: ActionItem.simple('随机漫画', Icons.shuffle, () => Navigator.of(context).push(CustomPageRoute.simple(context, (c) => MangaRandomPage()))), ), - ), + ActionRowView.four( + action1: ActionItem.simple('最近更新', Icons.cached, () => EventBusManager.instance.fire(ToRecentRequestedEvent())), + action2: ActionItem.simple('漫画排行', Icons.trending_up, () => EventBusManager.instance.fire(ToRankingRequestedEvent())), + action3: ActionItem.simple('漫画类别', Icons.category, () => EventBusManager.instance.fire(ToGenreRequestedEvent())), + action4: ActionItem.simple('外部打开', Icons.open_in_browser, () => launchInBrowser(context: context, url: WEB_HOMEPAGE_URL)), + ) + ], ), ), SizedBox(height: 12), - MangaColumnView(group: _data.serial.topGroup, type: MangaGroupType.serial, showTopMargin: false), // 热门连载 - MangaColumnView(group: _data.finish.topGroup, type: MangaGroupType.finish), // 经典完结 - MangaColumnView(group: _data.latest.topGroup, type: MangaGroupType.latest), // 最新上架 - for (var group in _data.serial.groups) MangaColumnView(group: group, type: MangaGroupType.serial, small: true), - for (var group in _data.finish.groups) MangaColumnView(group: group, type: MangaGroupType.finish, small: true), - for (var group in _data.latest.groups) MangaColumnView(group: group, type: MangaGroupType.latest, small: true), - for (var group in _data.serial.otherGroups) MangaColumnView(group: group, type: MangaGroupType.serial, small: true, singleLine: true), - for (var group in _data.finish.otherGroups) MangaColumnView(group: group, type: MangaGroupType.finish, small: true, singleLine: true), - for (var group in _data.latest.otherGroups) MangaColumnView(group: group, type: MangaGroupType.latest, small: true, singleLine: true), + WarningTextView( + text: '由于漫画柜主页推荐的漫画已有一段时间没有更新,因此本页的推荐列表也没有更新。', + isWarning: false, + ), + _buildGroup(_data!.serial.topGroup, MangaGroupType.serial, MangaGroupViewStyle.normalTruncate), // 热门连载 + _buildGroup(_data!.finish.topGroup, MangaGroupType.finish, MangaGroupViewStyle.normalTruncate), // 经典完结 + _buildGroup(_data!.latest.topGroup, MangaGroupType.latest, MangaGroupViewStyle.normalTruncate), // 最新上架 + for (var group in _data!.serial.groups) _buildGroup(group, MangaGroupType.serial, MangaGroupViewStyle.smallTruncate), // 热门连载... + for (var group in _data!.finish.groups) _buildGroup(group, MangaGroupType.finish, MangaGroupViewStyle.smallTruncate), // 经典完结... + for (var group in _data!.latest.groups) _buildGroup(group, MangaGroupType.latest, MangaGroupViewStyle.smallTruncate), // 最新上架... + for (var group in _data!.serial.otherGroups) _buildGroup(group, MangaGroupType.serial, MangaGroupViewStyle.smallOneLine), // 热门连载... + for (var group in _data!.finish.otherGroups) _buildGroup(group, MangaGroupType.finish, MangaGroupViewStyle.smallOneLine), // 经典完结... + for (var group in _data!.latest.otherGroups) _buildGroup(group, MangaGroupType.latest, MangaGroupViewStyle.smallOneLine), // 最新上架... + SizedBox(height: 12), ], ), ), @@ -151,7 +160,7 @@ class _RecommendSubPageState extends State with AutomaticKeepA condition: ScrollAnimatedCondition.direction, fab: FloatingActionButton( child: Icon(Icons.vertical_align_top), - heroTag: 'RecommendSubPage', + heroTag: null, onPressed: () => _controller.scrollToTop(), ), ), diff --git a/lib/page/page/shelf.dart b/lib/page/page/shelf.dart index 2110232..96c063b 100644 --- a/lib/page/page/shelf.dart +++ b/lib/page/page/shelf.dart @@ -1,72 +1,74 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/list.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:manhuagui_flutter/model/manga.dart'; +import 'package:manhuagui_flutter/page/view/list_hint.dart'; import 'package:manhuagui_flutter/page/view/login_first.dart'; import 'package:manhuagui_flutter/page/view/shelf_manga_line.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; -import 'package:manhuagui_flutter/service/state/auth.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; +import 'package:manhuagui_flutter/service/evb/auth_manager.dart'; -/// 订阅书架 +/// 订阅-书架 class ShelfSubPage extends StatefulWidget { const ShelfSubPage({ - Key key, + Key? key, this.action, }) : super(key: key); - final ActionController action; + final ActionController? action; @override _ShelfSubPageState createState() => _ShelfSubPageState(); } -class _ShelfSubPageState extends State with AutomaticKeepAliveClientMixin, NotifyReceiverMixin { +class _ShelfSubPageState extends State with AutomaticKeepAliveClientMixin { + final _pdvKey = GlobalKey(); final _controller = ScrollController(); - final _udvController = UpdatableDataViewController(); final _fabController = AnimatedFabController(); - var _data = []; - int _total; + VoidCallback? _cancelHandler; + AuthData? _oldAuthData; - @override - String get receiverKey => 'ShelfSubPage'; + var _loginChecking = true; + var _loginCheckError = ''; @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (AuthState.instance.logined) { + widget.action?.addAction(() => _controller.scrollToTop()); + WidgetsBinding.instance?.addPostFrameCallback((_) async { + _cancelHandler = AuthManager.instance.listen(() => _oldAuthData, (ev) { + _oldAuthData = AuthManager.instance.authData; + _loginChecking = false; + _loginCheckError = ev.error?.text ?? ''; if (mounted) setState(() {}); - } - }); - AuthState.instance.registerDefault(this, () { - if (mounted) setState(() {}); + if (AuthManager.instance.logined) { + WidgetsBinding.instance?.addPostFrameCallback((_) => _pdvKey.currentState?.refresh()); + } + }); + _loginChecking = true; + await AuthManager.instance.check(); }); - widget.action?.addAction('', () => _controller.scrollToTop()); } @override void dispose() { - AuthState.instance.unregisterDefault(this); - widget.action?.removeAction(''); + widget.action?.removeAction(); + _cancelHandler?.call(); _controller.dispose(); - _udvController.dispose(); _fabController.dispose(); super.dispose(); } - Future> _getData({int page}) async { - var dio = DioManager.instance.dio; - var client = RestClient(dio); - ErrorMessage err; - var result = await client.getShelfMangas(token: AuthState.instance.token, page: page).catchError((e) { - err = wrapError(e); + final _data = []; + var _total = 0; + + Future> _getData({required int page}) async { + final client = RestClient(DioManager.instance.dio); + var result = await client.getShelfMangas(token: AuthManager.instance.token, page: page).onError((e, s) { + return Future.error(wrapError(e, s).text); }); - if (err != null) { - return Future.error(err.text); - } _total = result.data.total; if (mounted) setState(() {}); return PagedList(list: result.data.data, next: result.data.page + 1); @@ -78,65 +80,55 @@ class _ShelfSubPageState extends State with AutomaticKeepAliveClie @override Widget build(BuildContext context) { super.build(context); - if (!AuthState.instance.logined) { + if (_loginChecking || _loginCheckError.isNotEmpty || !AuthManager.instance.logined) { _data.clear(); - return Center( - child: LoginFirstView(), + return LoginFirstView( + checking: _loginChecking, + error: _loginCheckError, + onErrorRetry: () async { + _loginChecking = true; + _loginCheckError = ''; + if (mounted) setState(() {}); + await AuthManager.instance.check(); + }, ); } return Scaffold( body: PaginationListView( + key: _pdvKey, data: _data, getData: ({indicator}) => _getData(page: indicator), - controller: _udvController, scrollController: _controller, paginationSetting: PaginationSetting( initialIndicator: 1, nothingIndicator: 0, ), setting: UpdatableDataViewSetting( - padding: EdgeInsets.zero, - placeholderSetting: PlaceholderSetting().toChinese(), + padding: EdgeInsets.symmetric(vertical: 0), + interactiveScrollbar: true, + scrollbarCrossAxisMargin: 2, + placeholderSetting: PlaceholderSetting().copyWithChinese(), + onPlaceholderStateChanged: (_, __) => _fabController.hide(), refreshFirst: true, - clearWhenError: false, clearWhenRefresh: false, + clearWhenError: false, updateOnlyIfNotEmpty: false, - onStateChanged: (_, __) => _fabController.hide(), - onAppend: (l) { - if (l.length > 0) { - Fluttertoast.showToast(msg: '新添了 ${l.length} 部漫画'); + onError: (e) { + if (_data.isNotEmpty) { + Fluttertoast.showToast(msg: e.toString()); } }, - onError: (e) => Fluttertoast.showToast(msg: e.toString()), ), - separator: Divider(height: 1), - itemBuilder: (c, item) => ShelfMangaLineView(manga: item), + separator: Divider(height: 0, thickness: 1), + itemBuilder: (c, _, item) => ShelfMangaLineView(manga: item), extra: UpdatableDataViewExtraWidgets( - innerTopWidget: Container( - color: Colors.white, - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - height: 26, - padding: EdgeInsets.only(left: 5), - child: Center( - child: Text('${AuthState.instance.username} 订阅的漫画'), - ), - ), - Container( - height: 26, - padding: EdgeInsets.only(right: 5), - child: Center( - child: Text('共 ${_total == null ? '?' : _total.toString()} 部'), - ), - ), - ], + innerTopWidgets: [ + ListHintView.textText( + leftText: '${AuthManager.instance.username} 订阅的漫画', + rightText: '共 $_total 部', ), - ), - innerTopDivider: Divider(height: 1, thickness: 1), + ], ), ), floatingActionButton: ScrollAnimatedFab( @@ -145,7 +137,7 @@ class _ShelfSubPageState extends State with AutomaticKeepAliveClie condition: ScrollAnimatedCondition.direction, fab: FloatingActionButton( child: Icon(Icons.vertical_align_top), - heroTag: 'ShelfSubPage', + heroTag: null, onPressed: () => _controller.scrollToTop(), ), ), diff --git a/lib/page/page/subscribe.dart b/lib/page/page/subscribe.dart index 5b6e56d..3e889ac 100644 --- a/lib/page/page/subscribe.dart +++ b/lib/page/page/subscribe.dart @@ -1,48 +1,51 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/page/download.dart'; import 'package:manhuagui_flutter/page/page/history.dart'; import 'package:manhuagui_flutter/page/page/shelf.dart'; import 'package:manhuagui_flutter/page/search.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; /// 订阅 class SubscribeSubPage extends StatefulWidget { const SubscribeSubPage({ - Key key, + Key? key, this.action, }) : super(key: key); - final ActionController action; + final ActionController? action; @override _SubscribeSubPageState createState() => _SubscribeSubPageState(); } class _SubscribeSubPageState extends State with SingleTickerProviderStateMixin { - TabController _controller; + late final _controller = TabController(length: _tabs.length, vsync: this); var _selectedIndex = 0; - var _tabs = ['书架', '浏览历史']; - var _actions = []; - var _pages = []; + late final _actions = List.generate(2, (_) => ActionController()); + late final _tabs = [ + Tuple2('书架', ShelfSubPage(action: _actions[0])), + Tuple2('浏览历史', HistorySubPage(action: _actions[1])), + ]; + VoidCallback? _cancelHandler; @override void initState() { super.initState(); - _controller = TabController( - length: _tabs.length, - vsync: this, - ); - _actions = List.generate(_tabs.length, (_) => ActionController()); - _pages = [ - ShelfSubPage(action: _actions[0]), - HistorySubPage(action: _actions[1]), - ]; - - widget.action?.addAction('', () => _actions[_controller.index].invoke('')); - widget.action?.addAction('to_shelf', () => _controller.animateTo(0)); + widget.action?.addAction(() => _actions[_controller.index].invoke()); + _cancelHandler = EventBusManager.instance.listen((_) { + _controller.animateTo(0); + }); + _cancelHandler = EventBusManager.instance.listen((_) { + _controller.animateTo(1); + }); } @override void dispose() { + widget.action?.removeAction(); + _cancelHandler?.call(); _controller.dispose(); _actions.forEach((a) => a.dispose()); super.dispose(); @@ -52,35 +55,50 @@ class _SubscribeSubPageState extends State with SingleTickerPr Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, - toolbarHeight: 45, title: TabBar( controller: _controller, isScrollable: true, indicatorSize: TabBarIndicatorSize.label, - labelStyle: Theme.of(context).primaryTextTheme.subtitle1, tabs: _tabs .map( (t) => Padding( - padding: EdgeInsets.symmetric(vertical: 6), - child: Text(t), + padding: EdgeInsets.symmetric(vertical: 5), + child: Text( + t.item1, + style: Theme.of(context).textTheme.subtitle1?.copyWith( + color: Colors.white, + fontSize: 16, + ), + ), ), ) .toList(), onTap: (idx) { if (idx == _selectedIndex) { - _actions[idx].invoke(''); + _actions[idx].invoke(); } else { _selectedIndex = idx; } }, ), + leading: AppBarActionButton.leading(context: context, allowDrawerButton: true), actions: [ - IconButton( + AppBarActionButton( + icon: Icon(Icons.download), + tooltip: '查看下载列表', + onPressed: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => DownloadPage(), + ), + ), + ), + AppBarActionButton( icon: Icon(Icons.search), tooltip: '搜索', onPressed: () => Navigator.of(context).push( - MaterialPageRoute( + CustomPageRoute( + context: context, builder: (c) => SearchPage(), ), ), @@ -89,7 +107,7 @@ class _SubscribeSubPageState extends State with SingleTickerPr ), body: TabBarView( controller: _controller, - children: _pages, + children: _tabs.map((t) => t.item2).toList(), ), ); } diff --git a/lib/page/page/view_extra.dart b/lib/page/page/view_extra.dart new file mode 100644 index 0000000..3f35d57 --- /dev/null +++ b/lib/page/page/view_extra.dart @@ -0,0 +1,350 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:manhuagui_flutter/model/chapter.dart'; +import 'package:manhuagui_flutter/page/view/network_image.dart'; + +/// 漫画章节阅读页-额外页 +class ViewExtraSubPage extends StatelessWidget { + const ViewExtraSubPage({ + Key? key, + required this.isHeader, + required this.reverseScroll, + required this.chapter, + required this.mangaCover, + required this.subscribing, + required this.subscribed, + required this.chapterTitleGetter, + required this.toJumpToImage, + required this.toSubscribe, + required this.toDownload, + required this.toGotoChapter, + required this.toShowToc, + required this.toShowComments, + required this.toPop, + }) : super(key: key); + + final bool isHeader; + final bool reverseScroll; + final MangaChapter chapter; + final String mangaCover; + final bool subscribing; + final bool subscribed; + final String? Function(int cid) chapterTitleGetter; + final void Function(int imageIndex, bool animated) toJumpToImage; + final void Function(bool gotoPrevious) toGotoChapter; + final void Function() toSubscribe; + final void Function() toDownload; + final void Function() toShowToc; + final void Function() toShowComments; + final void Function() toPop; + + Widget _buildChapters(BuildContext context) { + Widget _buildAction({required String text, required String subText, required bool left, required void Function() action, required bool disable}) { + return InkWell( + onTap: disable ? null : action, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: disable + ? Text( + text, + style: Theme.of(context).textTheme.subtitle1?.copyWith( + fontSize: 18, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Transform.rotate( + angle: left ? math.pi : 0, + child: Icon( + Icons.arrow_right_alt, + size: 28, + color: Theme.of(context).primaryColor, + ), + ), + Text( + text, + style: Theme.of(context).textTheme.subtitle1?.copyWith( + fontSize: 18, + color: Theme.of(context).primaryColor, + ), + ), + SizedBox(height: 6), + Text( + subText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } + + var prev = Expanded( + child: _buildAction( + text: chapter.prevCid != 0 ? '阅读上一章节' : '暂无上一章节', + subText: chapterTitleGetter.call(chapter.prevCid) ?? '未知话', + left: !reverseScroll ? true : false, + disable: chapter.prevCid == 0, + action: () => toGotoChapter.call(true), + ), + ); + + var next = Expanded( + child: _buildAction( + text: chapter.nextCid != 0 ? '阅读下一章节' : '暂无下一章节', + subText: chapterTitleGetter.call(chapter.nextCid) ?? '未知话', + left: !reverseScroll ? false : true, + disable: chapter.nextCid == 0, + action: () => toGotoChapter.call(false), + ), + ); + + return IntrinsicHeight( + child: Row( + children: [ + if (!reverseScroll) prev, // 上一章 + if (reverseScroll) next, // 下一章(反) + VerticalDivider(width: 36, thickness: 2), + if (!reverseScroll) next, // 下一章(反) + if (reverseScroll) prev, // 上一章 + ], + ), + ); + } + + Widget _buildActions(BuildContext context) { + Widget _buildAction({required String text, required IconData icon, required void Function() action, bool enable = true}) { + return InkWell( + onTap: enable ? action : null, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Column( + children: [ + Container( + height: 48, + width: 48, + decoration: BoxDecoration( + border: Border.all(width: 0.8, color: Colors.grey[400]!), + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 22, + color: enable ? Colors.grey[800] : Colors.grey, + ), + ), + SizedBox(height: 10), + Text( + text, + style: TextStyle(color: enable ? Colors.black : Colors.grey), + ), + ], + ), + ), + ); + } + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildAction( + text: '结束阅读', + icon: Icons.arrow_back, + action: () => toPop.call(), + ), + _buildAction( + text: !subscribed ? '订阅漫画' : '取消订阅', + icon: !subscribed ? Icons.star_border : Icons.star, + action: () => toSubscribe.call(), + enable: !subscribing, + ), + _buildAction( + text: '下载漫画', + icon: Icons.download, + action: () => toDownload.call(), + ), + _buildAction( + text: '漫画目录', + icon: Icons.menu, + action: () => toShowToc.call(), + ), + _buildAction( + text: '查看评论', + icon: Icons.forum, + action: () => toShowComments.call(), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: SizedBox( + width: MediaQuery.of(context).size.width, + child: Column( + children: [ + // **************************************************************** + // 额外页首页-头部框 + // **************************************************************** + if (isHeader) + Container( + color: Colors.white, + padding: EdgeInsets.symmetric(vertical: 18, horizontal: 18), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + NetworkImageView( + url: mangaCover, + height: 200, + width: 150, + border: Border.all( + width: 1.0, + color: Colors.grey[400]!, + ), + ), + SizedBox(width: 18), + Container( + width: MediaQuery.of(context).size.width - 18 * 3 - 150 - 2, // | ▢ ▢▢ | + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Text( + chapter.mangaTitle, + style: Theme.of(context).textTheme.headline6, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + SizedBox(height: 10), + Flexible( + child: Text( + chapter.title, + style: Theme.of(context).textTheme.subtitle1?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Theme.of(context).primaryColor, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + SizedBox(height: 18), + SizedBox( + height: 42, + width: 200, + child: ElevatedButton( + child: Text('开始阅读'), + onPressed: () => toJumpToImage.call(1, true), + ), + ), + ], + ), + ), + // **************************************************************** + // 额外页尾页-头部框 + // **************************************************************** + if (!isHeader) + Container( + color: Colors.white, + padding: EdgeInsets.symmetric(vertical: 18, horizontal: 18), + child: Column( + children: [ + NetworkImageView( + url: chapter.pages[0], + height: 150, + width: 150 / 0.618, + fit: BoxFit.cover, + border: Border.all( + width: 1.0, + color: Colors.grey[400]!, + ), + ), + SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + '- ${chapter.title} -', + style: Theme.of(context).textTheme.headline6?.copyWith( + fontWeight: FontWeight.w500, + color: Theme.of(context).primaryColor, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: 42, + width: 150, + child: ElevatedButton( + child: Text('重新阅读'), + onPressed: () => toJumpToImage.call(1, false), + ), + ), + SizedBox(width: 18), + SizedBox( + height: 42, + width: 150, + child: ElevatedButton( + child: Text('返回上一页'), + onPressed: () => toJumpToImage.call(chapter.pages.length, true), + ), + ), + ], + ), + ], + ), + ), + // **************************************************************** + // 上下章节 / 五个按钮 + // **************************************************************** + SizedBox(height: 18), + Container( + color: Colors.white, + padding: EdgeInsets.symmetric(horizontal: 18, vertical: 18 - 6), + child: Material( + color: Colors.transparent, + child: _buildChapters(context), // InkWell vertical padding: 6 + ), + ), + SizedBox(height: 18), + Container( + color: Colors.white, + padding: EdgeInsets.symmetric(horizontal: 18, vertical: 18 - 6), + child: Material( + color: Colors.transparent, + child: _buildActions(context), // InkWell vertical padding: 6 + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/page/page/view_setting.dart b/lib/page/page/view_setting.dart new file mode 100644 index 0000000..efb12f2 --- /dev/null +++ b/lib/page/page/view_setting.dart @@ -0,0 +1,298 @@ +import 'package:flutter/material.dart'; + +/// 漫画章节阅读页-阅读设置 + +class ViewSetting { + ViewSetting({ + required this.viewDirection, + required this.showPageHint, + this.showClock = true, + this.showNetwork = false, + this.showBattery = false, + required this.enablePageSpace, + required this.keepScreenOn, + required this.fullscreen, + required this.preloadCount, + }); + + final ViewDirection viewDirection; // 阅读方向 + final bool showPageHint; // 显示页面提示 + final bool showClock; // 显示当前时间 + final bool showNetwork; // 显示网络状态 + final bool showBattery; // 显示电源余量 + final bool enablePageSpace; // 显示页面间空白 + final bool keepScreenOn; // 屏幕常亮 + final bool fullscreen; // 全屏阅读 + final int preloadCount; // 预加载页数 + + ViewSetting.defaultSetting() + : this( + viewDirection: ViewDirection.leftToRight, + showPageHint: true, + showClock: true, + showNetwork: false, + showBattery: false, + enablePageSpace: true, + keepScreenOn: true, + fullscreen: false, + preloadCount: 2, + ); + + ViewSetting copyWith({ + ViewDirection? viewDirection, + bool? showPageHint, + bool? showClock, + bool? showNetwork, + bool? showBattery, + bool? enablePageSpace, + bool? keepScreenOn, + bool? fullscreen, + int? preloadCount, + }) { + return ViewSetting( + viewDirection: viewDirection ?? this.viewDirection, + showPageHint: showPageHint ?? this.showPageHint, + enablePageSpace: enablePageSpace ?? this.enablePageSpace, + showClock: showClock ?? this.showClock, + showNetwork: showNetwork ?? this.showNetwork, + showBattery: showBattery ?? this.showBattery, + keepScreenOn: keepScreenOn ?? this.keepScreenOn, + fullscreen: fullscreen ?? this.fullscreen, + preloadCount: preloadCount ?? this.preloadCount, + ); + } +} + +enum ViewDirection { + leftToRight, + rightToLeft, + topToBottom, +} + +extension ViewDirectionExtension on ViewDirection { + int toInt() { + if (this == ViewDirection.leftToRight) { + return 0; + } + if (this == ViewDirection.rightToLeft) { + return 1; + } + if (this == ViewDirection.topToBottom) { + return 2; + } + return 0; + } + + static ViewDirection fromInt(int i) { + if (i == 0) { + return ViewDirection.leftToRight; + } + if (i == 1) { + return ViewDirection.rightToLeft; + } + if (i == 2) { + return ViewDirection.topToBottom; + } + return ViewDirection.leftToRight; + } +} + +class ViewSettingSubPage extends StatefulWidget { + const ViewSettingSubPage({ + Key? key, + required this.setting, + required this.onSettingChanged, + }) : super(key: key); + + final ViewSetting setting; + final void Function(ViewSetting) onSettingChanged; + + @override + State createState() => _ViewSettingSubPageState(); +} + +class _ViewSettingSubPageState extends State { + late var _viewDirection = widget.setting.viewDirection; + late var _showPageHint = widget.setting.showPageHint; + late var _showClock = widget.setting.showClock; + late var _showNetwork = widget.setting.showNetwork; + late var _showBattery = widget.setting.showBattery; + late var _enablePageSpace = widget.setting.enablePageSpace; + late var _keepScreenOn = widget.setting.keepScreenOn; + late var _fullscreen = widget.setting.fullscreen; + late var _preloadCount = widget.setting.preloadCount; + + ViewSetting get _newestSetting => ViewSetting( + viewDirection: _viewDirection, + showPageHint: _showPageHint, + showClock: _showClock, + showNetwork: _showNetwork, + showBattery: _showBattery, + enablePageSpace: _enablePageSpace, + keepScreenOn: _keepScreenOn, + fullscreen: _fullscreen, + preloadCount: _preloadCount, + ); + + Widget _buildComboBox({ + required String title, + double width = 120, + required T value, + required List values, + required Widget Function(T) builder, + required void Function(T) onChanged, + }) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyText1, + ), + SizedBox( + height: 38, + width: width, + child: DropdownButton( + value: value, + items: values.map((s) => DropdownMenuItem(child: builder(s), value: s)).toList(), + underline: Container(color: Colors.white), + isExpanded: true, + onChanged: (v) { + if (v != null) { + onChanged.call(v); + } + }, + ), + ), + ], + ); + } + + Widget _buildSwitcher({ + required String title, + required bool value, + required void Function(bool) onChanged, + bool enable = true, + }) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyText1, + ), + SizedBox( + height: 38, + child: Switch( + value: value, + onChanged: enable ? onChanged : null, + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildComboBox( + title: '阅读方向        ', + value: _viewDirection, + values: [ViewDirection.leftToRight, ViewDirection.rightToLeft, ViewDirection.topToBottom], + builder: (s) => Text( + s == ViewDirection.leftToRight ? '从左往右' : (s == ViewDirection.rightToLeft ? '从右往左' : '从上往下'), + style: Theme.of(context).textTheme.bodyText2, + ), + onChanged: (s) { + _viewDirection = s; + widget.onSettingChanged.call(_newestSetting); + if (mounted) setState(() {}); + }, + ), + _buildSwitcher( + title: '显示页面提示文字', + value: _showPageHint, + onChanged: (b) { + _showPageHint = b; + widget.onSettingChanged.call(_newestSetting); + if (mounted) setState(() {}); + }, + ), + _buildSwitcher( + title: '显示当前时间', + value: _showClock, + enable: _showPageHint, + onChanged: (b) { + _showClock = b; + widget.onSettingChanged.call(_newestSetting); + if (mounted) setState(() {}); + }, + ), + _buildSwitcher( + title: '显示网络状态', + value: _showNetwork, + enable: _showPageHint, + onChanged: (b) { + _showNetwork = b; + widget.onSettingChanged.call(_newestSetting); + if (mounted) setState(() {}); + }, + ), + _buildSwitcher( + title: '显示电源余量', + value: _showBattery, + enable: _showPageHint, + onChanged: (b) { + _showBattery = b; + widget.onSettingChanged.call(_newestSetting); + if (mounted) setState(() {}); + }, + ), + _buildSwitcher( + title: '显示页面间空白', + value: _enablePageSpace, + onChanged: (b) { + _enablePageSpace = b; + widget.onSettingChanged.call(_newestSetting); + if (mounted) setState(() {}); + }, + ), + _buildSwitcher( + title: '屏幕常亮', + value: _keepScreenOn, + onChanged: (b) { + _keepScreenOn = b; + widget.onSettingChanged.call(_newestSetting); + if (mounted) setState(() {}); + }, + ), + _buildSwitcher( + title: '全屏阅读', + value: _fullscreen, + onChanged: (b) { + _fullscreen = b; + widget.onSettingChanged.call(_newestSetting); + if (mounted) setState(() {}); + }, + ), + _buildComboBox( + title: '预加载页数', + value: _preloadCount.clamp(0, 5), + values: [0, 1, 2, 3, 4, 5], + builder: (s) => Text( + s == 0 ? '禁用预加载' : '前后$s页', + style: Theme.of(context).textTheme.bodyText2, + ), + onChanged: (c) { + _preloadCount = c.clamp(0, 5); + widget.onSettingChanged.call(_newestSetting); + if (mounted) setState(() {}); + }, + ), + ], + ); + } +} diff --git a/lib/page/page/view_toc.dart b/lib/page/page/view_toc.dart new file mode 100644 index 0000000..bdb7fcc --- /dev/null +++ b/lib/page/page/view_toc.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/model/chapter.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/page/view/manga_toc.dart'; + +/// 漫画章节阅读页-章节目录 +class ViewTocSubPage extends StatefulWidget { + const ViewTocSubPage({ + Key? key, + required this.mangaId, + required this.mangaTitle, + required this.groups, + required this.highlightedChapter, + required this.downloadedChapters, + required this.onChapterPressed, + }) : super(key: key); + + final int mangaId; + final String mangaTitle; + final List groups; + final int highlightedChapter; + final List downloadedChapters; + final void Function(int cid) onChapterPressed; + + @override + State createState() => _ViewTocSubPageState(); +} + +class _ViewTocSubPageState extends State { + final _controller = ScrollController(); + var _loading = true; // fake loading flag + + @override + void initState() { + super.initState(); + WidgetsBinding.instance?.addPostFrameCallback((_) { + Future.delayed(Duration(milliseconds: 300), () { + _loading = false; + if (mounted) setState(() {}); + }); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.mangaTitle), + leading: AppBarActionButton.leading(context: context), + ), + body: PlaceholderText( + state: _loading ? PlaceholderState.loading : PlaceholderState.normal, + setting: PlaceholderSetting().copyWithChinese(), + childBuilder: (c) => Container( + color: Colors.white, + child: ScrollbarWithMore( + controller: _controller, + interactive: true, + crossAxisMargin: 2, + child: SingleChildScrollView( + controller: _controller, + child: MangaTocView( + groups: widget.groups, + full: true, + highlightedChapters: [widget.highlightedChapter], + customBadgeBuilder: (cid) => DownloadBadge.fromEntity( + entity: widget.downloadedChapters.where((el) => el.chapterId == cid).firstOrNull, + ), + onChapterPressed: widget.onChapterPressed, + ), + ), + ), + ), + ), + floatingActionButton: _loading + ? null + : ScrollAnimatedFab( + scrollController: _controller, + condition: ScrollAnimatedCondition.direction, + fab: FloatingActionButton( + child: Icon(Icons.vertical_align_top), + heroTag: null, + onPressed: () => _controller.scrollToTop(), + ), + ), + ); + } +} diff --git a/lib/page/search.dart b/lib/page/search.dart index 5922b70..aed4def 100644 --- a/lib/page/search.dart +++ b/lib/page/search.dart @@ -1,21 +1,22 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/list.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:flutter_ahlib/util.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:manhuagui_flutter/model/manga.dart'; import 'package:manhuagui_flutter/model/order.dart'; import 'package:manhuagui_flutter/page/manga.dart'; +import 'package:manhuagui_flutter/page/view/list_hint.dart'; +import 'package:manhuagui_flutter/page/view/my_drawer.dart'; import 'package:manhuagui_flutter/page/view/option_popup.dart'; import 'package:manhuagui_flutter/page/view/tiny_manga_line.dart'; -import 'package:manhuagui_flutter/service/prefs/search.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; +import 'package:manhuagui_flutter/service/prefs/search_history.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; import 'package:material_floating_search_bar/material_floating_search_bar.dart'; -/// 搜索 +/// 搜索页 class SearchPage extends StatefulWidget { - const SearchPage({Key key}) : super(key: key); + const SearchPage({Key? key}) : super(key: key); @override _SearchPageState createState() => _SearchPageState(); @@ -23,125 +24,119 @@ class SearchPage extends StatefulWidget { class _SearchPageState extends State { final _searchController = FloatingSearchBarController(); + final _searchScrollController = ScrollController(); final _scrollController = ScrollController(); - final _udvController = UpdatableDataViewController(); + final _pdvKey = GlobalKey(); final _fabController = AnimatedFabController(); - String __q; - var _histories = []; - var _data = []; - int _total; - var _order = MangaOrder.byPopular; - var _lastOrder = MangaOrder.byPopular; - var _disableOption = false; - String get _q => __q?.trim()?.isNotEmpty == true ? __q.trim() : null; + String? _keyword; + + set _q(String? s) => _keyword = (s?.trim().isNotEmpty == true) ? s!.trim() : null; - set _q(String s) => __q = s?.trim(); + String? get _q => _keyword; // 当前搜索的关键词 - String get _text => _searchController?.query?.trim()?.isNotEmpty == true ? _searchController.query.trim() : null; + set _text(String s) => _searchController.query = s.trim(); - set _text(String s) => _searchController?.query = s?.trim() ?? ''; + String get _text => _searchController.query.trim(); // 当前搜索框输入的内容 @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => Future.delayed(Duration(milliseconds: 200), () => _searchController.open())); + WidgetsBinding.instance?.addPostFrameCallback((_) async { + await Future.delayed(Duration(milliseconds: 450)); + _searchController.open(); + }); } @override void dispose() { _searchController.dispose(); + _searchScrollController.dispose(); _scrollController.dispose(); - _udvController.dispose(); _fabController.dispose(); super.dispose(); } - Future> _getData({int page}) async { - var dio = DioManager.instance.dio; - var client = RestClient(dio); - ErrorMessage err; - var result = await client.searchMangas(keyword: _q ?? '?', page: page, order: _order).catchError((e) { - err = wrapError(e); + final _data = []; + var _total = 0; + var _currOrder = MangaOrder.byPopular; + var _lastOrder = MangaOrder.byPopular; + var _getting = false; + final _histories = []; + + Future> _getData({required int page}) async { + final client = RestClient(DioManager.instance.dio); + var result = await client.searchMangas(keyword: _q!, page: page, order: _currOrder).onError((e, s) { + return Future.error(wrapError(e, s).text); }); - if (err != null) { - return Future.error(err.text); - } _total = result.data.total; if (mounted) setState(() {}); return PagedList(list: result.data.data, next: result.data.page + 1); } - Future> _getHistories({String keyword}) async { - var histories = await getSearchHistories(); - if (keyword == null) { - return histories; + Future _pop() async { + if (_q == null) { + return true; // 没搜索 => 退出 } - var keywords = keyword.split(' '); - return histories.where((s) { - for (var w in keywords) { - if (s.contains(w)) { - return true; - } - } - return false; - }).toList(); + if (_searchController.isOpen) { + _searchController.close(); // 有搜索,且列表打开着 => 关闭列表、恢复搜索框 + _text = _q!; + } else { + _q = null; // 有搜索,且列表关闭着 => 取消搜索、打开列表、清空搜索框、清空数据 + _searchController.open(); + _text = ''; + _data.clear(); + } + if (mounted) setState(() {}); // 搜索框状态变更,更新界面 + return false; } - void _search() { - if (_text == null) { - Fluttertoast.showToast(msg: '请输入搜索内容'); + void _search() async { + if (_text.isEmpty) { + Fluttertoast.showToast(msg: '请输入搜索内容'); // 搜索框为空 => 提示输入 return; } - if (_q != _text) { - _q = _text; + _q = _text; // 搜索框不为空,且与当前关键词不同 => 更新搜索关键词、关闭列表、添加搜索历史、执行搜索 _searchController.close(); - _udvController.refresh(); - addSearchHistory(_q); + await SearchHistoryPrefs.addSearchHistory(_q!); + _pdvKey.currentState?.refresh(); } else { - _searchController.close(); + _searchController.close(); // 搜索框不为空,且与当前关键词相同 => 关闭列表 } + if (mounted) setState(() {}); // 搜索框状态变更,更新界面 } - Future _pop() async { - if (_q == null) { - return true; // 没搜索 => 退出 - } - if (_searchController.isOpen) { - _searchController.close(); // 有搜索 => 关闭、恢复搜索框 - _text = _q; - } else { - _q = null; // 有搜索 => 取消搜索,打开、清空搜索框 - _data.clear(); - _searchController.open(); - _text = null; - if (mounted) setState(() {}); + Future> _getHistories({required String keyword}) async { + var histories = await SearchHistoryPrefs.getSearchHistories(); + if (keyword.isEmpty) { + return histories; } - return false; + var keywords = keyword.split(' '); + return histories.where((history) { + return keywords.any((word) => history.contains(word)); + }).toList(); } - void _changeFocus(bool focus) { - if (focus == false) { + Future _changeFocus(bool focus) async { + if (!focus) { if (_q != null) { - _text = _q; // 有搜索 => 恢复搜索框 + _text = _q!; // 取消聚焦,有搜索 => 恢复搜索框 } else { - Navigator.of(context).maybePop(); // 没搜索 => 退出 + Navigator.of(context).maybePop(); // 取消聚焦,没搜索 => 退出 } } else { - _getHistories(keyword: _text).then((l) { - _histories = l; - if (mounted) setState(() {}); - }); + var l = await _getHistories(keyword: _text); + _histories.clear(); // 获取聚焦 => 更新搜索历史 + _histories.addAll(l); + if (mounted) setState(() {}); } - if (mounted) setState(() {}); } - void _changeQuery(String s) { - _getHistories(keyword: _text).then((l) { - _histories = l; - if (mounted) setState(() {}); - }); + Future _changeQuery() async { + var l = await _getHistories(keyword: _text); + _histories.clear(); // 获取聚焦 => 更新搜索历史 + _histories.addAll(l); if (mounted) setState(() {}); } @@ -150,13 +145,15 @@ class _SearchPageState extends State { return WillPopScope( onWillPop: _pop, child: Scaffold( + drawer: MyDrawer( + currentDrawerSelection: DrawerSelection.search, + ), resizeToAvoidBottomInset: false, body: Stack( - fit: StackFit.expand, children: [ Positioned( top: 0, - child: Container( + child: SizedBox( width: MediaQuery.of(context).size.width, height: MediaQuery.of(context).padding.top, child: Container(color: Theme.of(context).primaryColor), @@ -164,267 +161,328 @@ class _SearchPageState extends State { ), Positioned.fill( top: MediaQuery.of(context).padding.top + 45, - child: PaginationListView( - data: _data, - getData: ({indicator}) => _getData(page: indicator), - controller: _udvController, - scrollController: _scrollController, - paginationSetting: PaginationSetting( - initialIndicator: 1, - nothingIndicator: 0, - ), - setting: UpdatableDataViewSetting( - padding: EdgeInsets.zero, - placeholderSetting: PlaceholderSetting( - showNothingIcon: _q != null, - showNothingRetry: _q != null, - ).toChinese( - nothingText: _q == null ? '请在搜索框中输入关键字...' : '无内容', + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: PaginationListView( + key: _pdvKey, + data: _data, + getData: ({indicator}) => _getData(page: indicator), + scrollController: _scrollController, + paginationSetting: PaginationSetting( + initialIndicator: 1, + nothingIndicator: 0, ), - refreshFirst: false, - clearWhenError: false, - clearWhenRefresh: true, - updateOnlyIfNotEmpty: false, - onStateChanged: (_, __) => _fabController.hide(), - onAppend: (l) { - if (l.length > 0) { - Fluttertoast.showToast(msg: '新添了 ${l.length} 部漫画'); - } - _lastOrder = _order; - if (mounted) setState(() {}); - }, - onError: (e) { - Fluttertoast.showToast(msg: e.toString()); - _order = _lastOrder; - if (mounted) setState(() {}); - }, - ), - separator: Divider(height: 1), - itemBuilder: (c, item) => TinyMangaLineView(manga: item.toTiny()), - extra: UpdatableDataViewExtraWidgets( - innerTopWidget: Container( - color: Colors.white, - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - height: 26, - padding: EdgeInsets.only(left: 5), - child: Center( - child: Text('"$_q" 的搜索结果 (共 ${_total == null ? '?' : _total.toString()} 部)'), - ), - ), - OptionPopupView( - title: _order.toTitle(), - top: 4, - value: _order, - items: [MangaOrder.byPopular, MangaOrder.byNew, MangaOrder.byUpdate], + setting: UpdatableDataViewSetting( + padding: EdgeInsets.zero, + interactiveScrollbar: true, + scrollbarCrossAxisMargin: 2, + placeholderSetting: PlaceholderSetting( + showNothingIcon: _q != null, + showNothingRetry: _q != null, + ).copyWithChinese( + nothingText: _q == null ? '请在搜索框中输入关键字...' : '无内容', + ), + onPlaceholderStateChanged: (_, __) => _fabController.hide(), + refreshFirst: false, + clearWhenRefresh: true, + clearWhenError: false, + updateOnlyIfNotEmpty: false, + onStartGettingData: () => mountedSetState(() => _getting = true), + onStopGettingData: () => mountedSetState(() => _getting = false), + onAppend: (_, l) { + _lastOrder = _currOrder; + }, + onError: (e) { + if (_data.isNotEmpty) { + Fluttertoast.showToast(msg: e.toString()); + } + _currOrder = _lastOrder; + if (mounted) setState(() {}); + }, + ), + separator: Divider(height: 0, thickness: 1), + itemBuilder: (c, _, item) => TinyMangaLineView(manga: item.toTiny()), + extra: UpdatableDataViewExtraWidgets( + innerTopWidgets: [ + ListHintView.textWidget( + leftText: '"$_q" 的搜索结果 (共 $_total 部)', + rightWidget: OptionPopupView( + items: const [MangaOrder.byPopular, MangaOrder.byNew, MangaOrder.byUpdate], + value: _currOrder, + titleBuilder: (c, v) => v.toTitle(), + enable: !_getting, onSelect: (o) { - if (_order != o) { - _lastOrder = _order; - _order = o; + if (_currOrder != o) { + _lastOrder = _currOrder; + _currOrder = o; if (mounted) setState(() {}); - _udvController.refresh(); + _pdvKey.currentState?.refresh(); } }, - optionBuilder: (c, v) => v.toTitle(), - enable: !_disableOption, ), - ], - ), + ), + ], ), - innerTopDivider: Divider(height: 1, thickness: 1), ), ), ), Positioned( top: MediaQuery.of(context).padding.top, - child: Container( + child: SizedBox( width: MediaQuery.of(context).size.width, - height: 35.0 + 5 * 2, // 45 - child: AppBar(automaticallyImplyLeading: false), + height: 45, + child: AppBar( + automaticallyImplyLeading: false, + toolbarHeight: 45, // keep the same as AppBarTheme + ), ), ), - Scrollbar( - child: FloatingSearchBar( - controller: _searchController, - height: 35, - hint: '输入标题名称、拼音或者 mid 搜索漫画', - textInputType: TextInputType.text, - textInputAction: TextInputAction.search, - iconColor: Colors.black54, - margins: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 5), - insets: EdgeInsets.symmetric(horizontal: 4), - padding: EdgeInsets.symmetric(horizontal: 3), - scrollPadding: EdgeInsets.only(top: 0, bottom: 32), - maxWidth: MediaQuery.of(context).size.width - 8 * 2, - openMaxWidth: MediaQuery.of(context).size.width - 8 * 2, - elevation: 3.0, - borderRadius: _searchController.isClosed - ? BorderRadius.circular(3) - : BorderRadius.only( - topLeft: Radius.circular(3), - topRight: Radius.circular(3), + Positioned( + top: 0, + bottom: _searchController.isOpen ? 0 : MediaQuery.of(context).size.height - (MediaQuery.of(context).padding.top + 45), + left: 0, + right: 0, + child: MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 5 + 35 + 46) /* padding_top_5 + height_35 + magic_46 */, + ), + child: ScrollbarWithMore( + controller: _searchScrollController, + interactive: true, + crossAxisMargin: 8 + 2 /* padding_right_8 + crossAxisMargin_2 */, + mainAxisMargin: -46 /* magic */, + child: FloatingSearchBar( + controller: _searchController, + scrollController: _searchScrollController, + height: 35 /* 35 + 5 + 5 => 45 */, + margins: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 5, left: 8, right: 8), + padding: EdgeInsets.symmetric(horizontal: 2), + insets: EdgeInsets.symmetric(horizontal: 4), + scrollPadding: EdgeInsets.only(bottom: 16), + elevation: 3.0, + borderRadius: _searchController.isClosed + ? BorderRadius.all(Radius.circular(4)) // all border sides have radius + : BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(4)) /* only top borders have radius */, + transitionDuration: Duration(milliseconds: 450), + transitionCurve: Curves.easeInOut, + transition: CircularFloatingSearchBarTransition(), + hint: '输入标题名称、拼音或者 mid 搜索漫画', + hintStyle: Theme.of(context).textTheme.bodyText2?.copyWith(color: Theme.of(context).hintColor), + queryStyle: Theme.of(context).textTheme.bodyText2, + textInputType: TextInputType.text, + textInputAction: TextInputAction.search, + clearQueryOnClose: false, + closeOnBackdropTap: false, + iconColor: Colors.black54, + automaticallyImplyBackButton: false, + automaticallyImplyDrawerHamburger: false, + leadingActions: [ + FloatingSearchBarAction( + showIfOpened: true, + showIfClosed: true, + child: CircularButton( + size: 18, + icon: Icon(Icons.arrow_back, size: 18), + tooltip: '返回', + onPressed: () => Navigator.of(context).maybePop(), // 返回 + ), ), - hintStyle: Theme.of(context).textTheme.bodyText2.copyWith(color: Theme.of(context).hintColor), - queryStyle: Theme.of(context).textTheme.bodyText2, - clearQueryOnClose: false, - closeOnBackdropTap: false, - automaticallyImplyBackButton: false, - automaticallyImplyDrawerHamburger: false, - transitionDuration: Duration(milliseconds: 500), - transitionCurve: Curves.easeInOut, - transition: CircularFloatingSearchBarTransition(), - leadingActions: [ - FloatingSearchBarAction.icon( - icon: Icon(Icons.arrow_back, size: 18), - size: 18, - showIfOpened: true, - showIfClosed: true, - onTap: () => Navigator.of(context).maybePop(), // 返回 - ), - ], - actions: [ - FloatingSearchBarAction.icon( - icon: Icon(Icons.close, size: 18), - size: 18, - showIfOpened: true, - showIfClosed: false, - onTap: () => mountedSetState(() => _searchController.clear()), // 清空 - ), - FloatingSearchBarAction.icon( - icon: Icon(Icons.search, size: 18), - size: 18, - showIfOpened: true, - showIfClosed: true, - onTap: () => _search(), // 搜索 - ), - ], - debounceDelay: Duration(milliseconds: 100), - onQueryChanged: _changeQuery, - onFocusChanged: _changeFocus, - onSubmitted: (_) => _search(), - builder: (_, __) => ClipRRect( - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(4), - bottomRight: Radius.circular(4), - ), - child: Material( - color: Colors.white, - child: Column( - children: [ - // =================================================================== - if (_text != null && _text != _q) - InkWell( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: IconText( - icon: Icon(Icons.search, color: Colors.black45), - text: Text('搜索 "$_text"'), - ), - ), - onTap: () => _search(), // 搜索 + ], + actions: [ + FloatingSearchBarAction( + showIfOpened: true, + showIfClosed: false, + child: CircularButton( + size: 18, + icon: Icon(Icons.close, size: 18), + tooltip: '清空', + onPressed: () => _text = '', + ), + ), + FloatingSearchBarAction( + showIfOpened: true, + showIfClosed: true, + child: CircularButton( + size: 18, + icon: Icon(Icons.search, size: 18), + tooltip: '清空', + onPressed: () => _search(), // 搜索 + ), + ), + ], + debounceDelay: Duration(milliseconds: 150), + onSubmitted: (_) => _search(), + onFocusChanged: (focus) => _changeFocus(focus), + onQueryChanged: (_) => _changeQuery(), + builder: (_, __) => Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: const [ + BoxShadow( + color: Colors.black26, + blurRadius: 4, + spreadRadius: -1, + offset: Offset(0, 5), ), - if (_text != null && (int.tryParse(_text) ?? 0) > 0) - InkWell( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: IconText( - icon: Icon(Icons.arrow_forward, color: Colors.black45), - text: Text('访问漫画 mid$_text'), - ), - ), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => MangaPage( - id: int.tryParse(_text), - title: '漫画 mid$_text', - url: '', + ], + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(4), + bottomRight: Radius.circular(4), + ), + ), + child: Material( + color: Colors.transparent, + child: Column( + children: [ + // =================================================================== + if (_text.isNotEmpty && _text != _q) + InkWell( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: IconText( + icon: Icon(Icons.search, color: Colors.black45), + text: Flexible( + child: Text( + '搜索 "$_text"', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), ), + onTap: () => _search(), // 搜索 ), - ), // 访问 - ), - if (_q != null) - InkWell( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: IconText( - icon: Icon(Icons.arrow_back, color: Colors.black45), - text: Text('返回 "$_q" 的搜索结果'), + if (_text.isNotEmpty && (int.tryParse(_text) ?? 0) > 0) + InkWell( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: IconText( + icon: Icon(Icons.arrow_forward, color: Colors.black45), + text: Text('访问漫画 mid: $_text'), + ), + ), + onTap: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaPage( + id: int.tryParse(_text)!, + title: '漫画 mid: $_text', + url: '', + ), + ), + ), // 访问 ), - ), - onTap: () => Navigator.of(context).maybePop(), // 返回 - ), - // =================================================================== - ..._histories.map( - (h) => InkWell( - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: IconText( - icon: Icon(Icons.history, color: Colors.black45), - text: Text(h), + if (_q != null) + InkWell( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: IconText.texts( + icon: Icon(Icons.arrow_back, color: Colors.black45), + texts: [ + Text('返回 "'), + Flexible( + child: Text( + _q!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text('" 的搜索结果'), + ], + ), + ), + onTap: () => Navigator.of(context).maybePop(), // 返回 ), - ), - onTap: () => _searchController.query = h, // 候选 - onLongPress: () => showDialog( - context: context, - builder: (c) => AlertDialog( - title: Text('删除搜索记录'), - content: Text('确定要删除 $h 吗?'), - actions: [ - FlatButton( - child: Text('删除'), - onPressed: () async { - Navigator.of(c).pop(); - _histories.remove(h); - await removeSearchHistory(h); - if (mounted) setState(() {}); - }, + // =================================================================== + for (var h in _histories) + InkWell( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: IconText( + icon: Icon(Icons.history, color: Colors.black45), + text: Flexible( + child: Text( + h, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ), - FlatButton( - child: Text('取消'), - onPressed: () => Navigator.of(c).pop(), + ), + onTap: () { + _text = h; + _search(); // 候选并搜索 + }, + onLongPress: () => showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('删除搜索记录'), + content: Text('确定要删除 "$h" 吗?'), + actions: [ + TextButton( + child: Text('删除'), + onPressed: () async { + Navigator.of(c).pop(); + _histories.remove(h); + await SearchHistoryPrefs.removeSearchHistory(h); + if (mounted) setState(() {}); + }, + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(c).pop(), + ), + ], ), - ], + ), // 删除 ), - ), // 删除 - ), - ), - // =================================================================== - if (_histories.isNotEmpty && _text == null) - InkWell( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Center( - child: Text('清空历史记录'), + // =================================================================== + if (_histories.isEmpty) + InkWell( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 10), + child: Center( + child: Text('暂无历史记录'), + ), + ), + onTap: () {}, // 返回 ), - ), - onTap: () => showDialog( - context: context, - builder: (c) => AlertDialog( - title: Text('清空历史记录'), - content: Text('确定要清空所有历史记录吗?'), - actions: [ - FlatButton( - child: Text('清空'), - onPressed: () { - _histories.clear(); - clearSearchHistories(); - if (mounted) setState(() {}); - Navigator.of(c).pop(); - }, + if (_histories.isNotEmpty && (_text.isEmpty || _q == _text)) + InkWell( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 10), + child: Center( + child: Text('清空历史记录'), ), - FlatButton( - child: Text('取消'), - onPressed: () => Navigator.of(c).pop(), + ), + onTap: () => showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('清空历史记录'), + content: Text('确定要清空所有历史记录吗?'), + actions: [ + TextButton( + child: Text('清空'), + onPressed: () async { + _histories.clear(); + await SearchHistoryPrefs.clearSearchHistories(); + if (mounted) setState(() {}); + Navigator.of(c).pop(); + }, + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(c).pop(), + ), + ], ), - ], + ), ), - ), - ), - // =================================================================== - ], + // =================================================================== + ], + ), + ), ), ), ), @@ -438,7 +496,7 @@ class _SearchPageState extends State { condition: ScrollAnimatedCondition.direction, fab: FloatingActionButton( child: Icon(Icons.vertical_align_top), - heroTag: 'SearchPage', + heroTag: null, onPressed: () => _scrollController.scrollToTop(), ), ), diff --git a/lib/page/setting.dart b/lib/page/setting.dart index b5e5b47..e2a1b1d 100644 --- a/lib/page/setting.dart +++ b/lib/page/setting.dart @@ -1,29 +1,31 @@ import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/config.dart'; -import 'package:manhuagui_flutter/service/natives/browser.dart'; +import 'package:manhuagui_flutter/page/page/dl_setting.dart'; +import 'package:manhuagui_flutter/page/page/view_setting.dart'; +import 'package:manhuagui_flutter/page/view/my_drawer.dart'; +import 'package:manhuagui_flutter/service/native/browser.dart'; +import 'package:manhuagui_flutter/service/prefs/dl_setting.dart'; +import 'package:manhuagui_flutter/service/prefs/view_setting.dart'; /// 设置页 class SettingPage extends StatefulWidget { + const SettingPage({Key? key}) : super(key: key); + @override _SettingPageState createState() => _SettingPageState(); } class _SettingPageState extends State { - Widget _item({@required String title, Function action}) { - return Container( + Widget _item({required String title, required void Function() action}) { + return Material( color: Colors.white, - child: Material( - color: Colors.transparent, - child: InkWell( - child: Container( - padding: EdgeInsets.symmetric(horizontal: 15, vertical: 12), - child: Text( - title, - style: Theme.of(context).textTheme.subtitle1, - ), - ), - onTap: action ?? () {}, + child: InkWell( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 15, vertical: 13), + child: Text(title, style: Theme.of(context).textTheme.subtitle1), ), + onTap: action, ), ); } @@ -31,8 +33,8 @@ class _SettingPageState extends State { Widget _divider() { return Container( color: Colors.white, - padding: EdgeInsets.only(left: 10, right: 10), - child: Divider(height: 1, thickness: 1), + padding: EdgeInsets.symmetric(horizontal: 10), + child: Divider(height: 0, thickness: 1), ); } @@ -44,65 +46,159 @@ class _SettingPageState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - centerTitle: true, - toolbarHeight: 45, title: Text('设置'), + leading: AppBarActionButton.leading(context: context, allowDrawerButton: false), + ), + drawer: MyDrawer( + currentDrawerSelection: DrawerSelection.setting, ), body: ListView( + padding: EdgeInsets.zero, + physics: AlwaysScrollableScrollPhysics(), children: [ + // ******************************************************* + _spacer(), + Align( + alignment: Alignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset('${ASSETS_PREFIX}ic_launcher_xxhdpi.png', height: 60, width: 60), + SizedBox(width: 15), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + APP_NAME, + style: Theme.of(context).textTheme.headline6?.copyWith(fontWeight: FontWeight.normal), + ), + Text( + APP_VERSION, + style: Theme.of(context).textTheme.subtitle2?.copyWith(fontWeight: FontWeight.normal), + ) + ], + ), + ], + ), + ), _spacer(), // ******************************************************* _item( - title: '漫画官网', - action: () => launchInBrowser( - context: context, - url: WEB_HOMEPAGE_URL, - ), + title: '漫画阅读设置', + action: () async { + var setting = await ViewSettingPrefs.getSetting(); + showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('漫画阅读设置'), + content: ViewSettingSubPage( + setting: setting, + onSettingChanged: (s) => setting = s, + ), + actions: [ + TextButton( + child: Text('确定'), + onPressed: () async { + Navigator.of(c).pop(); + await ViewSettingPrefs.setSetting(setting); + }, + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), + ); + }, ), _divider(), _item( - title: '客户端源码', - action: () => launchInBrowser( - context: context, - url: APP_HOMEPAGE_URL, - ), + title: '漫画下载设置', + action: () async { + var setting = await DlSettingPrefs.getSetting(); + showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('漫画下载设置'), + content: DlSettingSubPage( + setting: setting, + onSettingChanged: (s) => setting = s, + ), + actions: [ + TextButton( + child: Text('确定'), + onPressed: () async { + Navigator.of(c).pop(); + await DlSettingPrefs.setSetting(setting); + }, + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), + ); + }, + ), + _spacer(), + // ******************************************************* + _item( + title: '漫画柜/看漫画官网', + action: () => launchInBrowser(context: context, url: WEB_HOMEPAGE_URL), + ), + _divider(), + _item( + title: '本应用源代码', + action: () => launchInBrowser(context: context, url: SOURCE_CODE_URL), ), _spacer(), // ******************************************************* _item( title: '反馈及联系作者', - action: () => launchInBrowser( - context: context, - url: FEEDBACK_URL, - ), + action: () => launchInBrowser(context: context, url: FEEDBACK_URL), ), _divider(), _item( title: '检查更新', - action: () => launchInBrowser( + action: () => showDialog( context: context, - url: RELEASE_URL, + builder: (c) => AlertDialog( + title: Text('检查更新'), + content: Text('当前 $APP_NAME 版本为 $APP_VERSION。是否打开 GitHub Release 页面手动检查更新?'), + actions: [ + TextButton( + child: Text('打开'), + onPressed: () => launchInBrowser( + context: context, + url: RELEASE_URL, + ), + ), + TextButton( + child: Text('取消'), + onPressed: () => Navigator.of(c).pop(), + ), + ], + ), ), ), _divider(), _item( - title: '关于', + title: '关于本应用', action: () => showAboutDialog( context: context, useRootNavigator: false, applicationName: APP_NAME, applicationVersion: APP_VERSION, - applicationIcon: SizedBox( - height: 50, - width: 50, - child: Image.asset('lib/assets/ic_launcher_h.png'), - ), - applicationLegalese: '© 2020-2021 Aoi-hosizora', + applicationLegalese: APP_LEGALESE, + applicationIcon: Image.asset('${ASSETS_PREFIX}ic_launcher_xxhdpi.png', height: 60, width: 60), children: [ SizedBox(height: 20), - for (var r in APP_DESCRIPTIONS) + for (var description in APP_DESCRIPTIONS) Text( - r, + description, style: Theme.of(context).textTheme.subtitle1, ), ], @@ -113,7 +209,7 @@ class _SettingPageState extends State { Align( alignment: Alignment.center, child: Text( - '© 2020-2021 Aoi-hosizora', + APP_LEGALESE, style: TextStyle(color: Colors.grey), ), ), diff --git a/lib/page/view/action_row.dart b/lib/page/view/action_row.dart new file mode 100644 index 0000000..a0872f6 --- /dev/null +++ b/lib/page/view/action_row.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; + +class ActionItem { + const ActionItem({ + required this.text, + required this.icon, + required this.action, + this.longPress, + this.enable = true, + this.rotateAngle = 0, + }); + + const ActionItem.simple( + this.text, + this.icon, + this.action, [ + this.longPress, + this.enable = true, + this.rotateAngle = 0, + ]); + + final String text; + final IconData icon; + final void Function()? action; + final void Function()? longPress; + final bool enable; + final double rotateAngle; +} + +/// 一排按钮(四个/五个),在 [RecommendSubPage] / [MineSubPage] / [MangaPage] / [MangaViewerPage] / [DownloadTocPage] 使用 +class ActionRowView extends StatelessWidget { + const ActionRowView.four({ + Key? key, + required this.action1, + required this.action2, + required this.action3, + required this.action4, + this.compact = false, + this.shrink = true, + this.textColor, + this.iconColor, + this.disabledTextColor, + this.disabledIconColor, + }) : action5 = null, + super(key: key); + + const ActionRowView.five({ + Key? key, + required this.action1, + required this.action2, + required this.action3, + required this.action4, + required ActionItem this.action5, + this.compact = false, + this.shrink = true, + this.textColor, + this.iconColor, + this.disabledTextColor, + this.disabledIconColor, + }) : super(key: key); + + final ActionItem action1; + final ActionItem action2; + final ActionItem action3; + final ActionItem action4; + final ActionItem? action5; + final bool compact; + final bool shrink; + final Color? textColor; + final Color? iconColor; + final Color? disabledTextColor; + final Color? disabledIconColor; + + Widget _buildAction(ActionItem action) { + return InkWell( + onTap: action.enable ? action.action : null, + onLongPress: action.enable ? action.longPress : null, + child: Padding( + padding: compact + ? EdgeInsets.symmetric(horizontal: 10, vertical: 2) // compact + : EdgeInsets.symmetric(horizontal: 10, vertical: 6) /* normal */, + child: IconText( + alignment: IconTextAlignment.t2b, + space: compact + ? 2 // compact + : action5 == null + ? 8 // four + : 5 /* five */, + icon: Transform.rotate( + angle: action.rotateAngle, + child: Icon( + action.icon, + color: action.enable + ? (textColor ?? Colors.black54) // enabled + : (disabledTextColor ?? Colors.grey) /* disabled */, + ), + ), + text: Text( + action.text, + style: TextStyle( + color: action.enable + ? (iconColor ?? Colors.black) // enabled + : (disabledIconColor ?? Colors.grey) /* disabled */, + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Padding( + padding: compact + ? EdgeInsets.zero // compact + : action5 == null // normal + ? EdgeInsets.symmetric(horizontal: 35, vertical: 8) // four + : EdgeInsets.symmetric(horizontal: 20, vertical: 5) /* five */, + child: Row( + mainAxisAlignment: shrink + ? MainAxisAlignment.spaceBetween // shrink + : MainAxisAlignment.spaceAround /* normal */, + children: [ + _buildAction(action1), + _buildAction(action2), + _buildAction(action3), + _buildAction(action4), + if (action5 != null) // five + _buildAction(action5!), + ], + ), + ), + ); + } +} diff --git a/lib/page/view/chapter_grid.dart b/lib/page/view/chapter_grid.dart new file mode 100644 index 0000000..cbc3695 --- /dev/null +++ b/lib/page/view/chapter_grid.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:manhuagui_flutter/model/chapter.dart'; + +/// 章节列表展示,在 [MangaTocView] / [MangaSimpleTocView] 使用 +class ChapterGridView extends StatelessWidget { + const ChapterGridView({ + Key? key, + required this.chapters, + required this.padding, + this.invertOrder = true, + this.maxLines = -1, + this.highlightColor, + this.highlightedChapters = const [], + this.extrasInStack, + required this.onChapterPressed, + this.onChapterLongPressed, + }) : super(key: key); + + final List chapters; + final EdgeInsets padding; + final bool invertOrder; // true means desc + final int maxLines; // -1 means full + final Color? highlightColor; + final List highlightedChapters; + final List Function(TinyMangaChapter? chapter)? extrasInStack; + final void Function(TinyMangaChapter? chapter) onChapterPressed; + final void Function(TinyMangaChapter? chapter)? onChapterLongPressed; + + Widget _buildItem({required BuildContext context, required TinyMangaChapter? chapter}) { + return Stack( + children: [ + Positioned.fill( + child: OutlinedButton( + child: Text( + chapter?.title ?? '...', + style: TextStyle(color: Colors.black), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), + backgroundColor: !highlightedChapters.contains(chapter?.cid) + ? null // + : (highlightColor ?? Theme.of(context).primaryColorLight.withOpacity(0.6)), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => onChapterPressed(chapter), + onLongPress: onChapterLongPressed == null ? null : () => onChapterLongPressed!.call(chapter), + ), + ), + if (extrasInStack != null) // + ...extrasInStack!.call(chapter), + ], + ); + } + + @override + Widget build(BuildContext context) { + const hSpace = 8.0; + const vSpace = 8.0; + + final width = (MediaQuery.of(context).size.width - 2 * padding.left - 3 * hSpace) / 4; // | ▢ ▢ ▢ ▢ | + const height = 36.0; + + List shown = chapters.toList(); + if (!invertOrder) { + shown.sort((i, j) => i!.cid.compareTo(j!.cid)); + } else { + shown.sort((i, j) => j!.cid.compareTo(i!.cid)); + } + + if (maxLines > 0) { + var count = maxLines * 4; + if (shown.length > count) { + shown = [...shown.sublist(0, count - 1), null]; + // maxLines: 3 => X X X X | X X X X | X X X O + // maxLines: 1 => X X X O + } + } + + return Padding( + padding: padding, + child: Wrap( + spacing: hSpace, + runSpacing: vSpace, + children: [ + for (var chapter in shown) + SizedBox( + width: width, + height: height, + child: _buildItem( + context: context, + chapter: chapter, + ), + ), + ], + ), + ); + } +} diff --git a/lib/page/view/chapter_group.dart b/lib/page/view/chapter_group.dart deleted file mode 100644 index b076f2a..0000000 --- a/lib/page/view/chapter_group.dart +++ /dev/null @@ -1,322 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/util.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:manhuagui_flutter/model/chapter.dart'; -import 'package:manhuagui_flutter/page/chapter.dart'; -import 'package:manhuagui_flutter/page/manga_toc.dart'; - -/// View for [MangaChapterGroup]. -/// Used in [MangaPage] and [MangaTocPage]. -class ChapterGroupView extends StatefulWidget { - const ChapterGroupView({ - Key key, - this.action, - @required this.groups, - @required this.complete, - this.highlightChapter = 0, - @required this.mangaId, - @required this.mangaTitle, - @required this.mangaCover, - @required this.mangaUrl, - }) : assert(groups != null), - assert(complete != null), - assert(highlightChapter != null), - assert(mangaId != null), - assert(mangaTitle != null), - assert(mangaCover != null), - assert(mangaUrl != null), - super(key: key); - - final ActionController action; - final List groups; - final bool complete; - final int highlightChapter; - final int mangaId; - final String mangaTitle; - final String mangaCover; - final String mangaUrl; - - @override - _ChapterGroupViewState createState() => _ChapterGroupViewState(); -} - -class _ChapterGroupViewState extends State { - var _invertedOrder = true; - - Widget _buildGridItem(TinyMangaChapter chapter, int index, {double hSpace, double width, double height}) { - // **************************************************************** - // 每个章节 - // **************************************************************** - return Container( - width: width, - height: height, - margin: index == 0 - ? EdgeInsets.only(right: hSpace) - : index == 3 - ? EdgeInsets.only(left: hSpace) - : EdgeInsets.symmetric(horizontal: hSpace), - child: Stack( - children: [ - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(3), - color: chapter != null && widget.highlightChapter == chapter.cid ? Theme.of(context).primaryColor.withOpacity(0.5) : Colors.white, - ), - child: Theme( - data: Theme.of(context).copyWith( - buttonTheme: ButtonTheme.of(context).copyWith( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - child: OutlineButton( - child: Text( - chapter == null ? '...' : chapter.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => chapter == null - ? MangaTocPage( - action: widget.action, - mid: widget.mangaId, - title: widget.mangaTitle, - cover: widget.mangaCover, - url: widget.mangaUrl, - groups: widget.groups, - highlightChapter: widget.highlightChapter, - ) - : ChapterPage( - action: widget.action, - mid: chapter.mid, - cid: chapter.cid, - mangaTitle: widget.mangaTitle, - mangaCover: widget.mangaCover, - mangaUrl: widget.mangaUrl, - ), - ), - ), - ), - ), - ), - ), - if (chapter?.isNew == true) - Positioned( - top: 0, - right: 0, - child: Container( - padding: EdgeInsets.symmetric(vertical: 1, horizontal: 3), - decoration: BoxDecoration( - color: Colors.red, - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(2), - topRight: Radius.circular(1), - ), - ), - child: Text( - 'NEW', - style: TextStyle(fontSize: 9, color: Colors.white), - ), - ), - ), - ], - ), - ); - } - - Widget _buildSingleGroup(MangaChapterGroup group, {double hPadding, double vPadding, bool first = false}) { - var chapters = _invertedOrder ? group.chapters : group.chapters.reversed.toList(); - if (!widget.complete) { - if (first) { - if (chapters.length > 12) { - chapters = [...chapters.sublist(0, 11), null]; - } - } else { - if (chapters.length > 4) { - chapters = [...chapters.sublist(0, 3), null]; - } - } - } - - var hSpace = 3.0; - var vSpace = 2 * hSpace; - var width = (MediaQuery.of(context).size.width - 2 * hPadding - 6 * hSpace) / 4; // | ▢ ▢ ▢ ▢ | - var height = 36.0; - - var gridRows = []; - var rows = (chapters.length.toDouble() / 4).ceil(); - for (var r = 0; r < rows; r++) { - var columns = [ - for (var i = 4 * r; i < 4 * (r + 1) && i < chapters.length; i++) chapters[i], - ]; - gridRows.add( - // **************************************************************** - // 分组中的每一行 - // **************************************************************** - Row( - children: [ - for (var i = 0; i < columns.length; i++) - _buildGridItem( - columns[i], - i, - hSpace: hSpace, - width: width, - height: height, - ), - ], - ), - ); - if (r != rows - 1) { - gridRows.add( - SizedBox(height: vSpace), - ); - } - } - - // **************************************************************** - // 单个章节分组 - // **************************************************************** - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '・${group.title}・', - style: Theme.of(context).textTheme.subtitle1, - ), - SizedBox(height: vPadding), - ...gridRows, - ], - ); - } - - @override - Widget build(BuildContext context) { - if (widget.groups.length == 0) { - return SizedBox(height: 0); - } - - var hPadding = 12.0; - var vPadding = 10.0; - var groups = widget.groups; - var specificGroups = widget.groups.where((g) => g.title == '单话'); - if (specificGroups.length != 0) { - var sGroup = specificGroups.first; - groups = [sGroup]; - for (var group in widget.groups) { - if (group.title != sGroup.title && group.chapters.length != sGroup.chapters.length) { - groups.add(group); - } - } - } - - return Column( - children: [ - // **************************************************************** - // 头 - // **************************************************************** - Container( - color: Colors.white, - padding: EdgeInsets.only(left: 12, top: 2, bottom: 2, right: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '章节列表', - style: Theme.of(context).textTheme.subtitle1, - ), - // **************************************************************** - // 两个排序按钮 - // **************************************************************** - Row( - children: [ - Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - _invertedOrder = false; - if (mounted) setState(() {}); - }, - child: Padding( - padding: EdgeInsets.only(top: 6, bottom: 6, left: 5, right: 10), - child: IconText( - icon: Icon( - Icons.keyboard_arrow_up, - size: 18, - color: !_invertedOrder ? Theme.of(context).primaryColor : Colors.black, - ), - text: Text( - '正序', - style: TextStyle( - color: !_invertedOrder ? Theme.of(context).primaryColor : Colors.black, - ), - ), - space: 0, - ), - ), - ), - ), - Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - _invertedOrder = true; - if (mounted) setState(() {}); - }, - child: Padding( - padding: EdgeInsets.only(top: 6, bottom: 6, left: 5, right: 10), - child: IconText( - icon: Icon( - Icons.keyboard_arrow_down, - size: 18, - color: _invertedOrder ? Theme.of(context).primaryColor : Colors.black, - ), - text: Text( - '倒序', - style: TextStyle( - color: _invertedOrder ? Theme.of(context).primaryColor : Colors.black, - ), - ), - space: 0, - ), - ), - ), - ), - ], - ), - ], - ), - ), - Container( - padding: EdgeInsets.symmetric(horizontal: 12), - color: Colors.white, - child: Divider(height: 1, thickness: 1), - ), - // **************************************************************** - // 章节分组列表 - // **************************************************************** - Container( - padding: EdgeInsets.symmetric(horizontal: hPadding, vertical: vPadding / 2), - child: Column( - children: [ - // **************************************************************** - // 单个章节分组 - // **************************************************************** - for (var i = 0; i < groups.length; i++) - Padding( - padding: EdgeInsets.symmetric(vertical: vPadding / 2), - child: _buildSingleGroup( - groups[i], - first: i == 0, - hPadding: hPadding, - vPadding: vPadding, - ), - ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/page/view/comment_line.dart b/lib/page/view/comment_line.dart index e467489..1067697 100644 --- a/lib/page/view/comment_line.dart +++ b/lib/page/view/comment_line.dart @@ -1,31 +1,106 @@ import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/model/comment.dart'; import 'package:manhuagui_flutter/page/comment.dart'; import 'package:manhuagui_flutter/page/view/network_image.dart'; +import 'package:manhuagui_flutter/service/native/clipboard.dart'; -/// View for [Comment]. -/// Used in [MangaPage] and [CommentPage]. -class CommentLineView extends StatefulWidget { +enum CommentLineViewStyle { + normal, // used in list view, will also show reply lines of given comment + large, // used in detail view, will also be used to display replied comment +} + +/// 漫画评论行,在 [MangaPage] / [MangaCommentsPage] / [CommentPage] 使用 +class CommentLineView extends StatelessWidget { const CommentLineView({ - Key key, - @required this.comment, - }) : assert(comment != null), - super(key: key); + Key? key, + required this.comment, + this.replies, + this.index, + required this.style, + }) : super(key: key); final Comment comment; + final List? replies; // only for normal + final int? index; // only for large replied comment + final CommentLineViewStyle style; - @override - _CommentLineViewState createState() => _CommentLineViewState(); -} + bool get large => style == CommentLineViewStyle.large; + + Widget _buildReplyLines({required BuildContext context}) { + return Container( + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.all(Radius.circular(1.5)), + ), + padding: EdgeInsets.only(left: 8, right: 8, top: 6, bottom: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // **************************************************************** + // 每一楼评论 + // **************************************************************** + for (var line in comment.replyTimeline.sublist(0, comment.replyTimeline.length.clamp(0, 3))) + Padding( + padding: EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Text( + "${line.username == '-' ? '匿名用户' : line.username}: ", + style: Theme.of(context).textTheme.bodyText2?.copyWith(color: Theme.of(context).primaryColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Expanded( + child: Text( + line.content, + style: Theme.of(context).textTheme.bodyText2, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Container( + margin: EdgeInsets.only(left: 6), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.all(Radius.circular(3)), + ), + height: 15, + width: 15, + child: Center( + child: Text( + (comment.replyTimeline.indexOf(line) + 1).toString(), + style: TextStyle( + color: Colors.white, + fontSize: 11, + ), + ), + ), + ), + ], + ), + ), + if (comment.replyTimeline.length > 3) + Padding( + padding: EdgeInsets.only(bottom: 4), + child: Text( + '共 ${comment.replyTimeline.length} 条评论,点击查看该楼层...', + style: Theme.of(context).textTheme.bodyText2?.copyWith(color: Theme.of(context).primaryColor), + ), + ), + ], + ), + ); + } -class _CommentLineViewState extends State { @override Widget build(BuildContext context) { return Stack( children: [ Container( + color: Colors.white, width: MediaQuery.of(context).size.width, - padding: EdgeInsets.only(top: 10, bottom: 10, left: 12, right: 12), + padding: EdgeInsets.symmetric(horizontal: !large ? 12 : 15, vertical: !large ? 8 : 15), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -34,22 +109,20 @@ class _CommentLineViewState extends State { // **************************************************************** ClipOval( child: NetworkImageView( - url: widget.comment.avatar, - height: 32, - width: 32, - fit: BoxFit.cover, + url: comment.avatar, + height: !large ? 32 : 40, + width: !large ? 32 : 40, ), ), - SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // **************************************************************** - // 第一行 - // **************************************************************** - Container( - width: MediaQuery.of(context).size.width - 3 * 12 - 32, // | ▢▢ ▢▢▢▢▢ | - child: Row( + SizedBox(width: !large ? 12 : 15), + SizedBox( + width: !large + ? MediaQuery.of(context).size.width - 3 * 12 - 32 // | ▢ ▢▢ | + : MediaQuery.of(context).size.width - 3 * 15 - 40, // | ▢ ▢▢ | + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ // **************************************************************** @@ -60,23 +133,23 @@ class _CommentLineViewState extends State { children: [ Flexible( child: Text( - widget.comment.username == '-' ? '匿名用户' : widget.comment.username, + comment.username == '-' ? '匿名用户' : comment.username, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.subtitle1, + style: !large ? Theme.of(context).textTheme.bodyText2 : Theme.of(context).textTheme.subtitle1, ), ), SizedBox(width: 8), Container( decoration: BoxDecoration( - color: widget.comment.gender == 1 ? Colors.blue[300] : Colors.red[400], + color: comment.gender == 1 ? Colors.blue[300] : Colors.red[400], borderRadius: BorderRadius.all(Radius.circular(3)), ), height: 18, width: 18, child: Center( child: Text( - widget.comment.gender == 1 ? '♂' : '♀', + comment.gender == 1 ? '♂' : '♀', style: TextStyle(fontSize: 14, color: Colors.white), ), ), @@ -85,128 +158,56 @@ class _CommentLineViewState extends State { ), ), // **************************************************************** - // 楼层 + // 楼层数 // **************************************************************** - if (widget.comment.replyTimeline.length > 0) + if (comment.replyTimeline.isNotEmpty) Container( - margin: EdgeInsets.only(right: 8), + margin: EdgeInsets.only(right: !large ? 8 : 0), decoration: BoxDecoration( color: Theme.of(context).primaryColor, borderRadius: BorderRadius.all(Radius.circular(3)), ), - height: 15, - width: 15, + height: !large ? 15 : 18, + width: !large ? 15 : 26, child: Center( child: Text( - (widget.comment.replyTimeline.length + 1).toString(), + !large + ? '${index ?? comment.replyTimeline.length + 1}' // + : '#${index ?? comment.replyTimeline.length + 1}', style: TextStyle( color: Colors.white, - fontSize: 11, + fontSize: !large ? 11 : 14, ), ), ), ), ], ), - ), - SizedBox(height: 8), - // **************************************************************** - // 评论内容 - // **************************************************************** - Container( - width: MediaQuery.of(context).size.width - 3 * 12 - 32, - child: Text( - widget.comment.content, - style: TextStyle( - fontSize: Theme.of(context).textTheme.bodyText1.fontSize, - ), + SizedBox(height: !large ? 8 : 15), + // **************************************************************** + // 评论内容 + // **************************************************************** + Text( + comment.content, + style: !large ? Theme.of(context).textTheme.bodyText2 : Theme.of(context).textTheme.subtitle1, ), - ), - if (widget.comment.replyTimeline.length > 0) SizedBox(height: 10), - // **************************************************************** - // 楼层 - // **************************************************************** - if (widget.comment.replyTimeline.length > 0) - Container( - width: MediaQuery.of(context).size.width - 3 * 12 - 32, - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.all(Radius.circular(1.5)), - ), - padding: EdgeInsets.only(left: 8, right: 8, top: 6, bottom: 2), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // **************************************************************** - // 每一楼 - // **************************************************************** - for (var line in widget.comment.replyTimeline.sublist(0, widget.comment.replyTimeline.length <= 3 ? widget.comment.replyTimeline.length : 3)) - Padding( - padding: EdgeInsets.only(bottom: 4), - child: Row( - children: [ - Text( - "${line.username == '-' ? '匿名用户' : line.username}: ", - style: TextStyle( - fontSize: Theme.of(context).textTheme.bodyText1.fontSize, - color: Theme.of(context).primaryColor, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Expanded( - child: Text( - line.content, - style: TextStyle( - fontSize: Theme.of(context).textTheme.bodyText1.fontSize, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Container( - margin: EdgeInsets.only(left: 6), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.all(Radius.circular(3)), - ), - height: 15, - width: 15, - child: Center( - child: Text( - (widget.comment.replyTimeline.indexOf(line) + 1).toString(), - style: TextStyle( - color: Colors.white, - fontSize: 11, - ), - ), - ), - ), - ], - ), - ), - if (widget.comment.replyTimeline.length > 3) - Padding( - padding: EdgeInsets.only(bottom: 4), - child: Text( - '点击查看该楼层... (共 ${widget.comment.replyTimeline.length} 条评论)', - style: TextStyle(fontSize: Theme.of(context).textTheme.bodyText1.fontSize, color: Theme.of(context).primaryColor), - ), - ), - ], + SizedBox(height: !large ? 8 : 15), + // **************************************************************** + // 回复评论 + // **************************************************************** + if (!large && comment.replyTimeline.isNotEmpty) + Padding( + padding: EdgeInsets.only(bottom: 8), + child: _buildReplyLines(context: context), ), - ), - SizedBox(height: 10), - // **************************************************************** - // 评论信息 - // **************************************************************** - Container( - width: MediaQuery.of(context).size.width - 3 * 12 - 32, - child: Row( + // **************************************************************** + // 评论数据 + // **************************************************************** + Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - widget.comment.commentTime, + comment.commentTime, style: TextStyle(color: Colors.grey), ), Row( @@ -217,7 +218,7 @@ class _CommentLineViewState extends State { size: 16, ), SizedBox(width: 4), - Text(widget.comment.likeCount.toString()), + Text(comment.likeCount.toString()), SizedBox(width: 10), Icon( Icons.chat_bubble, @@ -225,31 +226,31 @@ class _CommentLineViewState extends State { size: 16, ), SizedBox(width: 4), - Text(widget.comment.replyCount.toString()), + Text(comment.replyCount.toString()), ], ), ], ), - ), - ], + ], + ), ), ], ), ), - // **************************************************************** - // 点击效果 - // **************************************************************** Positioned.fill( child: Material( color: Colors.transparent, child: InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => CommentPage( - comment: widget.comment, - ), - ), - ), + onTap: !large + ? () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => CommentPage( + comment: comment, + ), + ), + ) + : () => copyText(comment.content), ), ), ), diff --git a/lib/page/view/download_chapter_line.dart b/lib/page/view/download_chapter_line.dart new file mode 100644 index 0000000..456bc1b --- /dev/null +++ b/lib/page/view/download_chapter_line.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/service/storage/download_manga_task.dart'; + +/// 章节下载行,在 [DlUnfinishedSubPage] 使用(功能上实现了包括下载完和未下载完的所有状态) +class DownloadChapterLineView extends StatelessWidget { + const DownloadChapterLineView({ + Key? key, + required this.chapterEntity, + required this.downloadTask, + required this.onPressedWhenEnabled, + required this.onPressedWhenDisabled, + this.onLongPressed, + }) : super(key: key); + + final DownloadedChapter chapterEntity; + final DownloadMangaQueueTask? downloadTask; + final void Function() onPressedWhenEnabled; + final void Function() onPressedWhenDisabled; + final void Function()? onLongPressed; + + Widget _buildGeneral({ + required BuildContext context, + required String title, + required String subTitle, + required double? progress, + required IconData icon, + required bool disabled, + }) { + return InkWell( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 9), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text(subTitle), + ], + ), + SizedBox(height: 5), + LinearProgressIndicator( + value: progress, + color: disabled + ? Colors.grey // chapter downloading is unavailable + : Theme.of(context).progressIndicatorTheme.color, + backgroundColor: disabled + ? Colors.grey[300] // chapter downloading is unavailable + : Theme.of(context).progressIndicatorTheme.linearTrackColor, + ), + ], + ), + ), + Padding( + padding: EdgeInsets.only(left: 12), + child: Icon( + icon, + size: 20, + color: !disabled ? Theme.of(context).iconTheme.color : Colors.grey, + ), + ), + ], + ), + ), + onTap: !disabled ? onPressedWhenEnabled : onPressedWhenDisabled, + onLongPress: onLongPressed, + ); + } + + @override + Widget build(BuildContext context) { + var progress = DownloadChapterLineProgress.fromEntityAndTask(entity: chapterEntity, task: downloadTask); + + // !!! + final triedProgressText = '${progress.triedPageCount}/${progress.totalPageCount}'; + final successProgressText = '${progress.successPageCount}/${progress.totalPageCount}'; + final triedProgressValue = progress.totalPageCount == 0 ? 0.0 : progress.triedPageCount / progress.totalPageCount; + final successProgressValue = progress.totalPageCount == 0 ? 0.0 : progress.successPageCount / progress.totalPageCount; + + final title = '【${chapterEntity.chapterGroup}】${chapterEntity.chapterTitle}'; + String subTitle; + double? progressValue; + IconData icon; + switch (progress.status) { + case DownloadChapterLineStatus.waiting: // use success + subTitle = '$successProgressText (等待下载中)'; + progressValue = successProgressValue; + icon = Icons.pause; + break; + case DownloadChapterLineStatus.preparing: // use success + subTitle = '$successProgressText (正在获取章节信息)'; + progressValue = null; + icon = Icons.pause; + break; + case DownloadChapterLineStatus.downloading: // use tried + subTitle = '下载中,$triedProgressText'; + progressValue = triedProgressValue; + icon = Icons.pause; + break; + case DownloadChapterLineStatus.pausing: // use tried + subTitle = '$triedProgressText (暂停中)'; + progressValue = null; + icon = Icons.pause; + break; + case DownloadChapterLineStatus.paused: // use success + subTitle = '$successProgressText (${progress.unfinishedPageCount} 页未完成)'; + progressValue = successProgressValue; + icon = Icons.play_arrow; + break; + case DownloadChapterLineStatus.succeeded: // use success + subTitle = '已完成,$successProgressText'; + progressValue = successProgressValue; + icon = Icons.file_download_done; + break; + case DownloadChapterLineStatus.failed: // use success + subTitle = '$successProgressText (${progress.unfinishedPageCount} 页未完成)'; + progressValue = successProgressValue; + icon = Icons.priority_high; + break; + } + + return _buildGeneral( + context: context, + title: title, + subTitle: subTitle, + progress: progressValue, + icon: icon, + disabled: !progress.isMangaDownloading || progress.status == DownloadChapterLineStatus.pausing, + ); + } +} + +enum DownloadChapterLineStatus { + // 队列中 + waiting, // useEntity + preparing, // useEntity + downloading, // useTask + pausing, // preparing (useEntity) / downloading (useTask) + + // 已结束 + paused, // useEntity + succeeded, // useEntity + failed, // useEntity +} + +class DownloadChapterLineProgress { + const DownloadChapterLineProgress({ + required this.status, + required this.isMangaDownloading, + required this.totalPageCount, + required this.triedPageCount, + required this.successPageCount, + }); + + final DownloadChapterLineStatus status; + final bool isMangaDownloading; + final int totalPageCount; + final int triedPageCount; + final int successPageCount; + + int get unfinishedPageCount => totalPageCount - successPageCount; + + // !!! + static DownloadChapterLineProgress fromEntityAndTask({required DownloadedChapter entity, required DownloadMangaQueueTask? task}) { + assert(task == null || task.mangaId == entity.mangaId); + DownloadChapterLineStatus status; + + var isMangaDownloading = task != null && !task.succeeded && task.mangaId == entity.mangaId && !task.canceled; + if (task != null && !task.succeeded && task.mangaId == entity.mangaId) { + if (task.canceled) { + if (task.progress.currentChapterId == entity.chapterId) { + status = DownloadChapterLineStatus.pausing; // pause when preparing or downloading + } else { + status = DownloadChapterLineStatus.paused; // >>> + } + } else if (task.progress.startedChapters == null) { + status = DownloadChapterLineStatus.waiting; + } else { + if (task.progress.currentChapterId == entity.chapterId) { + if (task.progress.currentChapter == null) { + status = DownloadChapterLineStatus.preparing; + } else { + status = DownloadChapterLineStatus.downloading; + } + } else if (!task.progress.startedChapters!.any((el) => el?.cid == entity.chapterId)) { + status = DownloadChapterLineStatus.waiting; + } else { + status = DownloadChapterLineStatus.paused; // >>> + } + } + } else { + status = DownloadChapterLineStatus.paused; // >>> + } + if (status == DownloadChapterLineStatus.paused) { + if (entity.triedPageCount != entity.totalPageCount) { + status = DownloadChapterLineStatus.paused; + } else if (entity.successPageCount == entity.totalPageCount) { + status = DownloadChapterLineStatus.succeeded; + } else { + status = DownloadChapterLineStatus.failed; + } + } + + var useTask = false; + if (status == DownloadChapterLineStatus.downloading) { + useTask = true; + } else if (status == DownloadChapterLineStatus.pausing && task!.progress.currentChapterId == entity.chapterId && task.progress.currentChapter != null) { + useTask = true; + } + if (useTask) { + return DownloadChapterLineProgress( + status: status, + isMangaDownloading: isMangaDownloading, + totalPageCount: task!.progress.currentChapter!.pageCount, + triedPageCount: task.progress.triedChapterPageCount ?? 0, + successPageCount: task.progress.successChapterPageCount ?? 0, + ); + } + return DownloadChapterLineProgress( + status: status, + isMangaDownloading: isMangaDownloading, + totalPageCount: entity.totalPageCount, + triedPageCount: entity.triedPageCount, + successPageCount: entity.successPageCount, + ); + } +} diff --git a/lib/page/view/download_line.dart b/lib/page/view/download_line.dart new file mode 100644 index 0000000..34e3b4c --- /dev/null +++ b/lib/page/view/download_line.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/page/image_viewer.dart'; +import 'package:manhuagui_flutter/page/view/full_ripple.dart'; +import 'package:manhuagui_flutter/page/view/general_line.dart'; +import 'package:manhuagui_flutter/page/view/network_image.dart'; + +/// 通用的漫画下载行(小),在 [DownloadMangaLineView] 使用 +class DownloadLineView extends StatelessWidget { + const DownloadLineView({ + Key? key, + required this.imageUrl, + required this.title, + required this.icon1, + required this.text1, + required this.icon2, + required this.text2, + required this.icon3, + required this.text3, + required this.showProgressBar, + required this.progressBarValue, + required this.disableAction, + required this.actionIcon, + required this.onActionPressed, + required this.onLinePressed, + required this.onLineLongPressed, + }) : super(key: key); + + final String imageUrl; + final String title; + final IconData icon1; + final String text1; + final IconData icon2; + final String text2; + final IconData? icon3; + final String? text3; + final bool showProgressBar; + final double? progressBarValue; + final bool disableAction; + final IconData actionIcon; + final void Function() onActionPressed; + final void Function() onLinePressed; + final void Function()? onLineLongPressed; + + @override + Widget build(BuildContext context) { + return GeneralLineView.custom( + imageUrl: imageUrl, + title: title, + customRows: [ + GeneralLineIconText( + icon: icon1, + text: text1, + ), + GeneralLineIconText( + icon: icon2, + text: text2, + ), + GeneralLineIconText( + icon: icon3, + text: text3, + ), + ], + extrasInStack: [ + if (showProgressBar) + Positioned( + bottom: 8 + 24 / 2 - (Theme.of(context).progressIndicatorTheme.linearMinHeight ?? 4) / 2 - 2, + left: 75 + 14 * 2, + right: 24 + 8 * 2 + 14, + child: LinearProgressIndicator( + value: progressBarValue, + ), + ), + ], + topExtrasInStack: [ + Positioned( + right: 0, + bottom: 0, + child: InkWell( + child: Padding( + padding: EdgeInsets.all(8), + child: Icon( + actionIcon, + size: 24, + color: !disableAction ? Theme.of(context).iconTheme.color : Colors.grey, + ), + ), + onTap: !disableAction ? onActionPressed : null, + ), + ), + ], + onPressed: onLinePressed, + onLongPressed: onLineLongPressed, + ); + } +} + +/// 通用的漫画下载行(大),在 [DownloadMangaBlockView] 使用 +class LargeDownloadLineView extends StatelessWidget { + const LargeDownloadLineView({ + Key? key, + required this.imageUrl, + required this.title, + required this.icon1, + required this.text1, + required this.icon2, + required this.text2, + required this.icon3, + required this.text3, + }) : super(key: key); + + final String imageUrl; + final String title; + final IconData icon1; + final String text1; + final IconData icon2; + final String text2; + final IconData icon3; + final String text3; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // **************************************************************** + // 封面 + // **************************************************************** + Container( + padding: EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: FullRippleWidget( + child: NetworkImageView( + url: imageUrl, + height: 160, + width: 120, + ), + onTap: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => ImageViewerPage( + url: imageUrl, + title: '漫画封面', + ), + ), + ), + ), + ), + // **************************************************************** + // 信息 + // **************************************************************** + Container( + width: MediaQuery.of(context).size.width - 14 * 3 - 120, // | ▢ ▢▢ | + padding: EdgeInsets.only(top: 10, bottom: 10, right: 0), + alignment: Alignment.centerLeft, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Text( + title, + style: Theme.of(context).textTheme.headline6?.copyWith(fontWeight: FontWeight.normal), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + Divider(height: 20, thickness: 1.5), + GeneralLineIconText( + icon: icon1, + text: text1, + iconSize: 22, + textStyle: Theme.of(context).textTheme.subtitle2?.copyWith(fontSize: 16, fontWeight: FontWeight.normal), + padding: EdgeInsets.only(bottom: 4), + ), + GeneralLineIconText( + icon: icon2, + text: text2, + iconSize: 22, + textStyle: Theme.of(context).textTheme.subtitle2?.copyWith(fontSize: 16, fontWeight: FontWeight.normal), + padding: EdgeInsets.only(bottom: 4), + ), + GeneralLineIconText( + icon: icon3, + text: text3, + iconSize: 22, + textStyle: Theme.of(context).textTheme.subtitle2?.copyWith(fontSize: 16, fontWeight: FontWeight.normal), + padding: EdgeInsets.only(bottom: 4), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/page/view/download_manga_line.dart b/lib/page/view/download_manga_line.dart new file mode 100644 index 0000000..7034e54 --- /dev/null +++ b/lib/page/view/download_manga_line.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:intl/intl.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/page/view/download_line.dart'; +import 'package:manhuagui_flutter/service/storage/download_manga_task.dart'; + +/// 漫画下载行(小),在 [DownloadPage] 使用 +class DownloadMangaLineView extends StatelessWidget { + const DownloadMangaLineView({ + Key? key, + required this.mangaEntity, + required this.downloadTask, + required this.downloadedBytes, + required this.onActionPressed, + required this.onLinePressed, + required this.onLineLongPressed, + }) : super(key: key); + + final DownloadedManga mangaEntity; + final DownloadMangaQueueTask? downloadTask; + final int downloadedBytes; + final void Function() onActionPressed; + final void Function() onLinePressed; + final void Function()? onLineLongPressed; + + @override + Widget build(BuildContext context) { + var progress = DownloadMangaLineProgress.fromEntityAndTask(entity: mangaEntity, task: downloadTask); + var downloadedSize = filesize(downloadedBytes, 2, false); + + // !!! + switch (progress.status) { + case DownloadMangaLineStatus.waiting: + case DownloadMangaLineStatus.paused: + case DownloadMangaLineStatus.succeeded: + case DownloadMangaLineStatus.failed: + assert( + progress.stopped, + 'progress.stopped must be true when status is not downloading and pausing', + ); + return DownloadLineView( + imageUrl: mangaEntity.mangaCover, + title: mangaEntity.mangaTitle, + icon1: Icons.download, + text1: '已下载章节 ${progress.startedChapterCount}/${progress.totalChapterCount} ($downloadedSize)', + icon2: Icons.access_time, + text2: '下载于 ${DateFormat('yyyy-MM-dd HH:mm:ss').format(progress.lastDownloadTime!)}', + icon3: Icons.bar_chart, + text3: progress.status == DownloadMangaLineStatus.waiting + ? '等待中' + : progress.status == DownloadMangaLineStatus.paused + ? '已暂停 (${progress.notFinishedChapterCount!} 章节共 ${progress.notFinishedPageCount!} 页未完成)' + : progress.status == DownloadMangaLineStatus.succeeded + ? '已完成' + : progress.notFinishedPageCount! < 0 + ? '下载出错' + : '下载出错 (${progress.notFinishedChapterCount!} 章节共 ${progress.notFinishedPageCount!} 页未完成)', + showProgressBar: false, + progressBarValue: null, + disableAction: false, + actionIcon: progress.status == DownloadMangaLineStatus.waiting ? Icons.pause : Icons.play_arrow, + onActionPressed: onActionPressed, + onLinePressed: onLinePressed, + onLineLongPressed: onLineLongPressed, + ); + case DownloadMangaLineStatus.downloading: + case DownloadMangaLineStatus.pausing: + assert( + !progress.stopped, + 'progress.stopped must be false when status is downloading or pausing', + ); + return DownloadLineView( + imageUrl: mangaEntity.mangaCover, + title: mangaEntity.mangaTitle, + icon1: Icons.download, + text1: '正在下载章节 ${progress.startedChapterCount}/${progress.totalChapterCount} ($downloadedSize)', + icon2: Icons.download, + text2: (progress.preparing + ? progress.gettingManga + ? '正在获取漫画信息' + : '当前正在下载 未知章节' + : '当前正在下载 ${progress.chapterTitle!} ${progress.triedPageCount!}/${progress.totalPageCount!}页') + + (progress.status == DownloadMangaLineStatus.pausing ? ' (暂停中)' : ''), + icon3: null, + text3: ' ', + showProgressBar: true, + progressBarValue: progress.status == DownloadMangaLineStatus.pausing || progress.preparing + ? null // + : (progress.totalPageCount! == 0 ? 0.0 : progress.triedPageCount! / progress.totalPageCount!), + disableAction: progress.status == DownloadMangaLineStatus.pausing, + actionIcon: Icons.pause, + onActionPressed: onActionPressed, + onLinePressed: onLinePressed, + onLineLongPressed: onLineLongPressed, + ); + } + } +} + +/// 漫画下载行(大),在 [DownloadTocPage] 使用 +class LargeDownloadMangaLineView extends StatelessWidget { + const LargeDownloadMangaLineView({ + Key? key, + required this.mangaEntity, + required this.downloadTask, + required this.downloadedBytes, + }) : super(key: key); + + final DownloadedManga mangaEntity; + final DownloadMangaQueueTask? downloadTask; + final int downloadedBytes; + + @override + Widget build(BuildContext context) { + var progress = DownloadMangaLineProgress.fromEntityAndTask(entity: mangaEntity, task: downloadTask); + var downloadedSize = filesize(downloadedBytes, 2, false); + + // !!! + switch (progress.status) { + case DownloadMangaLineStatus.waiting: + case DownloadMangaLineStatus.paused: + case DownloadMangaLineStatus.succeeded: + case DownloadMangaLineStatus.failed: + assert( + progress.stopped, + 'progress.stopped must be true when status is not downloading and pausing', + ); + return LargeDownloadLineView( + imageUrl: mangaEntity.mangaCover, + title: mangaEntity.mangaTitle, + icon1: Icons.download, + text1: '已下载章节 ${progress.startedChapterCount}/${progress.totalChapterCount} ($downloadedSize)', + icon2: Icons.access_time, + text2: '下载于 ${DateFormat('yyyy-MM-dd HH:mm:ss').format(progress.lastDownloadTime!)}', + icon3: Icons.bar_chart, + text3: progress.status == DownloadMangaLineStatus.waiting + ? '等待中' + : progress.status == DownloadMangaLineStatus.paused + ? '已暂停 (${progress.notFinishedChapterCount!} 章节共 ${progress.notFinishedPageCount!} 页未完成)' + : progress.status == DownloadMangaLineStatus.succeeded + ? '已完成' + : progress.notFinishedPageCount! < 0 + ? '下载出错' + : '下载出错 (${progress.notFinishedChapterCount!} 章节共 ${progress.notFinishedPageCount!} 页未完成)', + ); + case DownloadMangaLineStatus.downloading: + case DownloadMangaLineStatus.pausing: + assert( + !progress.stopped, + 'progress.stopped must be false when status is downloading or pausing', + ); + return LargeDownloadLineView( + imageUrl: mangaEntity.mangaCover, + title: mangaEntity.mangaTitle, + icon1: Icons.download, + text1: '正在下载章节 ${progress.startedChapterCount}/${progress.totalChapterCount} ($downloadedSize)', + icon2: Icons.download, + text2: (progress.preparing + ? progress.gettingManga + ? '正在获取漫画信息' + : '当前正在下载 未知章节' + : '当前正在下载 ${progress.chapterTitle!} ${progress.triedPageCount!}/${progress.totalPageCount!}页') + + (progress.status == DownloadMangaLineStatus.pausing ? ' (暂停中)' : ''), + icon3: Icons.bar_chart, + text3: progress.status == DownloadMangaLineStatus.pausing ? '暂停中' : '下载中', + ); + } + } +} + +enum DownloadMangaLineStatus { + // 队列中 + waiting, // stopped + downloading, // preparing / running + pausing, // preparing / running + + // 已结束 + paused, // stopped + succeeded, // stopped + failed, // stopped +} + +class DownloadMangaLineProgress { + const DownloadMangaLineProgress.stopped({ + required this.status, + required this.startedChapterCount, + required this.totalChapterCount, + required int this.notFinishedPageCount, + required int this.notFinishedChapterCount, + required DateTime this.lastDownloadTime, + }) : stopped = true, + preparing = false, + gettingManga = false, + chapterTitle = null, + triedPageCount = null, + totalPageCount = null; + + const DownloadMangaLineProgress.preparing({ + required this.status, + required this.startedChapterCount, + required this.totalChapterCount, + required this.gettingManga, + }) : stopped = false, + preparing = true, + notFinishedPageCount = null, + notFinishedChapterCount = null, + lastDownloadTime = null, + chapterTitle = null, + triedPageCount = null, + totalPageCount = null; + + const DownloadMangaLineProgress.running({ + required this.status, + required this.startedChapterCount, + required this.totalChapterCount, + required String this.chapterTitle, + required int this.triedPageCount, + required int this.totalPageCount, + }) : stopped = false, + preparing = false, + gettingManga = false, + notFinishedPageCount = null, + notFinishedChapterCount = null, + lastDownloadTime = null; + + // both + final DownloadMangaLineStatus status; + final int startedChapterCount; + final int totalChapterCount; + + // stopped + final bool stopped; + final int? notFinishedPageCount; + final int? notFinishedChapterCount; + final DateTime? lastDownloadTime; + + // preparing / running + final bool preparing; + final bool gettingManga; + final String? chapterTitle; + final int? triedPageCount; + final int? totalPageCount; + + // !!! + static DownloadMangaLineProgress fromEntityAndTask({required DownloadedManga entity, required DownloadMangaQueueTask? task}) { + DownloadMangaLineStatus status; + if (task != null && !task.succeeded) { + if (!task.canceled) { + if (task.progress.stage == DownloadMangaProgressStage.waiting) { + status = DownloadMangaLineStatus.waiting; // stopped + } else { + status = DownloadMangaLineStatus.downloading; // preparing / running + } + } else { + status = DownloadMangaLineStatus.pausing; // preparing / running + } + } else { + if (!entity.error) { + if (entity.triedPageCountInAll != entity.totalPageCountInAll) { + status = DownloadMangaLineStatus.paused; // stopped + } else if (entity.successChapterIds.length == entity.totalChapterIds.length) { + status = DownloadMangaLineStatus.succeeded; // stopped + } else { + status = DownloadMangaLineStatus.failed; // stopped (failed to get chapter or download page) + } + } else { + status = DownloadMangaLineStatus.failed; // stopped (failed to get manga) + } + } + + if (task == null || task.succeeded || (!task.canceled && task.progress.stage == DownloadMangaProgressStage.waiting)) { + // waiting / paused / succeeded / failed / failed + assert( + status != DownloadMangaLineStatus.downloading && status != DownloadMangaLineStatus.pausing, + 'status must not be downloading and pausing and current progress is stopped', + ); + return DownloadMangaLineProgress.stopped( + status: status, + startedChapterCount: entity.triedChapterIds.length, + totalChapterCount: entity.totalChapterIds.length, + notFinishedPageCount: entity.error ? -1 : entity.totalPageCountInAll - entity.successPageCountInAll, + notFinishedChapterCount: entity.error ? -1 : entity.failedChapterCount, + lastDownloadTime: entity.updatedAt, + ); + } else { + // downloading / pausing + assert( + status == DownloadMangaLineStatus.downloading || status == DownloadMangaLineStatus.pausing, + 'status must be downloading or pausing and current progress is preparing or running', + ); + if (task.progress.manga == null || task.progress.currentChapter == null) { + return DownloadMangaLineProgress.preparing( + status: status, + startedChapterCount: task.progress.startedChapters?.length ?? 0, + totalChapterCount: task.chapterIds.length, + gettingManga: task.progress.manga == null, + ); + } else { + return DownloadMangaLineProgress.running( + status: status, + startedChapterCount: task.progress.startedChapters?.length ?? 0, + totalChapterCount: task.chapterIds.length, + chapterTitle: task.progress.currentChapter!.title, + triedPageCount: task.progress.triedChapterPageCount ?? 0, + totalPageCount: task.progress.currentChapter!.pageCount, + ); + } + } + } +} diff --git a/lib/page/view/extended_gallery.dart b/lib/page/view/extended_gallery.dart new file mode 100644 index 0000000..0e17e07 --- /dev/null +++ b/lib/page/view/extended_gallery.dart @@ -0,0 +1,446 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:photo_view/photo_view.dart'; + +class HorizontalGalleryView extends StatefulWidget { + const HorizontalGalleryView({ + Key? key, + required this.imageCount /* <<< */, + required this.imagePageBuilder /* <<< */, + required this.firstPageBuilder /* <<< */, + required this.lastPageBuilder /* <<< */, + this.onImageLongPressed /* <<< */, + this.backgroundDecoration, + this.wantKeepAlive = false, + this.gaplessPlayback = false, + this.reverse = false, + this.initialPage = 0 /* <<< */, + this.onPageChanged, + this.viewportFraction = 1.0 /* <<< */, + this.scaleStateChangedCallback, + this.enableRotation = false, + this.scrollPhysics, + this.customSize, + this.loadingBuilder, + this.errorBuilder, + this.pageMainAxisHintSize, + this.preloadPagesCount = 0, + }) : super(key: key); + + final int imageCount; + final ExtendedPhotoGalleryPageOptions Function(BuildContext, int) imagePageBuilder; + final Widget Function(BuildContext context) firstPageBuilder; + final Widget Function(BuildContext context) lastPageBuilder; + final void Function(int index)? onImageLongPressed; + final ScrollPhysics? scrollPhysics; + final BoxDecoration? backgroundDecoration; + final bool wantKeepAlive; + final bool gaplessPlayback; + final bool reverse; + final int initialPage; + final void Function(int index)? onPageChanged; + final double viewportFraction; + final ValueChanged? scaleStateChangedCallback; + final bool enableRotation; + final Size? customSize; + final LoadingPlaceholderBuilder? loadingBuilder; + final ErrorPlaceholderBuilder? errorBuilder; + final double? pageMainAxisHintSize; + final int preloadPagesCount; + + @override + State createState() => HorizontalGalleryViewState(); +} + +class HorizontalGalleryViewState extends State { + final _key = GlobalKey(); + late var _controller = PageController( + initialPage: widget.initialPage, + viewportFraction: widget.viewportFraction, + ); + late var _currentPageIndex = widget.initialPage; + + @override + void didUpdateWidget(covariant HorizontalGalleryView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.viewportFraction != oldWidget.viewportFraction) { + var oldController = _controller; + _controller = PageController( + initialPage: _currentPageIndex, + viewportFraction: widget.viewportFraction, + ); + WidgetsBinding.instance?.addPostFrameCallback((_) => oldController.dispose()); + } + } + + void reload(int page) { + _key.currentState?.reload(page); + } + + void jumpToPage(int page, {bool animated = false}) { + if (animated) { + _controller.defaultAnimateToPage(page); + } else { + _controller.jumpToPage(page); + } + } + + @override + Widget build(BuildContext context) { + return ExtendedPhotoGallery.advanced( + key: _key, + pageCount: widget.imageCount + 2, + builder: widget.imagePageBuilder, + advancedBuilder: (c, index, builder) { + if (index == 0) { + return widget.firstPageBuilder(c); + } + if (index == widget.imageCount + 1) { + return widget.lastPageBuilder(c); + } + return GestureDetector( + onLongPress: widget.onImageLongPressed == null ? null : () => widget.onImageLongPressed!(index - 1), + child: builder(c, index - 1), + ); + }, + backgroundDecoration: widget.backgroundDecoration, + wantKeepAlive: widget.wantKeepAlive, + gaplessPlayback: widget.gaplessPlayback, + reverse: widget.reverse, + pageController: _controller, + onPageChanged: (i) { + _currentPageIndex = i; + widget.onPageChanged?.call(i); + }, + changePageWhenFinished: true, + keepViewportMainAxisSize: true, + fractionWidthFactor: null, + fractionHeightFactor: null, + scaleStateChangedCallback: widget.scaleStateChangedCallback, + enableRotation: widget.enableRotation, + scrollPhysics: widget.scrollPhysics, + scrollDirection: Axis.horizontal, + customSize: widget.customSize, + loadingBuilder: widget.loadingBuilder, + errorBuilder: widget.errorBuilder, + pageMainAxisHintSize: widget.pageMainAxisHintSize, + preloadPagesCount: widget.preloadPagesCount, + ); + } +} + +class VerticalGalleryView extends StatefulWidget { + const VerticalGalleryView({ + Key? key, + required this.imageCount /* <<< */, + required this.imagePageBuilder /* <<< */, + required this.firstPageBuilder /* <<< */, + required this.lastPageBuilder /* <<< */, + this.onImageTapDown /* <<< */, + this.onImageTapUp /* <<< */, + this.onImageLongPressed /* <<< */, + this.backgroundDecoration, + this.wantKeepAlive = false, + this.gaplessPlayback = false, + this.initialPage = 0 /* <<< */, + this.onPageChanged, + this.viewportPageSpace = 0.0 /* <<< */, + this.scaleStateChangedCallback, + this.enableRotation = false, + this.scrollPhysics, + this.customSize, + this.loadingBuilder, + this.errorBuilder, + this.pageMainAxisHintSize, + this.preloadPagesCount = 0, + }) : super(key: key); + + final int imageCount; + final ExtendedPhotoGalleryPageOptions Function(BuildContext, int) imagePageBuilder; + final Widget Function(BuildContext context) firstPageBuilder; + final Widget Function(BuildContext context) lastPageBuilder; + final void Function(TapDownDetails details)? onImageTapDown; + final void Function(TapUpDetails details)? onImageTapUp; + final void Function(int index)? onImageLongPressed; + final BoxDecoration? backgroundDecoration; + final bool wantKeepAlive; + final bool gaplessPlayback; + final int initialPage; + final void Function(int)? onPageChanged; + final double viewportPageSpace; + final void Function(PhotoViewScaleState)? scaleStateChangedCallback; + final bool enableRotation; + final ScrollPhysics? scrollPhysics; + final Size? customSize; + final Widget Function(BuildContext, ImageChunkEvent?)? loadingBuilder; + final Widget Function(BuildContext, Object, StackTrace?)? errorBuilder; + final double? pageMainAxisHintSize; + final int preloadPagesCount; + + @override + State createState() => VerticalGalleryViewState(); +} + +const _kMaskDuration = Duration(milliseconds: 150); + +class VerticalGalleryViewState extends State { + final _listKey = GlobalKey>(); + late final _controller = ScrollController()..addListener(_onScrollChanged); + late List> _notifiers = List.generate(widget.imageCount, (index) => ValueNotifier('')); + late List>> _itemKeys = List.generate(widget.imageCount + 2, (index) => GlobalKey>()); + + @override + void initState() { + super.initState(); + _masking = true; + WidgetsBinding.instance?.addPostFrameCallback((_) async { + if (widget.initialPage > 0) { + await jumpToPage(widget.initialPage, masked: true); + } + _masking = false; + if (mounted) setState(() {}); + _onScrollChanged(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant VerticalGalleryView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.imageCount != oldWidget.imageCount) { + _notifiers = List.generate(widget.imageCount, (index) => ValueNotifier('')); + _itemKeys = List.generate(widget.imageCount + 2, (index) => GlobalKey>()); + } + } + + void reload(int index) { + _notifiers[index].value = DateTime.now().microsecondsSinceEpoch.toString(); + // no need to setState + } + + void _onScrollChanged() { + if (_jumping) { + return; + } + if (_controller.offset == 0) { + widget.onPageChanged?.call(0); + } else if (_controller.offset == _controller.position.maxScrollExtent) { + widget.onPageChanged?.call(widget.imageCount + 1); + } else { + var scrollRect = _ScrollHelper.getScrollViewRect(_listKey); + if (scrollRect != null) { + var index = _ScrollHelper.getVisibleTargetItemIndex(_itemKeys, scrollRect); + if (index != null) { + widget.onPageChanged?.call(index); + } + } + } + } + + var _jumping = false; + var _masking = false; + + Future jumpToPage(int page, {bool masked = false}) async { + var scrollRect = _ScrollHelper.getScrollViewRect(_listKey); + if (scrollRect == null) { + return; + } + + _jumping = true; + _masking = masked; + if (mounted) setState(() {}); + await Future.delayed(_kMaskDuration); + // TODO not accurate !!! + var ok = await _ScrollHelper.scrollToTargetIndex(_itemKeys, scrollRect, _controller, page, widget.viewportPageSpace); + _jumping = false; + _masking = false; + if (mounted) setState(() {}); + if (ok) { + widget.onPageChanged?.call(page); + } + } + + Widget _buildPhotoItem(BuildContext context, int index) { + final pageOption = widget.imagePageBuilder(context, index); // index excludes non-PhotoView pages + return ClipRect( + child: ValueListenableBuilder( + valueListenable: _notifiers[index], // <<< + builder: (_, v, __) => PhotoView( + key: ValueKey('$index-$v'), + imageProvider: pageOption.imageProviderBuilder(ValueKey('$index-$v')), + backgroundDecoration: widget.backgroundDecoration, + wantKeepAlive: widget.wantKeepAlive, + controller: pageOption.controller, + scaleStateController: pageOption.scaleStateController, + customSize: widget.customSize, + gaplessPlayback: widget.gaplessPlayback, + heroAttributes: pageOption.heroAttributes, + scaleStateChangedCallback: (state) => widget.scaleStateChangedCallback?.call(state), + enableRotation: widget.enableRotation, + initialScale: pageOption.initialScale, + minScale: pageOption.minScale, + maxScale: pageOption.maxScale, + scaleStateCycle: pageOption.scaleStateCycle, + onTapUp: null /* pageOption.onTapUp */, + onTapDown: null /* pageOption.onTapDown */, + onScaleEnd: pageOption.onScaleEnd, + gestureDetectorBehavior: pageOption.gestureDetectorBehavior, + tightMode: true /* pageOption.tightMode */, + filterQuality: pageOption.filterQuality, + basePosition: pageOption.basePosition, + disableGestures: true /* pageOption.disableGestures */, + enablePanAlways: pageOption.enablePanAlways, + loadingBuilder: pageOption.loadingBuilder ?? widget.loadingBuilder, + errorBuilder: pageOption.errorBuilder ?? widget.errorBuilder, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final pageMainAxisHintSize = widget.pageMainAxisHintSize ?? MediaQuery.of(context).size.height; // using this height is inaccuracy + return Stack( + children: [ + Positioned.fill( + child: ListView( + key: _listKey, + controller: _controller, + padding: EdgeInsets.zero, + physics: widget.scrollPhysics, + cacheExtent: widget.preloadPagesCount < 1 ? 0 : pageMainAxisHintSize * widget.preloadPagesCount - 1, + children: [ + // 1 + Padding( + key: _itemKeys[0], + padding: EdgeInsets.only(top: 0), + child: widget.firstPageBuilder(context), + ), + + // 2 + for (var i = 0; i < widget.imageCount; i++) + GestureDetector( + key: _itemKeys[i + 1], + behavior: HitTestBehavior.opaque, + onTapDown: widget.onImageTapDown /* <<< */, + onTapUp: widget.onImageTapUp /* <<< */, + onLongPress: widget.onImageLongPressed == null ? null : () => widget.onImageLongPressed!(i) /* <<< */, + child: Padding( + padding: EdgeInsets.only( + top: i == 0 + ? math.max(widget.viewportPageSpace, 10) // for first page, space must be larger than 10 + : widget.viewportPageSpace /* for remaining pages */, + ), + child: _buildPhotoItem(context, i), + ), + ), + + // 3 + Padding( + key: _itemKeys[widget.imageCount + 1], + padding: EdgeInsets.only( + top: math.max(widget.viewportPageSpace, 10), // for last page, space must be larger than 10 + ), + child: widget.lastPageBuilder(context), + ), + ], + ), + ), + + // <<< + Positioned.fill( + child: AnimatedSwitcher( + duration: _kMaskDuration, + child: !_masking + ? SizedBox(height: 0) + : Container( + decoration: widget.backgroundDecoration, + child: Center( + child: SizedBox( + height: 50, + width: 50, + child: CircularProgressIndicator(), + ), + ), + ), + ), + ), + ], + ); + } +} + +class _ScrollHelper { + static Rect? getScrollViewRect(GlobalKey key) { + var scrollRect = key.currentContext?.findRenderObject()?.getBoundInRootAncestorCoordinate(); + return scrollRect; + } + + static int? getVisibleTargetItemIndex(List> itemKeys, Rect scrollRect) { + int outIndex = -1; + for (var i = 0; i < itemKeys.length; i++) { + var itemRect = itemKeys[i].currentContext?.findRenderObject()?.getBoundInRootAncestorCoordinate(); + if (itemRect != null) { + if (itemRect.top <= scrollRect.top && itemRect.bottom > scrollRect.top) { + outIndex = i; + break; + } + } + } + if (outIndex < 0 || outIndex >= itemKeys.length) { + return null; + } + return outIndex; + } + + static Future scrollToTargetIndex( + List> itemKeys, + Rect scrollRect, + ScrollController controller, + int targetIndex, [ + double additionalOffset = 0, + ]) async { + Rect? getItemRect(int index) { + if (index < 0 || index >= itemKeys.length) { + return null; + } + return itemKeys[index].currentContext?.findRenderObject()?.getBoundInRootAncestorCoordinate(); + } + + // automatically scroll the data view, to make sure if the target item rect is not null + var targetItemRect = getItemRect(targetIndex); + while (targetItemRect == null) { + // get current visible item and check validity + var currIndex = getVisibleTargetItemIndex(itemKeys, scrollRect) ?? -1; + if (currIndex < 0) { + return false; // almost unreachable + } + if (currIndex == targetIndex) { + targetItemRect = getItemRect(targetIndex); + break; // almost unreachable + } + + // automatically scroll (almost the real height of scroll view) + var direction = currIndex > targetIndex ? -1 : 1; + await controller.jumpToAndWait(controller.offset + direction * scrollRect.height * 0.95); // jump and wait for widget building + if (controller.offset < 0 || controller.offset > 500000) { + return false; // almost unreachable, only for exception + } + targetItemRect = getItemRect(targetIndex); + } + if (targetItemRect == null) { + return false; // almost unreachable + } + + // scroll to target index in new data view style + await controller.jumpToAndWait(controller.offset + targetItemRect.top - scrollRect.top + additionalOffset + 1); + return true; + } +} diff --git a/lib/page/view/full_ripple.dart b/lib/page/view/full_ripple.dart new file mode 100644 index 0000000..4fbee1f --- /dev/null +++ b/lib/page/view/full_ripple.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class FullRippleWidget extends StatelessWidget { + const FullRippleWidget({ + Key? key, + required this.child, + this.radius, + required this.onTap, + this.onLongPress, + this.highlightColor = Colors.black26, + this.splashColor = Colors.black26, + }) : super(key: key); + + final Widget child; + final BorderRadius? radius; + final void Function()? onTap; + final void Function()? onLongPress; + final Color? highlightColor; + final Color? splashColor; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: radius ?? BorderRadius.zero, + child: Stack( + children: [ + child, + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + highlightColor: highlightColor, + splashColor: splashColor, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/page/view/gallery_page_view.dart b/lib/page/view/gallery_page_view.dart deleted file mode 100644 index 1eec1ed..0000000 --- a/lib/page/view/gallery_page_view.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:manhuagui_flutter/page/view/preload_page_view.dart'; -import 'package:photo_view/photo_view.dart'; - -typedef GalleryPageViewPageChangedCallback = void Function(int index); -typedef GalleryPageViewBuilder = GalleryPageViewPageOptions Function(BuildContext context, int index); - -/// A [PhotoViewGallery] with [FractionallySizedBox] for [itemBuilder]. -class GalleryPageView extends StatefulWidget { - const GalleryPageView({ - Key key, - @required this.itemCount, - @required this.builder, - this.loadingBuilder, - this.loadFailedChild, - this.backgroundDecoration, - this.gaplessPlayback = false, - this.reverse = false, - @required this.pageController, - this.onPageChanged, - this.scaleStateChangedCallback, - this.enableRotation = false, - this.scrollPhysics, - this.scrollDirection = Axis.horizontal, - this.customSize, - this.preloadPagesCount = 1, - }) : assert(itemCount != null), - assert(builder != null), - assert(pageController != null), - super(key: key); - - final int itemCount; - final GalleryPageViewBuilder builder; - final ScrollPhysics scrollPhysics; - final LoadingBuilder loadingBuilder; - final Widget loadFailedChild; - final Decoration backgroundDecoration; - final bool gaplessPlayback; - final bool reverse; - final PageController pageController; - final GalleryPageViewPageChangedCallback onPageChanged; - final ValueChanged scaleStateChangedCallback; - final bool enableRotation; - final Size customSize; - final Axis scrollDirection; - final int preloadPagesCount; - - @override - State createState() { - return _GalleryPageViewState(); - } -} - -class _GalleryPageViewState extends State { - @override - Widget build(BuildContext context) { - return PhotoViewGestureDetectorScope( - axis: widget.scrollDirection, - child: PreLoadPageView.builder( - reverse: widget.reverse, - controller: widget.pageController, - onPageChanged: widget.onPageChanged, - itemCount: widget.itemCount, - itemBuilder: (context, index) => FractionallySizedBox( - widthFactor: 1 / widget.pageController.viewportFraction, // <<< - child: _buildItem(context, index), - ), - preloadPagesCount: widget.preloadPagesCount ?? 1, // <<< - scrollDirection: widget.scrollDirection, - physics: widget.scrollPhysics, - ), - ); - } - - Widget _buildItem(BuildContext context, int index) { - final pageOption = widget.builder(context, index); - return ClipRect( - child: PhotoView( - key: ObjectKey(index), - imageProvider: pageOption.imageProvider, - loadingBuilder: widget.loadingBuilder, - loadFailedChild: widget.loadFailedChild, - backgroundDecoration: widget.backgroundDecoration, - controller: pageOption.controller, - scaleStateController: pageOption.scaleStateController, - customSize: widget.customSize, - gaplessPlayback: widget.gaplessPlayback, - heroAttributes: pageOption.heroAttributes, - scaleStateChangedCallback: (s) => widget.scaleStateChangedCallback?.call(s), - enableRotation: widget.enableRotation, - initialScale: pageOption.initialScale, - minScale: pageOption.minScale, - maxScale: pageOption.maxScale, - scaleStateCycle: pageOption.scaleStateCycle, - onTapUp: pageOption.onTapUp, - onTapDown: pageOption.onTapDown, - gestureDetectorBehavior: pageOption.gestureDetectorBehavior, - tightMode: pageOption.tightMode, - filterQuality: pageOption.filterQuality, - basePosition: pageOption.basePosition, - disableGestures: pageOption.disableGestures, - ), - ); - } -} - -class GalleryPageViewPageOptions { - const GalleryPageViewPageOptions({ - Key key, - @required this.imageProvider, - this.heroAttributes, - this.minScale, - this.maxScale, - this.initialScale, - this.controller, - this.scaleStateController, - this.basePosition, - this.scaleStateCycle, - this.onTapUp, - this.onTapDown, - this.gestureDetectorBehavior, - this.tightMode, - this.filterQuality, - this.disableGestures, - }) : assert(imageProvider != null); - - final ImageProvider imageProvider; - final PhotoViewHeroAttributes heroAttributes; - final dynamic minScale; - final dynamic maxScale; - final dynamic initialScale; - final PhotoViewController controller; - final PhotoViewScaleStateController scaleStateController; - final Alignment basePosition; - final ScaleStateCycle scaleStateCycle; - final PhotoViewImageTapUpCallback onTapUp; - final PhotoViewImageTapDownCallback onTapDown; - final HitTestBehavior gestureDetectorBehavior; - final bool tightMode; - final bool disableGestures; - final FilterQuality filterQuality; -} diff --git a/lib/page/view/general_line.dart b/lib/page/view/general_line.dart new file mode 100644 index 0000000..7b6fefb --- /dev/null +++ b/lib/page/view/general_line.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/page/view/network_image.dart'; + +class GeneralLineView extends StatelessWidget { + const GeneralLineView({ + Key? key, + required this.imageUrl, + required this.title, + required this.icon1, + required this.text1, + required this.icon2, + required this.text2, + required this.icon3, + required this.text3, + this.extrasInRow, + this.extraWidthInRow, + this.extrasInStack, + this.topExtrasInStack, + required this.onPressed, + this.onLongPressed, + }) : customRows = null, + super(key: key); + + const GeneralLineView.custom({ + Key? key, + required this.imageUrl, + required this.title, + required List this.customRows, + this.extrasInRow, + this.extraWidthInRow, + this.extrasInStack, + this.topExtrasInStack, + required this.onPressed, + this.onLongPressed, + }) : icon1 = null, + text1 = null, + icon2 = null, + text2 = null, + icon3 = null, + text3 = null, + super(key: key); + + // required + final String imageUrl; + final String title; + + // simple rows + final IconData? icon1; + final String? text1; + final IconData? icon2; + final String? text2; + final IconData? icon3; + final String? text3; + + // custom rows + final List? customRows; // 取代上面的 iconX 和 textX + + // optional + final List? extrasInRow; + final double? extraWidthInRow; + final List? extrasInStack; + final List? topExtrasInStack; + + // callbacks + final void Function() onPressed; + final void Function()? onLongPressed; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // **************************************************************** + // 左边图片 + // **************************************************************** + Container( + margin: EdgeInsets.symmetric(horizontal: 14, vertical: 5), + child: NetworkImageView( + url: imageUrl, + height: 100, + width: 75, + ), + ), + + // **************************************************************** + // 右边信息 + // **************************************************************** + Container( + width: MediaQuery.of(context).size.width - 14 * 3 - 75 - (extraWidthInRow ?? 0), // | ▢ ▢▢ | + margin: EdgeInsets.only(top: 5, bottom: 5, right: 0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // **************************************************************** + // 右上角标题 + // **************************************************************** + Padding( + padding: EdgeInsets.only(bottom: 4), + child: Text( + title, + style: Theme.of(context).textTheme.subtitle1, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + + // **************************************************************** + // 右边预定义控件 + // **************************************************************** + if (customRows == null) ...[ + GeneralLineIconText(icon: icon1, text: text1), + GeneralLineIconText(icon: icon2, text: text2), + GeneralLineIconText(icon: icon3, text: text3), + ], + + // **************************************************************** + // 右边自定义控件 + // **************************************************************** + if (customRows != null) ...customRows!, + ], + ), + ), + + // **************************************************************** + // 最右边自定义控件 + // **************************************************************** + if (extrasInRow != null) ...extrasInRow!, + ], + ), + + // **************************************************************** + // Stack 自定义控件,Ripple 效果以内 + // **************************************************************** + if (extrasInStack != null) ...extrasInStack!, + + // **************************************************************** + // Ripple 效果 + // **************************************************************** + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + onLongPress: onLongPressed, + ), + ), + ), + + // **************************************************************** + // Stack 自定义控件,Ripple 效果以外 + // **************************************************************** + if (topExtrasInStack != null) ...topExtrasInStack!, + ], + ); + } +} + +class GeneralLineIconText extends StatelessWidget { + const GeneralLineIconText({ + Key? key, + required this.icon, + required this.text, + this.padding, + this.iconSize, + this.textStyle, + this.space, + }) : super(key: key); + + final IconData? icon; + final String? text; + final EdgeInsets? padding; + final double? iconSize; + final TextStyle? textStyle; + final double? space; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding ?? EdgeInsets.only(bottom: 2), + child: IconText( + icon: Icon( + icon, + size: iconSize ?? 20, + color: Colors.orange, + ), + text: Flexible( + child: Text( + text ?? '', + style: textStyle ?? // + DefaultTextStyle.of(context).style.copyWith(color: Colors.grey[600]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + space: space ?? 8, + ), + ); + } +} diff --git a/lib/page/view/image_load.dart b/lib/page/view/image_load.dart new file mode 100644 index 0000000..4bd2595 --- /dev/null +++ b/lib/page/view/image_load.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; + +class ImageLoadingView extends StatelessWidget { + const ImageLoadingView({ + Key? key, + required this.title, + required this.event, + }) : super(key: key); + + final String title; + final ImageChunkEvent? event; + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black, + padding: EdgeInsets.symmetric(vertical: 30), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width - MediaQuery.of(context).padding.horizontal, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (title.isNotEmpty) + Text( + title, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 45, color: Colors.grey), + ), + Padding( + padding: EdgeInsets.all(30), + child: Container( + width: 50, + height: 50, + child: CircularProgressIndicator( + value: (event == null || (event!.expectedTotalBytes ?? 0) == 0) ? null : event!.cumulativeBytesLoaded / event!.expectedTotalBytes!, + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 30), + child: Text( + event == null + ? '' + : (event!.expectedTotalBytes ?? 0) == 0 + ? filesize(event!.cumulativeBytesLoaded) + : '${filesize(event!.cumulativeBytesLoaded)} / ${filesize(event!.expectedTotalBytes!)}', + style: TextStyle(color: Colors.grey), + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } +} + +class ImageLoadFailedView extends StatelessWidget { + const ImageLoadFailedView({ + Key? key, + required this.title, + this.error, + }) : super(key: key); + + final String title; + final dynamic error; + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black, + padding: EdgeInsets.symmetric(vertical: 30), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width - MediaQuery.of(context).padding.horizontal, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (title.isNotEmpty) + Text( + title, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 45, color: Colors.grey), + ), + Padding( + padding: EdgeInsets.all(30), + child: Container( + width: 50, + height: 50, + child: Icon( + Icons.broken_image, + color: Colors.grey, + size: 50, + ), + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 30), + child: Text( + error == null ? '' : wrapError(error, StackTrace.empty).text, + style: TextStyle(color: Colors.grey), + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } +} diff --git a/lib/page/view/image_load_view.dart b/lib/page/view/image_load_view.dart deleted file mode 100644 index a9309ee..0000000 --- a/lib/page/view/image_load_view.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:filesize/filesize.dart'; -import 'package:flutter/material.dart'; - -class ImageLoadingView extends StatelessWidget { - const ImageLoadingView({ - Key key, - @required this.event, - this.title, - this.width, - this.height, - }) : assert(width == null || width > 0), - assert(height == null || height > 0), - super(key: key); - - final String title; - final ImageChunkEvent event; - final double width; - final double height; - - @override - Widget build(BuildContext context) { - return Container( - color: Colors.black, - width: width, - height: height, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (title != null) - Text( - title, - textAlign: TextAlign.center, - style: TextStyle(fontSize: 45, color: Colors.grey), - ), - Padding( - padding: EdgeInsets.all(30), - child: Container( - width: 50, - height: 50, - child: CircularProgressIndicator( - value: (event?.expectedTotalBytes ?? 0 == 0) ? null : ((event?.cumulativeBytesLoaded ?? 0.0) / event.expectedTotalBytes), - ), - ), - ), - if (event?.cumulativeBytesLoaded != null) - Text( - event?.expectedTotalBytes == null ? '${filesize(event.cumulativeBytesLoaded)}' : '${filesize(event.cumulativeBytesLoaded)} / ${filesize(event.expectedTotalBytes)}', - style: TextStyle(color: Colors.grey), - ), - ], - ), - ); - } -} - -class ImageLoadFailedView extends StatelessWidget { - const ImageLoadFailedView({ - Key key, - this.title, - @required this.width, - @required this.height, - }) : assert(width == null || width > 0), - assert(height == null || height > 0), - super(key: key); - - final String title; - final double width; - final double height; - - @override - Widget build(BuildContext context) { - return Container( - color: Colors.black, - width: width, - height: height, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (title != null) - Text( - title, - textAlign: TextAlign.center, - style: TextStyle(fontSize: 45, color: Colors.grey), - ), - Padding( - padding: EdgeInsets.all(30), - child: Container( - width: 50, - height: 50, - child: Icon( - Icons.broken_image, - color: Colors.grey, - size: 50, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/page/view/list_hint.dart b/lib/page/view/list_hint.dart new file mode 100644 index 0000000..76acde2 --- /dev/null +++ b/lib/page/view/list_hint.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +enum ListHintViewStyle { + textText, + textWidget, + widgets, +} + +class ListHintView extends StatelessWidget { + const ListHintView.textText({ + Key? key, + required String this.leftText, + required String this.rightText, + }) : rightWidget = null, + widgets = null, + style = ListHintViewStyle.textText, + super(key: key); + + const ListHintView.textWidget({ + Key? key, + required String this.leftText, + required Widget this.rightWidget, + }) : rightText = null, + widgets = null, + style = ListHintViewStyle.textWidget, + super(key: key); + + const ListHintView.widgets({ + Key? key, + required List this.widgets, + }) : leftText = null, + rightText = null, + rightWidget = null, + style = ListHintViewStyle.widgets, + super(key: key); + + final String? leftText; + final String? rightText; + final Widget? rightWidget; + final List? widgets; + final ListHintViewStyle style; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + color: Colors.white, + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Row( + mainAxisAlignment: style == ListHintViewStyle.widgets + ? MainAxisAlignment.spaceAround /* |       | */ + : MainAxisAlignment.spaceBetween /* |     | */, + crossAxisAlignment: CrossAxisAlignment.center, + children: style == ListHintViewStyle.textText + ? [ + Flexible( + child: Padding( + padding: EdgeInsets.only(left: 5), + child: Text( + leftText!, + overflow: TextOverflow.ellipsis, + ), + ), + ), + SizedBox(height: 26, width: 20), + Padding( + padding: EdgeInsets.only(right: 5), + child: Text(rightText!), + ), + ] + : style == ListHintViewStyle.textWidget + ? [ + Flexible( + child: Padding( + padding: EdgeInsets.only(left: 5), + child: Text( + leftText!, + overflow: TextOverflow.ellipsis, + ), + ), + ), + SizedBox(height: 26, width: 15), + rightWidget!, + ] + : widgets!, + ), + ), + Divider(height: 0, thickness: 1), + ], + ); + } +} diff --git a/lib/page/view/login_first.dart b/lib/page/view/login_first.dart index 5f5d456..bac39ce 100644 --- a/lib/page/view/login_first.dart +++ b/lib/page/view/login_first.dart @@ -1,48 +1,50 @@ import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:manhuagui_flutter/page/login.dart'; -import 'package:manhuagui_flutter/service/auth/auth.dart'; +import 'package:manhuagui_flutter/service/evb/auth_manager.dart'; -class LoginFirstView extends StatefulWidget { - const LoginFirstView({Key key}) : super(key: key); +/// 登录提示,在 [ShelfSubPage] / [MineSubPage] 使用 +class LoginFirstView extends StatelessWidget { + const LoginFirstView({ + Key? key, + required this.checking, + this.error = '', + this.onErrorRetry, + }) : super(key: key); - @override - _LoginFirstViewState createState() => _LoginFirstViewState(); -} - -class _LoginFirstViewState extends State { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => checkAuth()); - } + final bool checking; + final String error; + final void Function()? onErrorRetry; @override Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.lock_open, - size: 50, - color: Colors.grey, - ), - SizedBox(height: 10), - Text( - '未登录,请先登录', - style: TextStyle(fontSize: 20), - ), - SizedBox(height: 10), - OutlineButton( - child: Text('登录'), - onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => LoginPage(), - ), - ), - ), - ], + return PlaceholderText( + state: checking ? PlaceholderState.loading : (error.isEmpty ? PlaceholderState.nothing : PlaceholderState.error), + errorText: error.isEmpty ? '' : '无法检查登录状态\n$error', + childBuilder: (c) => SizedBox(height: 0), + setting: PlaceholderSetting( + nothingIcon: Icons.lock_open, + ).copyWithChinese( + loadingText: '检查登录状态中...', + nothingText: '当前未登录,请先登录 Manhuagui', + nothingRetryText: '登录', + errorRetryText: '重试', ), + onRetryForNothing: () { + if (AuthManager.instance.logined) { + Fluttertoast.showToast(msg: '${AuthManager.instance.username} 登录成功'); + AuthManager.instance.notify(logined: true); + return; + } + Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => LoginPage(), + ), + ); + }, + onRetryForError: onErrorRetry, ); } } diff --git a/lib/page/view/manga_carousel.dart b/lib/page/view/manga_carousel.dart index 8931601..d1bf826 100644 --- a/lib/page/view/manga_carousel.dart +++ b/lib/page/view/manga_carousel.dart @@ -3,39 +3,44 @@ import 'dart:ui'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:carousel_slider/carousel_slider.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/model/manga.dart'; import 'package:manhuagui_flutter/page/manga.dart'; import 'package:manhuagui_flutter/page/view/network_image.dart'; +/// 漫画推荐展示,在 [RecommendSubPage] 使用 class MangaCarouselView extends StatefulWidget { - const MangaCarouselView({Key key, @required this.mangas}) - : assert(mangas != null && mangas.length != 0), - super(key: key); + const MangaCarouselView({ + Key? key, + required this.mangas, + required this.height, + required this.imageWidth, + }) : super(key: key); final List mangas; + final double height; + final double imageWidth; @override _MangaCarouselViewState createState() => _MangaCarouselViewState(); } -class _MangaCarouselViewState extends State { - CarouselController _carouselController; +class _MangaCarouselViewState extends State with AutomaticKeepAliveClientMixin { var _currentIndex = 0; + final _key = PageStorageKey(0); @override - void initState() { - super.initState(); - _carouselController = CarouselController(); - } + bool get wantKeepAlive => true; @override Widget build(BuildContext context) { + super.build(context); return Stack( children: [ CarouselSlider.builder( - carouselController: _carouselController, options: CarouselOptions( - height: 220, + pageViewKey: _key, + height: widget.height, autoPlay: true, autoPlayInterval: Duration(seconds: 4), autoPlayCurve: Curves.fastOutSlowIn, @@ -44,17 +49,17 @@ class _MangaCarouselViewState extends State { if (mounted) setState(() {}); }, enableInfiniteScroll: true, - viewportFraction: 1, // 0.8, + viewportFraction: 1, ), itemCount: widget.mangas.length, - itemBuilder: (c, i) => Container( + itemBuilder: (c, i, _) => Container( color: Colors.white, // Colors.accents[i], child: Stack( children: [ ClipRect( child: Container( - width: MediaQuery.of(context).size.width, // * 0.8, - height: 220, + width: MediaQuery.of(context).size.width, + height: widget.height, decoration: BoxDecoration( image: DecorationImage( image: CachedNetworkImageProvider( @@ -77,10 +82,9 @@ class _MangaCarouselViewState extends State { Positioned.fill( child: Center( child: NetworkImageView( - url: widget.mangas[i].cover, // 3x4 - height: 220, - width: 165, - fit: BoxFit.cover, + url: widget.mangas[i].cover, // 3:4 + height: widget.height, + width: widget.imageWidth, ), ), ), @@ -88,21 +92,16 @@ class _MangaCarouselViewState extends State { child: Material( color: Colors.transparent, child: InkWell( - onTap: () { - if (_currentIndex != i) { - _carouselController.animateToPage(i); - } else { - Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => MangaPage( - id: widget.mangas[i].mid, - title: widget.mangas[i].title, - url: widget.mangas[i].url, - ), - ), - ); - } - }, + onTap: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaPage( + id: widget.mangas[i].mid, + title: widget.mangas[i].title, + url: widget.mangas[i].url, + ), + ), + ), ), ), ), @@ -135,7 +134,7 @@ class _MangaCarouselViewState extends State { return Container( width: chose ? 10 : 8, height: chose ? 10 : 8, - margin: EdgeInsets.symmetric(horizontal: 2), + margin: EdgeInsets.symmetric(horizontal: 2.5), decoration: BoxDecoration( shape: BoxShape.circle, color: chose ? Colors.black87 : Colors.black26, diff --git a/lib/page/view/manga_column.dart b/lib/page/view/manga_column.dart deleted file mode 100644 index b7c7bbe..0000000 --- a/lib/page/view/manga_column.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:manhuagui_flutter/model/manga.dart'; -import 'package:manhuagui_flutter/page/manga_group.dart'; -import 'package:manhuagui_flutter/page/view/tiny_block_manga.dart'; - -/// View for [MangaGroup]. -/// Used in [RecommendSubPage] and [MangaGroupPage]. -class MangaColumnView extends StatefulWidget { - const MangaColumnView({ - Key key, - @required this.group, - @required this.type, - this.controller, - this.marginV = 12, - this.showTopMargin = true, - this.complete = false, - this.small = false, - this.singleLine = false, - }) : assert(group != null), - assert(type != null), - assert(showTopMargin != null), - assert(showTopMargin != null), - assert(complete != null), - assert(small != null), - assert(singleLine != null), - assert(!(complete && (small || singleLine))), - super(key: key); - - final MangaGroup group; - final MangaGroupType type; - final ScrollController controller; - final double marginV; - final bool showTopMargin; - final bool complete; - final bool small; - final bool singleLine; - - @override - _MangaColumnViewState createState() => _MangaColumnViewState(); -} - -class _MangaColumnViewState extends State { - Widget _buildBlock(TinyBlockManga manga, {bool left = false}) { - final hSpace = 5.0; - var width = (MediaQuery.of(context).size.width - hSpace * 4) / 3; // | ▢ ▢ ▢ | - if (widget.small) { - width = (MediaQuery.of(context).size.width - hSpace * 5) / 4; // | ▢ ▢ ▢ ▢ | - } - var height = width / 3 * 4; - - return TinyBlockMangaView( - manga: manga, - width: width, - height: height, - margin: EdgeInsets.only(left: left ? hSpace : 0, right: hSpace), - onMorePressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => MangaGroupPage( - group: widget.group, - type: widget.type, - ), - ), - ), - ); - } - - List _buildRows(double vSpace) { - var mangas = widget.group.mangas; - var cs = 3; - if (!widget.complete) { - if (!widget.small) { - if (!widget.singleLine) { - if (mangas.length > 6) { - mangas = [...mangas.sublist(0, 5), null]; // X X X | X X O - } - } else { - if (mangas.length > 3) { - mangas = [...mangas.sublist(0, 2), null]; // X X O - } - } - } else { - cs = 4; - if (!widget.singleLine) { - if (mangas.length > 8) { - mangas = [...mangas.sublist(0, 7), null]; // X X X X | X X X O - } - } else { - if (mangas.length > 4) { - mangas = [...mangas.sublist(0, 3), null]; // X X X O - } - } - } - } - - var gridRows = []; - var rows = (mangas.length.toDouble() / cs).ceil(); - for (var r = 0; r < rows; r++) { - var columns = [ - for (var i = cs * r; i < cs * (r + 1) && i < mangas.length; i++) mangas[i], - ]; - gridRows.add( - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < columns.length; i++) _buildBlock(columns[i], left: i == 0), - ], - ), - ); - if (r != rows - 1) { - gridRows.add( - SizedBox(height: vSpace), - ); - } - } - - return gridRows; - } - - @override - Widget build(BuildContext context) { - var title = widget.group.title.isEmpty ? widget.type.toTitle() : (widget.type.toTitle() + '・' + widget.group.title); - var icon = widget.type == MangaGroupType.serial - ? Icons.whatshot - : widget.type == MangaGroupType.finish - ? Icons.check_circle_outline - : Icons.fiber_new; - - var vSpace = 6.0; - var titleLine = Padding( - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: IconText( - icon: Icon(icon, size: 20, color: Colors.orange), - text: Text(title, style: Theme.of(context).textTheme.subtitle1), - space: 6, - ), - ); - var rows = _buildRows(vSpace); - - return Container( - color: Colors.white, - margin: EdgeInsets.only(top: widget.showTopMargin ? widget.marginV : 0), - padding: EdgeInsets.only(bottom: vSpace), - child: !widget.complete - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [titleLine, ...rows], - ) - : ListView( - controller: widget.controller, - children: [titleLine, ...rows], - ), - ); - } -} diff --git a/lib/page/view/manga_gallery.dart b/lib/page/view/manga_gallery.dart new file mode 100644 index 0000000..bb065e7 --- /dev/null +++ b/lib/page/view/manga_gallery.dart @@ -0,0 +1,304 @@ +import 'dart:io' show File; + +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:manhuagui_flutter/config.dart'; +import 'package:manhuagui_flutter/page/view/extended_gallery.dart'; +import 'package:manhuagui_flutter/page/view/image_load.dart'; +import 'package:photo_view/photo_view.dart'; + +/// 漫画画廊展示,在 [MangaViewerPage] 使用 +class MangaGalleryView extends StatefulWidget { + const MangaGalleryView({ + Key? key, + required this.imageCount, + required this.imageUrls, + required this.imageUrlFutures, + required this.imageFileFutures, + required this.preloadPagesCount, + required this.verticalScroll, + required this.horizontalReverseScroll, + required this.horizontalViewportFraction, + required this.verticalViewportPageSpace, + required this.slideWidthRatio, + required this.slideHeightRatio, + required this.onPageChanged, // exclude extra pages, starts from 1 + this.initialImageIndex = 1, // exclude extra pages, starts from 1 + this.onCenterAreaTapped, + required this.firstPageBuilder, // always the first + required this.lastPageBuilder, // always the last + required this.onSaveImage, // exclude extra pages, starts from 1 + required this.onShareImage, // exclude extra pages, starts from 1 + }) : super(key: key); + + final int imageCount; + final List imageUrls; + final List> imageUrlFutures; + final List> imageFileFutures; + final int preloadPagesCount; + final bool verticalScroll; + final bool horizontalReverseScroll; + final double horizontalViewportFraction; + final double verticalViewportPageSpace; + final double slideWidthRatio; + final double slideHeightRatio; + final void Function(int imageIndex, bool inFirstExtraPage, bool inLastExtraPage) onPageChanged; + final int initialImageIndex; + final void Function()? onCenterAreaTapped; + final Widget Function(BuildContext) firstPageBuilder; + final Widget Function(BuildContext) lastPageBuilder; + final void Function(int imageIndex) onSaveImage; + final void Function(int imageIndex) onShareImage; + + @override + State createState() => MangaGalleryViewState(); +} + +class MangaGalleryViewState extends State { + final CacheManager _cache = DefaultCacheManager(); + final _horizontalGalleryKey = GlobalKey(); + final _verticalGalleryKey = GlobalKey(); + + // current page index, include extra pages, starts from 0. + late var _currentPageIndex = widget.initialImageIndex - 1 + 1; + + // current image index, exclude extra pages, starts from 0. + int get _currentImageIndex => (_currentPageIndex - 1).clamp(0, widget.imageCount - 1); + + Offset? _pointerDownPosition; + + void _onPointerDown(Offset pos) { + _pointerDownPosition = pos; + } + + void _onPointerUp(Offset pos) { + if (_pointerDownPosition != null && _pointerDownPosition == pos) { + if (!widget.verticalScroll) { + var width = MediaQuery.of(context).size.width; + if (pos.dx < width * widget.slideWidthRatio) { + _jumpToPage(!widget.horizontalReverseScroll ? _currentPageIndex - 1 : _currentPageIndex + 1); // 上一页 / 下一页(反) + } else if (pos.dx > width * (1 - widget.slideWidthRatio)) { + _jumpToPage(!widget.horizontalReverseScroll ? _currentPageIndex + 1 : _currentPageIndex - 1); // 下一页 / 上一页(反) + } else { + widget.onCenterAreaTapped?.call(); + } + } else { + var height = MediaQuery.of(context).size.height; + if (pos.dy < height * widget.slideHeightRatio) { + _jumpToPage(_currentPageIndex - 1); // 上一页 + } else if (pos.dy > height * (1 - widget.slideHeightRatio)) { + _jumpToPage(_currentPageIndex + 1); // 下一页 + } else { + widget.onCenterAreaTapped?.call(); + } + } + } + _pointerDownPosition = null; + } + + void _jumpToPage(int pageIndex) { + if (pageIndex >= 0 && pageIndex <= widget.imageCount + 1) { + if (!widget.verticalScroll) { + _horizontalGalleryKey.currentState?.jumpToPage(pageIndex, animated: false); + } else { + _verticalGalleryKey.currentState?.jumpToPage(pageIndex, masked: false); + } + } + } + + // jump to image page, exclude extra pages, starts from 1. + void jumpToImage(int imageIndex, {bool animated = false}) { + if (imageIndex >= 1 && imageIndex <= widget.imageCount) { + var pageIndex = imageIndex + 1 - 1; // include extra pages, starts from 0 + if (!widget.verticalScroll) { + _horizontalGalleryKey.currentState?.jumpToPage(pageIndex, animated: animated); + } else { + _verticalGalleryKey.currentState?.jumpToPage(pageIndex, masked: !animated); + } + } + } + + Future _onLongPressed(int index) async { + await showPopupListMenu( + context: context, + title: Text('第${index + 1}页'), + barrierDismissible: true, + items: [ + IconTextMenuItem( + iconText: IconText.simple(Icons.refresh, '重新加载'), + action: () async { + await _cache.removeFile(widget.imageUrls[index]); + if (!widget.verticalScroll) { + _horizontalGalleryKey.currentState?.reload(index); // exclude extra pages, starts from 0 + } else { + _verticalGalleryKey.currentState?.reload(index); // exclude extra pages, starts from 0 + } + }, + ), + IconTextMenuItem( + iconText: IconText.simple(Icons.download, '保存该页'), + action: () => widget.onSaveImage.call(index + 1), + ), + IconTextMenuItem( + iconText: IconText.simple(Icons.share, '分享该页'), + action: () => widget.onShareImage.call(index + 1), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + if (!widget.verticalScroll) { + return HorizontalGalleryView( + key: _horizontalGalleryKey, + imageCount: widget.imageCount, + preloadPagesCount: widget.preloadPagesCount, + initialPage: _currentPageIndex /* initial to `initialPage - 1 + 1` */, + viewportFraction: widget.horizontalViewportFraction, + reverse: widget.horizontalReverseScroll, + backgroundDecoration: BoxDecoration(color: Colors.black), + scrollPhysics: AlwaysScrollableScrollPhysics(), + onPageChanged: (idx) { + _currentPageIndex = idx; + widget.onPageChanged.call(_currentImageIndex + 1, idx == 0, idx == widget.imageCount + 1); + }, + // **************************************************************** + // 漫画页 + // **************************************************************** + imagePageBuilder: (c, idx) => ExtendedPhotoGalleryPageOptions( + initialScale: PhotoViewComputedScale.contained, + minScale: PhotoViewComputedScale.contained / 2, + maxScale: PhotoViewComputedScale.covered * 2, + filterQuality: FilterQuality.high, + onTapDown: (c, d, v) => _onPointerDown(d.globalPosition), + onTapUp: (c, d, v) => _onPointerUp(d.globalPosition), + imageProviderBuilder: (key) => LocalOrCachedNetworkImageProvider.fromFutures( + key: key, + urlFuture: widget.imageUrlFutures[idx], + cacheManager: _cache, + headers: { + 'User-Agent': USER_AGENT, + 'Referer': REFERER, + }, + fileFuture: widget.imageFileFutures[idx], + fileMustExist: true, + ), + loadingBuilder: (_, ev) => GestureDetector( + onTapDown: (d) => _onPointerDown(d.globalPosition), + onTapUp: (d) => _onPointerUp(d.globalPosition), + onLongPress: () => _onLongPressed(idx), + child: ImageLoadingView( + title: (idx + 1).toString(), + event: ev, + ), + ), + errorBuilder: (_, err, __) => GestureDetector( + onTapDown: (d) => _onPointerDown(d.globalPosition), + onTapUp: (d) => _onPointerUp(d.globalPosition), + onLongPress: () => _onLongPressed(idx), + child: ImageLoadFailedView( + title: (idx + 1).toString(), + error: err, + ), + ), + ), + onImageLongPressed: (idx) => _onLongPressed(idx), + // **************************************************************** + // 额外页 + // **************************************************************** + firstPageBuilder: (c) => Container( + color: Theme.of(context).scaffoldBackgroundColor, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height - MediaQuery.of(context).padding.vertical, + maxWidth: MediaQuery.of(context).size.width - MediaQuery.of(context).padding.horizontal, + ), + child: widget.firstPageBuilder.call(c), // 额外页-开头 + ), + lastPageBuilder: (c) => Container( + color: Theme.of(context).scaffoldBackgroundColor, + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height - MediaQuery.of(context).padding.vertical, + maxWidth: MediaQuery.of(context).size.width - MediaQuery.of(context).padding.horizontal, + ), + child: widget.lastPageBuilder.call(c), // 额外页-末尾 + ), + ); + } + + return VerticalGalleryView( + key: _verticalGalleryKey, + imageCount: widget.imageCount, + preloadPagesCount: widget.preloadPagesCount, + initialPage: _currentPageIndex /* initial to `initialPage - 1 + 1` */, + viewportPageSpace: widget.verticalViewportPageSpace, + backgroundDecoration: BoxDecoration(color: Colors.black), + scrollPhysics: AlwaysScrollableScrollPhysics(), + onPageChanged: (idx) { + _currentPageIndex = idx; + widget.onPageChanged.call(_currentImageIndex + 1, idx == 0, idx == widget.imageCount + 1); + }, + // **************************************************************** + // 漫画页 + // **************************************************************** + imagePageBuilder: (c, idx) => ExtendedPhotoGalleryPageOptions( + initialScale: PhotoViewComputedScale.contained, + minScale: PhotoViewComputedScale.contained / 2, + maxScale: PhotoViewComputedScale.covered * 2, + filterQuality: FilterQuality.high, + onTapDown: null, + onTapUp: null, + imageProviderBuilder: (key) => LocalOrCachedNetworkImageProvider.fromFutures( + key: key, + urlFuture: widget.imageUrlFutures[idx], + cacheManager: _cache, + headers: { + 'User-Agent': USER_AGENT, + 'Referer': REFERER, + }, + fileFuture: widget.imageFileFutures[idx], + fileMustExist: true, + ), + loadingBuilder: (_, ev) => GestureDetector( + onTapDown: (d) => _onPointerDown(d.globalPosition), + onTapUp: (d) => _onPointerUp(d.globalPosition), + onLongPress: () => _onLongPressed(idx), + child: ImageLoadingView( + title: (idx + 1).toString(), + event: ev, + ), + ), + errorBuilder: (_, err, ___) => GestureDetector( + onTapDown: (d) => _onPointerDown(d.globalPosition), + onTapUp: (d) => _onPointerUp(d.globalPosition), + onLongPress: () => _onLongPressed(idx), + child: ImageLoadFailedView( + title: (idx + 1).toString(), + error: err, + ), + ), + ), + onImageTapDown: (d) => _onPointerDown(d.globalPosition), + onImageTapUp: (d) => _onPointerUp(d.globalPosition), + onImageLongPressed: (idx) => _onLongPressed(idx), + // **************************************************************** + // 额外页 + // **************************************************************** + firstPageBuilder: (c) => Container( + color: Theme.of(context).scaffoldBackgroundColor, + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width - MediaQuery.of(context).padding.horizontal, + ), + child: widget.firstPageBuilder.call(c), // 额外页-开头 + ), + lastPageBuilder: (c) => Container( + color: Theme.of(context).scaffoldBackgroundColor, + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width - MediaQuery.of(context).padding.horizontal, + ), + child: widget.lastPageBuilder.call(c), // 额外页-末尾 + ), + ); + } +} diff --git a/lib/page/view/manga_group.dart b/lib/page/view/manga_group.dart new file mode 100644 index 0000000..252d4de --- /dev/null +++ b/lib/page/view/manga_group.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/model/manga.dart'; +import 'package:manhuagui_flutter/page/manga.dart'; +import 'package:manhuagui_flutter/page/manga_group.dart'; +import 'package:manhuagui_flutter/page/view/network_image.dart'; + +enum MangaGroupViewStyle { + normalFull, + normalTruncate, + smallTruncate, + smallOneLine, +} + +/// 单个漫画分组,在 [RecommendSubPage] / [MangaGroupPage] 使用 +class MangaGroupView extends StatelessWidget { + const MangaGroupView({ + Key? key, + required this.group, + required this.type, + this.controller, + required this.style, + this.margin = EdgeInsets.zero, + this.padding = EdgeInsets.zero, + }) : super(key: key); + + final MangaGroup group; + final MangaGroupType type; + final ScrollController? controller; + final MangaGroupViewStyle style; + final EdgeInsets margin; + final EdgeInsets padding; + + Widget _buildItem({required BuildContext context, required TinyBlockManga? manga, required double width, required double height, void Function()? onMorePressed}) { + if (manga == null) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: const [0, 0.5, 1], + colors: [ + Colors.blue[100]!, + Colors.orange[200]!, + Colors.purple[100]!, + ], + ), + ), + child: Material( + color: Colors.transparent, + child: InkWell( + child: Center( + child: Text('查看更多...'), + ), + onTap: onMorePressed, + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + Container( + width: width, + height: height, + child: NetworkImageView( + url: manga.cover, + width: width, + height: height, + ), + ), + Positioned( + bottom: 0, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 6, vertical: 4), + width: width, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 1], + colors: [ + Colors.grey[800]!.withOpacity(0), + Colors.grey[800]!.withOpacity(0.9), + ], + ), + ), + child: Text( + manga.finished ? '${manga.newestChapter} 全' : '更新至 ${manga.newestChapter}', + style: TextStyle(color: Colors.white), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaPage( + id: manga.mid, + title: manga.title, + url: manga.url, + ), + ), + ), + ), + ), + ), + ], + ), + Container( + width: width, + padding: EdgeInsets.only(top: 1.5), + child: Text( + manga.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + + Widget _buildGroupItems({required BuildContext context}) { + const hSpace = 10.0; + const vSpace = 12.0; + + List mangas = group.mangas; + switch (style) { + case MangaGroupViewStyle.normalFull: + break; + case MangaGroupViewStyle.normalTruncate: + if (mangas.length > 6) { + mangas = [...mangas.sublist(0, 5), null]; // X X X | X X O + } + break; + case MangaGroupViewStyle.smallTruncate: + if (mangas.length > 8) { + mangas = [...mangas.sublist(0, 7), null]; // X X X X | X X X O + } + break; + case MangaGroupViewStyle.smallOneLine: + if (mangas.length > 4) { + mangas = [...mangas.sublist(0, 3), null]; // X X X O + } + break; + } + + final largerWidth = (MediaQuery.of(context).size.width - hSpace * 4) / 3; // | ▢ ▢ ▢ | + final smallerWidth = (MediaQuery.of(context).size.width - hSpace * 5) / 4; // | ▢ ▢ ▢ ▢ | + var widgets = []; + for (var manga in mangas) { + var width = style == MangaGroupViewStyle.smallTruncate || style == MangaGroupViewStyle.smallOneLine ? smallerWidth : largerWidth; + widgets.add( + _buildItem( + context: context, + manga: manga, + width: width, + height: width / 3 * 4, + onMorePressed: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaGroupPage( + group: group, + type: type, + ), + ), + ), + ), + ); + } + + return Padding( + padding: EdgeInsets.symmetric(horizontal: hSpace), + child: Wrap( + spacing: hSpace, + runSpacing: vSpace, + children: widgets, + ), + ); + } + + @override + Widget build(BuildContext context) { + var title = group.title.isEmpty ? type.toTitle() : (type.toTitle() + '・' + group.title); + var icon = type == MangaGroupType.serial + ? Icons.whatshot + : type == MangaGroupType.finish + ? Icons.check_circle_outline + : Icons.fiber_new; + + return Container( + color: Colors.white, + margin: margin, + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: IconText( + icon: Icon(icon, size: 20, color: Colors.orange), + text: Text(title, style: Theme.of(context).textTheme.subtitle1), + space: 6, + ), + ), + _buildGroupItems(context: context), + ], + ), + ); + } +} diff --git a/lib/page/view/manga_history_line.dart b/lib/page/view/manga_history_line.dart index 645c3ac..ab5ed49 100644 --- a/lib/page/view/manga_history_line.dart +++ b/lib/page/view/manga_history_line.dart @@ -1,133 +1,62 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/widget.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:intl/intl.dart'; -import 'package:manhuagui_flutter/model/manga.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; import 'package:manhuagui_flutter/page/manga.dart'; -import 'package:manhuagui_flutter/page/view/network_image.dart'; +import 'package:manhuagui_flutter/page/view/general_line.dart'; -/// View for [HistoryManga]. -/// Used in [HistorySubPage]. -class MangaHistoryLineView extends StatefulWidget { +/// 漫画浏览历史行,在 [HistorySubPage] 使用 +class MangaHistoryLineView extends StatelessWidget { const MangaHistoryLineView({ - Key key, - @required this.history, - @required this.onLongPressed, - }) : assert(history != null), - assert(onLongPressed != null), - super(key: key); + Key? key, + required this.history, + required this.onLongPressed, + }) : super(key: key); final MangaHistory history; final Function() onLongPressed; - @override - _MangaHistoryLineViewState createState() => _MangaHistoryLineViewState(); -} - -class _MangaHistoryLineViewState extends State { @override Widget build(BuildContext context) { - return Stack( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - margin: EdgeInsets.symmetric(horizontal: 14, vertical: 5), - child: NetworkImageView( - url: widget.history.mangaCover, - height: 100, - width: 75, - fit: BoxFit.cover, - ), - ), - Container( - width: MediaQuery.of(context).size.width - 14 * 3 - 75, // | ▢ ▢ | - margin: EdgeInsets.only(top: 5, bottom: 5, right: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(bottom: 4), - child: Text( - widget.history.mangaTitle, - style: Theme.of(context).textTheme.subtitle1, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (widget.history.read) - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.subject, size: 20, color: Colors.orange), - text: Text( - '阅读至 ${widget.history.chapterTitle}', - style: TextStyle(color: Colors.grey[600]), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - space: 8, - ), - ), - if (widget.history.read) - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.import_contacts, size: 20, color: Colors.orange), - text: Text( - '第${widget.history.chapterPage}页', - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - if (!widget.history.read) SizedBox(height: 22), - if (!widget.history.read) - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.subject, size: 20, color: Colors.orange), - text: Text( - '还没开始阅读', - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.access_time, size: 20, color: Colors.orange), - text: Text( - DateFormat('yyyy-MM-dd HH:mm:ss').format(widget.history.lastTime), - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - ], - ), - ), - ], - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => MangaPage( - id: widget.history.mangaId, - title: widget.history.mangaTitle ?? '?', - url: widget.history.mangaUrl ?? '', - ), - ), - ), - onLongPress: () => widget.onLongPressed(), - ), + var lastTime = DateFormat('yyyy-MM-dd HH:mm:ss').format(history.lastTime); + void onPressed() { + Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaPage( + id: history.mangaId, + title: history.mangaTitle, + url: history.mangaUrl, ), ), - ], + ); + } + + if (!history.read) { + return GeneralLineView( + imageUrl: history.mangaCover, + title: history.mangaTitle, + icon1: null, + text1: null, + icon2: Icons.subject, + text2: '未开始阅读', + icon3: Icons.access_time, + text3: lastTime, + onPressed: onPressed, + onLongPressed: onLongPressed, + ); + } + return GeneralLineView( + imageUrl: history.mangaCover, + title: history.mangaTitle, + icon1: Icons.subject, + text1: '阅读至 ${history.chapterTitle}', + icon2: Icons.import_contacts, + text2: '第${history.chapterPage}页', + icon3: Icons.access_time, + text3: lastTime, + onPressed: onPressed, + onLongPressed: onLongPressed, ); } } diff --git a/lib/page/view/manga_rank.dart b/lib/page/view/manga_rank.dart deleted file mode 100644 index f61e1f1..0000000 --- a/lib/page/view/manga_rank.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/widget.dart'; -import 'package:manhuagui_flutter/model/manga.dart'; -import 'package:manhuagui_flutter/page/manga.dart'; -import 'package:manhuagui_flutter/page/view/network_image.dart'; - -/// View for [MangaRank]. -/// Used in [RankingSubPage]. -class MangaRankView extends StatefulWidget { - const MangaRankView({ - Key key, - @required this.manga, - }) : assert(manga != null), - super(key: key); - - final MangaRank manga; - - @override - _MangaRankViewState createState() => _MangaRankViewState(); -} - -class _MangaRankViewState extends State { - @override - Widget build(BuildContext context) { - return Stack( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - margin: EdgeInsets.symmetric(horizontal: 14, vertical: 5), - child: NetworkImageView( - url: widget.manga.cover, - height: 100, - width: 75, - fit: BoxFit.cover, - ), - ), - Container( - width: MediaQuery.of(context).size.width - 14 * 4 - 75 - 35, // | ▢ ▢ ▢ | - margin: EdgeInsets.only(top: 5, bottom: 5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(bottom: 4), - child: Text( - widget.manga.title, - style: Theme.of(context).textTheme.subtitle1, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.edit, size: 20, color: Colors.orange), - text: Text( - widget.manga.finished ? '已完结' : '连载中', - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.subject, size: 20, color: Colors.orange), - text: Text( - (widget.manga.finished ? '共' : '更新至') + widget.manga.newestChapter, - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.access_time, size: 20, color: Colors.orange), - text: Text( - widget.manga.newestDate, - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - ], - ), - ), - Container( - margin: EdgeInsets.symmetric(horizontal: 14, vertical: 5), - width: 35, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - widget.manga.trend == 1 - ? Icon(Icons.arrow_drop_up, color: Colors.red) // up - : widget.manga.trend == 2 - ? Icon(Icons.arrow_drop_down, color: Colors.blue[400]) // down - : Icon(Icons.remove, color: Colors.grey), - Text( - widget.manga.score.toString(), - style: Theme.of(context).textTheme.subtitle1.copyWith(color: Colors.grey[600]), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - Positioned( - top: 0, - right: 0, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: widget.manga.order == 1 - ? Colors.red - : widget.manga.order == 2 - ? Colors.deepOrange - : widget.manga.order == 3 - ? Colors.orange - : Colors.grey[400], - borderRadius: BorderRadius.only(bottomLeft: Radius.circular(24)), - ), - child: Container( - padding: EdgeInsets.only(top: 3, left: widget.manga.order < 10 ? 12 : 7), - child: Text( - widget.manga.order.toString(), - style: TextStyle(color: Colors.white), - ), - ), - ), - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => MangaPage( - id: widget.manga.mid, - title: widget.manga.title, - url: widget.manga.url, - ), - ), - ), - ), - ), - ) - ], - ); - } -} diff --git a/lib/page/view/manga_rank_line.dart b/lib/page/view/manga_rank_line.dart new file mode 100644 index 0000000..79e4577 --- /dev/null +++ b/lib/page/view/manga_rank_line.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/model/manga.dart'; +import 'package:manhuagui_flutter/page/manga.dart'; +import 'package:manhuagui_flutter/page/view/general_line.dart'; + +/// 漫画排名行,在 [RankingSubPage] 使用 +class MangaRankLineView extends StatelessWidget { + const MangaRankLineView({ + Key? key, + required this.manga, + }) : super(key: key); + + final MangaRank manga; + + @override + Widget build(BuildContext context) { + return GeneralLineView( + imageUrl: manga.cover, + title: manga.title, + icon1: Icons.edit, + text1: manga.finished ? '已完结' : '连载中', + icon2: Icons.subject, + text2: '最新章节 ${manga.newestChapter}', + icon3: Icons.access_time, + text3: '更新于 ${manga.newestDate}', + extraWidthInRow: 35 + 14, + extrasInRow: [ + Container( + margin: EdgeInsets.only(right: 14, top: 5, bottom: 5), + width: 35, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + manga.trend == 1 + ? Icon(Icons.arrow_drop_up, color: Colors.red) // up + : manga.trend == 2 + ? Icon(Icons.arrow_drop_down, color: Colors.blue[400]) // down + : Icon(Icons.remove, color: Colors.grey), + Text( + manga.score.toString(), + style: Theme.of(context).textTheme.subtitle1, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + extrasInStack: [ + Positioned( + top: 0, + right: 0, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: manga.order == 1 + ? Colors.red + : manga.order == 2 + ? Colors.deepOrange + : manga.order == 3 + ? Colors.orange + : Colors.grey[400], + borderRadius: BorderRadius.only(bottomLeft: Radius.circular(24)), + ), + child: Container( + padding: EdgeInsets.only(top: 2, left: manga.order < 10 ? 12 : 6.5), + child: Text( + manga.order.toString(), + style: Theme.of(context).textTheme.bodyText2?.copyWith(color: Colors.white), + ), + ), + ), + ), + ], + onPressed: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaPage( + id: manga.mid, + title: manga.title, + url: manga.url, + ), + ), + ), + ); + } +} diff --git a/lib/page/view/manga_simple_toc.dart b/lib/page/view/manga_simple_toc.dart new file mode 100644 index 0000000..e78b11c --- /dev/null +++ b/lib/page/view/manga_simple_toc.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/model/chapter.dart'; +import 'package:manhuagui_flutter/page/view/chapter_grid.dart'; +import 'package:manhuagui_flutter/page/view/manga_toc.dart'; + +/// 漫画章节目录(给定章节列表),在 [DlFinishedSubPage] 使用 +class MangaSimpleTocView extends StatelessWidget { + const MangaSimpleTocView({ + Key? key, + required this.chapters, + this.gridPadding, + this.invertOrder = true, + this.highlightColor, + this.highlightedChapters = const [], + this.showNewBadge = true, + this.customBadgeBuilder, + required this.onChapterPressed, + this.onChapterLongPressed, + }) : super(key: key); + + final List> chapters; + final EdgeInsets? gridPadding; + final bool invertOrder; + final Color? highlightColor; + final List highlightedChapters; + final bool showNewBadge; + final Widget? Function(int cid)? customBadgeBuilder; + final void Function(int cid) onChapterPressed; + final void Function(int cid)? onChapterLongPressed; + + Widget _buildGrid({required int idx, required List chapters}) { + return ChapterGridView( + chapters: chapters, + padding: gridPadding ?? EdgeInsets.symmetric(horizontal: 12), + invertOrder: invertOrder /* true means desc */, + maxLines: -1 /* show all chapters */, + highlightColor: highlightColor, + highlightedChapters: highlightedChapters, + extrasInStack: (chapter) { + if (chapter == null) { + return []; + } + var newBadge = showNewBadge && chapter.isNew ? NewBadge() : null; + var customBadge = customBadgeBuilder?.call(chapter.cid); + return [ + if (newBadge != null) newBadge, + if (customBadge != null) customBadge, + ]; + }, + onChapterPressed: (chapter) { + if (chapter != null) { + onChapterPressed.call(chapter.cid); + } + }, + onChapterLongPressed: onChapterLongPressed == null + ? null + : (chapter) { + if (chapter != null) { + onChapterLongPressed!.call(chapter.cid); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + if (chapters.isEmpty) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 15, horizontal: 15), + child: Center( + child: Text( + '暂无章节', + style: Theme.of(context).textTheme.subtitle1, + ), + ), + ); + } + + var groupMap = >{}; + for (var chapter in chapters) { + var groupName = chapter.item1; + var group = groupMap[groupName] ?? []; + group.add(chapter.item2); + groupMap[groupName] = group; + } + var groups = []; + for (var kv in groupMap.entries) { + groups.add(MangaChapterGroup(title: kv.key, chapters: kv.value)); + } + groups = groups.makeSureRegularGroupIsFirst(); // 保证【单话】为首个章节分组 + + return Column( + children: [ + SizedBox(height: 10), + for (var i = 0; i < groups.length; i++) ...[ + Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Text( + '・${groups[i].title}・', + style: Theme.of(context).textTheme.subtitle1, + ), + ), + SizedBox(height: 10), + SizedBox( + width: MediaQuery.of(context).size.width, + child: _buildGrid( + idx: i, + chapters: groups[i].chapters, + ), + ), + SizedBox(height: 10), + ], + ], + ); + } +} diff --git a/lib/page/view/manga_toc.dart b/lib/page/view/manga_toc.dart new file mode 100644 index 0000000..5766d8f --- /dev/null +++ b/lib/page/view/manga_toc.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/model/chapter.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/page/view/chapter_grid.dart'; + +/// 漫画章节目录(给定章节分组列表),在 [MangaPage] / [MangaTocPage] / [ViewTocSubPage] / [DownloadSelectPage] 使用 +class MangaTocView extends StatefulWidget { + const MangaTocView({ + Key? key, + required this.groups, + required this.full, + this.gridPadding, + this.highlightColor, + this.highlightedChapters = const [], + this.showNewBadge = true, + this.customBadgeBuilder, + required this.onChapterPressed, + this.onMoreChaptersPressed, + this.onChapterLongPressed, + }) : super(key: key); + + final List groups; + final bool full; + final EdgeInsets? gridPadding; + final Color? highlightColor; + final List highlightedChapters; + final bool showNewBadge; + final Widget? Function(int cid)? customBadgeBuilder; + final void Function(int cid) onChapterPressed; + final void Function()? onMoreChaptersPressed; + final void Function(int cid)? onChapterLongPressed; + + @override + _MangaTocViewState createState() => _MangaTocViewState(); +} + +class _MangaTocViewState extends State { + var _invertOrder = true; + + Widget _buildHeader() { + Widget button({required IconData icon, required String text, required bool selected, required EdgeInsets padding, required void Function() onPressed}) { + Color color = selected ? Theme.of(context).primaryColor : Colors.black; + return InkWell( + onTap: onPressed, + child: Padding( + padding: padding, + child: IconText( + icon: Icon(icon, size: 18, color: color), + text: Text(text, style: Theme.of(context).textTheme.bodyText1?.copyWith(color: color)), + space: 0, + ), + ), + ); + } + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '章节列表', + style: Theme.of(context).textTheme.subtitle1, + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 3), + child: Material( + color: Colors.transparent, + child: Row( + children: [ + button( + icon: Icons.keyboard_arrow_up, + text: '正序', + selected: !_invertOrder, + padding: EdgeInsets.only(top: 3, bottom: 3, left: 5, right: 10), + onPressed: () => mountedSetState(() => _invertOrder = false), + ), + button( + icon: Icons.keyboard_arrow_down, + text: '倒序', + selected: _invertOrder, + padding: EdgeInsets.only(top: 3, bottom: 3, left: 5, right: 10), + onPressed: () => mountedSetState(() => _invertOrder = true), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildGrid({required int idx, required List chapters}) { + return ChapterGridView( + chapters: chapters, + padding: widget.gridPadding ?? EdgeInsets.symmetric(horizontal: 12), + invertOrder: _invertOrder, + maxLines: widget.full + ? -1 // show all chapters + : idx == 0 + ? 3 // first line => show the first three lines + : 1 /* following lines => show the first line */, + highlightColor: widget.highlightColor, + highlightedChapters: widget.highlightedChapters, + extrasInStack: (chapter) { + if (chapter == null) { + return []; + } + var newBadge = widget.showNewBadge && chapter.isNew ? NewBadge() : null; + var customBadge = widget.customBadgeBuilder?.call(chapter.cid); + return [ + if (newBadge != null) newBadge, + if (customBadge != null) customBadge, + ]; + }, + onChapterPressed: (chapter) { + if (chapter == null) { + widget.onMoreChaptersPressed?.call(); + } else { + widget.onChapterPressed.call(chapter.cid); + } + }, + onChapterLongPressed: widget.onChapterLongPressed == null + ? null + : (chapter) { + if (chapter != null) { + widget.onChapterLongPressed!.call(chapter.cid); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + if (widget.groups.isEmpty) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 15, horizontal: 15), + child: Center( + child: Text( + '暂无章节', + style: Theme.of(context).textTheme.subtitle1, + ), + ), + ); + } + + var groups = widget.groups.makeSureRegularGroupIsFirst(); // 保证【单话】为首个章节分组 + return Column( + children: [ + Container( + color: Colors.white, + padding: EdgeInsets.only(left: 12, right: 4, top: 2, bottom: 2), + child: _buildHeader(), + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 12), + color: Colors.white, + child: Divider(height: 0, thickness: 1), + ), + SizedBox(height: 10), + for (var i = 0; i < groups.length; i++) ...[ + Padding( + padding: EdgeInsets.symmetric(horizontal: 12), + child: Text( + '・${groups[i].title}・', + style: Theme.of(context).textTheme.subtitle1, + ), + ), + SizedBox(height: 10), + SizedBox( + width: MediaQuery.of(context).size.width, + child: _buildGrid( + idx: i, + chapters: groups[i].chapters, + ), + ), + SizedBox(height: 10), + ], + ], + ); + } +} + +class NewBadge extends StatelessWidget { + const NewBadge({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Positioned( + top: 0, + right: 0, + child: Container( + padding: EdgeInsets.symmetric(vertical: 1, horizontal: 3), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.all(Radius.circular(2.0)), + ), + child: Text( + 'NEW', + style: TextStyle( + fontSize: 9, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} + +enum DownloadBadgeState { + downloading, + done, + failed, +} + +class DownloadBadge extends StatelessWidget { + const DownloadBadge({ + Key? key, + required this.state, + }) : super(key: key); + + final DownloadBadgeState state; + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 1, + right: 1, + child: Container( + padding: EdgeInsets.symmetric(vertical: 1.25, horizontal: 1.25), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: state == DownloadBadgeState.downloading + ? Colors.blue + : state == DownloadBadgeState.done + ? Colors.green + : Colors.red, + ), + child: Icon( + state == DownloadBadgeState.downloading + ? Icons.download + : state == DownloadBadgeState.done + ? Icons.file_download_done + : Icons.priority_high, + size: 14, + color: Colors.white, + ), + ), + ); + } + + static DownloadBadge? fromEntity({required DownloadedChapter? entity}) { + if (entity == null) { + return null; + } + return DownloadBadge( + state: !entity.allTried + ? DownloadBadgeState.downloading + : entity.succeeded + ? DownloadBadgeState.done + : DownloadBadgeState.failed, + ); + } +} diff --git a/lib/page/view/my_drawer.dart b/lib/page/view/my_drawer.dart new file mode 100644 index 0000000..a73e172 --- /dev/null +++ b/lib/page/view/my_drawer.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/config.dart'; +import 'package:manhuagui_flutter/page/download.dart'; +import 'package:manhuagui_flutter/page/index.dart'; +import 'package:manhuagui_flutter/page/login.dart'; +import 'package:manhuagui_flutter/page/search.dart'; +import 'package:manhuagui_flutter/page/setting.dart'; +import 'package:manhuagui_flutter/service/evb/auth_manager.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; +import 'package:manhuagui_flutter/service/native/browser.dart'; + +enum DrawerSelection { + none, // MangaPage / AuthorPage / DownloadTocPage + home, // IndexPage + search, // SearchPage + download, // DownloadPage + setting, // SettingPage +} + +class MyDrawer extends StatefulWidget { + const MyDrawer({ + Key? key, + required this.currentDrawerSelection, + }) : super(key: key); + + final DrawerSelection currentDrawerSelection; + + @override + _MyDrawerState createState() => _MyDrawerState(); +} + +class _MyDrawerState extends State { + late final _items = [ + DrawerPageItem.simple('主页', Icons.home, IndexPage(), DrawerSelection.home, autoCloseWhenTapped: false), + if (!AuthManager.instance.logined) // + DrawerActionItem.simple('登录', Icons.login, () => Navigator.of(context).push(CustomPageRoute.simple(context, (c) => LoginPage()))), + DrawerPageItem.simple('搜索漫画', Icons.search, SearchPage(), DrawerSelection.search), + DrawerPageItem.simple('下载列表', Icons.download, DownloadPage(), DrawerSelection.download), + DrawerWidgetItem.simple(Divider(thickness: 1)), + // + + DrawerActionItem.simple('我的书架', Icons.star_outlined, () => _gotoHomePageTab(ToShelfRequestedEvent()), autoCloseWhenTapped: false), + DrawerActionItem.simple('浏览历史', Icons.history, () => _gotoHomePageTab(ToHistoryRequestedEvent()), autoCloseWhenTapped: false), + DrawerActionItem.simple('最近更新', Icons.cached, () => _gotoHomePageTab(ToRecentRequestedEvent()), autoCloseWhenTapped: false), + DrawerActionItem.simple('漫画排行', Icons.trending_up, () => _gotoHomePageTab(ToRankingRequestedEvent()), autoCloseWhenTapped: false), + DrawerWidgetItem.simple(Divider(thickness: 1)), + // + + DrawerActionItem.simple('漫画柜官网', Icons.open_in_browser, () => launchInBrowser(context: context, url: WEB_HOMEPAGE_URL)), + DrawerPageItem.simple('设置', Icons.settings, SettingPage(), DrawerSelection.setting), + ]; + + Future _popUntilFirst() async { + if (Scaffold.of(context).isDrawerOpen) { + Navigator.of(context).pop(); + await Future.delayed(Duration(milliseconds: 246)); // <<< + } + Navigator.of(context).popUntil((r) => r.isFirst); + } + + Future _gotoHomePageTab(dynamic event) async { + await _popUntilFirst(); + EventBusManager.instance.fire(event); + } + + void _navigatorTo(DrawerSelection? t, Widget page) async { + if (t == DrawerSelection.home) { + await _popUntilFirst(); + } else { + Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (_) => page, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + DrawerHeader( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0, 0.4, 0.6, 1], + colors: [ + Colors.blue[100]!, + Colors.orange[100]!, + Colors.orange[100]!, + Colors.purple[100]!, + ], + ), + ), + child: Stack( + children: [ + Center( + child: Container( + margin: EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.grey[600]!, + blurRadius: 5, + spreadRadius: -9, + ), + ], + ), + child: Image.asset( + '${ASSETS_PREFIX}ic_launcher_xxhdpi.png', + height: 80, + width: 80, + ), + ), + ), + Positioned( + left: 0, + bottom: 0, + child: Text( + !AuthManager.instance.logined ? '未登录用户' : AuthManager.instance.username, + style: Theme.of(context).textTheme.subtitle1, + ), + ), + ], + ), + ), + DrawerListView( + items: _items, + currentSelection: widget.currentDrawerSelection, + onNavigatorTo: _navigatorTo, + ), + ], + ), + ); + } +} diff --git a/lib/page/view/network_image.dart b/lib/page/view/network_image.dart index 7bde13c..cae658e 100644 --- a/lib/page/view/network_image.dart +++ b/lib/page/view/network_image.dart @@ -1,53 +1,76 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:manhuagui_flutter/config.dart'; class NetworkImageView extends StatelessWidget { const NetworkImageView({ - Key key, - @required this.url, - @required this.width, - @required this.height, + Key? key, + required this.url, + required this.width, + required this.height, this.fit = BoxFit.cover, - }) : assert(url != null && url != ''), - super(key: key); + this.border, + this.radius, + }) : super(key: key); final String url; final double width; final double height; final BoxFit fit; + final BoxBorder? border; + final BorderRadius? radius; @override Widget build(BuildContext context) { - return SizedBox( - width: this.width, - height: this.height, - child: CachedNetworkImage( - imageUrl: this.url, - width: this.width, - height: this.height, - fit: this.fit, - placeholder: (context, url) => Container( - child: Icon( - Icons.more_horiz, - color: Colors.grey, + var url = this.url; + if (url.startsWith('//')) { + url = 'https:$url'; + } + + return ClipRRect( + borderRadius: radius ?? BorderRadius.zero, + child: Container( + decoration: border == null ? null : BoxDecoration(border: border), + width: width, + height: height, + child: CachedNetworkImage( + imageUrl: url, + width: width, + height: height, + fit: fit, + httpHeaders: const { + 'User-Agent': USER_AGENT, + 'Referer': REFERER, + }, + placeholder: (context, url) => Container( + width: width, + height: height, + color: Colors.orange[50], + child: Center( + child: Icon( + Icons.more_horiz, + color: Colors.grey, + ), + ), ), - width: this.width, - height: this.height, - color: Colors.orange[50], - ), - errorWidget: (context, url, error) => Container( - child: Icon( - Icons.broken_image, - color: Colors.grey, + errorWidget: (_, url, __) => Container( + width: width, + height: height, + color: Colors.orange[50], + child: Center( + child: Icon( + Icons.broken_image, + color: Colors.grey, + ), + ), ), - width: this.width, - height: this.height, - color: Colors.orange[50], + cacheManager: DefaultCacheManager(), + fadeOutDuration: Duration(milliseconds: 1000), + fadeOutCurve: Curves.easeOut, + fadeInDuration: Duration(milliseconds: 500), + fadeInCurve: Curves.easeIn, ), - fadeOutDuration: Duration(milliseconds: 1000), - fadeOutCurve: Curves.easeOut, - fadeInDuration: Duration(milliseconds: 500), - fadeInCurve: Curves.easeIn, ), ); } diff --git a/lib/page/view/option_popup.dart b/lib/page/view/option_popup.dart index 411046b..9c47110 100644 --- a/lib/page/view/option_popup.dart +++ b/lib/page/view/option_popup.dart @@ -1,72 +1,91 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/widget.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; -class OptionPopupView extends StatefulWidget { +class OptionPopupView extends StatefulWidget { const OptionPopupView({ - Key key, - @required this.title, - this.top, + Key? key, + required this.items, + required this.value, + required this.titleBuilder, + required this.onSelect, this.height = 26.0, - this.width = 88.0, - @required this.value, - @required this.items, - this.doHighlight = false, - @required this.optionBuilder, - @required this.onSelect, + this.width, this.enable = true, - }) : assert(title != null), - assert(value != null), - assert(doHighlight != null), - assert(items != null), - assert(onSelect != null), - assert(optionBuilder != null), - assert(enable != null), - super(key: key); + }) : super(key: key); - final String title; - final double top; - final double height; - final double width; - final bool doHighlight; - final T value; final List items; - final String Function(BuildContext, T) optionBuilder; + final T value; + final String Function(BuildContext, T) titleBuilder; final void Function(T) onSelect; + final double height; + final double? width; final bool enable; @override _OptionPopupRouteViewState createState() => _OptionPopupRouteViewState(); } -class _OptionPopupRouteViewState extends State> { +class _OptionPopupRouteViewState extends State> { var _selected = false; - void _onTap() { - final itemBox = context.findRenderObject() as RenderBox; - final itemRect = itemBox.localToGlobal(Offset.zero) & itemBox.size; + void _onTap() async { _selected = true; if (mounted) setState(() {}); - // **************************************************************** - // 弹出选项路由 - // **************************************************************** - var result = Navigator.of(context).push( - _OptionPopupRoute( - buttonRect: itemRect, - transitionDuration: Duration(milliseconds: 300), - barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, - top: widget.top, - value: widget.value, - items: widget.items, - optionBuilder: widget.optionBuilder, + + final renderBox = context.findRenderObject()! as RenderBox; + final itemRect = renderBox.localToGlobal(Offset.zero) & renderBox.size; + + var result = await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.transparent, + pageBuilder: (c, _, __) => Stack( + children: [ + Positioned( + top: itemRect.bottom + 5 + 10 /* keep the same as ListHint vertical padding + some spaces */, + bottom: 0, + left: 0, + right: 0, + child: GestureDetector( + child: Container( + color: const Color(0x80000000), + ), + onTap: () => Navigator.of(context).pop(null), + ), + ), + Positioned( + top: itemRect.bottom + 5 + 1 /* keep the same as ListHint vertical padding + divider height */, + left: 0, + right: 0, + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 5, + spreadRadius: 0, + offset: Offset(0, 8), + ), + ], + ), + child: _OptionPopupRouteView( + value: widget.value, + items: widget.items, + titleBuilder: widget.titleBuilder, + ), + ), + ), + ], ), ); - result.then((T r) { - _selected = false; - if (mounted) setState(() {}); - if (r != null) { - widget.onSelect(r); - } - }); + + _selected = false; + if (mounted) setState(() {}); + if (result != null) { + widget.onSelect(result); + } } @override @@ -76,8 +95,8 @@ class _OptionPopupRouteViewState extends State> { child: InkWell( onTap: widget.enable ? _onTap : null, child: Container( - height: widget.height, // 26 - width: widget.width, // 88 + height: widget.height, // 26 (keep the same as ListHint height) + width: widget.width, child: IconText( alignment: IconTextAlignment.r2l, mainAxisAlignment: MainAxisAlignment.center, @@ -85,16 +104,12 @@ class _OptionPopupRouteViewState extends State> { textPadding: EdgeInsets.only(left: 10), icon: Icon( Icons.arrow_drop_down, - color: !widget.enable ? Colors.grey[300] : Colors.grey[700], + color: !widget.enable ? Colors.grey[300] : (_selected ? Colors.orange : Colors.grey[700]), ), text: Text( - widget.title, + widget.titleBuilder(context, widget.value), style: TextStyle( - color: !widget.enable - ? Colors.grey - : _selected && widget.doHighlight - ? Colors.orange - : Colors.black, + color: !widget.enable ? Colors.grey : (_selected ? Colors.orange : Colors.black), ), ), ), @@ -104,279 +119,64 @@ class _OptionPopupRouteViewState extends State> { } } -class _OptionPopupRoute extends PopupRoute { - _OptionPopupRoute({ - @required this.buttonRect, - @required this.transitionDuration, - this.barrierLabel, - this.top, - @required this.value, - @required this.items, - @required this.optionBuilder, - }) : assert(buttonRect != null), - assert(transitionDuration != null), - assert(value != null), - assert(items != null), - assert(optionBuilder != null); - - final Rect buttonRect; - final double top; - final T value; - final List items; - final String Function(BuildContext, T) optionBuilder; - - @override - final Duration transitionDuration; - - @override - final String barrierLabel; - - @override - bool get barrierDismissible => true; - - @override - Color get barrierColor => null; - - @override - Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { - return LayoutBuilder( - builder: (c, _) => CustomSingleChildLayout( - delegate: _OptionPopupRouteLayout( - buttonRect: buttonRect, - top: top, - ), - child: MediaQuery.removePadding( - context: c, - removeTop: true, - removeBottom: true, - removeLeft: true, - removeRight: true, - child: FadeTransition( - opacity: CurvedAnimation( - parent: this.animation, - curve: Interval(0, 0.25), - reverseCurve: Interval(0.75, 1), - ), - // **************************************************************** - // 选项界面 - // **************************************************************** - child: _OptionPopupRouteView( - value: value, - items: items, - optionBuilder: optionBuilder, - transitionDuration: transitionDuration, - ), - ), - ), - ), - ); - } -} - -class _OptionPopupRouteLayout extends SingleChildLayoutDelegate { - _OptionPopupRouteLayout({ - @required this.buttonRect, - this.top, - }) : assert(buttonRect != null); - - final Rect buttonRect; - final double top; - - @override - BoxConstraints getConstraintsForChild(BoxConstraints constraints) { - return BoxConstraints( - minWidth: 0.0, - // maxWidth: constraints.maxWidth, - minHeight: 0.0, - // maxHeight: constraints.maxHeight, - ); - } - - @override - Offset getPositionForChild(Size size, Size childSize) { - double left = buttonRect.left.clamp(0.0, size.width - childSize.width); - double top = buttonRect.top.clamp(0.0, size.height - childSize.height); - return Offset(left, top + buttonRect.height + this.top ?? 0); - } - - @override - bool shouldRelayout(_OptionPopupRouteLayout oldDelegate) { - return buttonRect != oldDelegate.buttonRect || top != oldDelegate.top; - } -} - -class _OptionPopupRouteView extends StatefulWidget { +class _OptionPopupRouteView extends StatelessWidget { const _OptionPopupRouteView({ - Key key, - @required this.value, - @required this.items, - @required this.optionBuilder, - @required this.transitionDuration, - }) : assert(value != null), - assert(items != null), - assert(optionBuilder != null), - assert(transitionDuration != null), - super(key: key); + Key? key, + required this.items, + required this.value, + required this.titleBuilder, + }) : super(key: key); - final T value; final List items; - final String Function(BuildContext, T) optionBuilder; - final Duration transitionDuration; - - @override - _OptionPopupViewRouteState createState() => _OptionPopupViewRouteState(); -} - -class _OptionPopupViewRouteState extends State<_OptionPopupRouteView> { - var _containerKey = GlobalKey(); - var _showBarrier = false; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _showBarrier = true; - if (mounted) setState(() {}); - }); - } + final T value; + final String Function(BuildContext, T) titleBuilder; - Widget _buildGridItem(T value, int index, {double hSpace, double width, double height}) { - var selected = widget.value != null && widget.value == value; - // **************************************************************** - // 每个选项 - // **************************************************************** - return Container( + Widget _buildItem({required BuildContext context, required T value, required double width, required double height}) { + final selected = this.value == value; + return SizedBox( width: width, height: height, - margin: index == 0 - ? EdgeInsets.only(right: hSpace) - : index == 3 - ? EdgeInsets.only(left: hSpace) - : EdgeInsets.symmetric(horizontal: hSpace), - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(3), - color: selected ? Theme.of(context).primaryColor : Colors.white, - ), - child: Theme( - data: Theme.of(context).copyWith( - buttonTheme: ButtonTheme.of(context).copyWith( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - child: OutlineButton( - onPressed: () => Navigator.of(context).pop(value), - child: Text( - widget.optionBuilder(context, value), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: selected ? Colors.white : Colors.black, - ), - ), + child: OutlinedButton( + child: Text( + titleBuilder.call(context, value), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected ? Colors.white : Colors.black, ), ), + style: OutlinedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 8), + backgroundColor: selected ? Theme.of(context).primaryColor : Colors.white, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () => Navigator.of(context).pop(value), ), ); } @override Widget build(BuildContext context) { - var hPadding = 15.0; - var vPadding = 10.0; - var hSpace = 3.0; - var vSpace = 2 * hSpace; - var width = (MediaQuery.of(context).size.width - 2 * hPadding - 6 * hSpace) / 4; // | ▢ ▢ ▢ ▢ | - var height = 36.0; - - var gridRows = []; - var rows = (widget.items.length.toDouble() / 4).ceil(); - for (var r = 0; r < rows; r++) { - var columns = [ - for (var i = 4 * r; i < 4 * (r + 1) && i < widget.items.length; i++) widget.items[i], - ]; - gridRows.add( - // **************************************************************** - // 选项中的每一行 - // **************************************************************** - Row( - children: [ - for (var i = 0; i < columns.length; i++) - _buildGridItem( - columns[i], - i, - hSpace: hSpace, - width: width, - height: height, - ), - ], - ), - ); - if (r != rows - 1) { - gridRows.add( - SizedBox(height: vSpace), - ); - } - } - - return Column( - children: [ - Container( - key: _containerKey, - width: MediaQuery.of(context).size.width, - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha(100), - blurRadius: 8, - offset: Offset(0, 10), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // **************************************************************** - // 所有选项 - // **************************************************************** - Container( - padding: EdgeInsets.symmetric(horizontal: hPadding, vertical: vPadding), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...gridRows, - ], - ), - ), - ], - ), - ), - // **************************************************************** - // 背景 - // **************************************************************** - if (_showBarrier) - AnimatedOpacity( - opacity: _showBarrier ? 1 : 0, - duration: widget.transitionDuration, - child: Builder( - builder: (c) { - final box = _containerKey.currentContext.findRenderObject() as RenderBox; - final size = box.size; - final position = box.localToGlobal(Offset.zero); - final paddingV = MediaQuery.of(context).padding.top + MediaQuery.of(context).padding.bottom; - return GestureDetector( - child: Container( - width: MediaQuery.of(context).size.width, - height: MediaQuery.of(context).size.height - paddingV - position.dy - size.height, - color: Colors.black.withAlpha(100), - ), - onTap: () => Navigator.pop(context), - ); - }, + const hSpace = 8.0; + const vSpace = 8.0; + const padding = EdgeInsets.symmetric(horizontal: 15, vertical: 10); + final width = (MediaQuery.of(context).size.width - 2 * padding.left - 3 * hSpace) / 4; // | ▢ ▢ ▢ ▢ | + + return Padding( + padding: padding, + child: Wrap( + spacing: hSpace, + runSpacing: vSpace, + children: [ + for (var item in items) + _buildItem( + context: context, + value: item, + width: width, + height: 36, ), - ), - ], + ], + ), ); } } diff --git a/lib/page/view/preload_page_view.dart b/lib/page/view/preload_page_view.dart deleted file mode 100644 index 2b8f6ab..0000000 --- a/lib/page/view/preload_page_view.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; - -final PageController _defaultPageController = PageController(); -const PageScrollPhysics _kPagePhysics = PageScrollPhysics(); - -/// A [PageView] with [preloadPagesCount]. -class PreLoadPageView extends StatefulWidget { - PreLoadPageView({ - Key key, - this.scrollDirection = Axis.horizontal, - this.reverse = false, - PageController controller, - this.physics, - this.pageSnapping = true, - this.onPageChanged, - List children = const [], - this.dragStartBehavior = DragStartBehavior.start, - this.restorationId, - this.clipBehavior = Clip.hardEdge, - this.preloadPagesCount = 1, - }) : assert(clipBehavior != null), - controller = controller ?? _defaultPageController, - childrenDelegate = SliverChildListDelegate(children), - super(key: key); - - PreLoadPageView.builder({ - Key key, - this.scrollDirection = Axis.horizontal, - this.reverse = false, - PageController controller, - this.physics, - this.pageSnapping = true, - this.onPageChanged, - @required IndexedWidgetBuilder itemBuilder, - int itemCount, - this.dragStartBehavior = DragStartBehavior.start, - this.restorationId, - this.clipBehavior = Clip.hardEdge, - this.preloadPagesCount = 1, - }) : assert(clipBehavior != null), - controller = controller ?? _defaultPageController, - childrenDelegate = SliverChildBuilderDelegate(itemBuilder, childCount: itemCount), - super(key: key); - - PreLoadPageView.custom({ - Key key, - this.scrollDirection = Axis.horizontal, - this.reverse = false, - PageController controller, - this.physics, - this.pageSnapping = true, - this.onPageChanged, - @required this.childrenDelegate, - this.dragStartBehavior = DragStartBehavior.start, - this.restorationId, - this.clipBehavior = Clip.hardEdge, - this.preloadPagesCount = 1, - }) : assert(childrenDelegate != null), - assert(clipBehavior != null), - controller = controller ?? _defaultPageController, - super(key: key); - - final String restorationId; - final Axis scrollDirection; - final bool reverse; - final PageController controller; - final ScrollPhysics physics; - final bool pageSnapping; - final ValueChanged onPageChanged; - final SliverChildDelegate childrenDelegate; - final DragStartBehavior dragStartBehavior; - final Clip clipBehavior; - final int preloadPagesCount; - - @override - _PreLoadPageViewState createState() => _PreLoadPageViewState(); -} - -class _PreLoadPageViewState extends State { - int _lastReportedPage = 0; - - @override - void initState() { - super.initState(); - _lastReportedPage = widget.controller.initialPage; - } - - AxisDirection _getDirection(BuildContext context) { - switch (widget.scrollDirection) { - case Axis.horizontal: - assert(debugCheckHasDirectionality(context)); - final TextDirection textDirection = Directionality.of(context); - final AxisDirection axisDirection = textDirectionToAxisDirection(textDirection); - return widget.reverse ? flipAxisDirection(axisDirection) : axisDirection; - case Axis.vertical: - return widget.reverse ? AxisDirection.up : AxisDirection.down; - default: - throw ArgumentError(); - } - } - - @override - Widget build(BuildContext context) { - final AxisDirection axisDirection = _getDirection(context); - final ScrollPhysics physics = widget.pageSnapping ? _kPagePhysics.applyTo(widget.physics) : widget.physics; - - return NotificationListener( - onNotification: (ScrollNotification notification) { - if (notification.depth == 0 && widget.onPageChanged != null && notification is ScrollUpdateNotification) { - final PageMetrics metrics = notification.metrics as PageMetrics; - final int currentPage = metrics.page.round(); - if (currentPage != _lastReportedPage) { - _lastReportedPage = currentPage; - widget.onPageChanged(currentPage); - } - } - return false; - }, - child: Scrollable( - dragStartBehavior: widget.dragStartBehavior, - axisDirection: axisDirection, - controller: widget.controller, - physics: physics, - restorationId: widget.restorationId, - viewportBuilder: (BuildContext context, ViewportOffset position) { - // see https://github.com/octomato/preload_page_view/blob/90bf545d49/lib/preload_page_view.dart#L592 - return Viewport( - cacheExtent: widget.preloadPagesCount == null || widget.preloadPagesCount < 1 - ? 0 - : widget.scrollDirection == Axis.horizontal - ? MediaQuery.of(context).size.width * widget.preloadPagesCount - 1 - : MediaQuery.of(context).size.height * widget.preloadPagesCount - 1, - // cacheExtentStyle: CacheExtentStyle.viewport, - axisDirection: axisDirection, - offset: position, - clipBehavior: widget.clipBehavior, - slivers: [ - SliverFillViewport( - viewportFraction: widget.controller.viewportFraction, - delegate: widget.childrenDelegate, - ), - ], - ); - }, - ), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder description) { - super.debugFillProperties(description); - description.add(EnumProperty('scrollDirection', widget.scrollDirection)); - description.add(FlagProperty('reverse', value: widget.reverse, ifTrue: 'reversed')); - description.add(DiagnosticsProperty('controller', widget.controller, showName: false)); - description.add(DiagnosticsProperty('physics', widget.physics, showName: false)); - description.add(FlagProperty('pageSnapping', value: widget.pageSnapping, ifFalse: 'snapping disabled')); - } -} diff --git a/lib/page/view/shelf_manga_line.dart b/lib/page/view/shelf_manga_line.dart index a956455..35d68b6 100644 --- a/lib/page/view/shelf_manga_line.dart +++ b/lib/page/view/shelf_manga_line.dart @@ -1,113 +1,39 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/widget.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/model/manga.dart'; import 'package:manhuagui_flutter/page/manga.dart'; -import 'package:manhuagui_flutter/page/view/network_image.dart'; +import 'package:manhuagui_flutter/page/view/general_line.dart'; -/// View for [ShelfManga]. -/// Used in [ShelfSubPage]. -class ShelfMangaLineView extends StatefulWidget { +/// 书架漫画行,在 [ShelfSubPage] 使用 +class ShelfMangaLineView extends StatelessWidget { const ShelfMangaLineView({ - Key key, - @required this.manga, - }) : assert(manga != null), - super(key: key); + Key? key, + required this.manga, + }) : super(key: key); final ShelfManga manga; - @override - _ShelfMangaLineViewState createState() => _ShelfMangaLineViewState(); -} - -class _ShelfMangaLineViewState extends State { @override Widget build(BuildContext context) { - return Stack( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - margin: EdgeInsets.symmetric(horizontal: 14, vertical: 5), - child: NetworkImageView( - url: widget.manga.cover, - height: 100, - width: 75, - fit: BoxFit.cover, - ), - ), - Container( - width: MediaQuery.of(context).size.width - 14 * 3 - 75, // | ▢ ▢ | - margin: EdgeInsets.only(top: 5, bottom: 5, right: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(bottom: 4), - child: Text( - widget.manga.title, - style: Theme.of(context).textTheme.subtitle1, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.subject, size: 20, color: Colors.orange), - text: Text( - '更新至 ' + widget.manga.newestChapter, - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.access_time, size: 20, color: Colors.orange), - text: Text( - '更新于 ${widget.manga.newestDuration}', - style: TextStyle(color: Colors.grey[600]), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - space: 8, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.import_contacts, size: 20, color: Colors.orange), - text: Text( - '最近阅读至 ${widget.manga.lastChapter.isEmpty ? '未知话' : widget.manga.lastChapter} (${widget.manga.lastDuration})', - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - ], - ), - ), - ], - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => MangaPage( - id: widget.manga.mid, - title: widget.manga.title, - url: widget.manga.url, - ), - ), - ), - ), + return GeneralLineView( + imageUrl: manga.cover, + title: manga.title, + icon1: Icons.subject, + text1: '最新章节 ' + manga.newestChapter, + icon2: Icons.access_time, + text2: '更新于 ${manga.newestDuration}', + icon3: Icons.import_contacts, + text3: '最近阅读至 ${manga.lastChapter.isEmpty ? '未知话' : manga.lastChapter} (${manga.lastDuration})', + onPressed: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaPage( + id: manga.mid, + title: manga.title, + url: manga.url, ), ), - ], + ), ); } } diff --git a/lib/page/view/small_author_line.dart b/lib/page/view/small_author_line.dart index 5c3f076..f25198b 100644 --- a/lib/page/view/small_author_line.dart +++ b/lib/page/view/small_author_line.dart @@ -1,110 +1,39 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/widget.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/model/author.dart'; import 'package:manhuagui_flutter/page/author.dart'; -import 'package:manhuagui_flutter/page/view/network_image.dart'; +import 'package:manhuagui_flutter/page/view/general_line.dart'; -/// View for [SmallAuthor] (Line style). -class SmallAuthorLineView extends StatefulWidget { +/// 作者行,[SmallAuthor],在 [AuthorSubPage] 使用 +class SmallAuthorLineView extends StatelessWidget { const SmallAuthorLineView({ - Key key, - @required this.author, - }) : assert(author != null), - super(key: key); + Key? key, + required this.author, + }) : super(key: key); final SmallAuthor author; - @override - _SmallAuthorLineViewState createState() => _SmallAuthorLineViewState(); -} - -class _SmallAuthorLineViewState extends State { @override Widget build(BuildContext context) { - return Stack( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - margin: EdgeInsets.symmetric(horizontal: 14, vertical: 5), - child: NetworkImageView( - url: widget.author.cover, - height: 100, - width: 75, - fit: BoxFit.cover, - ), - ), - Container( - width: MediaQuery.of(context).size.width - 14 * 3 - 75, // | ▢ ▢ | - margin: EdgeInsets.only(top: 5, bottom: 5, right: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(bottom: 4), - child: Text( - widget.author.name, - style: Theme.of(context).textTheme.subtitle1, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.place, size: 20, color: Colors.orange), - text: Text( - widget.author.zone, - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.edit, size: 20, color: Colors.orange), - text: Text( - '共 ${widget.author.mangaCount} 部漫画', - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.access_time, size: 20, color: Colors.orange), - text: Text( - '更新于 ${widget.author.newestDate}', - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - ], - ), - ), - ], - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => AuthorPage( - id: widget.author.aid, - name: widget.author.name, - url: widget.author.url, - ), - ), - ), - ), + return GeneralLineView( + imageUrl: author.cover, + title: author.name, + icon1: Icons.place, + text1: author.zone, + icon2: Icons.edit, + text2: '共 ${author.mangaCount} 部漫画', + icon3: Icons.access_time, + text3: '更新于 ${author.newestDate}', + onPressed: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => AuthorPage( + id: author.aid, + name: author.name, + url: author.url, ), ), - ], + ), ); } } diff --git a/lib/page/view/tiny_block_manga.dart b/lib/page/view/tiny_block_manga.dart deleted file mode 100644 index 813966d..0000000 --- a/lib/page/view/tiny_block_manga.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:manhuagui_flutter/model/manga.dart'; -import 'package:manhuagui_flutter/page/manga.dart'; -import 'package:manhuagui_flutter/page/view/network_image.dart'; - -/// View for [TinyBlockManga] (Block style). -/// Used in [MangaColumnView]. -class TinyBlockMangaView extends StatefulWidget { - const TinyBlockMangaView({ - Key key, - @required this.manga, - @required this.width, - @required this.height, - @required this.margin, - this.onMorePressed, - }) : assert(width != null), - assert(height != null), - assert(margin != null), - assert(manga != null || onMorePressed != null), - super(key: key); - - final TinyBlockManga manga; - final double width; - final double height; - final EdgeInsets margin; - final void Function() onMorePressed; - - @override - _TinyBlockMangaViewState createState() => _TinyBlockMangaViewState(); -} - -class _TinyBlockMangaViewState extends State { - @override - Widget build(BuildContext context) { - if (widget.manga == null) { - return Container( - width: widget.width, - height: widget.height, - margin: widget.margin, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - stops: [0, 0.5, 1], - colors: [ - Colors.blue[100], - Colors.orange[200], - Colors.purple[100], - ], - ), - ), - child: Material( - color: Colors.transparent, - child: InkWell( - child: Center( - child: Text('查看更多...'), - ), - onTap: widget.onMorePressed, - ), - ), - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - Container( - width: widget.width, - height: widget.height, - margin: widget.margin, - child: Stack( - children: [ - NetworkImageView( - url: widget.manga.cover, - width: widget.width, - height: widget.height, - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => MangaPage( - id: widget.manga.mid, - title: widget.manga.title, - url: widget.manga.url, - ), - ), - ), - ), - ), - ), - ], - ), - ), - Positioned( - bottom: 0, - child: Container( - margin: widget.margin, - padding: EdgeInsets.symmetric(horizontal: 6, vertical: 3), - width: widget.width, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - stops: [0, 1], - colors: [ - Color.fromRGBO(0, 0, 0, 0), - Color.fromRGBO(0, 0, 0, 1), - ], - ), - ), - child: Text( - (widget.manga.finished ? '共' : '更新至') + widget.manga.newestChapter, - style: TextStyle(color: Colors.white), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - Container( - width: widget.width, - margin: widget.margin, - padding: EdgeInsets.symmetric(vertical: 3), - child: Text( - widget.manga.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } -} diff --git a/lib/page/view/tiny_manga_line.dart b/lib/page/view/tiny_manga_line.dart index 8375ac1..587f2fa 100644 --- a/lib/page/view/tiny_manga_line.dart +++ b/lib/page/view/tiny_manga_line.dart @@ -1,111 +1,39 @@ import 'package:flutter/material.dart'; -import 'package:flutter_ahlib/widget.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; import 'package:manhuagui_flutter/model/manga.dart'; import 'package:manhuagui_flutter/page/manga.dart'; -import 'package:manhuagui_flutter/page/view/network_image.dart'; +import 'package:manhuagui_flutter/page/view/general_line.dart'; -/// View for [TinyManga]. -/// Used in [RecentSubPage], [OverallSubPage] and [GenreSubPage]. -class TinyMangaLineView extends StatefulWidget { +/// 漫画行,[TinyManga],在 [RecentSubPage] / [OverallSubPage] / [GenreSubPage] / [AuthorPage] / [SearchPage] 使用 +class TinyMangaLineView extends StatelessWidget { const TinyMangaLineView({ - Key key, - @required this.manga, - }) : assert(manga != null), - super(key: key); + Key? key, + required this.manga, + }) : super(key: key); final TinyManga manga; - @override - _TinyMangaLineViewState createState() => _TinyMangaLineViewState(); -} - -class _TinyMangaLineViewState extends State { @override Widget build(BuildContext context) { - return Stack( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - margin: EdgeInsets.symmetric(horizontal: 14, vertical: 5), - child: NetworkImageView( - url: widget.manga.cover, - height: 100, - width: 75, - fit: BoxFit.cover, - ), - ), - Container( - width: MediaQuery.of(context).size.width - 14 * 3 - 75, // | ▢ ▢ | - margin: EdgeInsets.only(top: 5, bottom: 5, right: 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only(bottom: 4), - child: Text( - widget.manga.title, - style: Theme.of(context).textTheme.subtitle1, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.edit, size: 20, color: Colors.orange), - text: Text( - widget.manga.finished ? '已完结' : '连载中', - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.subject, size: 20, color: Colors.orange), - text: Text( - (widget.manga.finished ? '共 ' : '更新至 ') + widget.manga.newestChapter, - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - Padding( - padding: EdgeInsets.only(bottom: 2), - child: IconText( - icon: Icon(Icons.access_time, size: 20, color: Colors.orange), - text: Text( - '更新于 ${widget.manga.newestDate}', - style: TextStyle(color: Colors.grey[600]), - ), - space: 8, - ), - ), - ], - ), - ), - ], - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (c) => MangaPage( - id: widget.manga.mid, - title: widget.manga.title, - url: widget.manga.url, - ), - ), - ), - ), + return GeneralLineView( + imageUrl: manga.cover, + title: manga.title, + icon1: Icons.edit, + text1: manga.finished ? '已完结' : '连载中', + icon2: Icons.subject, + text2: '最新章节 ${manga.newestChapter}', + icon3: Icons.access_time, + text3: '更新于 ${manga.newestDate}', + onPressed: () => Navigator.of(context).push( + CustomPageRoute( + context: context, + builder: (c) => MangaPage( + id: manga.mid, + title: manga.title, + url: manga.url, ), ), - ], + ), ); } } diff --git a/lib/page/view/warning_text.dart b/lib/page/view/warning_text.dart new file mode 100644 index 0000000..ea4eb18 --- /dev/null +++ b/lib/page/view/warning_text.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +/// 警告提醒文字,在 [RecommendSubPage] / [DownloadSelectPage] 使用 +class WarningTextView extends StatefulWidget { + const WarningTextView({ + Key? key, + required this.text, + required this.isWarning, + }) : super(key: key); + + final String text; + final bool isWarning; + + @override + State createState() => _WarningTextViewState(); +} + +class _WarningTextViewState extends State { + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric(vertical: 5, horizontal: 12), + decoration: BoxDecoration(color: Colors.yellow), + alignment: Alignment.center, + child: widget.isWarning + ? Text('【注意】${widget.text}') // + : Text('【提醒】${widget.text}'), + ); + } +} diff --git a/lib/service/auth/auth.dart b/lib/service/auth/auth.dart deleted file mode 100644 index c8de68f..0000000 --- a/lib/service/auth/auth.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:manhuagui_flutter/service/prefs/auth.dart'; -import 'package:manhuagui_flutter/service/retrofit/dio_manager.dart'; -import 'package:manhuagui_flutter/service/retrofit/retrofit.dart'; -import 'package:manhuagui_flutter/service/state/auth.dart'; -import 'package:synchronized/synchronized.dart'; - -// mutex lock for checkAuth -var _lock = Lock(); - -Future checkAuth() async { - return _lock.synchronized(() async { - if (AuthState.instance.logined) { - return; - } - var token = await getToken(); - if (token?.isNotEmpty != true || AuthState.instance.token == token) { - return; - } - - // check token - var dio = DioManager.instance.dio; - var client = RestClient(dio); - dynamic err; - var r = await client.checkUserLogin(token: token).catchError((e) => err = e); - if (err != null) { - await removeToken(); - return; - } - - // notify - AuthState.instance.token = token; - AuthState.instance.username = r.data.username; - AuthState.instance.notifyAll(); - }); -} diff --git a/lib/service/database/database.dart b/lib/service/database/database.dart deleted file mode 100644 index baed75b..0000000 --- a/lib/service/database/database.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:manhuagui_flutter/config.dart'; -import 'package:manhuagui_flutter/service/database/history.dart'; -import 'package:path/path.dart'; -import 'package:sqflite/sqflite.dart'; - -class DBProvider { - DBProvider._(); - - static DBProvider _instance; - - static DBProvider get instance { - if (_instance == null) { - _instance = DBProvider._(); - } - return _instance; - } - - Database _database; - - Future getDB() async { - if (_database == null || !_database.isOpen) { - var path = await getDatabasesPath(); - _database = await openDatabase( - join(path, DB_NAME), - version: 1, - onCreate: (db, ver) async { - await db.execute(createTblHistory); - }, - onUpgrade: (db, oldVer, newVer) async {}, - ); - } - return _database; - } - - Future closeDB() async { - await _database?.close(); - _database = null; - } -} diff --git a/lib/service/database/history.dart b/lib/service/database/history.dart deleted file mode 100644 index a81bdeb..0000000 --- a/lib/service/database/history.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:manhuagui_flutter/model/manga.dart'; -import 'package:manhuagui_flutter/service/database/database.dart'; -import 'package:sqflite/utils/utils.dart'; - -final _tblHistory = 'tbl_history'; -final _colUsername = 'username'; -final _colMangaId = 'id'; -final _colMangaTitle = 'manga_title'; -final _colMangaCover = 'manga_cover'; -final _colMangaUrl = 'manga_url'; -final _colChapterId = 'chapter_id'; -final _colChapterTitle = 'chapter_title'; -final _colChapterPage = 'chapter_page'; -final _colLastTime = 'last_time'; - -final createTblHistory = ''' -CREATE TABLE $_tblHistory( - $_colUsername VARCHAR(1023), - $_colMangaId INTEGER, - $_colMangaTitle VARCHAR(1023), - $_colMangaCover VARCHAR(1023), - $_colMangaUrl VARCHAR(1023), - $_colChapterId INTEGER, - $_colChapterTitle VARCHAR(1023), - $_colChapterPage INTEGER, - $_colLastTime DATETIME, - PRIMARY KEY ($_colUsername, $_colMangaId) -)'''; - -Future getHistoryCount({@required String username}) async { - username ??= ''; - - var db = await DBProvider.instance.getDB(); - var count = firstIntValue(await db.rawQuery( - '''SELECT COUNT(*) - FROM $_tblHistory - WHERE $_colUsername = ?''', - [username], - )); - return count; -} - -Future getHistory({@required String username, @required int mid}) async { - username ??= ''; - assert(mid != null); - - var db = await DBProvider.instance.getDB(); - var maps = await db.rawQuery( - '''SELECT $_colMangaTitle, $_colMangaCover, $_colMangaUrl, $_colChapterId, $_colChapterTitle, $_colChapterPage, $_colLastTime - FROM $_tblHistory - WHERE $_colUsername = ? AND $_colMangaId = ? - ORDER BY $_colLastTime DESC - LIMIT 1''', - [username, mid], - ); - if (maps.isEmpty) { - return null; - } - var m = maps.first; - return MangaHistory( - mangaId: mid, - mangaTitle: m[_colMangaTitle], - mangaCover: m[_colMangaCover], - mangaUrl: m[_colMangaUrl], - chapterId: m[_colChapterId], - chapterTitle: m[_colChapterTitle], - chapterPage: m[_colChapterPage], - lastTime: DateTime.parse(m[_colLastTime]), - ); -} - -Future> getHistories({@required String username, @required int page, int limit = 20, int offset = 0}) async { - username ??= ''; - assert(page == null || page >= 0); - page ??= 1; - - offset = limit * (page - 1) - offset; - if (offset < 0) { - offset = 0; - } - var db = await DBProvider.instance.getDB(); - var maps = await db.rawQuery( - '''SELECT $_colMangaId, $_colMangaTitle, $_colMangaCover, $_colMangaUrl, $_colChapterId, $_colChapterTitle, $_colChapterPage, $_colLastTime - FROM $_tblHistory - WHERE $_colUsername = ? - ORDER BY $_colLastTime DESC - LIMIT $limit OFFSET $offset''', - [username], - ); - var out = []; - for (var m in maps) { - out.add(MangaHistory( - mangaId: m[_colMangaId], - mangaTitle: m[_colMangaTitle], - mangaCover: m[_colMangaCover], - mangaUrl: m[_colMangaUrl], - chapterId: m[_colChapterId], - chapterTitle: m[_colChapterTitle], - chapterPage: m[_colChapterPage], - lastTime: DateTime.parse(m[_colLastTime]), - )); - } - return out; -} - -Future addHistory({@required String username, @required MangaHistory history}) async { - username ??= ''; - assert(history != null && history.mangaId != null && history.chapterId != null); - history.lastTime ??= DateTime.now(); - - var db = await DBProvider.instance.getDB(); - var count = firstIntValue(await db.rawQuery( - '''SELECT COUNT(*) - FROM $_tblHistory - WHERE $_colUsername = ? AND $_colMangaId = ?''', - [username, history.mangaId], - )); - - var rows = 0; - if (count == 0) { - // INSERT - rows = await db.rawInsert( - '''INSERT INTO $_tblHistory ($_colUsername, $_colMangaId, $_colMangaTitle, $_colMangaCover, $_colMangaUrl, $_colChapterId, $_colChapterTitle, $_colChapterPage, $_colLastTime) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)''', - [username, history.mangaId, history.mangaTitle, history.mangaCover, history.mangaUrl, history.chapterId, history.chapterTitle, history.chapterPage, history.lastTime.toIso8601String()], - ).catchError((_) {}); - } else { - // UPDATE - rows = await db.rawUpdate( - '''UPDATE $_tblHistory - SET $_colMangaTitle = ?, $_colMangaCover = ?, $_colMangaUrl = ?, $_colChapterId = ?, $_colChapterTitle = ?, $_colChapterPage = ?, $_colLastTime = ? - WHERE $_colUsername = ? AND $_colMangaId = ?''', - [history.mangaTitle, history.mangaCover, history.mangaUrl, history.chapterId, history.chapterTitle, history.chapterPage, history.lastTime.toIso8601String(), username, history.mangaId], - ).catchError((_) {}); - } - return rows >= 1; -} - -Future updateHistory({@required String username, @required MangaHistory history}) async { - username ??= ''; - assert(history != null && history.mangaId != null); - - var db = await DBProvider.instance.getDB(); - var rows = await db.rawUpdate( - '''UPDATE $_tblHistory - SET $_colMangaTitle = ?, $_colMangaCover = ?, $_colMangaUrl = ? - WHERE $_colUsername = ? AND $_colMangaId = ?''', - [history.mangaTitle, history.mangaCover, history.mangaUrl, username, history.mangaId], - ).catchError((_) {}); - return rows >= 1; -} - -Future deleteHistory({@required String username, @required int mid}) async { - username ??= ''; - assert(mid != null); - - var db = await DBProvider.instance.getDB(); - var rows = await db.rawDelete( - '''DELETE FROM $_tblHistory - WHERE $_colUsername = ? AND $_colMangaId = ?''', - [username, mid], - ).catchError((_) {}); - return rows >= 1; -} diff --git a/lib/service/db/db_manager.dart b/lib/service/db/db_manager.dart new file mode 100644 index 0000000..bba5051 --- /dev/null +++ b/lib/service/db/db_manager.dart @@ -0,0 +1,94 @@ +import 'package:manhuagui_flutter/config.dart'; +import 'package:manhuagui_flutter/service/db/download.dart'; +import 'package:manhuagui_flutter/service/db/history.dart'; +import 'package:path/path.dart'; +import 'package:sqflite/sqflite.dart'; + +class DBManager { + DBManager._(); + + static DBManager? _instance; + + static DBManager get instance { + _instance ??= DBManager._(); + return _instance!; + } + + Database? _database; // global Database instance + + Future getDB() async { + if (_database == null || !_database!.isOpen) { + var path = await getDatabasesPath(); + _database = await openDatabase( + join(path, DB_NAME), + version: 2, + onCreate: (db, _) async { + await HistoryDao.createTable(db); + await DownloadDao.createTable(db); + }, + onUpgrade: (db, version, _) async { + if (version <= 1) { + version = 2; // 1 -> 2 upgrade + await HistoryDao.upgradeFromVer1To2(db); + await DownloadDao.upgradeFromVer1To2(db); + } + if (version == 2) { + // ... + } + }, + ); + } + return _database!; + } + + Future closeDB() async { + await _database?.close(); + _database = null; + } +} + +extension DatabaseExtension on Database { + Future>?> safeRawQuery(String sql, [List? arguments]) async { + try { + return await rawQuery(sql, arguments); + } catch (e, s) { + print('===> exception when rawQuery:\n$e\n$s'); + return null; + } + } + + Future safeRawInsert(String sql, [List? arguments]) async { + try { + return await rawInsert(sql, arguments); + } catch (e, s) { + print('===> exception when rawInsert:\n$e\n$s'); + return null; + } + } + + Future safeRawUpdate(String sql, [List? arguments]) async { + try { + return await rawUpdate(sql, arguments); + } catch (e, s) { + print('===> exception when rawUpdate:\n$e\n$s'); + return null; + } + } + + Future safeRawDelete(String sql, [List? arguments]) async { + try { + return await rawDelete(sql, arguments); + } catch (e, s) { + print('===> exception when rawDelete:\n$e\n$s'); + return null; + } + } + + Future safeExecute(String sql, [List? arguments]) async { + try { + return await execute(sql, arguments); + } catch (e, s) { + print('===> exception when execute:\n$e\n$s'); + } + } +} diff --git a/lib/service/db/download.dart b/lib/service/db/download.dart new file mode 100644 index 0000000..abef02c --- /dev/null +++ b/lib/service/db/download.dart @@ -0,0 +1,277 @@ +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/service/db/db_manager.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:sqflite/utils/utils.dart'; + +class DownloadDao { + static const _tblDownloadManga = 'tbl_download_manga'; + static const _colDmMangaId = 'mid'; + static const _colDmMangaTitle = 'title'; + static const _colDmMangaCover = 'cover'; + static const _colDmMangaUrl = 'url'; + static const _colDmError = 'error'; + static const _colDmUpdatedAt = 'updated_at'; + + static const _createTblDownloadManga = ''' + CREATE TABLE $_tblDownloadManga( + $_colDmMangaId INTEGER, + $_colDmMangaTitle VARCHAR(1023), + $_colDmMangaCover VARCHAR(1023), + $_colDmMangaUrl VARCHAR(1023), + $_colDmError TINYINT, + $_colDmUpdatedAt DATETIME, + PRIMARY KEY ($_colDmMangaId) + )'''; + + static const _tblDownloadChapter = 'tbl_download_chapter'; + static const _colDcMangaId = 'mid'; + static const _colDcChapterId = 'cid'; + static const _colDcChapterTitle = 'title'; + static const _tblDcChapterGroup = 'group_name'; + static const _colDcTotalCount = 'total_count'; + static const _colDcTriedCount = 'tried_count'; + static const _colDcSuccessCount = 'success_count'; + + static const _createTblDownloadChapter = ''' + CREATE TABLE $_tblDownloadChapter( + $_colDcMangaId INTEGER, + $_colDcChapterId INTEGER, + $_colDcChapterTitle VARCHAR(1023), + $_tblDcChapterGroup VARCHAR(1023), + $_colDcTotalCount INTEGER, + $_colDcTriedCount INTEGER, + $_colDcSuccessCount INTEGER, + PRIMARY KEY ($_colDcMangaId, $_colDcChapterId) + )'''; + + static Future createTable(Database db) async { + await db.safeExecute(_createTblDownloadManga); + await db.safeExecute(_createTblDownloadChapter); + } + + static Future getMangaCount() async { + final db = await DBManager.instance.getDB(); + var results = await db.safeRawQuery( + '''SELECT COUNT(*) + FROM $_tblDownloadManga''', + ); + if (results == null) { + return null; + } + return firstIntValue(results); + } + + static Future getChapterCount({required int mid}) async { + final db = await DBManager.instance.getDB(); + var results = await db.safeRawQuery( + '''SELECT COUNT(*) + FROM $_tblDownloadChapter + WHERE $_colDcMangaId = ?''', + [mid], + ); + if (results == null) { + return null; + } + return firstIntValue(results); + } + + static Future?> getMangas() async { + final db = await DBManager.instance.getDB(); + var mangaResults = await db.safeRawQuery( + '''SELECT $_colDmMangaId, $_colDmMangaTitle, $_colDmMangaCover, $_colDmMangaUrl, $_colDmError, $_colDmUpdatedAt + FROM $_tblDownloadManga + ORDER BY $_colDmUpdatedAt DESC''', + ); + if (mangaResults == null) { + return null; + } + var chapterResults = await db.safeRawQuery( + '''SELECT $_colDcMangaId, $_colDcChapterId, $_colDcChapterTitle, $_tblDcChapterGroup, $_colDcTotalCount, $_colDcTriedCount, $_colDcSuccessCount + FROM $_tblDownloadChapter''', + ); + if (chapterResults == null) { + return null; + } + + var chaptersMap = >{}; + for (var r in chapterResults) { + var mangaId = r[_colDcMangaId]! as int; + chaptersMap[mangaId] ??= []; + chaptersMap[mangaId]!.add( + DownloadedChapter( + mangaId: mangaId, + chapterId: r[_colDcChapterId]! as int, + chapterTitle: r[_colDcChapterTitle]! as String, + chapterGroup: r[_tblDcChapterGroup]! as String, + totalPageCount: r[_colDcTotalCount]! as int, + triedPageCount: r[_colDcTriedCount]! as int, + successPageCount: r[_colDcSuccessCount]! as int, + ), + ); + } + + var out = []; + for (var r in mangaResults) { + var mid = r[_colDmMangaId]! as int; + out.add( + DownloadedManga( + mangaId: mid, + mangaTitle: r[_colDmMangaTitle]! as String, + mangaCover: r[_colDmMangaCover]! as String, + mangaUrl: r[_colDmMangaUrl]! as String, + error: r[_colDmError]! as int > 0, + updatedAt: DateTime.parse(r[_colDmUpdatedAt]! as String), + downloadedChapters: chaptersMap[mid] ?? [], + ), + ); + } + return out; + } + + static Future getManga({required int mid}) async { + final db = await DBManager.instance.getDB(); + var mangaResults = await db.safeRawQuery( + '''SELECT $_colDmMangaId, $_colDmMangaTitle, $_colDmMangaCover, $_colDmMangaUrl, $_colDmError, $_colDmUpdatedAt + FROM $_tblDownloadManga + WHERE $_colDmMangaId = ?''', + [mid], + ); + if (mangaResults == null || mangaResults.isEmpty) { + return null; + } + var chapterResults = await db.safeRawQuery( + '''SELECT $_colDcMangaId, $_colDcChapterId, $_colDcChapterTitle, $_tblDcChapterGroup, $_colDcTotalCount, $_colDcTriedCount, $_colDcSuccessCount + FROM $_tblDownloadChapter + WHERE $_colDcMangaId = ?''', + [mid], + ); + if (chapterResults == null) { + return null; + } + + var chapters = []; + for (var r in chapterResults) { + chapters.add( + DownloadedChapter( + mangaId: r[_colDcMangaId]! as int, + chapterId: r[_colDcChapterId]! as int, + chapterTitle: r[_colDcChapterTitle]! as String, + chapterGroup: r[_tblDcChapterGroup]! as String, + totalPageCount: r[_colDcTotalCount]! as int, + triedPageCount: r[_colDcTriedCount]! as int, + successPageCount: r[_colDcSuccessCount]! as int, + ), + ); + } + + var r = mangaResults.first; + return DownloadedManga( + mangaId: mid, + mangaTitle: r[_colDmMangaTitle]! as String, + mangaCover: r[_colDmMangaCover]! as String, + mangaUrl: r[_colDmMangaUrl]! as String, + error: r[_colDmError]! as int > 0, + updatedAt: DateTime.parse(r[_colDmUpdatedAt]! as String), + downloadedChapters: chapters, + ); + } + + static Future addOrUpdateManga({required DownloadedManga manga}) async { + final db = await DBManager.instance.getDB(); + var results = await db.safeRawQuery( + '''SELECT COUNT(*) + FROM $_tblDownloadManga + WHERE $_colDmMangaId = ? + ''', + [manga.mangaId], + ); + if (results == null) { + return false; + } + var count = firstIntValue(results); + + int? rows = 0; + if (count == 0) { + rows = await db.safeRawInsert( + '''INSERT INTO $_tblDownloadManga + ($_colDmMangaId, $_colDmMangaTitle, $_colDmMangaCover, $_colDmMangaUrl, $_colDmError, $_colDmUpdatedAt) + VALUES (?, ?, ?, ?, ?, ?)''', + [manga.mangaId, manga.mangaTitle, manga.mangaCover, manga.mangaUrl, manga.error ? 1 : 0, manga.updatedAt.toIso8601String()], + ); + } else { + rows = await db.safeRawUpdate( + '''UPDATE $_tblDownloadManga + SET $_colDmMangaTitle = ?, $_colDmMangaCover = ?, $_colDmMangaUrl = ?, $_colDmError = ?, $_colDmUpdatedAt = ? + WHERE $_colDmMangaId = ?''', + [manga.mangaTitle, manga.mangaCover, manga.mangaUrl, manga.error ? 1 : 0, manga.updatedAt.toIso8601String(), manga.mangaId], + ); + } + return rows != null && rows >= 1; + } + + static Future addOrUpdateChapter({required DownloadedChapter chapter}) async { + final db = await DBManager.instance.getDB(); + var results = await db.safeRawQuery( + '''SELECT COUNT(*) + FROM $_tblDownloadChapter + WHERE $_colDcMangaId = ? AND $_colDcChapterId = ?''', + [chapter.mangaId, chapter.chapterId], + ); + if (results == null) { + return false; + } + var count = firstIntValue(results); + + int? rows = 0; + if (count == 0) { + rows = await db.safeRawInsert( + '''INSERT INTO $_tblDownloadChapter + ($_colDcMangaId, $_colDcChapterId, $_colDcChapterTitle, $_tblDcChapterGroup, $_colDcTotalCount, $_colDcTriedCount, $_colDcSuccessCount) + VALUES (?, ?, ?, ?, ?, ?, ?)''', + [chapter.mangaId, chapter.chapterId, chapter.chapterTitle, chapter.chapterGroup, chapter.totalPageCount, chapter.triedPageCount, chapter.successPageCount], + ); + } else { + rows = await db.safeRawUpdate( + '''UPDATE $_tblDownloadChapter + SET $_colDcChapterTitle = ?, $_tblDcChapterGroup = ?, $_colDcTotalCount = ?, $_colDcTriedCount = ?, $_colDcSuccessCount = ? + WHERE $_colDcMangaId = ? AND $_colDcChapterId = ?''', + [chapter.chapterTitle, chapter.chapterGroup, chapter.totalPageCount, chapter.triedPageCount, chapter.successPageCount, chapter.mangaId, chapter.chapterId], + ); + } + return rows != null && rows >= 1; + } + + static Future deleteManga({required int mid}) async { + final db = await DBManager.instance.getDB(); + var rows = await db.safeRawDelete( + '''DELETE FROM $_tblDownloadManga + WHERE $_colDmMangaId = ?''', + [mid], + ); + return rows != null && rows >= 1; + } + + static Future deleteAllChapters({required int mid}) async { + final db = await DBManager.instance.getDB(); + var rows = await db.safeRawDelete( + '''DELETE FROM $_tblDownloadChapter + WHERE $_colDcMangaId = ?''', + [mid], + ); + return rows != null && rows >= 1; + } + + static Future deleteChapter({required int mid, required int cid}) async { + final db = await DBManager.instance.getDB(); + var rows = await db.safeRawDelete( + '''DELETE FROM $_tblDownloadChapter + WHERE $_colDcMangaId = ? AND $_colDcChapterId = ?''', + [mid, cid], + ); + return rows != null && rows >= 1; + } + + static Future upgradeFromVer1To2(Database db) async { + await createTable(db); + } +} diff --git a/lib/service/db/history.dart b/lib/service/db/history.dart new file mode 100644 index 0000000..8cf5903 --- /dev/null +++ b/lib/service/db/history.dart @@ -0,0 +1,153 @@ +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/service/db/db_manager.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:sqflite/utils/utils.dart'; + +class HistoryDao { + static const _tblHistory = 'tbl_history'; + static const _colUsername = 'username'; + static const _colMangaId = 'id'; + static const _colMangaTitle = 'manga_title'; + static const _colMangaCover = 'manga_cover'; + static const _colMangaUrl = 'manga_url'; + static const _colChapterId = 'chapter_id'; + static const _colChapterTitle = 'chapter_title'; + static const _colChapterPage = 'chapter_page'; + static const _colLastTime = 'last_time'; + + static const _createTblHistory = ''' + CREATE TABLE $_tblHistory( + $_colUsername VARCHAR(1023), + $_colMangaId INTEGER, + $_colMangaTitle VARCHAR(1023), + $_colMangaCover VARCHAR(1023), + $_colMangaUrl VARCHAR(1023), + $_colChapterId INTEGER, + $_colChapterTitle VARCHAR(1023), + $_colChapterPage INTEGER, + $_colLastTime DATETIME, + PRIMARY KEY ($_colUsername, $_colMangaId) + )'''; + + static Future createTable(Database db) async { + await db.safeExecute(_createTblHistory); + } + + static Future getHistoryCount({required String username}) async { + final db = await DBManager.instance.getDB(); + var results = await db.safeRawQuery( + '''SELECT COUNT(*) + FROM $_tblHistory + WHERE $_colUsername = ?''', + [username], + ); + if (results == null) { + return null; + } + return firstIntValue(results); + } + + static Future getHistory({required String username, required int mid}) async { + final db = await DBManager.instance.getDB(); + var results = await db.safeRawQuery( + '''SELECT $_colMangaTitle, $_colMangaCover, $_colMangaUrl, $_colChapterId, $_colChapterTitle, $_colChapterPage, $_colLastTime + FROM $_tblHistory + WHERE $_colUsername = ? AND $_colMangaId = ? + ORDER BY $_colLastTime DESC + LIMIT 1''', + [username, mid], + ); + if (results == null || results.isEmpty) { + return null; + } + var r = results.first; + return MangaHistory( + mangaId: mid, + mangaTitle: r[_colMangaTitle]! as String, + mangaCover: r[_colMangaCover]! as String, + mangaUrl: r[_colMangaUrl]! as String, + chapterId: r[_colChapterId]! as int, + chapterTitle: r[_colChapterTitle]! as String, + chapterPage: r[_colChapterPage]! as int, + lastTime: DateTime.parse(r[_colLastTime]! as String), + ); + } + + static Future?> getHistories({required String username, required int page, int limit = 20, int offset = 0}) async { + final db = await DBManager.instance.getDB(); + offset = limit * (page - 1) - offset; + if (offset < 0) { + offset = 0; + } + var results = await db.safeRawQuery( + '''SELECT $_colMangaId, $_colMangaTitle, $_colMangaCover, $_colMangaUrl, $_colChapterId, $_colChapterTitle, $_colChapterPage, $_colLastTime + FROM $_tblHistory + WHERE $_colUsername = ? + ORDER BY $_colLastTime DESC + LIMIT $limit OFFSET $offset''', + [username], + ); + if (results == null) { + return null; + } + var out = []; + for (var r in results) { + out.add(MangaHistory( + mangaId: r[_colMangaId]! as int, + mangaTitle: r[_colMangaTitle]! as String, + mangaCover: r[_colMangaCover]! as String, + mangaUrl: r[_colMangaUrl]! as String, + chapterId: r[_colChapterId]! as int, + chapterTitle: r[_colChapterTitle]! as String, + chapterPage: r[_colChapterPage]! as int, + lastTime: DateTime.parse(r[_colLastTime]! as String), + )); + } + return out; + } + + static Future addOrUpdateHistory({required String username, required MangaHistory history}) async { + final db = await DBManager.instance.getDB(); + var results = await db.safeRawQuery( + '''SELECT COUNT(*) + FROM $_tblHistory + WHERE $_colUsername = ? AND $_colMangaId = ?''', + [username, history.mangaId], + ); + if (results == null) { + return false; + } + var count = firstIntValue(results); + + int? rows = 0; + if (count == 0) { + rows = await db.safeRawInsert( + '''INSERT INTO $_tblHistory ($_colUsername, $_colMangaId, $_colMangaTitle, $_colMangaCover, $_colMangaUrl, $_colChapterId, $_colChapterTitle, $_colChapterPage, $_colLastTime) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)''', + [username, history.mangaId, history.mangaTitle, history.mangaCover, history.mangaUrl, history.chapterId, history.chapterTitle, history.chapterPage, history.lastTime.toIso8601String()], + ); + } else { + rows = await db.safeRawUpdate( + '''UPDATE $_tblHistory + SET $_colMangaTitle = ?, $_colMangaCover = ?, $_colMangaUrl = ?, $_colChapterId = ?, $_colChapterTitle = ?, $_colChapterPage = ?, $_colLastTime = ? + WHERE $_colUsername = ? AND $_colMangaId = ?''', + [history.mangaTitle, history.mangaCover, history.mangaUrl, history.chapterId, history.chapterTitle, history.chapterPage, history.lastTime.toIso8601String(), username, history.mangaId], + ); + } + return rows != null && rows >= 1; + } + + static Future deleteHistory({required String username, required int mid}) async { + final db = await DBManager.instance.getDB(); + var rows = await db.safeRawDelete( + '''DELETE FROM $_tblHistory + WHERE $_colUsername = ? AND $_colMangaId = ?''', + [username, mid], + ); + return rows != null && rows >= 1; + } + + static Future upgradeFromVer1To2(Database db) async { + // skip + } +} diff --git a/lib/service/dio/dio_manager.dart b/lib/service/dio/dio_manager.dart new file mode 100644 index 0000000..aaf408a --- /dev/null +++ b/lib/service/dio/dio_manager.dart @@ -0,0 +1,83 @@ +import 'package:dio/dio.dart'; +import 'package:manhuagui_flutter/config.dart'; + +class DioManager { + DioManager._(); + + static DioManager? _instance; + + static DioManager get instance { + _instance ??= DioManager._(); + return _instance!; + } + + Dio? _dio; // global Dio instance + + Dio get dio { + if (_dio == null) { + _dio = Dio(); + _dio!.options.connectTimeout = CONNECT_TIMEOUT; + _dio!.options.sendTimeout = SEND_TIMEOUT; + _dio!.options.receiveTimeout = RECEIVE_TIMEOUT; + _dio!.interceptors.add(LogInterceptor()); + } + return _dio!; + } + + Dio get silentDio { + return Dio() + ..options.connectTimeout = dio.options.connectTimeout + ..options.sendTimeout = dio.options.sendTimeout + ..options.receiveTimeout = dio.options.receiveTimeout; + } +} + +class LogInterceptor extends Interceptor { + @override + Future onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + print('┌─────────────────── Request ─────────────────────┐'); + print('date: ${DateTime.now().toIso8601String()}'); + print('uri: ${options.uri}'); + print('method: ${options.method}'); + if (options.extra.isNotEmpty) { + print('extra: ${options.extra}'); + } + print('headers:'); + options.headers.forEach((key, v) => print(' $key: $v')); + print('└─────────────────── Request ─────────────────────┘'); + return super.onRequest(options, handler); + } + + @override + Future onError(DioError err, ErrorInterceptorHandler handler) async { + print('┌─────────────────── DioError ────────────────────┐'); + print('date: ${DateTime.now().toIso8601String()}'); + print('uri: ${err.requestOptions.uri}'); + print('method: ${err.requestOptions.method}'); + print('error: $err'); + if (err.response != null) { + _printResponse(err.response!); + } + print('└─────────────────── DioError ────────────────────┘'); + return super.onError(err, handler); + } + + @override + Future onResponse(Response response, ResponseInterceptorHandler handler) async { + print('┌─────────────────── Response ────────────────────┐'); + print('date: ${DateTime.now().toIso8601String()}'); + _printResponse(response); + print('└─────────────────── Response ────────────────────┘'); + return super.onResponse(response, handler); + } + + void _printResponse(Response response) { + print('uri: ${response.requestOptions.uri}'); + print('method: ${response.requestOptions.method}'); + print('statusCode: ${response.statusCode}'); + if (!response.headers.isEmpty) { + print('headers:'); + response.headers.forEach((key, v) => print(' $key: ${v.join(',')}')); + } + } +} diff --git a/lib/service/retrofit/retrofit.dart b/lib/service/dio/retrofit.dart similarity index 51% rename from lib/service/retrofit/retrofit.dart rename to lib/service/dio/retrofit.dart index c68c99d..c84616b 100644 --- a/lib/service/retrofit/retrofit.dart +++ b/lib/service/dio/retrofit.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:manhuagui_flutter/config.dart'; import 'package:manhuagui_flutter/model/author.dart'; import 'package:manhuagui_flutter/model/category.dart'; import 'package:manhuagui_flutter/model/chapter.dart'; @@ -11,18 +12,21 @@ import 'package:retrofit/http.dart'; part 'retrofit.g.dart'; -@RestApi() +@RestApi(baseUrl: BASE_API_URL) abstract class RestClient { factory RestClient(Dio dio, {String baseUrl}) = _RestClient; @GET('/manga') - Future>> getAllMangas({@Query('page') int page, @Query('order') MangaOrder order}); + Future>> getAllMangas({@Query('page') required int page, @Query('order') required MangaOrder order}); @GET('/manga/{mid}') - Future> getManga({@Path() int mid}); + Future> getManga({@Path() required int mid}); @GET('/manga/{mid}/{cid}') - Future> getMangaChapter({@Path() int mid, @Path() int cid}); + Future> getMangaChapter({@Path() required int mid, @Path() required int cid}); + + @GET('/manga/random') + Future> getRandomManga(); @GET('/list/serial') Future> getHotSerialMangas(); @@ -37,62 +41,62 @@ abstract class RestClient { Future> getHomepageMangas(); @GET('/list/updated') - Future>> getRecentUpdatedMangas({@Query('page') int page, @Query('limit') int limit = 42}); + Future>> getRecentUpdatedMangas({@Query('page') required int page, @Query('limit') int limit = 42}); @GET('/category/genre') Future>> getGenres(); @GET('/category/genre/{genre}') - Future>> getGenreMangas({@Path() String genre, @Query('zone') String zone, @Query('age') String age, @Query('status') String status, @Query('page') int page, @Query('order') MangaOrder order}); + Future>> getGenreMangas({@Path() required String genre, @Query('zone') required String zone, @Query('age') required String age, @Query('status') required String status, @Query('page') required int page, @Query('order') required MangaOrder order}); @GET('/search/{keyword}') - Future>> searchMangas({@Path() String keyword, @Query('page') int page, @Query('order') MangaOrder order}); + Future>> searchMangas({@Path() required String keyword, @Query('page') required int page, @Query('order') required MangaOrder order}); @GET('/author') - Future>> getAllAuthors({@Query('genre') String genre, @Query('zone') String zone, @Query('age') String age, @Query('page') int page, @Query('order') AuthorOrder order}); + Future>> getAllAuthors({@Query('genre') required String genre, @Query('zone') required String zone, @Query('age') required String age, @Query('page') required int page, @Query('order') required AuthorOrder order}); @GET('/author/{aid}') - Future> getAuthor({@Path() int aid}); + Future> getAuthor({@Path() required int aid}); @GET('/author/{aid}/manga') - Future>> getAuthorMangas({@Path() int aid, @Query('page') int page, @Query('order') MangaOrder order}); + Future>> getAuthorMangas({@Path() required int aid, @Query('page') required int page, @Query('order') required MangaOrder order}); @GET('/rank/day') - Future>> getDayRanking({@Query('type') String type}); + Future>> getDayRanking({@Query('type') required String type}); @GET('/rank/week') - Future>> getWeekRanking({@Query('type') String type}); + Future>> getWeekRanking({@Query('type') required String type}); @GET('/rank/month') - Future>> getMonthRanking({@Query('type') String type}); + Future>> getMonthRanking({@Query('type') required String type}); @GET('/rank/total') - Future>> getTotalRanking({@Query('type') String type}); + Future>> getTotalRanking({@Query('type') required String type}); @GET('/comment/manga/{mid}') - Future>> getMangaComments({@Path() int mid, @Query('page') int page}); + Future>> getMangaComments({@Path() required int mid, @Query('page') required int page}); @POST('/user/check_login') - Future> checkUserLogin({@Header('Authorization') String token}); + Future> checkUserLogin({@Header('Authorization') required String token}); @GET('/user/info') - Future> getUserInfo({@Header('Authorization') String token}); + Future> getUserInfo({@Header('Authorization') required String token}); @POST('/user/login') - Future> login({@Query('username') String username, @Query('password') String password}); + Future> login({@Query('username') required String username, @Query('password') required String password}); - @GET('/user/manga/{mid}/{cid}') - Future recordManga({@Header('Authorization') String token, @Path() int mid, @Path() int cid}); + @POST('/user/manga/{mid}/{cid}') + Future recordManga({@Header('Authorization') required String token, @Path() required int mid, @Path() required int cid}); @GET('/shelf') - Future>> getShelfMangas({@Header('Authorization') String token, @Query('page') int page}); + Future>> getShelfMangas({@Header('Authorization') required String token, @Query('page') required int page}); @GET('/shelf/{mid}') - Future> checkShelfMangas({@Header('Authorization') String token, @Path() int mid}); + Future> checkShelfManga({@Header('Authorization') required String token, @Path() required int mid}); @POST('/shelf/{mid}') - Future addToShelf({@Header('Authorization') String token, @Path() int mid}); + Future addToShelf({@Header('Authorization') required String token, @Path() required int mid}); @DELETE('/shelf/{mid}') - Future removeFromShelf({@Header('Authorization') String token, @Path() int mid}); + Future removeFromShelf({@Header('Authorization') required String token, @Path() required int mid}); } diff --git a/lib/service/dio/retrofit.g.dart b/lib/service/dio/retrofit.g.dart new file mode 100644 index 0000000..f33da62 --- /dev/null +++ b/lib/service/dio/retrofit.g.dart @@ -0,0 +1,655 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'retrofit.dart'; + +// ************************************************************************** +// RetrofitGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers + +class _RestClient implements RestClient { + _RestClient(this._dio, {this.baseUrl}) { + baseUrl ??= 'https://api-manhuagui.aoihosizora.top/v1/'; + } + + final Dio _dio; + + String? baseUrl; + + @override + Future>> getAllMangas( + {required page, required order}) async { + const _extra = {}; + final queryParameters = { + r'page': page, + r'order': order.toJson() + }; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/manga', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => TinyManga.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future> getManga({required mid}) async { + const _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/manga/${mid}', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => Manga.fromJson(json as Map), + ); + return value; + } + + @override + Future> getMangaChapter( + {required mid, required cid}) async { + const _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/manga/${mid}/${cid}', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => MangaChapter.fromJson(json as Map), + ); + return value; + } + + @override + Future> getRandomManga() async { + const _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/manga/random', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => RandomMangaInfo.fromJson(json as Map), + ); + return value; + } + + @override + Future> getHotSerialMangas() async { + const _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/list/serial', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => MangaGroupList.fromJson(json as Map), + ); + return value; + } + + @override + Future> getFinishedMangas() async { + const _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/list/finish', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => MangaGroupList.fromJson(json as Map), + ); + return value; + } + + @override + Future> getLatestMangas() async { + const _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/list/latest', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => MangaGroupList.fromJson(json as Map), + ); + return value; + } + + @override + Future> getHomepageMangas() async { + const _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/list/homepage', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => HomepageMangaGroupList.fromJson(json as Map), + ); + return value; + } + + @override + Future>> getRecentUpdatedMangas( + {required page, limit = 42}) async { + const _extra = {}; + final queryParameters = {r'page': page, r'limit': limit}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/list/updated', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => TinyManga.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future>> getGenres() async { + const _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/category/genre', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => Category.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future>> getGenreMangas( + {required genre, + required zone, + required age, + required status, + required page, + required order}) async { + const _extra = {}; + final queryParameters = { + r'zone': zone, + r'age': age, + r'status': status, + r'page': page, + r'order': order.toJson() + }; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/category/genre/${genre}', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => TinyManga.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future>> searchMangas( + {required keyword, required page, required order}) async { + const _extra = {}; + final queryParameters = { + r'page': page, + r'order': order.toJson() + }; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/search/${keyword}', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => SmallManga.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future>> getAllAuthors( + {required genre, + required zone, + required age, + required page, + required order}) async { + const _extra = {}; + final queryParameters = { + r'genre': genre, + r'zone': zone, + r'age': age, + r'page': page, + r'order': order.toJson() + }; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/author', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => SmallAuthor.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future> getAuthor({required aid}) async { + const _extra = {}; + final queryParameters = {}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/author/${aid}', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => Author.fromJson(json as Map), + ); + return value; + } + + @override + Future>> getAuthorMangas( + {required aid, required page, required order}) async { + const _extra = {}; + final queryParameters = { + r'page': page, + r'order': order.toJson() + }; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/author/${aid}/manga', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => SmallManga.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future>> getDayRanking({required type}) async { + const _extra = {}; + final queryParameters = {r'type': type}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/rank/day', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => MangaRank.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future>> getWeekRanking({required type}) async { + const _extra = {}; + final queryParameters = {r'type': type}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/rank/week', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => MangaRank.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future>> getMonthRanking({required type}) async { + const _extra = {}; + final queryParameters = {r'type': type}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/rank/month', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => MangaRank.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future>> getTotalRanking({required type}) async { + const _extra = {}; + final queryParameters = {r'type': type}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/rank/total', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => MangaRank.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future>> getMangaComments( + {required mid, required page}) async { + const _extra = {}; + final queryParameters = {r'page': page}; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/comment/manga/${mid}', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => Comment.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future> checkUserLogin({required token}) async { + const _extra = {}; + final queryParameters = {}; + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose(_dio.options, '/user/check_login', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => LoginCheckResult.fromJson(json as Map), + ); + return value; + } + + @override + Future> getUserInfo({required token}) async { + const _extra = {}; + final queryParameters = {}; + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/user/info', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => User.fromJson(json as Map), + ); + return value; + } + + @override + Future> login({required username, required password}) async { + const _extra = {}; + final queryParameters = { + r'username': username, + r'password': password + }; + final _headers = {}; + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose(_dio.options, '/user/login', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => Token.fromJson(json as Map), + ); + return value; + } + + @override + Future> recordManga( + {required token, required mid, required cid}) async { + const _extra = {}; + final queryParameters = {}; + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose(_dio.options, '/user/manga/${mid}/${cid}', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => json as dynamic, + ); + return value; + } + + @override + Future>> getShelfMangas( + {required token, required page}) async { + const _extra = {}; + final queryParameters = {r'page': page}; + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/shelf', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result>.fromJson( + _result.data!, + (json) => ResultPage.fromJson( + json as Map, + (json) => ShelfManga.fromJson(json as Map), + ), + ); + return value; + } + + @override + Future> checkShelfManga( + {required token, required mid}) async { + const _extra = {}; + final queryParameters = {}; + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'GET', headers: _headers, extra: _extra) + .compose(_dio.options, '/shelf/${mid}', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => ShelfStatus.fromJson(json as Map), + ); + return value; + } + + @override + Future> addToShelf({required token, required mid}) async { + const _extra = {}; + final queryParameters = {}; + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'POST', headers: _headers, extra: _extra) + .compose(_dio.options, '/shelf/${mid}', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => json as dynamic, + ); + return value; + } + + @override + Future> removeFromShelf( + {required token, required mid}) async { + const _extra = {}; + final queryParameters = {}; + final _headers = {r'Authorization': token}; + _headers.removeWhere((k, v) => v == null); + final _data = {}; + final _result = await _dio.fetch>( + _setStreamType>( + Options(method: 'DELETE', headers: _headers, extra: _extra) + .compose(_dio.options, '/shelf/${mid}', + queryParameters: queryParameters, data: _data) + .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl))); + final value = Result.fromJson( + _result.data!, + (json) => json as dynamic, + ); + return value; + } + + RequestOptions _setStreamType(RequestOptions requestOptions) { + if (T != dynamic && + !(requestOptions.responseType == ResponseType.bytes || + requestOptions.responseType == ResponseType.stream)) { + if (T == String) { + requestOptions.responseType = ResponseType.plain; + } else { + requestOptions.responseType = ResponseType.json; + } + } + return requestOptions; + } +} diff --git a/lib/service/dio/wrap_error.dart b/lib/service/dio/wrap_error.dart new file mode 100644 index 0000000..fbcd4fc --- /dev/null +++ b/lib/service/dio/wrap_error.dart @@ -0,0 +1,244 @@ +import 'dart:io'; + +import 'package:manhuagui_flutter/config.dart'; +import 'package:manhuagui_flutter/model/result.dart'; +import 'package:basic_utils/basic_utils.dart'; +import 'package:dio/dio.dart'; + +enum ErrorType { + networkError, + statusError, + resultError, + otherError, +} + +class ErrorMessage { + const ErrorMessage({required this.type, required this.error, required this.stack, required this.text, this.response, this.serviceCode, this.detail, this.castError}); + + const ErrorMessage.network(this.error, this.stack, this.text) + : type = ErrorType.networkError, + response = null, + serviceCode = null, + detail = null, + castError = null; + + const ErrorMessage.status(this.error, this.stack, this.text, {this.response}) + : type = ErrorType.statusError, + serviceCode = null, + detail = null, + castError = null; + + const ErrorMessage.result(this.error, this.stack, this.text, {this.response, this.serviceCode, this.detail}) + : type = ErrorType.resultError, + castError = null; + + const ErrorMessage.other(this.error, this.stack, this.text, {this.castError}) + : type = ErrorType.otherError, + response = null, + serviceCode = null, + detail = null; + + final ErrorType type; + final dynamic error; + final StackTrace stack; + final String text; + + final Response? response; + final int? serviceCode; + final dynamic detail; + final bool? castError; +} + +/// Wraps given error to [ErrorMessage]. +ErrorMessage wrapError(dynamic e, StackTrace s, {bool useResult = true}) { + print('┌─────────────────── WrapError ───────────────────┐'); + print('===> date: ${DateTime.now().toIso8601String()}'); + + // DioError [DioErrorType.other]: SocketException: Network error (OS Error: Network is unreachable, errno = 101) + // DioError [DioErrorType.other]: SocketException: Failed host lookup: '...' (OS Error: No address associated with hostname, errno = 7) + // DioError [DioErrorType.other]: SocketException: Connection refused (OS Error: Connection refused, errno = 111) + // DioError [DioErrorType.other]: SocketException: Write failed (OS Error: Broken pipe, errno = 32), address = ... + // DioError [DioErrorType.other]: HttpException: Connection reset by peer, uri = ... + // DioError [DioErrorType.other]: HttpException: Connection closed before full header was received, uri = ... + // DioError [DioErrorType.connectTimeout]: Connecting timed out [1ms] + // DioError [DioErrorType.response]: Http status error [502] + // TlsException [HandshakeException]: Handshake error in client (OS Error: SSLV3_ALERT_HANDSHAKE_FAILURE) + // TlsException [HandshakeException]: Connection terminated during handshake + // _CastError: type 'String' is not a subtype of type 'Map?' in type cast + // _CastError: type 'Null' is not a subtype of type 'Map' in type cast + // _CastError: Null check operator used on a null value + + if (e is DioError) { + var response = e.response; + print('===> uri: ${e.requestOptions.uri}'); + print('===> method: ${e.requestOptions.method}'); + + // ====================================================================================================================== + // ErrorType.networkError (DioError) + if (response == null) { + var text = '网络连接异常 (未知错误)'; // Unknown network error + switch (e.type) { + case DioErrorType.other: + var msg = e.error.toString().toLowerCase(); + if (msg.contains('unreachable') || msg.contains('failed host lookup')) { + text = '网络不可用'; // Network is unavailable + } else if (msg.contains('connection refused')) { + text = '网络连接异常 (Connection refused)'; // Network error + } else if (msg.contains('broken pipe')) { + text = '网络连接异常 (Broken pipe)'; + } else if (msg.contains('connection reset')) { + text = '网络连接异常 (Connection reset)'; + } else if (msg.contains('connection closed')) { + text = '网络连接异常 (Connection closed)'; + } else if (msg.contains('handshake') && (msg.contains('error') || msg.contains('terminated'))) { + text = '网络连接异常 (HTTPS error)'; + } else if (DEBUG_ERROR) { + text = '网络连接异常 ([DEBUG] DioError: ${e.error.toString()})'; + } + break; + case DioErrorType.connectTimeout: + text = '连接超时'; // Connection timed out + break; + case DioErrorType.sendTimeout: + text = '发送请求超时'; // Sending request timed out + break; + case DioErrorType.receiveTimeout: + text = '获取响应超时'; // Receiving response timed out + break; + case DioErrorType.cancel: + text = '请求被取消'; // Request is cancelled + break; + case DioErrorType.response: + break; + } + print('===> type: ${ErrorType.networkError}'); + print('===> text: $text'); + print('===> error: DioError [${e.type}]: ${e.error.toString()}'); + print('===> trace:\n${e.stackTrace}'); + print('└─────────────────── WrapError ───────────────────┘'); + return ErrorMessage.network(e, e.stackTrace!, text); + } + + // ====================================================================================================================== + // ErrorType.statusError + if (!useResult || response.data is! Map) { + var err = '${response.statusCode!} ${StringUtils.capitalize(response.statusMessage!, allWords: true)}'.trim(); + String text; + if (response.statusCode! < 500) { + text = '请求有误 ($err)'; // Bad request + } else { + text = '服务器出错 ($err)'; // Bad server + } + print('===> type: ${ErrorType.statusError}'); + print('===> text: $text'); + print('===> error: $err'); + print('└─────────────────── WrapError ───────────────────┘'); + return ErrorMessage.status(err, e.stackTrace!, text, response: response); + } + + // ====================================================================================================================== + // ErrorType.resultError + try { + var r = Result.fromJson(response.data, (_) => null); // <<< + var msg = StringUtils.capitalize(r.message, allWords: true); + var err = '${r.code} $msg (${response.statusCode!} ${StringUtils.capitalize(response.statusMessage!, allWords: true)})'.trim(); + String text; + if (r.code < 50000) { + text = msg; + } else { + text = '服务器出错 (${r.code} $msg)'; // Bad server + } + var detail = response.data['error'] is Map ? response.data['error']['detail'] : null; + print('===> type: ${ErrorType.resultError}'); + print('===> text: $text'); + print('===> detail: $detail'); + print('===> error: $err'); + print('└─────────────────── WrapError ───────────────────┘'); + return ErrorMessage.result(err, e.stackTrace!, text, response: response, serviceCode: r.code, detail: detail); + } catch (e, s) { + // must goto ErrorType.otherError + return wrapError(e, s); + } + } + + // ====================================================================================================================== + // ErrorType.networkError (TlsException) + if (e is TlsException) { + var text = '网络连接异常 (未知错误)'; // Unknown network error + var msg = e.message.toLowerCase(); + if (msg.contains('handshake') && (msg.contains('error') || msg.contains('terminated'))) { + text = '网络连接异常 (HTTPS error)'; // Network error + } else if (DEBUG_ERROR) { + // type: HandshakeException / CertificateException + text = '网络连接异常 ([DEBUG] ${e.type}: ${e.message})'; + } + + print('===> uri: ?'); + print('===> method: ?'); + print('===> type: ${ErrorType.networkError}'); + print('===> text: $text'); + print('===> error: TlsException [${e.type}]: ${e.message}${e.osError == null ? '' : ' ${e.osError}'}'); + print('===> trace:\n$s'); + print('└─────────────────── WrapError ───────────────────┘'); + return ErrorMessage.network(e, s, text); + } + + // ====================================================================================================================== + // ErrorType.networkError (_ClientSocketException) + if (e.runtimeType.toString() == '_ClientSocketException') { + var text = '网络连接异常 (未知错误)'; // Unknown network error + var msg = e.message.toLowerCase(); + if (msg.contains('unreachable') || msg.contains('failed host lookup')) { + text = '网络不可用'; // Network is unavailable + } else if (DEBUG_ERROR) { + text = '网络连接异常 ([DEBUG] ${e.type}: ${e.message})'; // Network error + } + + print('===> uri: ?'); + print('===> method: ?'); + print('===> type: ${ErrorType.networkError}'); + print('===> text: $text'); + print('===> error: ${e.runtimeType}: $e'); + print('===> trace:\n$s'); + print('└─────────────────── WrapError ───────────────────┘'); + return ErrorMessage.network(e, s, text); + } + + // ====================================================================================================================== + // ErrorType.otherError + String text; + if (!DEBUG_ERROR) { + text = '程序发生错误 (${e.runtimeType})\n如果该错误反复出现,请向开发者反馈'; + } else { + // [DEBUG] _CastError: type 'xxx' is not a subtype of type 'yyy' in type cast + text = '程序发生错误 ([DEBUG] ${e.runtimeType}: $e)'; // Something went wrong + } + var cast = e.runtimeType.toString() == '_CastError'; + if (cast) { + var msg = e.toString(); + var newText = ''; + if (msg.contains('Null check operator used on a null value')) { + newText = '[DEBUG] Got unexpected null value'; + } else { + // type 'String' is not a subtype of type 'Map?' in type cast + var match = RegExp("type '(.+)' is not a subtype of type '(.+)' in type cast").firstMatch(msg); + if (match != null) { + newText = '[DEBUG] Want "${match.group(2)}" type but got "${match.group(1)}" type'; + } + } + if (newText.isNotEmpty) { + // #0 _$LoginCheckResultFromJson (package:manhuagui_flutter/model/user.g.dart:41:35) + var top = RegExp('#0\\s*(.+) \\(package:.+').firstMatch(s.toString())?.group(1); + if (top != null) { + newText = '$newText, in $top'; + } + } + text = '程序发生错误 ($newText)'; // Something went wrong + } + print('===> type: ${ErrorType.otherError}'); + print('===> text: $text'); + print('===> error: ${e.runtimeType}: $e'); + print('===> trace:\n$s'); + print('└─────────────────── WrapError ───────────────────┘'); + return ErrorMessage.other(e, s, text, castError: cast); +} diff --git a/lib/service/evb/auth_manager.dart b/lib/service/evb/auth_manager.dart new file mode 100644 index 0000000..9d57953 --- /dev/null +++ b/lib/service/evb/auth_manager.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/prefs/auth.dart'; +import 'package:synchronized/synchronized.dart'; + +class AuthData { + const AuthData({ + required this.username, + required this.token, + }); + + final String username; + final String token; + + bool equals(AuthData? o) { + return o != null && username == o.username && token == o.token; + } +} + +class AuthManager { + AuthManager._(); + + static AuthManager? _instance; + + static AuthManager get instance { + _instance ??= AuthManager._(); + return _instance!; + } + + var _data = AuthData(username: '', token: ''); // global auth data + + AuthData get authData => _data; + + String get username => _data.username; + + String get token => _data.token; + + bool get logined => _data.token.isNotEmpty; + + void record({required String username, required String token}) { + _data = AuthData(username: username, token: token); + } + + void Function() listen(AuthData? Function()? authDataGetter, void Function(AuthChangedEvent) onData) { + return EventBusManager.instance.listen((ev) { + if (authDataGetter?.call()?.equals(authData) != true) { + onData.call(ev); + } + }); + } + + AuthChangedEvent notify({required bool logined, ErrorMessage? error}) { + final ev = AuthChangedEvent(logined: logined, error: error); + EventBusManager.instance.fire(ev); + return ev; + } + + final _lock = Lock(); + + Future check() async { + return _lock.synchronized(() async { + // logined + if (AuthManager.instance.logined) { + return AuthManager.instance.notify(logined: true); + } + + // no token stored in prefs + var token = await AuthPrefs.getToken(); + if (token.isEmpty) { + return AuthManager.instance.notify(logined: false); + } + + // check stored token + final client = RestClient(DioManager.instance.dio); + try { + var r = await client.checkUserLogin(token: token); + AuthManager.instance.record(username: r.data.username, token: token); + return AuthManager.instance.notify(logined: true); + } catch (e, s) { + var we = wrapError(e, s); + if (we.type == ErrorType.resultError && we.response!.statusCode == 401) { + await AuthPrefs.setToken(''); + } + return AuthManager.instance.notify(logined: false, error: we); + } + }); + } +} + +class AuthChangedEvent { + const AuthChangedEvent({required this.logined, this.error}); + + final bool logined; + final ErrorMessage? error; +} diff --git a/lib/service/evb/evb_manager.dart b/lib/service/evb/evb_manager.dart new file mode 100644 index 0000000..9f831e8 --- /dev/null +++ b/lib/service/evb/evb_manager.dart @@ -0,0 +1,28 @@ +import 'package:event_bus/event_bus.dart'; + +class EventBusManager { + EventBusManager._(); + + static EventBusManager? _instance; + + static EventBusManager get instance { + _instance ??= EventBusManager._(); + return _instance!; + } + + EventBus? _eventBus; // global EventBus instance + + EventBus get eventBus { + _eventBus ??= EventBus(); + return _eventBus!; + } + + void Function() listen(void Function(T event) onData) { + var stream = eventBus.on().listen(onData); + return () => stream.cancel(); + } + + void fire(dynamic event) { + eventBus.fire(event); + } +} diff --git a/lib/service/evb/events.dart b/lib/service/evb/events.dart new file mode 100644 index 0000000..4d9295c --- /dev/null +++ b/lib/service/evb/events.dart @@ -0,0 +1,43 @@ +class ToShelfRequestedEvent { + const ToShelfRequestedEvent(); +} + +class ToHistoryRequestedEvent { + const ToHistoryRequestedEvent(); +} + +class ToGenreRequestedEvent { + const ToGenreRequestedEvent(); +} + +class ToRecentRequestedEvent { + const ToRecentRequestedEvent(); +} + +class ToRankingRequestedEvent { + const ToRankingRequestedEvent(); +} + +class HistoryUpdatedEvent { + const HistoryUpdatedEvent(); +} + +class SubscribeUpdatedEvent { + const SubscribeUpdatedEvent({required this.mangaId, required this.subscribe}); + + final int mangaId; + final bool subscribe; +} + +class DownloadMangaProgressChangedEvent { + const DownloadMangaProgressChangedEvent({required this.mangaId, required this.finished}); + + final int mangaId; + final bool finished; +} + +class DownloadedMangaEntityChangedEvent { + const DownloadedMangaEntityChangedEvent({required this.mangaId}); + + final int mangaId; +} diff --git a/lib/service/native/browser.dart b/lib/service/native/browser.dart new file mode 100644 index 0000000..1149c0c --- /dev/null +++ b/lib/service/native/browser.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_web_browser/flutter_web_browser.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +Future launchInBrowser({ + required BuildContext context, + required String url, + bool useLaunch = false, + LaunchMode launchMode = LaunchMode.externalApplication, +}) async { + if (useLaunch) { + try { + await launchUrlString(url, mode: launchMode); + } catch (_) {} + return; + } + + try { + await FlutterWebBrowser.openWebPage( + url: url, + customTabsOptions: CustomTabsOptions( + defaultColorSchemeParams: CustomTabsColorSchemeParams( + toolbarColor: Theme.of(context).primaryColor, + ), + shareState: CustomTabsShareState.on, + instantAppsEnabled: false, + showTitle: true, + urlBarHidingEnabled: true, + ), + ); + } catch (_) {} +} diff --git a/lib/service/natives/clipboard.dart b/lib/service/native/clipboard.dart similarity index 50% rename from lib/service/natives/clipboard.dart rename to lib/service/native/clipboard.dart index a8dc1d2..9978ddb 100644 --- a/lib/service/natives/clipboard.dart +++ b/lib/service/native/clipboard.dart @@ -4,15 +4,12 @@ import 'package:fluttertoast/fluttertoast.dart'; Future copyText( String text, { bool showToast = true, - Function() callback, -}) { - assert(showToast != null); - +}) async { var data = ClipboardData(text: text); - return Clipboard.setData(data).then((_) { + try { + await Clipboard.setData(data); if (showToast) { - Fluttertoast.showToast(msg: '$text 已经复制到剪贴板'); + Fluttertoast.showToast(msg: '"$text" 已经复制到剪贴板'); } - callback?.call(); - }).catchError((_) {}); + } catch (_) {} } diff --git a/lib/service/native/notification.dart b/lib/service/native/notification.dart new file mode 100644 index 0000000..744ca2b --- /dev/null +++ b/lib/service/native/notification.dart @@ -0,0 +1,223 @@ +import 'dart:convert'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:manhuagui_flutter/config.dart'; +import 'package:manhuagui_flutter/service/native/notification_handler.dart'; + +class NotificationManager with NotificationHandlerMixin { + NotificationManager._(); + + static NotificationManager? _instance; + + static NotificationManager get instance { + _instance ??= NotificationManager._(); + return _instance!; + } + + FlutterLocalNotificationsPlugin? _plugin; // global FlutterLocalNotificationsPlugin instance + + Future getPlugin() async { + if (_plugin == null) { + _plugin = FlutterLocalNotificationsPlugin(); + await _plugin!.resolvePlatformSpecificImplementation()?.requestPermission(); + await _plugin!.initialize( + InitializationSettings( + android: AndroidInitializationSettings('flutter_icon'), + ), + onSelectNotification: _onNotificationSelected, + ); + } + return _plugin!; + } + + @pragma('vm:entry-point') + static void _onNotificationSelected(String? payloadString) { + var payload = NotificationPayload.fromString(payloadString ?? '{}'); + if (payload != null) { + NotificationHandlerMixin.handleSelectedEvent( + channelId: payload.channelId, + messageId: payload.messageId, + messageTag: payload.messageTag, + arguments: payload.arguments, + ); + } + } + + static const progressCategory = 'progress'; + static const statusCategory = 'status'; + static const errCategory = 'err'; + + static AndroidNotificationDetails _buildSilentNotificationDetails({ + required String channelId, + required String channelName, + required String channelDescription, + String? icon, + String? largeIcon, + String? subText, + String? ticker, + String? tag, + bool autoCancel = true, + bool ongoing = false, + bool showProgress = false, + bool indeterminate = false, + int maxProgress = 0, + int progress = 0, + String? category, + }) { + // https://pub.dev/packages/flutter_local_notifications#-usage + // https://developer.android.com/reference/android/R.drawable#stat_sys_download + // https://github.com/xiaojieonly/Ehviewer_CN_SXJ/blob/1.9.2/app/src/main/java/com/hippo/ehviewer/download/DownloadService.java#L218 + // https://github.com/MaikuB/flutter_local_notifications/blob/flutter_local_notifications-v12.0.3/flutter_local_notifications/lib/src/platform_specifics/android/categories.dart + return AndroidNotificationDetails( + /* channel */ + channelId, + channelName, + channelDescription: channelDescription, + /* data */ + icon: icon, + largeIcon: largeIcon == null ? null : DrawableResourceAndroidBitmap(largeIcon), + subText: subText, + ticker: ticker, + tag: tag, + autoCancel: autoCancel, + ongoing: ongoing, + showProgress: showProgress, + indeterminate: indeterminate, + maxProgress: maxProgress, + progress: progress, + category: category, + /* silent setting */ + importance: Importance.low, + priority: Priority.low, + playSound: false, + enableVibration: false, + onlyAlertOnce: false, + showWhen: true, + usesChronometer: false, + channelShowBadge: false, + enableLights: false, + timeoutAfter: null, + fullScreenIntent: false, + colorized: false, + channelAction: AndroidNotificationChannelAction.createIfNotExists, + visibility: NotificationVisibility.secret, + ); + } + + static const mipMapIcLaunch = '@mipmap/ic_launcher'; + static const drawableStatDownload = '@android:drawable/stat_sys_download'; + static const drawableStatDownloadDone = '@android:drawable/stat_sys_download_done'; + + Future showDownloadChannelNotification({ + required int id, + required String title, + String? body, + String? subText, + String? ticker, + String? tag, + Object? payloadArguments, + String? icon, + String? largeIcon, + bool autoCancel = true, + bool ongoing = false, + bool showProgress = false, + bool indeterminate = false, + int maxProgress = 0, + int progress = 0, + String? category, + }) async { + var plugin = await getPlugin(); + try { + await plugin.show( + id, + title, + body, + NotificationDetails( + android: _buildSilentNotificationDetails( + channelId: DL_NTFC_ID, + channelName: DL_NTFC_NAME, + channelDescription: DL_NTFC_DESCRIPTION, + subText: subText, + ticker: ticker, + tag: tag, + icon: icon, + largeIcon: largeIcon, + autoCancel: autoCancel, + ongoing: ongoing, + showProgress: showProgress, + indeterminate: indeterminate, + maxProgress: maxProgress, + progress: progress, + category: category, + ), + ), + payload: NotificationPayload( + channelId: DL_NTFC_ID, + messageId: id, + messageTag: tag, + arguments: payloadArguments, + ).buildString(), + ); + return true; + } catch (e, s) { + print('===> exception when showDownloadChannelNotification:\n$e\n$s'); + return false; + } + } + + Future cancelNotification({required int id, String? tag}) async { + var plugin = await getPlugin(); + try { + await plugin.cancel(id, tag: tag); + return true; + } catch (e, s) { + print('===> exception when cancelNotification:\n$e\n$s'); + return false; + } + } +} + +class NotificationPayload { + const NotificationPayload({ + required this.channelId, + required this.messageId, + this.messageTag, + this.arguments, + }); + + final String channelId; + final int messageId; + final String? messageTag; + final Object? arguments; + + String buildString() { + var m = { + 'channelId': channelId, + 'messageId': messageId, + 'messageTag': messageTag, + 'arguments': arguments, + }; + return json.encode(m); + } + + static NotificationPayload? fromString(String payload) { + try { + var m = json.decode(payload) as Map; + var channelId = m['channelId']; + var messageId = m['messageId']; + var messageTag = m['messageTag']; + var arguments = m['arguments']; + if (channelId == null || messageId == null) { + return null; + } + return NotificationPayload( + channelId: channelId, + messageId: messageId, + messageTag: messageTag, + arguments: arguments, + ); + } catch (_) { + return null; + } + } +} diff --git a/lib/service/native/notification_handler.dart b/lib/service/native/notification_handler.dart new file mode 100644 index 0000000..a9f52ee --- /dev/null +++ b/lib/service/native/notification_handler.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/config.dart'; +import 'package:manhuagui_flutter/page/download_toc.dart'; + +mixin NotificationHandlerMixin { + static BuildContext? _hackedContext; // global BuildContext instance + + void registerContext(BuildContext context) { + _hackedContext = context; + } + + @pragma('vm:entry-point') + static Future handleSelectedEvent({ + required String channelId, + required int messageId, + String? messageTag, + Object? arguments, + }) async { + switch (channelId) { + case DL_NTFC_ID: + var mangaId = messageId; + if (_hackedContext != null && !DownloadTocPage.isCurrentRoute(_hackedContext!, mangaId)) { + Navigator.of(_hackedContext!).push( + CustomPageRoute( + context: _hackedContext!, + builder: (c) => DownloadTocPage( + mangaId: mangaId, + gotoDownloading: true, + ), + settings: DownloadTocPage.buildRouteSetting( + mangaId: mangaId, + ), + ), + ); + } + break; + + default: + break; + } + } +} diff --git a/lib/service/native/share.dart b/lib/service/native/share.dart new file mode 100644 index 0000000..f39fdba --- /dev/null +++ b/lib/service/native/share.dart @@ -0,0 +1,14 @@ +import 'package:flutter_share/flutter_share.dart'; + +Future shareText({ + String? title, + String? text, +}) async { + var shared = await FlutterShare.share( + title: title ?? '无标题', + text: text, + linkUrl: null, + chooserTitle: null, + ); + return shared ?? false; +} diff --git a/lib/service/native/system_ui.dart b/lib/service/native/system_ui.dart new file mode 100644 index 0000000..7cd574c --- /dev/null +++ b/lib/service/native/system_ui.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void setDefaultSystemUIOverlayStyle() async { + setSystemUIOverlayStyle(); +} + +void setSystemUIOverlayStyle({ + // status bar + Color? statusBarColor, + Brightness? statusBarBrightness, + Brightness? statusBarIconBrightness, + // navigation bar + Color? navigationBarColor, + Brightness? navigationBarIconBrightness, + Color? navigationBarDividerColor, +}) async { + SystemChrome.setSystemUIOverlayStyle( + SystemUiOverlayStyle( + statusBarColor: statusBarColor, + statusBarBrightness: statusBarBrightness ?? Brightness.dark, + statusBarIconBrightness: statusBarIconBrightness ?? Brightness.light, + systemNavigationBarColor: navigationBarColor ?? Color.fromRGBO(250, 250, 250, 1.0), + systemNavigationBarIconBrightness: navigationBarIconBrightness ?? Brightness.dark, + systemNavigationBarDividerColor: navigationBarDividerColor ?? Color.fromRGBO(250, 250, 250, 1.0), + ), + ); +} + +Future setEdgeToEdgeSystemUIMode() async { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge, overlays: SystemUiOverlay.values); +} + +Future setManualSystemUIMode(List overlays) async { + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: overlays); +} diff --git a/lib/service/natives/browser.dart b/lib/service/natives/browser.dart deleted file mode 100644 index fec7971..0000000 --- a/lib/service/natives/browser.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_web_browser/flutter_web_browser.dart'; - -Future launchInBrowser({ - @required BuildContext context, - @required String url, -}) async { - assert(context != null); - assert(url != null); - if (url.isEmpty) { - return; - } - - try { - return await FlutterWebBrowser.openWebPage( - url: url, - customTabsOptions: CustomTabsOptions( - toolbarColor: Theme.of(context).primaryColor, - ), - ); - } catch (ex) { - return Future.error(ex); - } -} diff --git a/lib/service/natives/prefs.dart b/lib/service/natives/prefs.dart deleted file mode 100644 index d65ebd4..0000000 --- a/lib/service/natives/prefs.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:shared_preferences/shared_preferences.dart'; - -Future getPrefs() async { - return await SharedPreferences.getInstance(); -} diff --git a/lib/service/prefs/auth.dart b/lib/service/prefs/auth.dart index 38ff9c9..45ffb39 100644 --- a/lib/service/prefs/auth.dart +++ b/lib/service/prefs/auth.dart @@ -1,76 +1,94 @@ import 'dart:convert'; -import 'package:flutter_ahlib/util.dart'; -import 'package:manhuagui_flutter/service/natives/prefs.dart'; +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:manhuagui_flutter/service/prefs/prefs_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -const _TOKEN = 'TOKEN'; // string -const _REMEMBER_USERNAME = 'REMEMBER_USERNAME'; // bool -const _REMEMBER_PASSWORD = 'REMEMBER_PASSWORD'; // bool -const _USERNAME_PASSWORD_PAIRS = 'USERNAME_PASSWORD_PAIRS'; // list +class AuthPrefs { + AuthPrefs._(); -Future getToken() async { - var prefs = await getPrefs(); - return prefs.getString(_TOKEN); -} + static const _tokenKey = 'AuthPrefs_token'; // string + static const _rememberUsernameKey = 'AuthPrefs_rememberUsername'; // bool + static const _rememberPasswordKey = 'AuthPrefs_rememberPassword'; // bool + static const _usernamePasswordPairsKey = 'AuthPrefs_usernamePasswordPairs'; // list -Future setToken(String token) async { - var prefs = await getPrefs(); - await prefs.setString(_TOKEN, token); -} + static Future getToken() async { + final prefs = await PrefsManager.instance.loadPrefs(); + return prefs.safeGetString(_tokenKey) ?? ''; + } -Future removeToken() async { - var prefs = await getPrefs(); - await prefs.remove(_TOKEN); -} + static Future setToken(String token) async { + final prefs = await PrefsManager.instance.loadPrefs(); + await prefs.setString(_tokenKey, token); + } -Future> getRememberOptions() async { - var prefs = await getPrefs(); - return Tuple2(prefs.getBool(_REMEMBER_USERNAME) ?? true, prefs.getBool(_REMEMBER_PASSWORD) ?? false); -} + static Future> getRememberOption() async { + final prefs = await PrefsManager.instance.loadPrefs(); + var rememberUsername = prefs.safeGetBool(_rememberUsernameKey) ?? true; + var rememberPassword = prefs.safeGetBool(_rememberPasswordKey) ?? false; + return Tuple2(rememberUsername, rememberPassword); + } -Future setRememberOptions(bool rememberUsername, bool rememberPassword) async { - var prefs = await getPrefs(); - await prefs.setBool(_REMEMBER_USERNAME, rememberUsername); - await prefs.setBool(_REMEMBER_PASSWORD, rememberPassword); -} + static Future setRememberOption(bool rememberUsername, bool rememberPassword) async { + final prefs = await PrefsManager.instance.loadPrefs(); + await prefs.setBool(_rememberUsernameKey, rememberUsername); + await prefs.setBool(_rememberPasswordKey, rememberPassword); + } -Future>> getUsernamePasswordPairs() async { - var prefs = await getPrefs(); - var pairs = prefs.getStringList(_USERNAME_PASSWORD_PAIRS) ?? []; - var out = >[]; - for (var jsn in pairs) { - var m = json.decode(jsn) as Map; - var username = m['username']; - var password = m['password']; - if (username == null || password == null) { - continue; + static Future>> getUsernamePasswordPairs() async { + final prefs = await PrefsManager.instance.loadPrefs(); + var data = prefs.safeGetStringList(_usernamePasswordPairsKey) ?? []; + return _usernamePasswordStringsToTuples(data); + } + + static Future>> addUsernamePasswordPair(String username, String password) async { + final prefs = await PrefsManager.instance.loadPrefs(); + var data = await getUsernamePasswordPairs(); + data.removeWhere((t) => t.item1 == username); + data.insert(0, Tuple2(username, password)); // 新 > 旧 + await prefs.setStringList(_usernamePasswordPairsKey, _usernamePasswordTuplesToStrings(data)); + return data; + } + + static Future>> removeUsernamePasswordPair(String username) async { + final prefs = await PrefsManager.instance.loadPrefs(); + var data = await getUsernamePasswordPairs(); + data.removeWhere((t) => t.item1 == username); + await prefs.setStringList(_usernamePasswordPairsKey, _usernamePasswordTuplesToStrings(data)); + return data; + } + + static List> _usernamePasswordStringsToTuples(List data) { + var out = >[]; + for (var jsn in data) { + var m = json.decode(jsn) as Map; + var username = m['username']; + var password = m['password']; + if (username == null || password == null) { + continue; + } + out.add(Tuple2(username, password)); } - out.add(Tuple2(username, password)); + return out; } - return out; -} -Future addUsernamePasswordPair(String username, String password) async { - var prefs = await getPrefs(); - var list = await getUsernamePasswordPairs(); - list.removeWhere((t) => t.item1 == username); - list.insert(0, Tuple2(username, password)); // 新 > 旧 - var pairs = []; - for (var pair in list) { - var jsn = '{"username": "${pair.item1}", "password": "${pair.item2}"}'; - pairs.add(jsn); + static List _usernamePasswordTuplesToStrings(List> data) { + var out = []; + for (var tup in data) { + var m = { + 'username': tup.item1, + 'password': tup.item2, + }; + var jsn = json.encode(m); + out.add(jsn); + } + return out; } - await prefs.setStringList(_USERNAME_PASSWORD_PAIRS, pairs); -} -Future removeUsernamePasswordPair(String username) async { - var prefs = await getPrefs(); - var list = await getUsernamePasswordPairs(); - list.removeWhere((t) => t.item1 == username); - var pairs = []; - for (var pair in list) { - var jsn = '{"username": "${pair.item1}", "password": "${pair.item2}"}'; - pairs.add(jsn); + static Future upgradeFromVer1To2(SharedPreferences prefs) async { + await prefs.migrateString(oldKey: 'TOKEN', newKey: _tokenKey, defaultValue: ''); + await prefs.migrateBool(oldKey: 'REMEMBER_USERNAME', newKey: _rememberUsernameKey, defaultValue: true); + await prefs.migrateBool(oldKey: 'REMEMBER_PASSWORD', newKey: _rememberPasswordKey, defaultValue: false); + await prefs.migrateStringList(oldKey: 'USERNAME_PASSWORD_PAIRS', newKey: _usernamePasswordPairsKey, defaultValue: []); } - await prefs.setStringList(_USERNAME_PASSWORD_PAIRS, pairs); } diff --git a/lib/service/prefs/chapter.dart b/lib/service/prefs/chapter.dart deleted file mode 100644 index f8b217e..0000000 --- a/lib/service/prefs/chapter.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:manhuagui_flutter/service/natives/prefs.dart'; - -const _SCROLL_DIRECTION = 'SCROLL_DIRECTION'; // bool -const _SHOW_PAGE_HINT = 'SHOW_PAGE_HINT'; // bool -const _USE_SWIPE_FOR_CHAPTER = 'USE_SWIPE_FOR_CHAPTER'; // bool -const _USE_CLICK_FOR_CHAPTER = 'USE_CLICK_FOR_CHAPTER'; // bool -const _NEED_CHECK_FOR_CHAPTER = 'NEED_CHECK_FOR_CHAPTER'; // bool -const _ENABLE_PAGE_SPACE = 'ENABLE_PAGE_SPACE'; // bool -const _PRELOAD_COUNT = 'PRELOAD_COUNT'; // int - -class ChapterPageSetting { - ChapterPageSetting(); - - bool reverseScroll; // 反转拖动 - bool showPageHint; // 显示页码提示 - bool useSwipeForChapter; // 滑动跳转至章节 - bool useClickForChapter; // 点击跳转至章节 - bool needCheckForChapter; // 跳转章节时弹出提示 - bool enablePageSpace; // 显示页面间隔 - int preloadCount; // 预加载页数 - - ChapterPageSetting.defaultSetting() { - reverseScroll = false; - showPageHint = true; - useSwipeForChapter = true; - useClickForChapter = true; - needCheckForChapter = true; - enablePageSpace = true; - preloadCount = 2; - } - - Future existed() async { - var prefs = await getPrefs(); - return prefs.containsKey(_SCROLL_DIRECTION); - } - - Future load() async { - var def = ChapterPageSetting.defaultSetting(); - var prefs = await getPrefs(); - reverseScroll = prefs.getBool(_SCROLL_DIRECTION) ?? def.reverseScroll; - showPageHint = prefs.getBool(_SHOW_PAGE_HINT) ?? def.showPageHint; - useSwipeForChapter = prefs.getBool(_USE_SWIPE_FOR_CHAPTER) ?? def.useSwipeForChapter; - useClickForChapter = prefs.getBool(_USE_CLICK_FOR_CHAPTER) ?? def.useClickForChapter; - needCheckForChapter = prefs.getBool(_NEED_CHECK_FOR_CHAPTER) ?? def.needCheckForChapter; - enablePageSpace = prefs.getBool(_ENABLE_PAGE_SPACE) ?? def.enablePageSpace; - preloadCount = prefs.getInt(_PRELOAD_COUNT) ?? def.preloadCount; - } - - Future save() async { - var prefs = await getPrefs(); - await prefs.setBool(_SCROLL_DIRECTION, reverseScroll); - await prefs.setBool(_SHOW_PAGE_HINT, showPageHint); - await prefs.setBool(_USE_SWIPE_FOR_CHAPTER, useSwipeForChapter); - await prefs.setBool(_USE_CLICK_FOR_CHAPTER, useClickForChapter); - await prefs.setBool(_NEED_CHECK_FOR_CHAPTER, needCheckForChapter); - await prefs.setBool(_ENABLE_PAGE_SPACE, enablePageSpace); - await prefs.setInt(_PRELOAD_COUNT, preloadCount); - } -} diff --git a/lib/service/prefs/dl_setting.dart b/lib/service/prefs/dl_setting.dart new file mode 100644 index 0000000..7b8f391 --- /dev/null +++ b/lib/service/prefs/dl_setting.dart @@ -0,0 +1,32 @@ +import 'package:manhuagui_flutter/page/page/dl_setting.dart'; +import 'package:manhuagui_flutter/service/prefs/prefs_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class DlSettingPrefs { + DlSettingPrefs._(); + + static const _defaultToDeleteFilesKey = 'DlSettingPrefs_defaultToDeleteFiles'; // bool + static const _downloadPagesTogetherKey = 'DlSettingPrefs_downloadPagesTogether'; // int + static const _invertDownloadOrderKey = 'DlSettingPrefs_invertDownloadOrder'; // int + + static Future getSetting() async { + final prefs = await PrefsManager.instance.loadPrefs(); + var def = DlSetting.defaultSetting(); + return DlSetting( + defaultToDeleteFiles: prefs.safeGetBool(_defaultToDeleteFilesKey) ?? def.defaultToDeleteFiles, + downloadPagesTogether: prefs.safeGetInt(_downloadPagesTogetherKey) ?? def.downloadPagesTogether, + invertDownloadOrder: prefs.safeGetBool(_invertDownloadOrderKey) ?? def.invertDownloadOrder, + ); + } + + static Future setSetting(DlSetting setting) async { + final prefs = await PrefsManager.instance.loadPrefs(); + await prefs.setBool(_defaultToDeleteFilesKey, setting.defaultToDeleteFiles); + await prefs.setInt(_downloadPagesTogetherKey, setting.downloadPagesTogether); + await prefs.setBool(_invertDownloadOrderKey, setting.invertDownloadOrder); + } + + static Future upgradeFromVer1To2(SharedPreferences prefs) async { + // pass + } +} diff --git a/lib/service/prefs/prefs_manager.dart b/lib/service/prefs/prefs_manager.dart new file mode 100644 index 0000000..b6a03bf --- /dev/null +++ b/lib/service/prefs/prefs_manager.dart @@ -0,0 +1,106 @@ +import 'package:manhuagui_flutter/service/prefs/auth.dart'; +import 'package:manhuagui_flutter/service/prefs/dl_setting.dart'; +import 'package:manhuagui_flutter/service/prefs/search_history.dart'; +import 'package:manhuagui_flutter/service/prefs/view_setting.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class PrefsManager { + PrefsManager._(); + + static PrefsManager? _instance; + + static PrefsManager get instance { + _instance ??= PrefsManager._(); + return _instance!; + } + + SharedPreferences? _prefs; // global SharedPreferences instance + + Future loadPrefs() async { + if (_prefs == null) { + _prefs = await SharedPreferences.getInstance(); + await _checkUpgrade(_prefs!); + } + return _prefs!; + } + + Future reloadPrefs() async { + _prefs = await SharedPreferences.getInstance(); + return _prefs!; + } + + static const newestVersion = 2; // current newest SharedPreferences version + + Future _checkUpgrade(SharedPreferences prefs) async { + var version = prefs.getInt('VERSION') ?? 1; + if (version == newestVersion) { + return; + } + + if (version <= 1) { + version = 2; // 1 -> 2 upgrade + await AuthPrefs.upgradeFromVer1To2(prefs); + await DlSettingPrefs.upgradeFromVer1To2(prefs); + await SearchHistoryPrefs.upgradeFromVer1To2(prefs); + await ViewSettingPrefs.upgradeFromVer1To2(prefs); + } + if (version == 2) { + // ... + } + + prefs.setInt('VERSION', newestVersion); + } +} + +extension SharedPreferencesExtension on SharedPreferences { + String? safeGetString(String key) => _safeGet(() => getString(key)); + + bool? safeGetBool(String key) => _safeGet(() => getBool(key)); + + int? safeGetInt(String key) => _safeGet(() => getInt(key)); + + double? safeGetDouble(String key) => _safeGet(() => getDouble(key)); + + List? safeGetStringList(String key) => _safeGet>(() => getStringList(key)); + + T? _safeGet(T? Function() getter) { + try { + return getter(); + } catch (e, s) { + print('===> exception when _safeGet:\n$e\n$s'); + return null; + } + } + + Future migrateString({required String oldKey, required String newKey, String? defaultValue}) async => // + await _migrate(oldKey, newKey, getString, setString, defaultValue); + + Future migrateBool({required String oldKey, required String newKey, bool? defaultValue}) async => // + await _migrate(oldKey, newKey, getBool, setBool, defaultValue); + + Future migrateInt({required String oldKey, required String newKey, int? defaultValue}) async => // + await _migrate(oldKey, newKey, getInt, setInt, defaultValue); + + Future migrateDouble({required String oldKey, required String newKey, double? defaultValue}) async => // + await _migrate(oldKey, newKey, getDouble, setDouble, defaultValue); + + Future migrateStringList({required String oldKey, required String newKey, List? defaultValue}) async => // + await _migrate>(oldKey, newKey, getStringList, setStringList, defaultValue); + + Future _migrate(String oldKey, String newKey, T? Function(String) getter, Future Function(String, T) setter, T? defaultValue) async { + if (oldKey == newKey) { + return true; + } + try { + T? data = getter(oldKey) ?? defaultValue; + if (data != null) { + var result = await setter(newKey, data); + remove(oldKey); + return result; + } + } catch (e, s) { + print('===> exception when migrate:\n$e\n$s'); + } + return false; + } +} diff --git a/lib/service/prefs/search.dart b/lib/service/prefs/search.dart deleted file mode 100644 index 48a0c7f..0000000 --- a/lib/service/prefs/search.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:manhuagui_flutter/service/natives/prefs.dart'; - -const _SEARCH_HISTORY = 'SEARCH_HISTORY'; // string[] - -/// 拿到搜索历史,按照时间新到旧排序 -Future> getSearchHistories() async { - var prefs = await getPrefs(); - var l = prefs.getStringList(_SEARCH_HISTORY); - return l ?? []; -} - -/// 插入到搜索记录最前面 -Future addSearchHistory(String s) async { - var prefs = await getPrefs(); - var l = prefs.getStringList(_SEARCH_HISTORY) ?? []; - l.remove(s); - l.insert(0, s); - await prefs.setStringList(_SEARCH_HISTORY, l); -} - -Future removeSearchHistory(String s) async { - var prefs = await getPrefs(); - var l = prefs.getStringList(_SEARCH_HISTORY) ?? []; - l.remove(s); - await prefs.setStringList(_SEARCH_HISTORY, l); -} - -Future clearSearchHistories() async { - var prefs = await getPrefs(); - var l = prefs.getStringList(_SEARCH_HISTORY) ?? []; - l.clear(); - await prefs.setStringList(_SEARCH_HISTORY, l); -} diff --git a/lib/service/prefs/search_history.dart b/lib/service/prefs/search_history.dart new file mode 100644 index 0000000..433ef3c --- /dev/null +++ b/lib/service/prefs/search_history.dart @@ -0,0 +1,39 @@ +import 'package:manhuagui_flutter/service/prefs/prefs_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SearchHistoryPrefs { + SearchHistoryPrefs._(); + + static const _searchHistoryKey = 'SearchHistoryPrefs_searchHistory'; // list + + static Future> getSearchHistories() async { + final prefs = await PrefsManager.instance.loadPrefs(); + return prefs.safeGetStringList(_searchHistoryKey) ?? []; + } + + static Future clearSearchHistories() async { + final prefs = await PrefsManager.instance.loadPrefs(); + await prefs.setStringList(_searchHistoryKey, []); + } + + static Future> addSearchHistory(String s) async { + final prefs = await PrefsManager.instance.loadPrefs(); + var data = await getSearchHistories(); + data.remove(s); + data.insert(0, s); // 新 > 旧 + await prefs.setStringList(_searchHistoryKey, data); + return data; + } + + static Future> removeSearchHistory(String s) async { + final prefs = await PrefsManager.instance.loadPrefs(); + var data = await getSearchHistories(); + data.remove(s); + await prefs.setStringList(_searchHistoryKey, data); + return data; + } + + static Future upgradeFromVer1To2(SharedPreferences prefs) async { + await prefs.migrateStringList(oldKey: 'SEARCH_HISTORY', newKey: _searchHistoryKey, defaultValue: []); + } +} diff --git a/lib/service/prefs/view_setting.dart b/lib/service/prefs/view_setting.dart new file mode 100644 index 0000000..a22246b --- /dev/null +++ b/lib/service/prefs/view_setting.dart @@ -0,0 +1,57 @@ +import 'package:manhuagui_flutter/page/page/view_setting.dart'; +import 'package:manhuagui_flutter/service/prefs/prefs_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ViewSettingPrefs { + ViewSettingPrefs._(); + + static const _viewDirectionKey = 'ViewSettingPrefs_viewDirection'; // int + static const _showPageHintKey = 'ViewSettingPrefs_showPageHint'; // bool + static const _showClockKey = 'ViewSettingPrefs_showClock'; // bool + static const _showNetworkKey = 'ViewSettingPrefs_showNetwork'; // bool + static const _showBatteryKey = 'ViewSettingPrefs_showBattery'; // bool + static const _enablePageSpaceKey = 'ViewSettingPrefs_enablePageSpace'; // bool + static const _keepScreenOnKey = 'ViewSettingPrefs_keepScreenOn'; // bool + static const _fullscreenKey = 'ViewSettingPrefs_fullscreen'; // bool + static const _preloadCountKey = 'ViewSettingPrefs_preloadCount'; // int + + static Future getSetting() async { + final prefs = await PrefsManager.instance.loadPrefs(); + var def = ViewSetting.defaultSetting(); + return ViewSetting( + viewDirection: ViewDirectionExtension.fromInt(prefs.safeGetInt(_viewDirectionKey) ?? def.viewDirection.toInt()), + showPageHint: prefs.safeGetBool(_showPageHintKey) ?? def.showPageHint, + showClock: prefs.safeGetBool(_showClockKey) ?? def.showClock, + showNetwork: prefs.safeGetBool(_showNetworkKey) ?? def.showNetwork, + showBattery: prefs.safeGetBool(_showBatteryKey) ?? def.showBattery, + enablePageSpace: prefs.safeGetBool(_enablePageSpaceKey) ?? def.enablePageSpace, + keepScreenOn: prefs.safeGetBool(_keepScreenOnKey) ?? def.keepScreenOn, + fullscreen: prefs.safeGetBool(_fullscreenKey) ?? def.fullscreen, + preloadCount: prefs.safeGetInt(_preloadCountKey) ?? def.preloadCount, + ); + } + + static Future setSetting(ViewSetting setting) async { + final prefs = await PrefsManager.instance.loadPrefs(); + await prefs.setInt(_viewDirectionKey, setting.viewDirection.toInt()); + await prefs.setBool(_showPageHintKey, setting.showPageHint); + await prefs.setBool(_showClockKey, setting.showClock); + await prefs.setBool(_showNetworkKey, setting.showNetwork); + await prefs.setBool(_showBatteryKey, setting.showBattery); + await prefs.setBool(_enablePageSpaceKey, setting.enablePageSpace); + await prefs.setBool(_keepScreenOnKey, setting.keepScreenOn); + await prefs.setBool(_fullscreenKey, setting.fullscreen); + await prefs.setInt(_preloadCountKey, setting.preloadCount); + } + + static Future upgradeFromVer1To2(SharedPreferences prefs) async { + var def = ViewSetting.defaultSetting(); + await prefs.remove('SCROLL_DIRECTION'); + await prefs.migrateBool(oldKey: 'SHOW_PAGE_HINT', newKey: _showPageHintKey, defaultValue: def.showPageHint); + await prefs.remove('USE_SWIPE_FOR_CHAPTER'); + await prefs.remove('USE_CLICK_FOR_CHAPTER'); + await prefs.remove('NEED_CHECK_FOR_CHAPTER'); + await prefs.migrateBool(oldKey: 'ENABLE_PAGE_SPACE', newKey: _enablePageSpaceKey, defaultValue: def.enablePageSpace); + await prefs.migrateInt(oldKey: 'PRELOAD_COUNT', newKey: _preloadCountKey, defaultValue: def.preloadCount); + } +} diff --git a/lib/service/retrofit/dio_manager.dart b/lib/service/retrofit/dio_manager.dart deleted file mode 100644 index 1e7023d..0000000 --- a/lib/service/retrofit/dio_manager.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:manhuagui_flutter/config.dart'; -import 'package:manhuagui_flutter/model/result.dart'; - -class DioManager { - DioManager._(); - - static DioManager _instance; - - static DioManager get instance { - if (_instance == null) { - _instance = DioManager._(); - _instance._initDio(); - } - return _instance; - } - - Dio _dio; - - /// A global dio instance. - Dio get dio => _dio; - - void _initDio() { - _dio = Dio(); - _dio.options.baseUrl = BASE_API_URL; - _dio.options.connectTimeout = CONNECT_TIMEOUT; - _dio.options.sendTimeout = SEND_TIMEOUT; - _dio.options.receiveTimeout = RECEIVE_TIMEOUT; - _dio.interceptors.add(LogInterceptor()); - } -} - -class LogInterceptor extends Interceptor { - @override - Future onRequest(RequestOptions options) async { - print('┌─────────────────── Request ─────────────────────┐'); - print('date: ${DateTime.now().toIso8601String()}'); - print('uri: ${options.uri}'); - print('method: ${options.method}'); - if (options.extra.isNotEmpty) { - print('extra: ${options.extra}'); - } - print('headers:'); - options.headers.forEach((key, v) => print(' $key: $v')); - print('└─────────────────── Request ─────────────────────┘'); - } - - @override - Future onError(DioError err) async { - print('┌─────────────────── DioError ────────────────────┐'); - print('date: ${DateTime.now().toIso8601String()}'); - print('uri: ${err.request.uri}'); - print('method: ${err.request.method}'); - print('error: $err'); - if (err.response != null) { - _printResponse(err.response); - } - print('└─────────────────── DioError ────────────────────┘'); - } - - @override - Future onResponse(Response response) async { - print('┌─────────────────── Response ────────────────────┐'); - print('date: ${DateTime.now().toIso8601String()}'); - _printResponse(response); - print('└─────────────────── Response ────────────────────┘'); - } - - void _printResponse(Response response) { - print('uri: ${response.request.uri}'); - print('method: ${response.request.method}'); - print('statusCode: ${response.statusCode}'); - if (response.headers != null) { - print('headers:'); - response.headers.forEach((key, v) => print(' $key: ${v.join(',')}')); - } - } -} - -enum ErrorType { - NETWORK_ERROR, - RESULT_ERROR, - STATUS_ERROR, - OTHER_ERROR, -} - -class ErrorMessage { - ErrorType type; - dynamic error; - String text; - int httpCode; - int serviceCode; - - ErrorMessage({this.type, this.error, this.text, this.httpCode, this.serviceCode}); -} - -/// Wrap error to [ErrorMessage]. -ErrorMessage wrapError(dynamic e, {bool isResult = true}) { - print('┌─────────────────── WrapError ───────────────────┐'); - print('date: ${DateTime.now().toIso8601String()}'); - - if (e is DioError) { - assert(isResult != null); - - print('uri: ${e.request.uri}'); - print('method: ${e.request.method}'); - - // ====================================================================================================================== - // NETWORK_ERROR - if (e.response == null) { - // DioError [DioErrorType.DEFAULT]: SocketException: Connection failed (OS Error: Network is unreachable, errno = 101) - // DioError [DioErrorType.DEFAULT]: SocketException: OS Error: Connection refused - // DioError [DioErrorType.DEFAULT]: HandshakeException: Handshake error in client (OS Error) - // DioError [DioErrorType.CONNECT_TIMEOUT]: Connecting timed out - var text = 'Unknown error'; - switch (e.type) { - case DioErrorType.DEFAULT: - case DioErrorType.CANCEL: - text = 'Network error'; - if (!e.toString().contains('unreachable')) { - text = 'Server unreachable'; - } - break; - case DioErrorType.CONNECT_TIMEOUT: - case DioErrorType.SEND_TIMEOUT: - case DioErrorType.RECEIVE_TIMEOUT: - text = 'Timeout error'; - break; - case DioErrorType.RESPONSE: // x - text = 'Response unknown error'; - break; - } - print('type: ${ErrorType.NETWORK_ERROR}'); - print('error: $e'); - print('text: $text'); - print('└─────────────────── WrapError ───────────────────┘'); - return ErrorMessage(type: ErrorType.NETWORK_ERROR, error: e, text: text); - } - - // ====================================================================================================================== - // STATUS_ERROR - if (!isResult) { - var err = '${e.response.statusCode}: ${e.response.statusMessage}'; - var text = 'Respond $err'; - print('type: ${ErrorType.STATUS_ERROR}'); - print('error: $err'); - print('text: $text'); - print('└─────────────────── WrapError ───────────────────┘'); - return ErrorMessage(type: ErrorType.STATUS_ERROR, error: err, text: text, httpCode: e.response.statusCode); - } - - // ====================================================================================================================== - // RESULT_ERROR - try { - var r = Result.fromJson(e.response.data); - r.message = '${r.message[0].toUpperCase()}${r.message.substring(1)}'; - var err = '${e.response.statusCode}: ${r.code} ${r.message}'; - var text = r.message; - var data = e.response.data as Map; - var detail = data != null && data['error'] is Map ? data['error']['detail'] : null; - print('type: ${ErrorType.RESULT_ERROR}'); - print('error: $err'); - print('text: $text'); - print('detail: $detail'); - print('└─────────────────── WrapError ───────────────────┘'); - return ErrorMessage(type: ErrorType.RESULT_ERROR, error: err, text: text, httpCode: e.response.statusCode, serviceCode: r.code); - } catch (e) { - // non DioError - return wrapError(e, isResult: isResult); - } - } - - // ====================================================================================================================== - // OTHER_ERROR - var text = 'Some strange error.'; - if (DEBUG) { - // _CastError: type 'xxx' is not a subtype of type 'yyy' in type cast - text = '${e.runtimeType}: ${e.toString()}'; - } - print('type: ${ErrorType.OTHER_ERROR}'); - print('error: $e'); - print('text: $text'); - print('└─────────────────── WrapError ───────────────────┘'); - return ErrorMessage(type: ErrorType.OTHER_ERROR, error: e, text: text); -} diff --git a/lib/service/retrofit/retrofit.g.dart b/lib/service/retrofit/retrofit.g.dart deleted file mode 100644 index 041f94f..0000000 --- a/lib/service/retrofit/retrofit.g.dart +++ /dev/null @@ -1,532 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'retrofit.dart'; - -// ************************************************************************** -// RetrofitGenerator -// ************************************************************************** - -class _RestClient implements RestClient { - _RestClient(this._dio, {this.baseUrl}) { - ArgumentError.checkNotNull(_dio, '_dio'); - } - - final Dio _dio; - - String baseUrl; - - @override - Future>> getAllMangas({page, order}) async { - const _extra = {}; - final queryParameters = { - r'page': page, - r'order': order?.toJson() - }; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/manga', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future> getManga({mid}) async { - const _extra = {}; - final queryParameters = {}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/manga/$mid', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future> getMangaChapter({mid, cid}) async { - const _extra = {}; - final queryParameters = {}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/manga/$mid/$cid', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future> getHotSerialMangas() async { - const _extra = {}; - final queryParameters = {}; - final _data = {}; - final _result = await _dio.request>('/list/serial', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future> getFinishedMangas() async { - const _extra = {}; - final queryParameters = {}; - final _data = {}; - final _result = await _dio.request>('/list/finish', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future> getLatestMangas() async { - const _extra = {}; - final queryParameters = {}; - final _data = {}; - final _result = await _dio.request>('/list/latest', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future> getHomepageMangas() async { - const _extra = {}; - final queryParameters = {}; - final _data = {}; - final _result = await _dio.request>('/list/homepage', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future>> getRecentUpdatedMangas( - {page, limit = 42}) async { - const _extra = {}; - final queryParameters = {r'page': page, r'limit': limit}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/list/updated', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future>> getGenres() async { - const _extra = {}; - final queryParameters = {}; - final _data = {}; - final _result = await _dio.request>('/category/genre', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future>> getGenreMangas( - {genre, zone, age, status, page, order}) async { - const _extra = {}; - final queryParameters = { - r'zone': zone, - r'age': age, - r'status': status, - r'page': page, - r'order': order?.toJson() - }; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>( - '/category/genre/$genre', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future>> searchMangas( - {keyword, page, order}) async { - const _extra = {}; - final queryParameters = { - r'page': page, - r'order': order?.toJson() - }; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/search/$keyword', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future>> getAllAuthors( - {genre, zone, age, page, order}) async { - const _extra = {}; - final queryParameters = { - r'genre': genre, - r'zone': zone, - r'age': age, - r'page': page, - r'order': order?.toJson() - }; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/author', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future> getAuthor({aid}) async { - const _extra = {}; - final queryParameters = {}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/author/$aid', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future>> getAuthorMangas( - {aid, page, order}) async { - const _extra = {}; - final queryParameters = { - r'page': page, - r'order': order?.toJson() - }; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>( - '/author/$aid/manga', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future>> getDayRanking({type}) async { - const _extra = {}; - final queryParameters = {r'type': type}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/rank/day', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future>> getWeekRanking({type}) async { - const _extra = {}; - final queryParameters = {r'type': type}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/rank/week', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future>> getMonthRanking({type}) async { - const _extra = {}; - final queryParameters = {r'type': type}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/rank/month', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future>> getTotalRanking({type}) async { - const _extra = {}; - final queryParameters = {r'type': type}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/rank/total', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future>> getMangaComments({mid, page}) async { - const _extra = {}; - final queryParameters = {r'page': page}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>( - '/comment/manga/$mid', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future> checkUserLogin({token}) async { - const _extra = {}; - final queryParameters = {}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>( - '/user/check_login', - queryParameters: queryParameters, - options: RequestOptions( - method: 'POST', - headers: {r'Authorization': token}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future> getUserInfo({token}) async { - const _extra = {}; - final queryParameters = {}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/user/info', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {r'Authorization': token}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future> login({username, password}) async { - const _extra = {}; - final queryParameters = { - r'username': username, - r'password': password - }; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/user/login', - queryParameters: queryParameters, - options: RequestOptions( - method: 'POST', - headers: {}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future> recordManga({token, mid, cid}) async { - const _extra = {}; - final queryParameters = {}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>( - '/user/manga/$mid/$cid', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {r'Authorization': token}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future>> getShelfMangas({token, page}) async { - const _extra = {}; - final queryParameters = {r'page': page}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/shelf', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {r'Authorization': token}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result>.fromJson(_result.data); - return value; - } - - @override - Future> checkShelfMangas({token, mid}) async { - const _extra = {}; - final queryParameters = {}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/shelf/$mid', - queryParameters: queryParameters, - options: RequestOptions( - method: 'GET', - headers: {r'Authorization': token}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future> addToShelf({token, mid}) async { - const _extra = {}; - final queryParameters = {}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/shelf/$mid', - queryParameters: queryParameters, - options: RequestOptions( - method: 'POST', - headers: {r'Authorization': token}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } - - @override - Future> removeFromShelf({token, mid}) async { - const _extra = {}; - final queryParameters = {}; - queryParameters.removeWhere((k, v) => v == null); - final _data = {}; - final _result = await _dio.request>('/shelf/$mid', - queryParameters: queryParameters, - options: RequestOptions( - method: 'DELETE', - headers: {r'Authorization': token}, - extra: _extra, - baseUrl: baseUrl), - data: _data); - final value = Result.fromJson(_result.data); - return value; - } -} diff --git a/lib/service/state/auth.dart b/lib/service/state/auth.dart deleted file mode 100644 index 985a3a0..0000000 --- a/lib/service/state/auth.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter_ahlib/util.dart'; - -class AuthState extends NotifiableData { - AuthState._(); - - static AuthState _instance; - - static AuthState get instance => _instance ??= AuthState._(); - - /// 全局 token - String token; - - /// 全局用户名 - String username; - - /// 是否登录 - bool get logined => token?.isNotEmpty == true; -} diff --git a/lib/service/storage/download_file.dart b/lib/service/storage/download_file.dart new file mode 100644 index 0000000..1db063c --- /dev/null +++ b/lib/service/storage/download_file.dart @@ -0,0 +1,193 @@ +import 'dart:io' show File; + +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:http/http.dart' as http; +import 'package:manhuagui_flutter/config.dart'; +import 'package:manhuagui_flutter/service/storage/storage.dart'; + +enum DownloadBehavior { + preferUsingCache, + mustUseCache, + forceDownload, +} + +enum OverwriteBehavior { + notAllow, + overwrite, + addSuffix, +} + +Future _defaultWhenOverwrite(String filepath) async { + return OverwriteBehavior.notAllow; +} + +String _defaultSuffixBuilder(int index) { + return ' ($index)'; +} + +class DownloadOption { + const DownloadOption({ + this.behavior = DownloadBehavior.preferUsingCache, + this.redecideFilepath, + this.ignoreHeadError = false, + this.whenOverwrite = _defaultWhenOverwrite, + this.suffixBuilder = _defaultSuffixBuilder, + this.headTimeout = const Duration(milliseconds: HEAD_TIMEOUT), + this.downloadTimeout, + }); + + final DownloadBehavior behavior; + final String Function(String? mime, String? extension)? redecideFilepath; + final bool ignoreHeadError; + final Future Function(String filepath) whenOverwrite; + final String Function(int index) suffixBuilder; + final Duration? headTimeout; + final Duration? downloadTimeout; +} + +enum DownloadExceptionType { + head, + existed, + noCache, + download, + other, +} + +class DownloadException implements Exception { + const DownloadException._(this.msg, [this.type = DownloadExceptionType.other]); + + const DownloadException._head(String msg) : this._(msg, DownloadExceptionType.head); + + const DownloadException._existed(String msg) : this._(msg, DownloadExceptionType.existed); + + const DownloadException._noCache(String msg) : this._(msg, DownloadExceptionType.noCache); + + const DownloadException._download(String msg) : this._(msg, DownloadExceptionType.download); + + final DownloadExceptionType type; + final String msg; + + factory DownloadException._fromObject(Object o, [DownloadExceptionType type = DownloadExceptionType.other]) { + if (o is DownloadException) { + return o; + } + return DownloadException._(o.toString(), type); + } + + @override + String toString() { + return '[$type] $msg'; + } +} + +// !!! +Future downloadFile({ + required String url, + required String filepath, + Map? headers, + CacheManager? cacheManager, + String? cacheKey, + DownloadOption? option, +}) async { + option ??= DownloadOption(); + var uri = Uri.parse(url); + + // 1. make http HEAD request asynchronously + Future filepathFuture; + if (option.redecideFilepath == null) { + filepathFuture = Future.value(filepath); + } else { + filepathFuture = Future.microtask(() async { + http.Response resp; + try { + var future = http.head(uri, headers: headers); + if (option!.headTimeout != null) { + future = future.timeout(option.headTimeout!, onTimeout: () => throw DownloadException._head('timed out')); + } + resp = await future; + } catch (e) { + throw DownloadException._head('Failed to make http HEAD request to $url: $e.'); + } + if (resp.statusCode != 200 && resp.statusCode != 201) { + throw DownloadException._head('Got invalid status code ${resp.statusCode} from $url.'); + } + var mime = resp.headers['content-type'] ?? ''; + var extension = getPreferredExtensionFromMime(mime); + return option.redecideFilepath!.call(mime, extension); + }).onError((e, s) { + if (!option!.ignoreHeadError) { + return Future.error(DownloadException._fromObject(e!), s); + } + return Future.value(option.redecideFilepath!.call(null, null)); + }); + } + + // 2. check file existence asynchronously + var fileFuture = filepathFuture.then((filepath) async { + var newFile = File(filepath); + if (await newFile.exists()) { + switch (await option!.whenOverwrite(filepath)) { + case OverwriteBehavior.overwrite: + await newFile.delete(); + break; + case OverwriteBehavior.addSuffix: + for (var i = 1;; i++) { + var basename = PathUtils.getWithoutExtension(filepath); + var extension = PathUtils.getExtension(filepath); + var fallbackFile = File('$basename${option.suffixBuilder(i)}$extension'); + if (!(await fallbackFile.exists())) { + newFile = fallbackFile; + break; + } + } + break; + case OverwriteBehavior.notAllow: + default: + throw DownloadException._existed('File $filepath exists before saving.'); + } + } + await newFile.create(recursive: true); + return newFile; + }).onError((e, s) { + return Future.error(DownloadException._fromObject(e!)); + }); + + try { + // 3. save cached data to file + if (option.behavior != DownloadBehavior.forceDownload) { + cacheManager ??= DefaultCacheManager(); + var cached = await cacheManager.getFileFromCache(cacheKey ?? url); + if (cached != null && !cached.validTill.isBefore(DateTime.now())) { + var destination = await fileFuture; + return await cached.file.copy(destination.path); + } + if (option.behavior == DownloadBehavior.mustUseCache) { + throw DownloadException._noCache('There is no data for $url in cache.'); + } + } + + // 4. download and save to file + http.Response resp; + try { + var future = http.get(uri, headers: headers); + if (option.downloadTimeout != null) { + future = future.timeout(option.downloadTimeout!, onTimeout: () => throw DownloadException._download('timed out')); + } + resp = await future; + } catch (e) { + throw DownloadException._download('Failed to make http GET request to $url: $e.'); + } + if (resp.statusCode != 200 && resp.statusCode != 201) { + throw DownloadException._download('Got invalid status code ${resp.statusCode} from $url.'); + } + var destination = await fileFuture; + return await destination.writeAsBytes(resp.bodyBytes, flush: true); + } catch (e) { + try { + var destination = await fileFuture; + await destination.delete(); + } catch (_) {} + throw DownloadException._fromObject(e); + } +} diff --git a/lib/service/storage/download_image.dart b/lib/service/storage/download_image.dart new file mode 100644 index 0000000..1687c30 --- /dev/null +++ b/lib/service/storage/download_image.dart @@ -0,0 +1,142 @@ +import 'dart:io' show File, Directory; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:manhuagui_flutter/config.dart'; +import 'package:manhuagui_flutter/service/storage/download_file.dart'; +import 'package:manhuagui_flutter/service/storage/storage.dart'; + +// ==== +// path +// ==== + +Future _getDownloadImageDirectoryPath(String url) async { + var basename = getTimestampTokenForFilename(); + var extension = PathUtils.getExtension(url.split('?')[0]); + var filename = '$basename$extension'; + return PathUtils.joinPath([await getPublicStorageDirectoryPath(), 'manhuagui_image', 'IMG_$filename']); // IMG_20220917_131013_206.jpg +} + +Future _getDownloadMangaDirectoryPath([int? mangaId, int? chapterId]) async { + if (mangaId == null) { + return PathUtils.joinPath([await getPublicStorageDirectoryPath(), 'manhuagui_download']); + } + if (chapterId == null) { + return PathUtils.joinPath([await getPublicStorageDirectoryPath(), 'manhuagui_download', mangaId.toString()]); + } + return PathUtils.joinPath([await getPublicStorageDirectoryPath(), 'manhuagui_download', mangaId.toString(), chapterId.toString()]); +} + +Future getDownloadedChapterPageFilePath({required int mangaId, required int chapterId, required int pageIndex, required String url}) async { + var basename = (pageIndex + 1).toString().padLeft(4, '0'); + var extension = PathUtils.getExtension(url.split('?')[0]); + var filename = '$basename$extension'; + return PathUtils.joinPath([await _getDownloadMangaDirectoryPath(mangaId, chapterId), filename]); +} + +// ======== +// download +// ======== + +Future downloadImageToGallery(String url) async { + var filepath = await _getDownloadImageDirectoryPath(url); + try { + var f = await downloadFile( + url: url, + filepath: filepath, + headers: { + 'User-Agent': USER_AGENT, + 'Referer': REFERER, + }, + cacheManager: DefaultCacheManager(), + option: DownloadOption( + behavior: DownloadBehavior.preferUsingCache, + whenOverwrite: (_) async => OverwriteBehavior.addSuffix, + downloadTimeout: Duration(milliseconds: DOWNLOAD_IMAGE_TIMEOUT), + ), + ); + await addToGallery(f); // <<< + return f; + } catch (e, s) { + print('===> exception when downloadImageToGallery:\n$e\n$s'); + return null; + } +} + +Future downloadChapterPage({required int mangaId, required int chapterId, required int pageIndex, required String url}) async { + var filepath = await getDownloadedChapterPageFilePath(mangaId: mangaId, chapterId: chapterId, pageIndex: pageIndex, url: url); + if (await File(filepath).exists()) { + return true; + } + try { + await downloadFile( + url: url, + filepath: filepath, + headers: { + 'User-Agent': USER_AGENT, + 'Referer': REFERER, + }, + cacheManager: DefaultCacheManager(), + option: DownloadOption( + behavior: DownloadBehavior.preferUsingCache, + whenOverwrite: (_) async => OverwriteBehavior.overwrite, + downloadTimeout: Duration(milliseconds: DOWNLOAD_IMAGE_TIMEOUT), + ), + ); + return true; + } catch (e, s) { + print('===> exception when downloadChapterPage:\n$e\n$s'); + return false; + } +} + +Future createNomediaFile() async { + var nomediaPath = PathUtils.joinPath([await _getDownloadMangaDirectoryPath(), '.nomedia']); + var nomediaFile = File(nomediaPath); + if (!(await nomediaFile.exists())) { + await nomediaFile.create(recursive: true); + } +} + +Future getDownloadedMangaBytes({required int mangaId}) async { + var mangaPath = await _getDownloadMangaDirectoryPath(mangaId); + var directory = Directory(mangaPath); + if (!(await directory.exists())) { + return 0; + } + + var totalBytes = 0; + await for (var entity in directory.list(recursive: true, followLinks: false)) { + if (entity is File) { + totalBytes += await entity.length(); + } + } + return totalBytes; +} + +// ====== +// delete +// ====== + +Future deleteDownloadedManga({required int mangaId}) async { + var mangaPath = await _getDownloadMangaDirectoryPath(mangaId); + var directory = Directory(mangaPath); + try { + await directory.delete(recursive: true); + return true; + } catch (e, s) { + print('===> exception when deleteDownloadedManga:\n$e\n$s'); + return false; + } +} + +Future deleteDownloadedChapter({required int mangaId, required int chapterId}) async { + var mangaPath = await _getDownloadMangaDirectoryPath(mangaId, chapterId); + var directory = Directory(mangaPath); + try { + await directory.delete(recursive: true); + return true; + } catch (e, s) { + print('===> exception when deleteDownloadedChapter:\n$e\n$s'); + return false; + } +} diff --git a/lib/service/storage/download_manga_task.dart b/lib/service/storage/download_manga_task.dart new file mode 100644 index 0000000..e744fb3 --- /dev/null +++ b/lib/service/storage/download_manga_task.dart @@ -0,0 +1,653 @@ +import 'package:flutter_ahlib/flutter_ahlib.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:manhuagui_flutter/model/chapter.dart'; +import 'package:manhuagui_flutter/model/entity.dart'; +import 'package:manhuagui_flutter/model/manga.dart'; +import 'package:manhuagui_flutter/page/page/dl_setting.dart'; +import 'package:manhuagui_flutter/service/db/download.dart'; +import 'package:manhuagui_flutter/service/dio/dio_manager.dart'; +import 'package:manhuagui_flutter/service/dio/retrofit.dart'; +import 'package:manhuagui_flutter/service/dio/wrap_error.dart'; +import 'package:manhuagui_flutter/service/evb/evb_manager.dart'; +import 'package:manhuagui_flutter/service/evb/events.dart'; +import 'package:manhuagui_flutter/service/native/notification.dart'; +import 'package:manhuagui_flutter/service/storage/download_image.dart'; +import 'package:manhuagui_flutter/service/storage/queue_manager.dart'; +import 'package:queue/queue.dart'; + +class DownloadMangaQueueTask extends QueueTask { + DownloadMangaQueueTask({ + required this.mangaId, + required this.mangaTitle, + required this.chapterIds, + required this.invertOrder, + int? parallel, + }) : _doingTask = false, + _succeeded = false, + _canceled = false, + _progress = DownloadMangaProgress.waiting(), + _pageQueue = Queue(parallel: parallel ?? DlSetting.defaultSetting().downloadPagesTogether); + + final int mangaId; + final String mangaTitle; + final List chapterIds; + final bool invertOrder; + + bool _doingTask; + + bool get doingTask => _doingTask; + + bool _succeeded; + + bool get succeeded => _succeeded; + + @override + Future doTask() async { + _doingTask = true; + _succeeded = await _coreDoTask(); + _doingTask = false; + _progress.showNotification(mangaId: mangaId, mangaTitle: mangaTitle, canceled: canceled, success: _succeeded); + } + + bool _canceled; + + @override + bool get canceled => _canceled; + + @override + void cancel() { + super.cancel(); + _canceled = true; + DownloadMangaProgress.cancelNotification(mangaId: mangaId); + if (!_doingTask) { + QueueManager.instance.tasks.remove(this); + doDefer(); // finished: true + } else { + var ev = DownloadMangaProgressChangedEvent(mangaId: mangaId, finished: false); + EventBusManager.instance.fire(ev); + } + } + + @override + Future doDefer() { + var ev = DownloadMangaProgressChangedEvent(mangaId: mangaId, finished: true); // finished means task is removed from queue + EventBusManager.instance.fire(ev); + var ev2 = DownloadedMangaEntityChangedEvent(mangaId: mangaId); + EventBusManager.instance.fire(ev2); + return Future.value(null); + } + + DownloadMangaProgress _progress; + + DownloadMangaProgress get progress => _progress; + + void _updateProgress(DownloadMangaProgress progress, {bool sendNotification = false}) { + _progress = progress; + if (sendNotification) { + _progress.showNotification(mangaId: mangaId, mangaTitle: mangaTitle, canceled: canceled); + } + var ev = DownloadMangaProgressChangedEvent(mangaId: mangaId, finished: false); + EventBusManager.instance.fire(ev); + } + + final Queue _pageQueue; + + void changeParallel(int parallel) { + _pageQueue.parallel = parallel; + } + + Future prepare({ + required String mangaCover, + required String mangaUrl, + required Tuple3? Function(int cid) getChapterTitleGroupPages, + }) async { + // 1. 更新任务状态 + _updateProgress( + DownloadMangaProgress.waiting(), + ); + + // 2. 合并请求下载的章节与数据库已有的章节,且保留请求下载章节的顺序 + var oldManga = await DownloadDao.getManga(mid: mangaId); + var oldChapterIds = oldManga?.downloadedChapters.map((el) => el.chapterId).toList() ?? []; + var dedupOldChapterIds = oldChapterIds.toList()..removeWhere((el) => chapterIds.contains(el)); + dedupOldChapterIds.sort((i, j) => !invertOrder ? i.compareTo(j) : j.compareTo(i)); + chapterIds.sort((i, j) => !invertOrder ? i.compareTo(j) : j.compareTo(i)); + chapterIds.addAll(dedupOldChapterIds); + + // 3. 检查漫画下载任务是否存在 + List newChapterIds; + var currentTasks = QueueManager.instance.getDownloadMangaQueueTasks(includingPreparing: false); + var previousTask = currentTasks.where((el) => el.mangaId == mangaId && !el.canceled).firstOrNull; + if (previousTask != null) { + // 下载任务已存在 => 找到新增的章节 + newChapterIds = chapterIds.where((el) => !previousTask.chapterIds.contains(el)).toList(); + } else { + // 下载任务不存在 => 保留原样 + newChapterIds = chapterIds.toList(); + } + if (newChapterIds.isEmpty) { + // 没有新增章节 => 无需任何变更,不需要入队 + return false; + } + + // 4. 更新漫画下载表 + await DownloadDao.addOrUpdateManga( + manga: DownloadedManga( + mangaId: mangaId, + mangaTitle: mangaTitle, + mangaCover: mangaCover, + mangaUrl: mangaUrl, + error: false /* 恢复为无错误 */, + updatedAt: (oldManga == null || chapterIds.length > oldChapterIds.length) + ? DateTime.now() // 有新增下载章节 => 更新为当前时间 + : oldManga.updatedAt /* 没有新增下载章节 => 无需更新时间 */, + downloadedChapters: [], + ), + ); + + // 5. 更新章节下载表,并通知数据库发生变化 + for (var chapterId in newChapterIds.toList()) { + var chapterTuple = getChapterTitleGroupPages(chapterId); + if (chapterTuple == null) { + newChapterIds.remove(chapterId); // almost unreachable + continue; + } + var oldChapter = oldManga?.downloadedChapters.where((el) => el.chapterId == chapterId).firstOrNull; + await DownloadDao.addOrUpdateChapter( + chapter: DownloadedChapter( + mangaId: mangaId, + chapterId: chapterId, + chapterTitle: chapterTuple.item1, + chapterGroup: chapterTuple.item2, + totalPageCount: chapterTuple.item3, + triedPageCount: oldChapter?.successPageCount ?? 0 /* 将尝试下载页数直接置为已成功下载页数 */, + successPageCount: oldChapter?.successPageCount ?? 0, + ), + ); + } + var ev = DownloadedMangaEntityChangedEvent(mangaId: mangaId); + EventBusManager.instance.fire(ev); + + // 6. 判断是否入队 + if (previousTask != null) { + // 漫画下载任务已存在 => 往后添加新漫画章节,标记为需要不需要入队 + previousTask.chapterIds.addAll(newChapterIds); + return false; + } + // 新的漫画下载任务 => 整体更新漫画章节,标记为需要入队 (由 doTask 处理准备列表) + chapterIds.clear(); + chapterIds.addAll(newChapterIds); + return true; + } + + Future _coreDoTask() async { + final client = RestClient(DioManager.instance.dio); + + // 1. 创建必要文件,并更新状态 + await createNomediaFile(); + _updateProgress( + DownloadMangaProgress.gettingManga(), + sendNotification: true, + ); + + // 2. 获取漫画数据 + var oldManga = await DownloadDao.getManga(mid: mangaId); + Manga manga; + try { + manga = (await client.getManga(mid: mangaId)).data; + } catch (e, s) { + // 请求错误 => 更新漫画下载表为下载错误,然后直接返回 + var err = wrapError(e, s).text; + print('===> exception when DownloadMangaQueueTask (manga):\n$err'); + await Fluttertoast.cancel(); + Fluttertoast.showToast(msg: '获取《$mangaTitle》信息出错:$err'); + if (oldManga != null) { + await DownloadDao.addOrUpdateManga( + manga: oldManga.copyWith(error: true), + ); + } + return false; + } + + // 3. 更新漫画下载表 + await DownloadDao.addOrUpdateManga( + manga: DownloadedManga( + mangaId: manga.mid, + mangaTitle: manga.title, + mangaCover: manga.cover, + mangaUrl: manga.url, + error: false, + updatedAt: oldManga?.updatedAt ?? DateTime.now(), + downloadedChapters: [], + ), + ); + + // 4. 先处理所有已下载完的章节 + _updateProgress( + DownloadMangaProgress.gettingChapter( + manga: manga, + startedChapters: [], + currentChapterId: chapterIds.first, + ), + sendNotification: true, + ); + var startedChapters = []; + for (var chapterId in chapterIds) { + // 4.1. 判断请求是否被取消 + if (canceled) { + return false; // 被取消 => 直接结束 + } + + // 4.2. 判断当前章节是否下载完 + var oldChapter = oldManga?.downloadedChapters.where((el) => el.chapterId == chapterId).firstOrNull; + if (oldChapter == null || !oldChapter.succeeded) { + continue; // 未找到 / 未成功 => 后续需要下载该章节 + } + + // 4.3. 根据请求获得的漫画数据更新章节下载表 + var chapterTuple = manga.chapterGroups.findChapterAndGroupName(chapterId); + if (chapterTuple != null) { + var totalPageCount = chapterTuple.item1.pageCount; + await DownloadDao.addOrUpdateChapter( + chapter: DownloadedChapter( + mangaId: mangaId, + chapterId: chapterId, + chapterTitle: chapterTuple.item1.title, + chapterGroup: chapterTuple.item2, + totalPageCount: totalPageCount /* 已下载完的章节,total == tried == success */, + triedPageCount: totalPageCount, + successPageCount: totalPageCount, + ), + ); + } + + // 4.4. 往已开始的章节列表添加空占位,并发送通知 + startedChapters.add(null); + _updateProgress( + DownloadMangaProgress.gettingChapter( + manga: manga, + startedChapters: startedChapters, + currentChapterId: chapterId, + ), + ); + } + + // 5. 再按顺序处理所有未下载完的章节 + var somePagesFailed = false; + for (var i = 0; i < chapterIds.length /* appendable */; i++) { + // 5.1. 判断请求是否被取消 + if (canceled) { + return false; // 被取消 => 直接结束 + } + + // 5.2. 判断当前章节是否下载完 + var chapterId = chapterIds[i]; + var oldChapter = oldManga?.downloadedChapters.where((el) => el.chapterId == chapterId).firstOrNull; + if (oldChapter != null && oldChapter.succeeded) { + continue; // 跳过已下载完的章节 + } + + // 5.3. 获取章节数据,并记录至已开始的章节列表 + startedChapters.add(null); // 占位 + _updateProgress( + DownloadMangaProgress.gettingChapter( + manga: manga, + startedChapters: startedChapters, + currentChapterId: chapterId, + ), + sendNotification: true, + ); + MangaChapter chapter; + try { + chapter = (await client.getMangaChapter(mid: mangaId, cid: chapterId)).data; + } catch (e, s) { + // 请求错误 => 更新章节下载表,并跳过当前章节 + print('===> exception when DownloadMangaQueueTask (chapter):\n${wrapError(e, s).text}'); + if (oldChapter != null) { + await DownloadDao.addOrUpdateChapter( + chapter: oldChapter.copyWith( + triedPageCount: oldChapter.totalPageCount /* 直接将漫画章节表的尝试下载页数置为所有页数,表示出错 */, + successPageCount: oldChapter.successPageCount /* 已成功下载的页数不做变化 */, + ), + ); + var ev = DownloadedMangaEntityChangedEvent(mangaId: mangaId); + EventBusManager.instance.fire(ev); + } + continue; + } + startedChapters[startedChapters.length - 1] = chapter; // 更新占位 + _updateProgress( + DownloadMangaProgress.gotChapter( + manga: manga, + startedChapters: startedChapters, + currentChapterId: chapterId, + currentChapter: chapter, + ), + sendNotification: true, + ); + + // 5.4. 更新章节信息表 + var chapterGroup = manga.chapterGroups.findChapterAndGroupName(chapterId)?.item2 ?? ''; + await DownloadDao.addOrUpdateChapter( + chapter: DownloadedChapter( + mangaId: chapter.mid, + chapterId: chapter.cid, + chapterTitle: chapter.title, + chapterGroup: chapterGroup, + totalPageCount: chapter.pageCount, + triedPageCount: 0 /* 从零开始 */, + successPageCount: 0 /* 从零开始 */, + ), + ); + + // 5.5. 按顺序处理章节每一页 + var successChapterPageCount = 0; + var failedChapterPageCount = 0; + for (var i = 0; i < chapter.pages.length; i++) { + // 5.5.1. 判断请求是否被取消 + if (canceled) { + break; // 被取消 => 跳出当前页面处理逻辑,跳到 5.6 + } + + var pageIndex = i; + _pageQueue.add(() async { + // 5.5.2. 判断请求是否被取消 + if (canceled) { + return; // 被取消 => 跳出当前页面处理逻辑,跳到 5.6 + } + + // 5.5.3. 下载页面,若文件已存在则跳过 + var pageUrl = chapter.pages[pageIndex]; + var ok = await downloadChapterPage( + mangaId: chapter.mid, + chapterId: chapter.cid, + pageIndex: pageIndex, + url: pageUrl, + ); + if (!ok) { + failedChapterPageCount++; + somePagesFailed = true; + } else { + successChapterPageCount++; + } + + // 5.5.4. 通知页面下载进度 + _updateProgress( + DownloadMangaProgress.gotPage( + manga: manga, + startedChapters: startedChapters, + currentChapterId: chapterId, + currentChapter: chapter, + triedChapterPageCount: successChapterPageCount + failedChapterPageCount, + successChapterPageCount: successChapterPageCount, + ), + sendNotification: true, + ); + }).onError((e, s) { + if (e is! QueueCancelledException) { + print('===> exception when DownloadMangaQueueTask (queue):\n$e\n$s'); + } // 出错 => 跳到 5.7 + }); + } // for in chapter.pages + + try { + // 5.6. 判断请求是否被取消 + if (canceled) { + // 被取消 => 直接取消页面下载队列,在返回前会跳到 5.7 更新章节下载表 + _pageQueue.cancel(); + return false; + } else { + // 不被取消 => 等待章节中所有页面处理结束 + await _pageQueue.onComplete; + } + } catch (e, s) { + if (e is! QueueCancelledException) { + print('===> exception when DownloadMangaQueueTask (queue):\n$e\n$s'); + } + } finally { + // 5.7. 无论是否被取消,都需要更新章节下载表 + await DownloadDao.addOrUpdateChapter( + chapter: DownloadedChapter( + mangaId: chapter.mid, + chapterId: chapter.cid, + chapterTitle: chapter.title, + chapterGroup: chapterGroup, + totalPageCount: chapter.pages.length, + triedPageCount: successChapterPageCount + failedChapterPageCount /* 真实的尝试下载页数 */, + successPageCount: successChapterPageCount, + ), + ); + var ev = DownloadedMangaEntityChangedEvent(mangaId: mangaId); + EventBusManager.instance.fire(ev); + } + } // for in chapterIds + + // 6. 返回下载结果,用于更新下载任务的 succeeded 标志 + if (somePagesFailed) { + return false; + } + return true; + } +} + +enum DownloadMangaProgressStage { + waiting, + gettingManga, + gettingChapter, + gotChapter, + gotPage, +} + +class DownloadMangaProgress { + const DownloadMangaProgress.waiting() + : stage = DownloadMangaProgressStage.waiting, + manga = null, + startedChapters = null, + currentChapterId = null, + currentChapter = null, + triedChapterPageCount = null, + successChapterPageCount = null; + + const DownloadMangaProgress.gettingManga() + : stage = DownloadMangaProgressStage.gettingManga, + manga = null, + currentChapterId = null, + startedChapters = null, + currentChapter = null, + triedChapterPageCount = null, + successChapterPageCount = null; + + const DownloadMangaProgress.gettingChapter({ + required Manga this.manga, + required List this.startedChapters, + required int this.currentChapterId, + }) : stage = DownloadMangaProgressStage.gettingChapter, + currentChapter = null, + triedChapterPageCount = null, + successChapterPageCount = null; + + const DownloadMangaProgress.gotChapter({ + required Manga this.manga, + required List this.startedChapters, + required int this.currentChapterId, + required MangaChapter this.currentChapter, + }) : stage = DownloadMangaProgressStage.gotChapter, + triedChapterPageCount = null, + successChapterPageCount = null; + + const DownloadMangaProgress.gotPage({ + required Manga this.manga, + required List this.startedChapters, + required int this.currentChapterId, + required MangaChapter this.currentChapter, + required int this.triedChapterPageCount, + required int this.successChapterPageCount, + }) : stage = DownloadMangaProgressStage.gotPage; + + // 当前阶段 + final DownloadMangaProgressStage stage; + + // 已获得/已开始的数据 + final Manga? manga; + final List? startedChapters; + + // 当前下载的章节 + final int? currentChapterId; + final MangaChapter? currentChapter; + final int? triedChapterPageCount; + final int? successChapterPageCount; + + Future showNotification({required int mangaId, required String mangaTitle, required bool canceled, bool? success}) async { + if (canceled) { + return; + } + + if (success != null) { + await NotificationManager.instance.showDownloadChannelNotification( + id: mangaId, + title: mangaTitle, + body: success ? '下载已完成' : '下载失败', + icon: NotificationManager.drawableStatDownloadDone, + largeIcon: NotificationManager.mipMapIcLaunch, + autoCancel: true, + ongoing: false, + showProgress: false, + category: success ? NotificationManager.statusCategory : NotificationManager.errCategory, + ); + } else { + Future show(int mangaId, String mangaTitle, String bodyText, int? triedPageCount, int? totalPageCount) async { + await NotificationManager.instance.showDownloadChannelNotification( + id: mangaId, + title: mangaTitle, + body: bodyText, + icon: NotificationManager.drawableStatDownload, + largeIcon: NotificationManager.mipMapIcLaunch, + autoCancel: false, + ongoing: true, + showProgress: true, + indeterminate: triedPageCount == null, + progress: triedPageCount ?? 0, + maxProgress: totalPageCount ?? 1, + category: NotificationManager.progressCategory, + ); + } + + switch (stage) { + case DownloadMangaProgressStage.gettingManga: + show(mangaId, mangaTitle, '获取漫画信息中', null, null); + break; + case DownloadMangaProgressStage.gettingChapter: + show(mangaId, mangaTitle, '获取章节信息中', null, null); + break; + case DownloadMangaProgressStage.gotChapter: + var body = '${currentChapter!.title} 0/${currentChapter!.pageCount}'; + show(mangaId, mangaTitle, body, 0, 1); + break; + case DownloadMangaProgressStage.gotPage: + var body = '${currentChapter!.title} ${triedChapterPageCount!}/${currentChapter!.pageCount}'; + show(mangaId, mangaTitle, body, triedChapterPageCount!, currentChapter!.pageCount); + break; + default: + // skip + } + } + } + + static Future cancelNotification({required int mangaId}) async { + await NotificationManager.instance.cancelNotification(id: mangaId); + } +} + +extension QueueManagerExtension on QueueManager { + static final preparingTasks = []; + + List getDownloadMangaQueueTasks({bool includingPreparing = true}) { + var prepared = tasks.whereType().toList(); + if (!includingPreparing) { + return prepared; + } + + var preparing = preparingTasks.toList(); + prepared.addAll(preparing.where((t1) => !prepared.any((t) => t.mangaId == t1.mangaId))); + return prepared; + } + + DownloadMangaQueueTask? getDownloadMangaQueueTask(int mangaId) { + return tasks.whereType().where((t) => t.mangaId == mangaId).firstOrNull ?? // prepared + preparingTasks.where((t) => t.mangaId == mangaId).firstOrNull; // preparing + } +} + +// !!! +Future quickBuildDownloadMangaQueueTask({ + required int mangaId, + required String mangaTitle, + required String mangaCover, + required String mangaUrl, + required List chapterIds, + required int parallel, + required bool invertOrder, + required bool addToTask, + // + List? throughGroupList, + List? throughChapterList, +}) async { + // 1. 构造漫画下载任务 + var newTask = DownloadMangaQueueTask( + mangaId: mangaId, + mangaTitle: mangaTitle, + chapterIds: chapterIds, + parallel: parallel, + invertOrder: invertOrder, + ); + + // 2. 更新数据库 + QueueManagerExtension.preparingTasks.add(newTask); // 此时还未入队,先添加至"准备列表"中 + var need = await newTask.prepare( + mangaCover: mangaCover, + mangaUrl: mangaUrl, + getChapterTitleGroupPages: (cid) { + // => DownloadSelectPage + if (throughGroupList != null) { + var tuple = throughGroupList.findChapterAndGroupName(cid); + if (tuple == null) { + return null; // almost unreachable + } + var chapterTitle = tuple.item1.title; + var groupName = tuple.item2; + var pageCount = tuple.item1.pageCount; + return Tuple3(chapterTitle, groupName, pageCount); + } + + // => DownloadPage / DownloadTocPage + if (throughChapterList != null) { + var chapter = throughChapterList.where((el) => el.chapterId == cid).firstOrNull; + if (chapter == null) { + return null; // almost unreachable + } + var chapterTitle = chapter.chapterTitle; + var groupName = chapter.chapterGroup; + var pageCount = chapter.totalPageCount; + return Tuple3(chapterTitle, groupName, pageCount); + } + + // almost unreachable + assert( + false, + 'Invalid using of quickBuildDownloadMangaQueueTask, ' + 'throughGroupList and throughChapterList must have at least one noo-null value.', + ); + return null; + }, + ); + QueueManagerExtension.preparingTasks.removeWhere((el) => el.mangaId == mangaId); // 完成准备,直接从"准备列表"中移除 + + // 3. 必要时入队,异步等待执行 + if (!need) { + return null; + } + if (addToTask) { + QueueManager.instance.addTask(newTask); + } + return newTask; +} diff --git a/lib/service/storage/queue_manager.dart b/lib/service/storage/queue_manager.dart new file mode 100644 index 0000000..8c479e0 --- /dev/null +++ b/lib/service/storage/queue_manager.dart @@ -0,0 +1,87 @@ +import 'package:queue/queue.dart'; + +class QueueManager { + QueueManager._(); + + static QueueManager? _instance; + + static QueueManager get instance { + _instance ??= QueueManager._(); + return _instance!; + } + + Queue? _queue; + List>? _tasks; + + Queue get queue { + if (_queue == null) { + _queue = Queue(parallel: 1); + _tasks = >[]; + } + return _queue!; + } + + List> get tasks { + var _ = queue; + return _tasks!; + } + + Future addTask(QueueTask task) async { + tasks.add(task); + try { + var result = await queue.add(() async { + if (task.canceled) { + return null; // canceled when not started + } + return await task.doTask(); + }); + return result; + } catch (e, s) { + if (e is QueueCancelledException) { + return Future.value(null); + } + return Future.error(e, s); + } finally { + if (tasks.contains(task)) { + tasks.remove(task); + await task.doDefer(); + } + } + } +} + +abstract class QueueTask { + var _canceled = false; + + bool get canceled => _canceled; + + void cancel() { + _canceled = true; + } + + Future doTask(); + + Future doDefer() { + return Future.value(null); + } +} + +class FuncQueueTask extends QueueTask { + FuncQueueTask({ + required this.task, + this.defer, + }); + + final Future Function() task; + final Future Function()? defer; + + @override + Future doTask() { + return task.call(); + } + + @override + Future doDefer() { + return defer?.call() ?? Future.value(null); + } +} diff --git a/lib/service/storage/storage.dart b/lib/service/storage/storage.dart new file mode 100644 index 0000000..6cd44fc --- /dev/null +++ b/lib/service/storage/storage.dart @@ -0,0 +1,82 @@ +import 'dart:io' show File, Directory, Platform; + +import 'package:external_path/external_path.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:manhuagui_flutter/config.dart'; +import 'package:path/path.dart' as path_; +import 'package:path_provider/path_provider.dart'; + +// ============= +// path and name +// ============= + +Future getPublicStorageDirectoryPath() async { + final storageDirectories = await ExternalPath.getExternalStorageDirectories(); + if (storageDirectories.isEmpty) { + throw Exception('Cannot get external storage directory.'); + } + return await PathUtils.joinPathAndCheck( + [storageDirectories.first, APP_NAME], + isDirectoryPath: true, + ); // /storage/emulated/0/Manhuagui +} + +Future getPrivateStorageDirectoryPath() async { + final storageDirectory = await getExternalStorageDirectory(); // sandbox + return await PathUtils.joinPathAndCheck( + [storageDirectory!.path], + isDirectoryPath: true, + ); // /storage/emulated/0/android/com.aoihosizora.manhuagui +} + +String getTimestampTokenForFilename([DateTime? time, String? pattern]) { + final df = DateFormat(pattern ?? 'yyyyMMdd_HHmmss_SSS'); + return df.format(time ?? DateTime.now()); +} + +// ========== +// path utils +// ========== + +class PathUtils { + static String joinPath(List paths) { + return path_.joinAll(paths); + } + + static String getWithoutExtension(String path) { + return path_.withoutExtension(path); + } + + static String getExtension(String path) { + return path_.extension(path); + } + + static Future joinPathAndCheck(List paths, {bool isDirectoryPath = false}) async { + var newPath = path_.joinAll(paths); + var directory = Directory(isDirectoryPath ? newPath : path_.dirname(newPath)); + if (!(await directory.exists())) { + await directory.create(recursive: true); + } + return newPath; + } +} + +// ======= +// gallery +// ======= + +const _channelName = 'com.aoihosizora.manhuagui'; +const _channel = MethodChannel(_channelName); +const _insertMediaMethodName = 'insertMedia'; + +Future addToGallery(File file) async { + if (!Platform.isAndroid) { + return; // unreachable + } + + // Intent.ACTION_MEDIA_SCANNER_SCAN_FILE + await _channel.invokeMethod(_insertMediaMethodName, { + 'filepath': file.path, + }); +} diff --git a/pubspec.lock b/pubspec.lock index a63a400..0f7f507 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -7,224 +7,343 @@ packages: name: _fe_analyzer_shared url: "https://pub.flutter-io.cn" source: hosted - version: "12.0.0" + version: "40.0.0" analyzer: dependency: transitive description: name: analyzer url: "https://pub.flutter-io.cn" source: hosted - version: "0.40.6" + version: "4.1.0" args: dependency: transitive description: name: args url: "https://pub.flutter-io.cn" source: hosted - version: "1.6.0" + version: "2.3.1" async: dependency: transitive description: name: async url: "https://pub.flutter-io.cn" source: hosted - version: "2.5.0-nullsafety.1" + version: "2.8.2" + basic_utils: + dependency: "direct main" + description: + name: basic_utils + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.3.0" + battery_info: + dependency: "direct main" + description: + name: battery_info + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" boolean_selector: dependency: transitive description: name: boolean_selector url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.0-nullsafety.1" + version: "2.1.0" build: dependency: transitive description: name: build url: "https://pub.flutter-io.cn" source: hosted - version: "1.5.0" + version: "2.3.0" build_config: dependency: transitive description: name: build_config url: "https://pub.flutter-io.cn" source: hosted - version: "0.4.2" + version: "1.1.0" build_daemon: dependency: transitive description: name: build_daemon url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.4" + version: "3.1.0" build_resolvers: dependency: transitive description: name: build_resolvers url: "https://pub.flutter-io.cn" source: hosted - version: "1.4.3" + version: "2.0.9" build_runner: dependency: "direct dev" description: name: build_runner url: "https://pub.flutter-io.cn" source: hosted - version: "1.10.4" + version: "2.2.0" build_runner_core: dependency: transitive description: name: build_runner_core url: "https://pub.flutter-io.cn" source: hosted - version: "6.0.3" + version: "7.2.3" built_collection: dependency: transitive description: name: built_collection url: "https://pub.flutter-io.cn" source: hosted - version: "4.3.2" + version: "5.1.1" built_value: dependency: transitive description: name: built_value url: "https://pub.flutter-io.cn" source: hosted - version: "7.1.0" + version: "8.4.1" cached_network_image: dependency: "direct main" description: name: cached_network_image url: "https://pub.flutter-io.cn" source: hosted - version: "2.3.3" + version: "3.2.0" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" carousel_slider: dependency: "direct main" description: name: carousel_slider url: "https://pub.flutter-io.cn" source: hosted - version: "2.3.1" + version: "4.1.1" characters: dependency: transitive description: name: characters url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.0-nullsafety.3" + version: "1.2.0" charcode: dependency: transitive description: name: charcode url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.3.1" checked_yaml: dependency: transitive description: name: checked_yaml url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.2" - cli_util: - dependency: transitive - description: - name: cli_util - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.2.0" + version: "2.0.1" clock: dependency: transitive description: name: clock url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.0-nullsafety.1" + version: "1.1.0" code_builder: dependency: transitive description: name: code_builder url: "https://pub.flutter-io.cn" source: hosted - version: "3.5.0" + version: "4.1.0" collection: dependency: transitive description: name: collection url: "https://pub.flutter-io.cn" source: hosted - version: "1.15.0-nullsafety.3" + version: "1.15.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.6+1" + connectivity_plus_linux: + dependency: transitive + description: + name: connectivity_plus_linux + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.1" + connectivity_plus_macos: + dependency: transitive + description: + name: connectivity_plus_macos + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.6" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + connectivity_plus_web: + dependency: transitive + description: + name: connectivity_plus_web + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.5" + connectivity_plus_windows: + dependency: transitive + description: + name: connectivity_plus_windows + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" convert: dependency: transitive description: name: convert url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.1" + version: "3.0.2" crypto: dependency: transitive description: name: crypto url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.5" + version: "3.0.2" cupertino_icons: dependency: "direct main" description: name: cupertino_icons url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.3" + version: "1.0.5" dart_style: dependency: transitive description: name: dart_style url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.9" + version: "2.2.3" + dbus: + dependency: transitive + description: + name: dbus + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.3" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.0" + device_info_plus_linux: + dependency: transitive + description: + name: device_info_plus_linux + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" + device_info_plus_macos: + dependency: transitive + description: + name: device_info_plus_macos + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.3" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.1" + device_info_plus_web: + dependency: transitive + description: + name: device_info_plus_web + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" + device_info_plus_windows: + dependency: transitive + description: + name: device_info_plus_windows + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" dio: dependency: "direct main" description: name: dio url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.10" + version: "4.0.6" + event_bus: + dependency: "direct main" + description: + name: event_bus + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" + external_path: + dependency: "direct main" + description: + name: external_path + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" fake_async: dependency: transitive description: name: fake_async url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.2.0" ffi: dependency: transitive description: name: ffi url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.3" + version: "1.2.1" file: dependency: transitive description: name: file url: "https://pub.flutter-io.cn" source: hosted - version: "5.2.1" - filesize: - dependency: "direct main" - description: - name: filesize - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.0.4" + version: "6.1.4" fixnum: dependency: transitive description: name: fixnum url: "https://pub.flutter-io.cn" source: hosted - version: "0.10.11" + version: "1.0.1" flutter: dependency: "direct main" description: flutter @@ -233,9 +352,9 @@ packages: flutter_ahlib: dependency: "direct main" description: - name: flutter_ahlib - url: "https://pub.flutter-io.cn" - source: hosted + path: "../flutter_ahlib" + relative: true + source: path version: "1.2.0" flutter_blurhash: dependency: transitive @@ -243,35 +362,63 @@ packages: name: flutter_blurhash url: "https://pub.flutter-io.cn" source: hosted - version: "0.5.0" + version: "0.7.0" flutter_cache_manager: - dependency: transitive + dependency: "direct main" description: name: flutter_cache_manager url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.0" + version: "3.3.0" flutter_keyboard_visibility: dependency: transitive description: name: flutter_keyboard_visibility url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.2" + version: "5.3.0" flutter_keyboard_visibility_platform_interface: dependency: transitive description: name: flutter_keyboard_visibility_platform_interface url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.1" + version: "2.0.0" flutter_keyboard_visibility_web: dependency: transitive description: name: flutter_keyboard_visibility_web url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.1" + version: "2.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.4" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.9.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.1" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.0.0" flutter_localizations: dependency: "direct dev" description: flutter @@ -283,14 +430,21 @@ packages: name: flutter_rating_bar url: "https://pub.flutter-io.cn" source: hosted - version: "3.2.0+1" + version: "4.0.1" + flutter_share: + dependency: "direct main" + description: + name: flutter_share + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" flutter_staggered_grid_view: dependency: transitive description: name: flutter_staggered_grid_view url: "https://pub.flutter-io.cn" source: hosted - version: "0.3.2" + version: "0.6.2" flutter_test: dependency: "direct dev" description: flutter @@ -302,14 +456,14 @@ packages: name: flutter_typeahead url: "https://pub.flutter-io.cn" source: hosted - version: "1.9.1" + version: "3.2.7" flutter_web_browser: dependency: "direct main" description: name: flutter_web_browser url: "https://pub.flutter-io.cn" source: hosted - version: "0.13.0" + version: "0.17.1" flutter_web_plugins: dependency: transitive description: flutter @@ -321,336 +475,420 @@ packages: name: fluttertoast url: "https://pub.flutter-io.cn" source: hosted - version: "4.0.1" + version: "8.0.7" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" glob: dependency: transitive description: name: glob url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.0" + version: "2.1.0" graphs: dependency: transitive description: name: graphs url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.0" + version: "2.1.0" http: dependency: transitive description: name: http url: "https://pub.flutter-io.cn" source: hosted - version: "0.12.2" + version: "0.13.5" http_multi_server: dependency: transitive description: name: http_multi_server url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.0" + version: "3.2.1" http_parser: dependency: transitive description: name: http_parser url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.4" + version: "4.0.1" intl: dependency: "direct main" description: name: intl url: "https://pub.flutter-io.cn" source: hosted - version: "0.16.1" + version: "0.17.0" io: dependency: transitive description: name: io url: "https://pub.flutter-io.cn" source: hosted - version: "0.3.4" + version: "1.0.3" js: dependency: transitive description: name: js url: "https://pub.flutter-io.cn" source: hosted - version: "0.6.3-nullsafety.1" + version: "0.6.3" json_annotation: dependency: "direct main" description: name: json_annotation url: "https://pub.flutter-io.cn" source: hosted - version: "3.1.0" + version: "4.6.0" json_serializable: dependency: "direct dev" description: name: json_serializable url: "https://pub.flutter-io.cn" source: hosted - version: "3.5.0" + version: "6.3.1" + lints: + dependency: transitive + description: + name: lints + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" logging: dependency: transitive description: name: logging url: "https://pub.flutter-io.cn" source: hosted - version: "0.11.4" + version: "1.0.2" matcher: dependency: transitive description: name: matcher url: "https://pub.flutter-io.cn" source: hosted - version: "0.12.10-nullsafety.1" + version: "0.12.11" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.3" material_floating_search_bar: dependency: "direct main" description: name: material_floating_search_bar url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.5" + version: "0.3.7" meta: dependency: transitive description: name: meta url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.0-nullsafety.4" + version: "1.7.0" mime: dependency: transitive description: name: mime url: "https://pub.flutter-io.cn" source: hosted - version: "0.9.7" - node_interop: - dependency: transitive - description: - name: node_interop - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.2.0" - node_io: + version: "1.0.2" + nm: dependency: transitive description: - name: node_io + name: nm url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.1" + version: "0.5.0" octo_image: dependency: transitive description: name: octo_image url: "https://pub.flutter-io.cn" source: hosted - version: "0.3.0" + version: "1.0.2" package_config: dependency: transitive description: name: package_config url: "https://pub.flutter-io.cn" source: hosted - version: "1.9.3" + version: "2.1.0" path: dependency: "direct main" description: name: path url: "https://pub.flutter-io.cn" source: hosted - version: "1.8.0-nullsafety.1" + version: "1.8.0" path_provider: dependency: "direct main" description: name: path_provider url: "https://pub.flutter-io.cn" source: hosted - version: "1.6.24" + version: "2.0.11" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.20" + path_provider_ios: + dependency: transitive + description: + name: path_provider_ios + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.11" path_provider_linux: dependency: transitive description: name: path_provider_linux url: "https://pub.flutter-io.cn" source: hosted - version: "0.0.1+2" + version: "2.1.7" path_provider_macos: dependency: transitive description: name: path_provider_macos url: "https://pub.flutter-io.cn" source: hosted - version: "0.0.4+6" + version: "2.0.6" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.4" + version: "2.0.4" path_provider_windows: dependency: transitive description: name: path_provider_windows url: "https://pub.flutter-io.cn" source: hosted - version: "0.0.4+3" + version: "2.0.7" pedantic: dependency: transitive description: name: pedantic url: "https://pub.flutter-io.cn" source: hosted - version: "1.9.2" + version: "1.11.1" permission_handler: dependency: "direct main" description: name: permission_handler url: "https://pub.flutter-io.cn" source: hosted - version: "5.0.1+1" + version: "10.0.2" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.0.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.0.4" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.1" + version: "3.8.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.0" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.4.0" photo_view: dependency: "direct main" description: name: photo_view url: "https://pub.flutter-io.cn" source: hosted - version: "0.10.3" + version: "0.14.0" platform: dependency: transitive description: name: platform url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.1" + version: "3.1.0" plugin_platform_interface: dependency: transitive description: name: plugin_platform_interface url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.3" + version: "2.1.3" + pointycastle: + dependency: transitive + description: + name: pointycastle + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.6.2" pool: dependency: transitive description: name: pool url: "https://pub.flutter-io.cn" source: hosted - version: "1.4.0" + version: "1.5.1" process: dependency: transitive description: name: process url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.13" + version: "4.2.4" pub_semver: dependency: transitive description: name: pub_semver url: "https://pub.flutter-io.cn" source: hosted - version: "1.4.4" + version: "2.1.1" pubspec_parse: dependency: transitive description: name: pubspec_parse url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.5" + version: "1.2.1" + queue: + dependency: "direct main" + description: + name: queue + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.0+1" quiver: dependency: transitive description: name: quiver url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.5" + version: "3.1.0" retrofit: dependency: "direct main" description: name: retrofit url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.4+1" + version: "3.0.1+1" retrofit_generator: dependency: "direct dev" description: name: retrofit_generator url: "https://pub.flutter-io.cn" source: hosted - version: "1.4.0+1" + version: "4.0.3+2" rxdart: dependency: transitive description: name: rxdart url: "https://pub.flutter-io.cn" source: hosted - version: "0.24.1" + version: "0.27.5" shared_preferences: dependency: "direct main" description: name: shared_preferences url: "https://pub.flutter-io.cn" source: hosted - version: "0.5.12+4" + version: "2.0.15" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.13" + shared_preferences_ios: + dependency: transitive + description: + name: shared_preferences_ios + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux url: "https://pub.flutter-io.cn" source: hosted - version: "0.0.2+4" + version: "2.1.1" shared_preferences_macos: dependency: transitive description: name: shared_preferences_macos url: "https://pub.flutter-io.cn" source: hosted - version: "0.0.1+11" + version: "2.0.4" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.4" + version: "2.1.0" shared_preferences_web: dependency: transitive description: name: shared_preferences_web url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.2+7" + version: "2.0.4" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows url: "https://pub.flutter-io.cn" source: hosted - version: "0.0.1+3" + version: "2.1.1" shelf: dependency: transitive description: name: shelf url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.9" + version: "1.3.2" shelf_web_socket: dependency: transitive description: name: shelf_web_socket url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.3" + version: "1.0.2" sky_engine: dependency: transitive description: flutter @@ -662,189 +900,259 @@ packages: name: source_gen url: "https://pub.flutter-io.cn" source: hosted - version: "0.9.8" + version: "1.2.2" + source_helper: + dependency: transitive + description: + name: source_helper + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.2" source_span: dependency: transitive description: name: source_span url: "https://pub.flutter-io.cn" source: hosted - version: "1.8.0-nullsafety.2" + version: "1.8.1" sqflite: dependency: "direct main" description: name: sqflite url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.2+1" + version: "2.0.2+1" sqflite_common: dependency: transitive description: name: sqflite_common url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.2+1" + version: "2.3.0" stack_trace: dependency: transitive description: name: stack_trace url: "https://pub.flutter-io.cn" source: hosted - version: "1.10.0-nullsafety.4" + version: "1.10.0" stream_channel: dependency: transitive description: name: stream_channel url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.0-nullsafety.1" + version: "2.1.0" stream_transform: dependency: transitive description: name: stream_transform url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.0" + version: "2.0.0" string_scanner: dependency: transitive description: name: string_scanner url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.0-nullsafety.1" + version: "1.1.0" synchronized: dependency: "direct main" description: name: synchronized url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.0+2" + version: "3.0.0+3" term_glyph: dependency: transitive description: name: term_glyph url: "https://pub.flutter-io.cn" source: hosted - version: "1.2.0-nullsafety.1" + version: "1.2.0" test_api: dependency: transitive description: name: test_api url: "https://pub.flutter-io.cn" source: hosted - version: "0.2.19-nullsafety.2" + version: "0.4.8" + timezone: + dependency: transitive + description: + name: timezone + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.8.0" timing: dependency: transitive description: name: timing url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.1+2" + version: "1.0.0" tuple: dependency: transitive description: name: tuple url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.3" + version: "2.0.0" typed_data: dependency: transitive description: name: typed_data url: "https://pub.flutter-io.cn" source: hosted - version: "1.3.0-nullsafety.3" + version: "1.3.0" url_launcher: dependency: "direct main" description: name: url_launcher url: "https://pub.flutter-io.cn" source: hosted - version: "5.7.10" + version: "6.1.5" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.19" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.17" url_launcher_linux: dependency: transitive description: name: url_launcher_linux url: "https://pub.flutter-io.cn" source: hosted - version: "0.0.1+4" + version: "3.0.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos url: "https://pub.flutter-io.cn" source: hosted - version: "0.0.1+9" + version: "3.0.1" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.9" + version: "2.1.0" url_launcher_web: dependency: transitive description: name: url_launcher_web url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.5+1" + version: "2.0.13" url_launcher_windows: dependency: transitive description: name: url_launcher_windows url: "https://pub.flutter-io.cn" source: hosted - version: "0.0.1+3" + version: "3.0.1" uuid: dependency: transitive description: name: uuid url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.2" + version: "3.0.6" vector_math: dependency: transitive description: name: vector_math url: "https://pub.flutter-io.cn" source: hosted - version: "2.1.0-nullsafety.3" + version: "2.1.1" + wakelock: + dependency: "direct main" + description: + name: wakelock + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.2" + wakelock_macos: + dependency: transitive + description: + name: wakelock_macos + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.0" + wakelock_platform_interface: + dependency: transitive + description: + name: wakelock_platform_interface + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.0" + wakelock_web: + dependency: transitive + description: + name: wakelock_web + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.0" + wakelock_windows: + dependency: transitive + description: + name: wakelock_windows + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.0" watcher: dependency: transitive description: name: watcher url: "https://pub.flutter-io.cn" source: hosted - version: "0.9.7+15" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel url: "https://pub.flutter-io.cn" source: hosted - version: "1.1.0" + version: "2.2.0" win32: dependency: transitive description: name: win32 url: "https://pub.flutter-io.cn" source: hosted - version: "1.7.4" + version: "2.5.2" xdg_directories: dependency: transitive description: name: xdg_directories url: "https://pub.flutter-io.cn" source: hosted - version: "0.1.2" + version: "0.2.0+2" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.3.1" yaml: dependency: transitive description: name: yaml url: "https://pub.flutter-io.cn" source: hosted - version: "2.2.1" + version: "3.1.1" sdks: - dart: ">=2.10.2 <=2.11.0-242.0.dev" - flutter: ">=1.22.2 <2.0.0" + dart: ">=2.16.2 <3.0.0" + flutter: ">=2.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index c1c6cf4..bce6486 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,45 +1,65 @@ name: manhuagui_flutter -description: An unofficial application for manhuagui written in flutter +description: An unofficial android application for manhuagui, built in flutter. publish_to: 'none' -version: 1.0.2 +version: 1.2.0 environment: - sdk: ">=2.7.0 <3.0.0" + sdk: ">=2.16.2 <3.0.0" dependencies: flutter: sdk: flutter - cupertino_icons: ^0.1.3 - fluttertoast: ^4.0.0 - url_launcher: ^5.7.10 - permission_handler: ^5.0.1+1 - json_annotation: ^3.1.0 - retrofit: ^1.3.4+1 - dio: ^3.0.10 - flutter_ahlib: ^1.2.0 - flutter_web_browser: ^0.13.0 - cached_network_image: ^2.3.3 - flutter_rating_bar: ^3.2.0+1 - photo_view: ^0.10.3 - filesize: ^1.0.4 - shared_preferences: ^0.5.12+4 - material_floating_search_bar: ^0.2.5 - synchronized: ^2.2.0+2 - carousel_slider: ^2.3.1 - path_provider: ^1.6.9 - path: ^1.7.0 - sqflite: ^1.3.0+1 - intl: ^0.16.1 - flutter_typeahead: ^1.9.1 + + # basic + cupertino_icons: ^1.0.5 + fluttertoast: 8.0.7 + url_launcher: ^6.1.5 + permission_handler: ^10.0.0 + json_annotation: ^4.6.0 + dio: ^4.0.6 + retrofit: ^3.0.1+1 + intl: ^0.17.0 + basic_utils: ^5.2.2 + synchronized: ^3.0.0+3 + queue: ^3.1.0+1 + + # assists + flutter_web_browser: ^0.17.1 + flutter_share: ^2.0.0 + wakelock: ^0.6.2 + device_info_plus: 4.0.0 + battery_info: ^1.1.1 + connectivity_plus: 2.3.6+1 + flutter_local_notifications: 9.9.1 + + # widgets + flutter_ahlib: + path: ../flutter_ahlib + flutter_cache_manager: ^3.3.0 + cached_network_image: ^3.2.0 + photo_view: ^0.14.0 + material_floating_search_bar: ^0.3.7 + flutter_rating_bar: ^4.0.1 + carousel_slider: ^4.1.1 + flutter_typeahead: 3.2.7 + + # file system & others + path: 1.8.0 + path_provider: ^2.0.11 + external_path: ^1.0.1 + shared_preferences: ^2.0.15 + event_bus: ^2.0.0 + sqflite: ^2.0.2+1 dev_dependencies: flutter_test: sdk: flutter flutter_localizations: sdk: flutter - build_runner: ^1.10.4 - json_serializable: ^3.5.0 - retrofit_generator: ^1.4.0+1 + flutter_lints: ^1.0.0 + build_runner: ^2.1.11 + json_serializable: ^6.2.0 + retrofit_generator: ^4.0.3+2 flutter: uses-material-design: true diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html index cf997af..5e7f5ec 100644 --- a/web/index.html +++ b/web/index.html @@ -8,10 +8,13 @@ The path provided below has to start and end with a slash "/" in order for it to work correctly. - Fore more details: + For more details: * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base + + This is a placeholder for base href that will be replaced by the value of + the `--base-href` argument provided to `flutter build`. --> - + @@ -30,16 +33,72 @@ - - - + scriptLoaded = true; + var scriptTag = document.createElement('script'); + scriptTag.src = 'main.dart.js'; + scriptTag.type = 'application/javascript'; + document.body.append(scriptTag); + } + + if ('serviceWorker' in navigator) { + // Service workers are supported. Use them. + window.addEventListener('load', function () { + // Wait for registration to finish before dropping the diff --git a/web/manifest.json b/web/manifest.json index 17dbc47..fb8d707 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -18,6 +18,18 @@ "src": "icons/Icon-512.png", "sizes": "512x512", "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] }